From 3ba2f8a501c677e1db594ea92649f867f93841b9 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 5 May 2026 14:03:36 +0200 Subject: [PATCH] fix: harden startup doctor and tool schemas --- packages/pi-ai/src/providers/mistral.ts | 242 +- .../providers/provider-capabilities.test.ts | 374 ++- src/resources/extensions/sf/auto.js | 2981 +++++++++-------- .../extensions/sf/doctor-engine-checks.js | 561 ++-- .../extensions/sf/doctor-environment.js | 1180 +++---- .../extensions/sf/doctor-git-checks.js | 1013 +++--- .../extensions/sf/doctor-proactive.js | 666 ++-- src/resources/extensions/sf/doctor.js | 2724 ++++++++------- .../extensions/sf/snapshot-safety.js | 48 + .../sf/tests/auto-startup-doctor.test.mjs | 31 + .../sf/tests/doctor-environment-fix.test.mjs | 100 + .../doctor-plan-dir-normalization.test.mjs | 110 + .../sf/tests/snapshot-safety.test.mjs | 111 + 13 files changed, 5748 insertions(+), 4393 deletions(-) create mode 100644 src/resources/extensions/sf/snapshot-safety.js create mode 100644 src/resources/extensions/sf/tests/auto-startup-doctor.test.mjs create mode 100644 src/resources/extensions/sf/tests/doctor-environment-fix.test.mjs create mode 100644 src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs create mode 100644 src/resources/extensions/sf/tests/snapshot-safety.test.mjs diff --git a/packages/pi-ai/src/providers/mistral.ts b/packages/pi-ai/src/providers/mistral.ts index fa8051918..2c4045978 100644 --- a/packages/pi-ai/src/providers/mistral.ts +++ b/packages/pi-ai/src/providers/mistral.ts @@ -18,6 +18,7 @@ async function getMistralClass(): Promise { } return _MistralClass; } + import { getEnvApiKey } from "../env-api-keys.js"; import { calculateCost } from "../models.js"; import type { @@ -38,7 +39,11 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { shortHash } from "../utils/hash.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { buildBaseOptions, clampReasoning, resolveReasoningLevel } from "./simple-options.js"; +import { + buildBaseOptions, + clampReasoning, + resolveReasoningLevel, +} from "./simple-options.js"; import { transformMessagesWithReport } from "./transform-messages.js"; const MISTRAL_TOOL_CALL_ID_LENGTH = 9; @@ -48,14 +53,22 @@ const MAX_MISTRAL_ERROR_BODY_CHARS = 4000; * Provider-specific options for the Mistral API. */ export interface MistralOptions extends StreamOptions { - toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } }; + toolChoice?: + | "auto" + | "none" + | "any" + | "required" + | { type: "function"; function: { name: string } }; promptMode?: "reasoning"; } /** * Stream responses from Mistral using `chat.stream`. */ -export const streamMistral: StreamFunction<"mistral-conversations", MistralOptions> = ( +export const streamMistral: StreamFunction< + "mistral-conversations", + MistralOptions +> = ( model: Model<"mistral-conversations">, context: Context, options?: MistralOptions, @@ -79,14 +92,27 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio }); const normalizeMistralToolCallId = createMistralToolCallIdNormalizer(); - const transformedMessages = transformMessagesWithReport(context.messages, model, (id) => normalizeMistralToolCallId(id), "mistral-conversations"); + const transformedMessages = transformMessagesWithReport( + context.messages, + model, + (id) => normalizeMistralToolCallId(id), + "mistral-conversations", + ); - let payload = buildChatPayload(model, context, transformedMessages, options); + let payload = buildChatPayload( + model, + context, + transformedMessages, + options, + ); const nextPayload = await options?.onPayload?.(payload, model); if (nextPayload !== undefined) { payload = nextPayload as ChatCompletionStreamRequest; } - const mistralStream = await mistral.chat.stream(payload, buildRequestOptions(model, options)); + const mistralStream = await mistral.chat.stream( + payload, + buildRequestOptions(model, options), + ); stream.push({ type: "start", partial: output }); await consumeChatStream(model, output, stream, mistralStream); @@ -114,7 +140,10 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio /** * Maps provider-agnostic `SimpleStreamOptions` to Mistral options. */ -export const streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions> = ( +export const streamSimpleMistral: StreamFunction< + "mistral-conversations", + SimpleStreamOptions +> = ( model: Model<"mistral-conversations">, context: Context, options?: SimpleStreamOptions, @@ -125,11 +154,15 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple } const base = buildBaseOptions(model, options, apiKey); - const reasoning = clampReasoning(resolveReasoningLevel(model, options?.reasoning)); + const reasoning = clampReasoning( + resolveReasoningLevel(model, options?.reasoning), + ); return streamMistral(model, context, { ...base, - promptMode: shouldUseMistralReasoningPromptMode(model, reasoning) ? "reasoning" : undefined, + promptMode: shouldUseMistralReasoningPromptMode(model, reasoning) + ? "reasoning" + : undefined, } satisfies MistralOptions); }; @@ -186,7 +219,8 @@ function createMistralToolCallIdNormalizer(): (id: string) => string { function deriveMistralToolCallId(id: string, attempt: number): string { const normalized = id.replace(/[^a-zA-Z0-9]/g, ""); - if (attempt === 0 && normalized.length === MISTRAL_TOOL_CALL_ID_LENGTH) return normalized; + if (attempt === 0 && normalized.length === MISTRAL_TOOL_CALL_ID_LENGTH) + return normalized; const seedBase = normalized || id; const seed = attempt === 0 ? seedBase : `${seedBase}:${attempt}`; return shortHash(seed) @@ -197,12 +231,15 @@ function deriveMistralToolCallId(id: string, attempt: number): string { function formatMistralError(error: unknown): string { if (error instanceof Error) { const sdkError = error as Error & { statusCode?: unknown; body?: unknown }; - const statusCode = typeof sdkError.statusCode === "number" ? sdkError.statusCode : undefined; - const bodyText = typeof sdkError.body === "string" ? sdkError.body.trim() : undefined; + const statusCode = + typeof sdkError.statusCode === "number" ? sdkError.statusCode : undefined; + const bodyText = + typeof sdkError.body === "string" ? sdkError.body.trim() : undefined; if (statusCode !== undefined && bodyText) { return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`; } - if (statusCode !== undefined) return `Mistral API error (${statusCode}): ${error.message}`; + if (statusCode !== undefined) + return `Mistral API error (${statusCode}): ${error.message}`; return error.message; } return safeJsonStringify(error); @@ -222,7 +259,10 @@ function safeJsonStringify(value: unknown): string { } } -function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions): RequestOptions { +function buildRequestOptions( + model: Model<"mistral-conversations">, + options?: MistralOptions, +): RequestOptions { const requestOptions: RequestOptions = {}; if (options?.signal) requestOptions.signal = options.signal; requestOptions.retries = { strategy: "none" }; @@ -257,9 +297,11 @@ function buildChatPayload( }; if (context.tools?.length) payload.tools = toFunctionTools(context.tools); - if (options?.temperature !== undefined) payload.temperature = options.temperature; + if (options?.temperature !== undefined) + payload.temperature = options.temperature; if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens; - if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice); + if (options?.toolChoice) + payload.toolChoice = mapToolChoice(options.toolChoice); if (options?.promptMode) payload.promptMode = options.promptMode as any; if (context.systemPrompt) { @@ -312,7 +354,8 @@ async function consumeChatStream( output.usage.output = chunk.usage.completionTokens || 0; output.usage.cacheRead = 0; output.usage.cacheWrite = 0; - output.usage.totalTokens = chunk.usage.totalTokens || output.usage.input + output.usage.output; + output.usage.totalTokens = + chunk.usage.totalTokens || output.usage.input + output.usage.output; calculateCost(model, output.usage); } @@ -325,7 +368,8 @@ async function consumeChatStream( const delta = choice.delta; if (delta.content !== null && delta.content !== undefined) { - const contentItems = typeof delta.content === "string" ? [delta.content] : delta.content; + const contentItems = + typeof delta.content === "string" ? [delta.content] : delta.content; for (const item of contentItems) { if (typeof item === "string") { const textDelta = sanitizeSurrogates(item); @@ -333,7 +377,11 @@ async function consumeChatStream( finishCurrentBlock(currentBlock); currentBlock = { type: "text", text: "" }; output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + stream.push({ + type: "text_start", + contentIndex: blockIndex(), + partial: output, + }); } currentBlock.text += textDelta; stream.push({ @@ -356,7 +404,11 @@ async function consumeChatStream( finishCurrentBlock(currentBlock); currentBlock = { type: "thinking", thinking: "" }; output.content.push(currentBlock); - stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + stream.push({ + type: "thinking_start", + contentIndex: blockIndex(), + partial: output, + }); } currentBlock.thinking += thinkingDelta; stream.push({ @@ -374,7 +426,11 @@ async function consumeChatStream( finishCurrentBlock(currentBlock); currentBlock = { type: "text", text: "" }; output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + stream.push({ + type: "text_start", + contentIndex: blockIndex(), + partial: output, + }); } currentBlock.text += textDelta; stream.push({ @@ -418,7 +474,11 @@ async function consumeChatStream( }; output.content.push(block); toolBlocksByKey.set(key, output.content.length - 1); - stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); + stream.push({ + type: "toolcall_start", + contentIndex: output.content.length - 1, + partial: output, + }); } const argsDelta = @@ -426,7 +486,9 @@ async function consumeChatStream( ? toolCall.function.arguments : JSON.stringify(toolCall.function.arguments || {}); block.partialArgs = (block.partialArgs || "") + argsDelta; - block.arguments = parseStreamingJson>(block.partialArgs); + block.arguments = parseStreamingJson>( + block.partialArgs, + ); stream.push({ type: "toolcall_delta", contentIndex: toolBlocksByKey.get(key)!, @@ -441,7 +503,9 @@ async function consumeChatStream( const block = output.content[index]; if (block.type !== "toolCall") continue; const toolBlock = block as ToolCall & { partialArgs?: string }; - toolBlock.arguments = parseStreamingJson>(toolBlock.partialArgs); + toolBlock.arguments = parseStreamingJson>( + toolBlock.partialArgs, + ); delete toolBlock.partialArgs; stream.push({ type: "toolcall_end", @@ -452,19 +516,61 @@ async function consumeChatStream( } } -function toFunctionTools(tools: Tool[]): Array { +export function sanitizeMistralToolParameters( + value: unknown, +): Record { + const sanitized = sanitizeJsonSchemaValue(value); + if (isPlainRecord(sanitized)) return sanitized; + return { type: "object", properties: {} }; +} + +function sanitizeJsonSchemaValue(value: unknown): unknown { + if (value === null) return null; + if (Array.isArray(value)) { + return value + .map((item) => sanitizeJsonSchemaValue(item)) + .filter((item) => item !== undefined); + } + if (isPlainRecord(value)) { + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + const sanitized = sanitizeJsonSchemaValue(item); + if (sanitized !== undefined) result[key] = sanitized; + } + return result; + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + return undefined; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toFunctionTools( + tools: Tool[], +): Array { return tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, - parameters: tool.parameters as unknown as Record, + parameters: sanitizeMistralToolParameters(tool.parameters), strict: false, }, })); } -function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessage[] { +function toChatMessages( + messages: Message[], + supportsImages: boolean, +): ChatCompletionStreamRequestMessage[] { const result: ChatCompletionStreamRequestMessage[] = []; for (const msg of messages) { @@ -477,27 +583,41 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl const content: ContentChunk[] = msg.content .filter((item) => item.type === "text" || supportsImages) .map((item) => { - if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) }; - return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` }; + if (item.type === "text") + return { type: "text", text: sanitizeSurrogates(item.text) }; + return { + type: "image_url", + imageUrl: `data:${item.mimeType};base64,${item.data}`, + }; }); if (content.length > 0) { result.push({ role: "user", content }); continue; } if (hadImages && !supportsImages) { - result.push({ role: "user", content: "(image omitted: model does not support images)" }); + result.push({ + role: "user", + content: "(image omitted: model does not support images)", + }); } continue; } if (msg.role === "assistant") { const contentParts: ContentChunk[] = []; - const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = []; + const toolCalls: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }> = []; for (const block of msg.content) { if (block.type === "text") { if (block.text.trim().length > 0) { - contentParts.push({ type: "text", text: sanitizeSurrogates(block.text) }); + contentParts.push({ + type: "text", + text: sanitizeSurrogates(block.text), + }); } continue; } @@ -505,7 +625,9 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl if (block.thinking.trim().length > 0) { contentParts.push({ type: "thinking", - thinking: [{ type: "text", text: sanitizeSurrogates(block.thinking) }], + thinking: [ + { type: "text", text: sanitizeSurrogates(block.thinking) }, + ], }); } continue; @@ -516,24 +638,37 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl toolCalls.push({ id: block.id, type: "function", - function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) }, + function: { + name: block.name, + arguments: JSON.stringify(block.arguments || {}), + }, }); } - const assistantMessage: ChatCompletionStreamRequestMessage = { role: "assistant" }; + const assistantMessage: ChatCompletionStreamRequestMessage = { + role: "assistant", + }; if (contentParts.length > 0) assistantMessage.content = contentParts; if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls; - if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage); + if (contentParts.length > 0 || toolCalls.length > 0) + result.push(assistantMessage); continue; } const toolContent: ContentChunk[] = []; const textResult = msg.content .filter((part) => part.type === "text") - .map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : "")) + .map((part) => + part.type === "text" ? sanitizeSurrogates(part.text) : "", + ) .join("\n"); const hasImages = msg.content.some((part) => part.type === "image"); - const toolText = buildToolResultText(textResult, hasImages, supportsImages, msg.isError); + const toolText = buildToolResultText( + textResult, + hasImages, + supportsImages, + msg.isError, + ); toolContent.push({ type: "text", text: toolText }); for (const part of msg.content) { if (!supportsImages) continue; @@ -554,18 +689,28 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl return result; } -function buildToolResultText(text: string, hasImages: boolean, supportsImages: boolean, isError: boolean): string { +function buildToolResultText( + text: string, + hasImages: boolean, + supportsImages: boolean, + isError: boolean, +): string { const trimmed = text.trim(); const errorPrefix = isError ? "[tool error] " : ""; if (trimmed.length > 0) { - const imageSuffix = hasImages && !supportsImages ? "\n[tool image omitted: model does not support images]" : ""; + const imageSuffix = + hasImages && !supportsImages + ? "\n[tool image omitted: model does not support images]" + : ""; return `${errorPrefix}${trimmed}${imageSuffix}`; } if (hasImages) { if (supportsImages) { - return isError ? "[tool error] (see attached image)" : "(see attached image)"; + return isError + ? "[tool error] (see attached image)" + : "(see attached image)"; } return isError ? "[tool error] (image omitted: model does not support images)" @@ -577,9 +722,20 @@ function buildToolResultText(text: string, hasImages: boolean, supportsImages: b function mapToolChoice( choice: MistralOptions["toolChoice"], -): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined { +): + | "auto" + | "none" + | "any" + | "required" + | { type: "function"; function: { name: string } } + | undefined { if (!choice) return undefined; - if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") { + if ( + choice === "auto" || + choice === "none" || + choice === "any" || + choice === "required" + ) { return choice as any; } return { diff --git a/packages/pi-ai/src/providers/provider-capabilities.test.ts b/packages/pi-ai/src/providers/provider-capabilities.test.ts index 03efe95c7..098f02e7c 100644 --- a/packages/pi-ai/src/providers/provider-capabilities.test.ts +++ b/packages/pi-ai/src/providers/provider-capabilities.test.ts @@ -1,188 +1,280 @@ // SF — Provider Capabilities Registry Tests (ADR-005 Phase 1) -import { describe, test } from 'vitest'; -import assert from "node:assert/strict"; +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { - PROVIDER_CAPABILITIES, - getProviderCapabilities, - getUnsupportedFeatures, - mergeCapabilityOverrides, - getRegisteredApis, + sanitizeMistralToolParameters, + shouldUseMistralReasoningPromptMode, +} from "./mistral.js"; +import { + getProviderCapabilities, + getRegisteredApis, + getUnsupportedFeatures, + mergeCapabilityOverrides, + PROVIDER_CAPABILITIES, } from "./provider-capabilities.js"; -import { shouldUseMistralReasoningPromptMode } from "./mistral.js"; // ─── Registry Completeness ────────────────────────────────────────────────── describe("PROVIDER_CAPABILITIES registry", () => { - const EXPECTED_APIS = [ - "anthropic-messages", - "anthropic-vertex", - "openai-responses", - "azure-openai-responses", - "openai-codex-responses", - "openai-completions", - "google-generative-ai", - "google-gemini-cli", - "google-vertex", - "mistral-conversations", - "bedrock-converse-stream", - "ollama-chat", - ]; + const EXPECTED_APIS = [ + "anthropic-messages", + "anthropic-vertex", + "openai-responses", + "azure-openai-responses", + "openai-codex-responses", + "openai-completions", + "google-generative-ai", + "google-gemini-cli", + "google-vertex", + "mistral-conversations", + "bedrock-converse-stream", + "ollama-chat", + ]; - test("covers all expected API providers", () => { - for (const api of EXPECTED_APIS) { - assert.ok( - PROVIDER_CAPABILITIES[api], - `Missing capability entry for API: ${api}`, - ); - } - }); + test("covers all expected API providers", () => { + for (const api of EXPECTED_APIS) { + assert.ok( + PROVIDER_CAPABILITIES[api], + `Missing capability entry for API: ${api}`, + ); + } + }); - test("getRegisteredApis returns all entries", () => { - const registered = getRegisteredApis(); - for (const api of EXPECTED_APIS) { - assert.ok(registered.includes(api), `getRegisteredApis missing: ${api}`); - } - }); + test("getRegisteredApis returns all entries", () => { + const registered = getRegisteredApis(); + for (const api of EXPECTED_APIS) { + assert.ok(registered.includes(api), `getRegisteredApis missing: ${api}`); + } + }); - test("all entries have required fields", () => { - for (const [api, caps] of Object.entries(PROVIDER_CAPABILITIES)) { - assert.equal(typeof caps.toolCalling, "boolean", `${api}.toolCalling`); - assert.equal(typeof caps.maxTools, "number", `${api}.maxTools`); - assert.equal(typeof caps.imageToolResults, "boolean", `${api}.imageToolResults`); - assert.equal(typeof caps.structuredOutput, "boolean", `${api}.structuredOutput`); - assert.ok(caps.toolCallIdFormat, `${api}.toolCallIdFormat`); - assert.equal(typeof caps.toolCallIdFormat.maxLength, "number", `${api}.toolCallIdFormat.maxLength`); - assert.ok(caps.toolCallIdFormat.allowedChars instanceof RegExp, `${api}.toolCallIdFormat.allowedChars`); - assert.ok( - ["full", "text-only", "none"].includes(caps.thinkingPersistence), - `${api}.thinkingPersistence is "${caps.thinkingPersistence}"`, - ); - assert.ok(Array.isArray(caps.unsupportedSchemaFeatures), `${api}.unsupportedSchemaFeatures`); - } - }); + test("all entries have required fields", () => { + for (const [api, caps] of Object.entries(PROVIDER_CAPABILITIES)) { + assert.equal(typeof caps.toolCalling, "boolean", `${api}.toolCalling`); + assert.equal(typeof caps.maxTools, "number", `${api}.maxTools`); + assert.equal( + typeof caps.imageToolResults, + "boolean", + `${api}.imageToolResults`, + ); + assert.equal( + typeof caps.structuredOutput, + "boolean", + `${api}.structuredOutput`, + ); + assert.ok(caps.toolCallIdFormat, `${api}.toolCallIdFormat`); + assert.equal( + typeof caps.toolCallIdFormat.maxLength, + "number", + `${api}.toolCallIdFormat.maxLength`, + ); + assert.ok( + caps.toolCallIdFormat.allowedChars instanceof RegExp, + `${api}.toolCallIdFormat.allowedChars`, + ); + assert.ok( + ["full", "text-only", "none"].includes(caps.thinkingPersistence), + `${api}.thinkingPersistence is "${caps.thinkingPersistence}"`, + ); + assert.ok( + Array.isArray(caps.unsupportedSchemaFeatures), + `${api}.unsupportedSchemaFeatures`, + ); + } + }); }); // ─── Provider-specific Values ─────────────────────────────────────────────── describe("provider-specific capabilities", () => { - test("Anthropic supports full thinking persistence", () => { - assert.equal(PROVIDER_CAPABILITIES["anthropic-messages"].thinkingPersistence, "full"); - }); + test("Anthropic supports full thinking persistence", () => { + assert.equal( + PROVIDER_CAPABILITIES["anthropic-messages"].thinkingPersistence, + "full", + ); + }); - test("Anthropic supports image tool results", () => { - assert.equal(PROVIDER_CAPABILITIES["anthropic-messages"].imageToolResults, true); - }); + test("Anthropic supports image tool results", () => { + assert.equal( + PROVIDER_CAPABILITIES["anthropic-messages"].imageToolResults, + true, + ); + }); - test("Anthropic tool call ID is 64 chars max", () => { - assert.equal(PROVIDER_CAPABILITIES["anthropic-messages"].toolCallIdFormat.maxLength, 64); - }); + test("Anthropic tool call ID is 64 chars max", () => { + assert.equal( + PROVIDER_CAPABILITIES["anthropic-messages"].toolCallIdFormat.maxLength, + 64, + ); + }); - test("Mistral tool call ID is 9 chars max", () => { - assert.equal(PROVIDER_CAPABILITIES["mistral-conversations"].toolCallIdFormat.maxLength, 9); - }); + test("Mistral tool call ID is 9 chars max", () => { + assert.equal( + PROVIDER_CAPABILITIES["mistral-conversations"].toolCallIdFormat.maxLength, + 9, + ); + }); - test("Mistral has no thinking persistence", () => { - assert.equal(PROVIDER_CAPABILITIES["mistral-conversations"].thinkingPersistence, "none"); - }); + test("Mistral has no thinking persistence", () => { + assert.equal( + PROVIDER_CAPABILITIES["mistral-conversations"].thinkingPersistence, + "none", + ); + }); - test("Mistral reasoning prompt mode is limited to Magistral models", () => { - const baseModel = { - id: "mistral-small-latest", - reasoning: true, - } as any; + test("Mistral reasoning prompt mode is limited to Magistral models", () => { + const baseModel = { + id: "mistral-small-latest", + reasoning: true, + } as any; - assert.equal(shouldUseMistralReasoningPromptMode(baseModel, "medium"), false); - assert.equal( - shouldUseMistralReasoningPromptMode({ ...baseModel, id: "magistral-medium-latest" }, "medium"), - true, - ); - }); + assert.equal( + shouldUseMistralReasoningPromptMode(baseModel, "medium"), + false, + ); + assert.equal( + shouldUseMistralReasoningPromptMode( + { ...baseModel, id: "magistral-medium-latest" }, + "medium", + ), + true, + ); + }); - test("Google does not support patternProperties", () => { - assert.ok( - PROVIDER_CAPABILITIES["google-generative-ai"].unsupportedSchemaFeatures.includes("patternProperties"), - ); - }); + test("Mistral tool schema drops TypeBox symbol metadata", () => { + const kind = Symbol("TypeBox.Kind"); + const schema = { + type: "object", + required: ["path"], + properties: { + path: { + type: "string", + [kind]: "String", + }, + }, + [kind]: "Object", + }; - test("Google does not support const", () => { - assert.ok( - PROVIDER_CAPABILITIES["google-generative-ai"].unsupportedSchemaFeatures.includes("const"), - ); - }); + const sanitized = sanitizeMistralToolParameters(schema); - test("OpenAI Responses does not support image tool results", () => { - assert.equal(PROVIDER_CAPABILITIES["openai-responses"].imageToolResults, false); - }); + assert.deepEqual(Object.getOwnPropertySymbols(sanitized), []); + assert.deepEqual( + Object.getOwnPropertySymbols((sanitized.properties as any).path), + [], + ); + assert.deepEqual(sanitized, { + type: "object", + required: ["path"], + properties: { + path: { + type: "string", + }, + }, + }); + }); - test("OpenAI Responses has text-only thinking persistence", () => { - assert.equal(PROVIDER_CAPABILITIES["openai-responses"].thinkingPersistence, "text-only"); - }); + test("Google does not support patternProperties", () => { + assert.ok( + PROVIDER_CAPABILITIES[ + "google-generative-ai" + ].unsupportedSchemaFeatures.includes("patternProperties"), + ); + }); + + test("Google does not support const", () => { + assert.ok( + PROVIDER_CAPABILITIES[ + "google-generative-ai" + ].unsupportedSchemaFeatures.includes("const"), + ); + }); + + test("OpenAI Responses does not support image tool results", () => { + assert.equal( + PROVIDER_CAPABILITIES["openai-responses"].imageToolResults, + false, + ); + }); + + test("OpenAI Responses has text-only thinking persistence", () => { + assert.equal( + PROVIDER_CAPABILITIES["openai-responses"].thinkingPersistence, + "text-only", + ); + }); }); // ─── getProviderCapabilities ──────────────────────────────────────────────── describe("getProviderCapabilities", () => { - test("returns known provider capabilities", () => { - const caps = getProviderCapabilities("anthropic-messages"); - assert.equal(caps.toolCalling, true); - assert.equal(caps.thinkingPersistence, "full"); - }); + test("returns known provider capabilities", () => { + const caps = getProviderCapabilities("anthropic-messages"); + assert.equal(caps.toolCalling, true); + assert.equal(caps.thinkingPersistence, "full"); + }); - test("returns permissive defaults for unknown providers", () => { - const caps = getProviderCapabilities("unknown-provider-xyz"); - assert.equal(caps.toolCalling, true); - assert.equal(caps.imageToolResults, true); - assert.deepEqual(caps.unsupportedSchemaFeatures, []); - }); + test("returns permissive defaults for unknown providers", () => { + const caps = getProviderCapabilities("unknown-provider-xyz"); + assert.equal(caps.toolCalling, true); + assert.equal(caps.imageToolResults, true); + assert.deepEqual(caps.unsupportedSchemaFeatures, []); + }); }); // ─── getUnsupportedFeatures ───────────────────────────────────────────────── describe("getUnsupportedFeatures", () => { - test("returns unsupported features for Google", () => { - const unsupported = getUnsupportedFeatures("google-generative-ai", ["patternProperties", "const"]); - assert.deepEqual(unsupported, ["patternProperties", "const"]); - }); + test("returns unsupported features for Google", () => { + const unsupported = getUnsupportedFeatures("google-generative-ai", [ + "patternProperties", + "const", + ]); + assert.deepEqual(unsupported, ["patternProperties", "const"]); + }); - test("returns empty for Anthropic with any features", () => { - const unsupported = getUnsupportedFeatures("anthropic-messages", ["patternProperties", "const"]); - assert.deepEqual(unsupported, []); - }); + test("returns empty for Anthropic with any features", () => { + const unsupported = getUnsupportedFeatures("anthropic-messages", [ + "patternProperties", + "const", + ]); + assert.deepEqual(unsupported, []); + }); - test("returns empty for unknown provider", () => { - const unsupported = getUnsupportedFeatures("unknown-xyz", ["patternProperties"]); - assert.deepEqual(unsupported, []); - }); + test("returns empty for unknown provider", () => { + const unsupported = getUnsupportedFeatures("unknown-xyz", [ + "patternProperties", + ]); + assert.deepEqual(unsupported, []); + }); }); // ─── mergeCapabilityOverrides ─────────────────────────────────────────────── describe("mergeCapabilityOverrides", () => { - test("overrides individual fields", () => { - const merged = mergeCapabilityOverrides("openai-responses", { - imageToolResults: true, - }); - assert.equal(merged.imageToolResults, true); - // Non-overridden fields preserved - assert.equal(merged.toolCalling, true); - assert.equal(merged.thinkingPersistence, "text-only"); - }); + test("overrides individual fields", () => { + const merged = mergeCapabilityOverrides("openai-responses", { + imageToolResults: true, + }); + assert.equal(merged.imageToolResults, true); + // Non-overridden fields preserved + assert.equal(merged.toolCalling, true); + assert.equal(merged.thinkingPersistence, "text-only"); + }); - test("deep-merges toolCallIdFormat", () => { - const merged = mergeCapabilityOverrides("anthropic-messages", { - toolCallIdFormat: { maxLength: 128 }, - }); - assert.equal(merged.toolCallIdFormat.maxLength, 128); - // allowedChars preserved from base - assert.ok(merged.toolCallIdFormat.allowedChars instanceof RegExp); - }); + test("deep-merges toolCallIdFormat", () => { + const merged = mergeCapabilityOverrides("anthropic-messages", { + toolCallIdFormat: { maxLength: 128 }, + }); + assert.equal(merged.toolCallIdFormat.maxLength, 128); + // allowedChars preserved from base + assert.ok(merged.toolCallIdFormat.allowedChars instanceof RegExp); + }); - test("uses permissive defaults for unknown provider", () => { - const merged = mergeCapabilityOverrides("unknown-xyz", { - imageToolResults: false, - }); - assert.equal(merged.imageToolResults, false); - assert.equal(merged.toolCalling, true); // from default - }); + test("uses permissive defaults for unknown provider", () => { + const merged = mergeCapabilityOverrides("unknown-xyz", { + imageToolResults: false, + }); + assert.equal(merged.imageToolResults, false); + assert.equal(merged.toolCalling, true); // from default + }); }); diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index 0d076c66d..f23816b32 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -10,69 +10,193 @@ * telling the LLM which files to read and what to do. */ import { getManifestStatus } from "./files.js"; -import { assessInterruptedSession, readPausedSessionMetadata, } from "./interrupted-session.js"; +import { + assessInterruptedSession, + readPausedSessionMetadata, +} from "./interrupted-session.js"; import { deriveState } from "./state.js"; import { parseUnitId } from "./unit-id.js"; + export { inlinePriorMilestoneSummary } from "./files.js"; -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs"; + +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { pathToFileURL } from "node:url"; -import { clearCmuxSidebar, logCmuxEvent, syncCmuxSidebar, } from "../cmux/index.js"; +import { + clearCmuxSidebar, + logCmuxEvent, + syncCmuxSidebar, +} from "../cmux/index.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { getRtkSessionSavings } from "../shared/rtk-session-stats.js"; import { deactivateSF } from "../shared/sf-phase-state.js"; import { clearActivityLogState } from "./activity-log.js"; import { atomicWriteSync } from "./atomic-write.js"; -import { getAutoSession, } from "./auto/session.js"; +import { getAutoSession } from "./auto/session.js"; // import { startSliceParallel } from "./slice-parallel-orchestrator.js"; (decoy for legacy regex tests) -import { getBudgetAlertLevel, getBudgetEnforcementAction, getNewBudgetAlertLevel, } from "./auto-budget.js"; -import { updateProgressWidget as _updateProgressWidget, clearSliceProgressCache, hideFooter, updateSliceProgressCache, } from "./auto-dashboard.js"; +import { + getBudgetAlertLevel, + getBudgetEnforcementAction, + getNewBudgetAlertLevel, +} from "./auto-budget.js"; +import { + updateProgressWidget as _updateProgressWidget, + clearSliceProgressCache, + hideFooter, + updateSliceProgressCache, +} from "./auto-dashboard.js"; import { DISPATCH_RULES, resolveDispatch } from "./auto-dispatch.js"; -import { _resetPendingResolve, autoLoop, isSessionSwitchInFlight, resolveAgentEnd, resolveAgentEndCancelled, runUokKernelLoop, } from "./auto-loop.js"; -import { clearToolBaseline, resolveModelId, selectAndApplyModel, } from "./auto-model-selection.js"; -import { autoCommitUnit, postUnitPostVerification, postUnitPreVerification, } from "./auto-post-unit.js"; +import { + _resetPendingResolve, + autoLoop, + isSessionSwitchInFlight, + resolveAgentEnd, + resolveAgentEndCancelled, + runUokKernelLoop, +} from "./auto-loop.js"; +import { + clearToolBaseline, + resolveModelId, + selectAndApplyModel, +} from "./auto-model-selection.js"; +import { + autoCommitUnit, + postUnitPostVerification, + postUnitPreVerification, +} from "./auto-post-unit.js"; import { reconcileMergeState } from "./auto-recovery.js"; -import { bootstrapAutoSession, openProjectDbIfPresent, } from "./auto-start.js"; -import { deregisterSigtermHandler as _deregisterSigtermHandler, registerSigtermHandler as _registerSigtermHandler, } from "./auto-supervisor.js"; +import { bootstrapAutoSession, openProjectDbIfPresent } from "./auto-start.js"; +import { + deregisterSigtermHandler as _deregisterSigtermHandler, + registerSigtermHandler as _registerSigtermHandler, +} from "./auto-supervisor.js"; // ── Extracted modules ────────────────────────────────────────────────────── import { startUnitSupervision } from "./auto-timers.js"; -import { getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs, markToolEnd as _markToolEnd, markToolStart as _markToolStart, clearInFlightTools, isQueuedUserMessageSkip, isToolInvocationError, } from "./auto-tool-tracking.js"; +import { + getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs, + markToolEnd as _markToolEnd, + markToolStart as _markToolStart, + clearInFlightTools, + isQueuedUserMessageSkip, + isToolInvocationError, +} from "./auto-tool-tracking.js"; import { closeoutUnit } from "./auto-unit-closeout.js"; import { runPostUnitVerification } from "./auto-verification.js"; -import { autoWorktreeBranch, checkResourcesStale, createAutoWorktree, enterAutoWorktree, escapeStaleWorktree, getAutoWorktreePath, isInAutoWorktree, mergeMilestoneToMain, syncProjectRootToWorktree, syncWorktreeStateBack, teardownAutoWorktree, } from "./auto-worktree.js"; +import { + autoWorktreeBranch, + checkResourcesStale, + createAutoWorktree, + enterAutoWorktree, + escapeStaleWorktree, + getAutoWorktreePath, + isInAutoWorktree, + mergeMilestoneToMain, + syncProjectRootToWorktree, + syncWorktreeStateBack, + teardownAutoWorktree, +} from "./auto-worktree.js"; import { invalidateAllCaches } from "./cache.js"; import { countPendingCaptures } from "./captures.js"; -import { clearLock, emitCrashRecoveredUnitEnd, formatCrashInfo, isLockProcessAlive, readCrashLock, writeLock, } from "./crash-recovery.js"; +import { + clearLock, + emitCrashRecoveredUnitEnd, + formatCrashInfo, + isLockProcessAlive, + readCrashLock, + writeLock, +} from "./crash-recovery.js"; import { debugLog, isDebugEnabled, writeDebugSummary } from "./debug-logger.js"; import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js"; import { rebuildState, runSFDoctor } from "./doctor.js"; -import { healAutoStartupRuntime, preDispatchHealthGate, resetProactiveHealing, setLevelChangeCallback, } from "./doctor-proactive.js"; +import { + healAutoStartupRuntime, + preDispatchHealthGate, + resetProactiveHealing, + setLevelChangeCallback, +} from "./doctor-proactive.js"; import { getErrorMessage } from "./error-utils.js"; import { GitServiceImpl } from "./git-service.js"; import { initHealthWidget } from "./health-widget.js"; -import { emitJournalEvent as _emitJournalEvent, } from "./journal.js"; -import { formatCost, formatTokenCount, getLedger, getProjectTotals, initMetrics, resetMetrics, } from "./metrics.js"; +import { emitJournalEvent as _emitJournalEvent } from "./journal.js"; +import { + formatCost, + formatTokenCount, + getLedger, + getProjectTotals, + initMetrics, + resetMetrics, +} from "./metrics.js"; import { sendDesktopNotification } from "./notifications.js"; -import { milestonesDir, resolveDir, resolveMilestoneFile, resolveMilestonePath, sfRoot, } from "./paths.js"; -import { clearPersistedHookState, resetHookState, restoreHookState, runPreDispatchHooks, } from "./post-unit-hooks.js"; -import { getIsolationMode, loadEffectiveSFPreferences, resolveAutoSupervisorConfig, } from "./preferences.js"; +import { + milestonesDir, + resolveDir, + resolveMilestoneFile, + resolveMilestonePath, + sfRoot, +} from "./paths.js"; +import { + clearPersistedHookState, + resetHookState, + restoreHookState, + runPreDispatchHooks, +} from "./post-unit-hooks.js"; +import { + getIsolationMode, + loadEffectiveSFPreferences, + resolveAutoSupervisorConfig, +} from "./preferences.js"; import { reorderForCaching } from "./prompt-ordering.js"; import { pruneQueueOrder } from "./queue-order.js"; import { recordOutcome, resetRoutingHistory } from "./routing-history.js"; import { convertDispatchRules, initRegistry } from "./rule-registry.js"; -import { getDeepDiagnostic, readActiveMilestoneId, synthesizeCrashRecovery, } from "./session-forensics.js"; -import { acquireSessionLock, getSessionLockStatus, releaseSessionLock, updateSessionLock, } from "./session-lock.js"; +import { + getDeepDiagnostic, + readActiveMilestoneId, + synthesizeCrashRecovery, +} from "./session-forensics.js"; +import { + acquireSessionLock, + getSessionLockStatus, + releaseSessionLock, + updateSessionLock, +} from "./session-lock.js"; import { getMilestone, isDbAvailable } from "./sf-db.js"; import { clearSkillSnapshot } from "./skill-discovery.js"; -import { captureAvailableSkills, resetSkillTelemetry, } from "./skill-telemetry.js"; +import { + captureAvailableSkills, + resetSkillTelemetry, +} from "./skill-telemetry.js"; import { resolveUokFlags } from "./uok/flags.js"; import { runAutoLoopWithUok } from "./uok/kernel.js"; -import { writeParityHeartbeat, writeParityReport } from "./uok/parity-report.js"; +import { + writeParityHeartbeat, + writeParityReport, +} from "./uok/parity-report.js"; import { logWarning, setLogBasePath } from "./workflow-logger.js"; -import { autoCommitCurrentBranch, captureIntegrationBranch, detectWorktreeName, getCurrentBranch, getMainBranch, setActiveMilestoneId, } from "./worktree.js"; -import { WorktreeResolver, } from "./worktree-resolver.js"; -export { MAX_LIFETIME_DISPATCHES, MAX_UNIT_DISPATCHES, NEW_SESSION_TIMEOUT_MS, STUB_RECOVERY_THRESHOLD, } from "./auto/session.js"; +import { + autoCommitCurrentBranch, + captureIntegrationBranch, + detectWorktreeName, + getCurrentBranch, + getMainBranch, + setActiveMilestoneId, +} from "./worktree.js"; +import { WorktreeResolver } from "./worktree-resolver.js"; + +export { + MAX_LIFETIME_DISPATCHES, + MAX_UNIT_DISPATCHES, + NEW_SESSION_TIMEOUT_MS, + STUB_RECOVERY_THRESHOLD, +} from "./auto/session.js"; + // ── ENCAPSULATION INVARIANT ───────────────────────────────────────────────── // ALL mutable auto-mode state lives in the AutoSession class (auto/session.ts). // This file must NOT declare module-level `let` or `var` variables for state. @@ -89,71 +213,62 @@ const s = getAutoSession(); /** Throttle STATE.md rebuilds — at most once per 30 seconds */ const _STATE_REBUILD_MIN_INTERVAL_MS = 30_000; function captureProjectRootEnv(projectRoot) { - if (!s.projectRootEnvCaptured) { - s.hadProjectRootEnv = Object.hasOwn(process.env, "SF_PROJECT_ROOT"); - s.previousProjectRootEnv = process.env.SF_PROJECT_ROOT ?? null; - s.projectRootEnvCaptured = true; - } - process.env.SF_PROJECT_ROOT = projectRoot; + if (!s.projectRootEnvCaptured) { + s.hadProjectRootEnv = Object.hasOwn(process.env, "SF_PROJECT_ROOT"); + s.previousProjectRootEnv = process.env.SF_PROJECT_ROOT ?? null; + s.projectRootEnvCaptured = true; + } + process.env.SF_PROJECT_ROOT = projectRoot; } function restoreProjectRootEnv() { - if (!s.projectRootEnvCaptured) - return; - if (s.hadProjectRootEnv && s.previousProjectRootEnv !== null) { - process.env.SF_PROJECT_ROOT = s.previousProjectRootEnv; - } - else { - delete process.env.SF_PROJECT_ROOT; - } - s.previousProjectRootEnv = null; - s.hadProjectRootEnv = false; - s.projectRootEnvCaptured = false; + if (!s.projectRootEnvCaptured) return; + if (s.hadProjectRootEnv && s.previousProjectRootEnv !== null) { + process.env.SF_PROJECT_ROOT = s.previousProjectRootEnv; + } else { + delete process.env.SF_PROJECT_ROOT; + } + s.previousProjectRootEnv = null; + s.hadProjectRootEnv = false; + s.projectRootEnvCaptured = false; } function captureMilestoneLockEnv(milestoneId) { - if (!s.milestoneLockEnvCaptured) { - s.hadMilestoneLockEnv = Object.hasOwn(process.env, "SF_MILESTONE_LOCK"); - s.previousMilestoneLockEnv = process.env.SF_MILESTONE_LOCK ?? null; - s.milestoneLockEnvCaptured = true; - } - if (milestoneId) { - process.env.SF_MILESTONE_LOCK = milestoneId; - } - else { - delete process.env.SF_MILESTONE_LOCK; - } + if (!s.milestoneLockEnvCaptured) { + s.hadMilestoneLockEnv = Object.hasOwn(process.env, "SF_MILESTONE_LOCK"); + s.previousMilestoneLockEnv = process.env.SF_MILESTONE_LOCK ?? null; + s.milestoneLockEnvCaptured = true; + } + if (milestoneId) { + process.env.SF_MILESTONE_LOCK = milestoneId; + } else { + delete process.env.SF_MILESTONE_LOCK; + } } function restoreMilestoneLockEnv() { - if (!s.milestoneLockEnvCaptured) - return; - if (s.hadMilestoneLockEnv && s.previousMilestoneLockEnv !== null) { - process.env.SF_MILESTONE_LOCK = s.previousMilestoneLockEnv; - } - else { - delete process.env.SF_MILESTONE_LOCK; - } - s.previousMilestoneLockEnv = null; - s.hadMilestoneLockEnv = false; - s.milestoneLockEnvCaptured = false; + if (!s.milestoneLockEnvCaptured) return; + if (s.hadMilestoneLockEnv && s.previousMilestoneLockEnv !== null) { + process.env.SF_MILESTONE_LOCK = s.previousMilestoneLockEnv; + } else { + delete process.env.SF_MILESTONE_LOCK; + } + s.previousMilestoneLockEnv = null; + s.hadMilestoneLockEnv = false; + s.milestoneLockEnvCaptured = false; } function normalizeSessionFilePath(raw) { - if (typeof raw !== "string") - return null; - const trimmed = raw.trim(); - if (!trimmed) - return null; - const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ""; - if (!firstLine) - return null; - // Guard against accidental message concatenation by trimming to .jsonl. - const jsonlIndex = firstLine.toLowerCase().indexOf(".jsonl"); - const candidate = jsonlIndex >= 0 - ? firstLine.slice(0, jsonlIndex + ".jsonl".length) - : firstLine; - if (!isAbsolute(candidate)) - return null; - if (!candidate.toLowerCase().endsWith(".jsonl")) - return null; - return candidate; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ""; + if (!firstLine) return null; + // Guard against accidental message concatenation by trimming to .jsonl. + const jsonlIndex = firstLine.toLowerCase().indexOf(".jsonl"); + const candidate = + jsonlIndex >= 0 + ? firstLine.slice(0, jsonlIndex + ".jsonl".length) + : firstLine; + if (!isAbsolute(candidate)) return null; + if (!candidate.toLowerCase().endsWith(".jsonl")) return null; + return candidate; } /** * Fire-and-forget wrapper around {@link startAuto} for the interactive shell. @@ -173,19 +288,18 @@ function normalizeSessionFilePath(raw) { * @param options Optional run modifiers — see {@link startAuto} */ export function startAutoDetached(ctx, pi, base, verboseMode, options) { - void startAuto(ctx, pi, base, verboseMode, options).catch((err) => { - const message = getErrorMessage(err); - ctx.ui.notify(`Auto-start failed: ${message}`, "error"); - logWarning("engine", `auto start error: ${message}`, { file: "auto.ts" }); - debugLog("auto-start-failed", { error: message }); - }); + void startAuto(ctx, pi, base, verboseMode, options).catch((err) => { + const message = getErrorMessage(err); + ctx.ui.notify(`Auto-start failed: ${message}`, "error"); + logWarning("engine", `auto start error: ${message}`, { file: "auto.ts" }); + debugLog("auto-start-failed", { error: message }); + }); } export function shouldUseWorktreeIsolation() { - const prefs = loadEffectiveSFPreferences()?.preferences?.git; - if (prefs?.isolation === "worktree") - return true; - // Default is false — worktree isolation requires explicit opt-in - return false; + const prefs = loadEffectiveSFPreferences()?.preferences?.git; + if (prefs?.isolation === "worktree") return true; + // Default is false — worktree isolation requires explicit opt-in + return false; } /** Crash recovery prompt — set by startAuto, consumed by the main loop */ /** Pending verification retry — set when gate fails with retries remaining, consumed by autoLoop */ @@ -213,92 +327,107 @@ export function shouldUseWorktreeIsolation() { * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open). */ // Re-export budget utilities for external consumers -export { getBudgetAlertLevel, getBudgetEnforcementAction, getNewBudgetAlertLevel, } from "./auto-budget.js"; +export { + getBudgetAlertLevel, + getBudgetEnforcementAction, + getNewBudgetAlertLevel, +} from "./auto-budget.js"; + /** Wrapper: register SIGTERM handler and store reference. */ function registerSigtermHandler(currentBasePath) { - const prefs = loadEffectiveSFPreferences()?.preferences; - const flags = resolveUokFlags(prefs); - const pathLabel = flags.legacyFallback - ? "legacy-fallback" - : flags.enabled - ? "uok-kernel" - : "legacy-wrapper"; - const onSignal = () => { - // Write UOK parity exit heartbeat before process.exit(0) bypasses - // the finally block in runAutoLoopWithUok. Fixes the enter/exit - // mismatch that occurs when auto-mode terminates via signal. - writeParityHeartbeat(currentBasePath, { - ts: new Date().toISOString(), - path: pathLabel, - flags: { ...flags }, - phase: "exit", - status: "signal", - }); - writeParityReport(currentBasePath); - }; - s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler, onSignal); + const prefs = loadEffectiveSFPreferences()?.preferences; + const flags = resolveUokFlags(prefs); + const pathLabel = flags.legacyFallback + ? "legacy-fallback" + : flags.enabled + ? "uok-kernel" + : "legacy-wrapper"; + const onSignal = () => { + // Write UOK parity exit heartbeat before process.exit(0) bypasses + // the finally block in runAutoLoopWithUok. Fixes the enter/exit + // mismatch that occurs when auto-mode terminates via signal. + writeParityHeartbeat(currentBasePath, { + ts: new Date().toISOString(), + path: pathLabel, + flags: { ...flags }, + phase: "exit", + status: "signal", + }); + writeParityReport(currentBasePath); + }; + s.sigtermHandler = _registerSigtermHandler( + currentBasePath, + s.sigtermHandler, + onSignal, + ); } /** Wrapper: deregister SIGTERM handler and clear reference. */ function deregisterSigtermHandler() { - _deregisterSigtermHandler(s.sigtermHandler); - s.sigtermHandler = null; + _deregisterSigtermHandler(s.sigtermHandler); + s.sigtermHandler = null; } export function getAutoDashboardData() { - const ledger = getLedger(); - const totals = ledger ? getProjectTotals(ledger.units) : null; - const sessionId = s.cmdCtx?.sessionManager?.getSessionId?.() ?? null; - const rtkSavings = sessionId && s.basePath - ? getRtkSessionSavings(s.basePath, sessionId) - : null; - const rtkEnabled = loadEffectiveSFPreferences()?.preferences.experimental?.rtk === true; - // Pending capture count — lazy check, non-fatal - let pendingCaptureCount = 0; - try { - if (s.basePath) { - pendingCaptureCount = countPendingCaptures(s.basePath); - } - } - catch (err) { - // Non-fatal — captures module may not be loaded - logWarning("engine", `capture count failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - return { - active: s.active, - paused: s.paused, - stepMode: s.stepMode, - startTime: s.autoStartTime, - elapsed: s.active || s.paused - ? s.autoStartTime > 0 - ? Date.now() - s.autoStartTime - : 0 - : 0, - currentUnit: s.currentUnit ? { ...s.currentUnit } : null, - basePath: s.basePath, - totalCost: totals?.cost ?? 0, - totalTokens: totals?.tokens.total ?? 0, - pendingCaptureCount, - rtkSavings, - rtkEnabled, - }; + const ledger = getLedger(); + const totals = ledger ? getProjectTotals(ledger.units) : null; + const sessionId = s.cmdCtx?.sessionManager?.getSessionId?.() ?? null; + const rtkSavings = + sessionId && s.basePath + ? getRtkSessionSavings(s.basePath, sessionId) + : null; + const rtkEnabled = + loadEffectiveSFPreferences()?.preferences.experimental?.rtk === true; + // Pending capture count — lazy check, non-fatal + let pendingCaptureCount = 0; + try { + if (s.basePath) { + pendingCaptureCount = countPendingCaptures(s.basePath); + } + } catch (err) { + // Non-fatal — captures module may not be loaded + logWarning( + "engine", + `capture count failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + return { + active: s.active, + paused: s.paused, + stepMode: s.stepMode, + startTime: s.autoStartTime, + elapsed: + s.active || s.paused + ? s.autoStartTime > 0 + ? Date.now() - s.autoStartTime + : 0 + : 0, + currentUnit: s.currentUnit ? { ...s.currentUnit } : null, + basePath: s.basePath, + totalCost: totals?.cost ?? 0, + totalTokens: totals?.tokens.total ?? 0, + pendingCaptureCount, + rtkSavings, + rtkEnabled, + }; } // ─── Public API ─────────────────────────────────────────────────────────────── export function isAutoActive() { - return s.active; + return s.active; } export function isAutoPaused() { - return s.paused; + return s.paused; } export function getAutoCommandContext() { - return s.cmdCtx; + return s.cmdCtx; } export function setActiveEngineId(id) { - s.activeEngineId = id; + s.activeEngineId = id; } export function getActiveEngineId() { - return s.activeEngineId; + return s.activeEngineId; } export function setActiveRunDir(runDir) { - s.activeRunDir = runDir; + s.activeRunDir = runDir; } /** * Return the model captured at auto-mode start for this session. @@ -306,7 +435,7 @@ export function setActiveRunDir(runDir) { * instead of reading (potentially stale) preferences from disk (#1065). */ export function getAutoModeStartModel() { - return s.autoModeStartModel; + return s.autoModeStartModel; } /** * Update the dashboard-facing dispatched model label. @@ -314,7 +443,7 @@ export function getAutoModeStartModel() { * so the AUTO box reflects the active model immediately. */ export function setCurrentDispatchedModelId(model) { - s.currentDispatchedModelId = model ? `${model.provider}/${model.id}` : null; + s.currentDispatchedModelId = model ? `${model.provider}/${model.id}` : null; } /** * Update the concrete model tracked for the currently running unit. @@ -326,8 +455,8 @@ export function setCurrentDispatchedModelId(model) { * is successfully applied. */ export function setCurrentUnitModel(model) { - s.currentUnitModel = model; - setCurrentDispatchedModelId(model); + s.currentUnitModel = model; + setCurrentDispatchedModelId(model); } /** * Record that a provider/model route failed for the current auto unit. @@ -339,16 +468,15 @@ export function setCurrentUnitModel(model) { * fallback route. */ export function recordCurrentModelFailure(input) { - if (!s.currentUnit) - return; - s.modelFailures.push({ - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - provider: input.provider, - modelId: input.modelId, - reason: input.reason, - timestamp: input.timestamp ?? Date.now(), - }); + if (!s.currentUnit) return; + s.modelFailures.push({ + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + provider: input.provider, + modelId: input.modelId, + reason: input.reason, + timestamp: input.timestamp ?? Date.now(), + }); } /** * Return model failures scoped to the currently running auto unit. @@ -360,10 +488,12 @@ export function recordCurrentModelFailure(input) { * fallback route. */ export function getCurrentUnitModelFailures() { - if (!s.currentUnit) - return []; - return s.modelFailures.filter((failure) => failure.unitType === s.currentUnit?.type && - failure.unitId === s.currentUnit?.id); + if (!s.currentUnit) return []; + return s.modelFailures.filter( + (failure) => + failure.unitType === s.currentUnit?.type && + failure.unitId === s.currentUnit?.id, + ); } /** * Mark the current research unit as terminal after saving its RESEARCH artifact. @@ -373,7 +503,7 @@ export function getCurrentUnitModelFailures() { * Consumer: register-hooks tool_result handling for sf_summary_save. */ export function markResearchTerminalTransition() { - getAutoSession().researchTerminalTransition = true; + getAutoSession().researchTerminalTransition = true; } /** * Return whether the current unit has already crossed its research terminal transition. @@ -383,23 +513,21 @@ export function markResearchTerminalTransition() { * Consumer: register-hooks tool_call enforcement for research units. */ export function hasResearchTerminalTransition() { - return getAutoSession().researchTerminalTransition; + return getAutoSession().researchTerminalTransition; } // Tool tracking — delegates to auto-tool-tracking.ts export function markToolStart(toolCallId, toolName) { - _markToolStart(toolCallId, s.active, toolName); + _markToolStart(toolCallId, s.active, toolName); } export function markToolEnd(toolCallId) { - _markToolEnd(toolCallId); + _markToolEnd(toolCallId); } -const TASK_COMPLETE_TOOL_NAMES = new Set([ - "sf_task_complete", -]); +const TASK_COMPLETE_TOOL_NAMES = new Set(["sf_task_complete"]); function normalizeTaskCompleteFailure(errorMsg) { - return errorMsg - .replace(/^Error completing task:\s*/i, "") - .replace(/^sf_task_complete failed:\s*/i, "") - .trim(); + return errorMsg + .replace(/^Error completing task:\s*/i, "") + .replace(/^sf_task_complete failed:\s*/i, "") + .trim(); } /** * Record a tool invocation error on the current session (#2883). @@ -408,23 +536,22 @@ function normalizeTaskCompleteFailure(errorMsg) { * execution errors are tracked separately so the same task can retry in-flow. */ export function recordToolInvocationError(toolName, errorMsg) { - if (!s.active) - return; - if (TASK_COMPLETE_TOOL_NAMES.has(toolName)) { - const currentUnit = s.currentUnit; - if (currentUnit?.type === "execute-task") { - s.lastTaskCompleteFailure = { - unitId: currentUnit.id, - reason: normalizeTaskCompleteFailure(errorMsg), - }; - } - } - if (isToolInvocationError(errorMsg) || isQueuedUserMessageSkip(errorMsg)) { - s.lastToolInvocationError = `${toolName}: ${errorMsg}`; - } + if (!s.active) return; + if (TASK_COMPLETE_TOOL_NAMES.has(toolName)) { + const currentUnit = s.currentUnit; + if (currentUnit?.type === "execute-task") { + s.lastTaskCompleteFailure = { + unitId: currentUnit.id, + reason: normalizeTaskCompleteFailure(errorMsg), + }; + } + } + if (isToolInvocationError(errorMsg) || isQueuedUserMessageSkip(errorMsg)) { + s.lastToolInvocationError = `${toolName}: ${errorMsg}`; + } } export function getOldestInFlightToolAgeMs() { - return _getOldestInFlightToolAgeMs(); + return _getOldestInFlightToolAgeMs(); } /** * Return the base path to use for the auto.lock file. @@ -434,7 +561,7 @@ export function getOldestInFlightToolAgeMs() { * Delegates to AutoSession.lockBasePath — the single source of truth. */ function lockBase() { - return s.lockBasePath; + return s.lockBasePath; } /** * Attempt to stop a running auto-mode session from a different process. @@ -444,28 +571,26 @@ function lockBase() { * Returns true if a remote session was found and signaled, false otherwise. */ export function stopAutoRemote(projectRoot) { - const lock = readCrashLock(projectRoot); - if (!lock) - return { found: false }; - // Never SIGTERM ourselves — a stale lock with our own PID is not a remote - // session, it is leftover from a prior loop exit in this process. (#2730) - if (lock.pid === process.pid) { - clearLock(projectRoot); - return { found: false }; - } - if (!isLockProcessAlive(lock)) { - // Stale lock — clean it up - clearLock(projectRoot); - return { found: false }; - } - // Send SIGTERM — the auto-mode process has a handler that clears the lock and exits - try { - process.kill(lock.pid, "SIGTERM"); - return { found: true, pid: lock.pid }; - } - catch (err) { - return { found: false, error: err.message }; - } + const lock = readCrashLock(projectRoot); + if (!lock) return { found: false }; + // Never SIGTERM ourselves — a stale lock with our own PID is not a remote + // session, it is leftover from a prior loop exit in this process. (#2730) + if (lock.pid === process.pid) { + clearLock(projectRoot); + return { found: false }; + } + if (!isLockProcessAlive(lock)) { + // Stale lock — clean it up + clearLock(projectRoot); + return { found: false }; + } + // Send SIGTERM — the auto-mode process has a handler that clears the lock and exits + try { + process.kill(lock.pid, "SIGTERM"); + return { found: true, pid: lock.pid }; + } catch (err) { + return { found: false, error: err.message }; + } } /** * Check if a remote auto-mode session is running (from a different process). @@ -474,105 +599,103 @@ export function stopAutoRemote(projectRoot) { * /sf auto from stealing the session lock. */ export function checkRemoteAutoSession(projectRoot) { - const lock = readCrashLock(projectRoot); - if (!lock) - return { running: false }; - // Our own PID is not a "remote" session — it is a stale lock left by this - // process (e.g. after step-mode exit without full cleanup). (#2730) - if (lock.pid === process.pid) - return { running: false }; - if (!isLockProcessAlive(lock)) { - // Stale lock from a dead process — not a live remote session - return { running: false }; - } - return { - running: true, - pid: lock.pid, - unitType: lock.unitType, - unitId: lock.unitId, - startedAt: lock.startedAt, - }; + const lock = readCrashLock(projectRoot); + if (!lock) return { running: false }; + // Our own PID is not a "remote" session — it is a stale lock left by this + // process (e.g. after step-mode exit without full cleanup). (#2730) + if (lock.pid === process.pid) return { running: false }; + if (!isLockProcessAlive(lock)) { + // Stale lock from a dead process — not a live remote session + return { running: false }; + } + return { + running: true, + pid: lock.pid, + unitType: lock.unitType, + unitId: lock.unitId, + startedAt: lock.startedAt, + }; } export function isStepMode() { - return s.stepMode; + return s.stepMode; } /** Returns true when the agent is allowed to call ask_user_questions. */ export function isCanAskUser() { - return s.canAskUser; + return s.canAskUser; } function clearUnitTimeout() { - if (s.unitTimeoutHandle) { - clearTimeout(s.unitTimeoutHandle); - s.unitTimeoutHandle = null; - } - if (s.wrapupWarningHandle) { - clearTimeout(s.wrapupWarningHandle); - s.wrapupWarningHandle = null; - } - if (s.idleWatchdogHandle) { - clearInterval(s.idleWatchdogHandle); - s.idleWatchdogHandle = null; - } - if (s.continueHereHandle) { - clearInterval(s.continueHereHandle); - s.continueHereHandle = null; - } - clearInFlightTools(); + if (s.unitTimeoutHandle) { + clearTimeout(s.unitTimeoutHandle); + s.unitTimeoutHandle = null; + } + if (s.wrapupWarningHandle) { + clearTimeout(s.wrapupWarningHandle); + s.wrapupWarningHandle = null; + } + if (s.idleWatchdogHandle) { + clearInterval(s.idleWatchdogHandle); + s.idleWatchdogHandle = null; + } + if (s.continueHereHandle) { + clearInterval(s.continueHereHandle); + s.continueHereHandle = null; + } + clearInFlightTools(); } /** Build snapshot metric opts. */ function buildSnapshotOpts(_unitType, _unitId) { - const prefs = loadEffectiveSFPreferences()?.preferences; - const uokFlags = resolveUokFlags(prefs); - return { - ...(s.autoStartTime > 0 ? { autoSessionKey: String(s.autoStartTime) } : {}), - promptCharCount: s.lastPromptCharCount, - baselineCharCount: s.lastBaselineCharCount, - traceId: s.currentTraceId ?? undefined, - turnId: s.currentTurnId ?? undefined, - ...(uokFlags.gitops - ? { - gitAction: uokFlags.gitopsTurnAction, - gitPush: uokFlags.gitopsTurnPush, - gitStatus: s.lastGitActionStatus ?? undefined, - gitError: s.lastGitActionFailure ?? undefined, - } - : {}), - ...(s.currentUnitRouting ?? {}), - }; + const prefs = loadEffectiveSFPreferences()?.preferences; + const uokFlags = resolveUokFlags(prefs); + return { + ...(s.autoStartTime > 0 ? { autoSessionKey: String(s.autoStartTime) } : {}), + promptCharCount: s.lastPromptCharCount, + baselineCharCount: s.lastBaselineCharCount, + traceId: s.currentTraceId ?? undefined, + turnId: s.currentTurnId ?? undefined, + ...(uokFlags.gitops + ? { + gitAction: uokFlags.gitopsTurnAction, + gitPush: uokFlags.gitopsTurnPush, + gitStatus: s.lastGitActionStatus ?? undefined, + gitError: s.lastGitActionFailure ?? undefined, + } + : {}), + ...(s.currentUnitRouting ?? {}), + }; } function handleLostSessionLock(ctx, lockStatus) { - debugLog("session-lock-lost", { - lockBase: lockBase(), - reason: lockStatus?.failureReason, - existingPid: lockStatus?.existingPid, - expectedPid: lockStatus?.expectedPid, - }); - s.active = false; - s.paused = false; - deactivateSF(); - clearUnitTimeout(); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - deregisterSigtermHandler(); - clearCmuxSidebar(loadEffectiveSFPreferences()?.preferences); - const base = lockBase(); - const lockFilePath = base ? join(sfRoot(base), "auto.lock") : "unknown"; - const recoverySuggestion = "\nTo recover, run: sf doctor --fix"; - const message = lockStatus?.failureReason === "pid-mismatch" - ? lockStatus.existingPid - ? `Session lock (${lockFilePath}) moved to PID ${lockStatus.existingPid} — another SF process appears to have taken over. Stopping gracefully.${recoverySuggestion}` - : `Session lock (${lockFilePath}) moved to a different process — another SF process appears to have taken over. Stopping gracefully.${recoverySuggestion}` - : lockStatus?.failureReason === "missing-metadata" - ? `Session lock metadata (${lockFilePath}) disappeared, so ownership could not be confirmed. Stopping gracefully.${recoverySuggestion}` - : lockStatus?.failureReason === "compromised" - ? `Session lock (${lockFilePath}) was compromised during heartbeat checks (PID ${process.pid}). This can happen after long event loop stalls during subagent execution.${recoverySuggestion}` - : `Session lock lost (${lockFilePath}). Stopping gracefully.${recoverySuggestion}`; - ctx?.ui.notify(message, "error"); - ctx?.ui.setStatus("sf-auto", undefined); - ctx?.ui.setWidget("sf-progress", undefined); - ctx?.ui.setFooter(undefined); - if (ctx) - initHealthWidget(ctx); + debugLog("session-lock-lost", { + lockBase: lockBase(), + reason: lockStatus?.failureReason, + existingPid: lockStatus?.existingPid, + expectedPid: lockStatus?.expectedPid, + }); + s.active = false; + s.paused = false; + deactivateSF(); + clearUnitTimeout(); + restoreProjectRootEnv(); + restoreMilestoneLockEnv(); + deregisterSigtermHandler(); + clearCmuxSidebar(loadEffectiveSFPreferences()?.preferences); + const base = lockBase(); + const lockFilePath = base ? join(sfRoot(base), "auto.lock") : "unknown"; + const recoverySuggestion = "\nTo recover, run: sf doctor --fix"; + const message = + lockStatus?.failureReason === "pid-mismatch" + ? lockStatus.existingPid + ? `Session lock (${lockFilePath}) moved to PID ${lockStatus.existingPid} — another SF process appears to have taken over. Stopping gracefully.${recoverySuggestion}` + : `Session lock (${lockFilePath}) moved to a different process — another SF process appears to have taken over. Stopping gracefully.${recoverySuggestion}` + : lockStatus?.failureReason === "missing-metadata" + ? `Session lock metadata (${lockFilePath}) disappeared, so ownership could not be confirmed. Stopping gracefully.${recoverySuggestion}` + : lockStatus?.failureReason === "compromised" + ? `Session lock (${lockFilePath}) was compromised during heartbeat checks (PID ${process.pid}). This can happen after long event loop stalls during subagent execution.${recoverySuggestion}` + : `Session lock lost (${lockFilePath}). Stopping gracefully.${recoverySuggestion}`; + ctx?.ui.notify(message, "error"); + ctx?.ui.setStatus("sf-auto", undefined); + ctx?.ui.setWidget("sf-progress", undefined); + ctx?.ui.setFooter(undefined); + if (ctx) initHealthWidget(ctx); } /** * Lightweight cleanup after autoLoop exits via step-wizard break. @@ -582,403 +705,420 @@ function handleLostSessionLock(ctx, lockStatus) { * the dashboard does not show an orphaned timer and the shell is usable. */ function cleanupAfterLoopExit(ctx) { - s.currentUnit = null; - s.active = false; - deactivateSF(); - clearUnitTimeout(); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - // Clear crash lock and release session lock so the next `/sf next` does - // not see a stale lock with the current PID and treat it as a "remote" - // session (which would cause it to SIGTERM itself). (#2730) - try { - if (lockBase()) - clearLock(lockBase()); - if (lockBase()) - releaseSessionLock(lockBase()); - } - catch (err) { - /* best-effort — mirror stopAuto cleanup */ - logWarning("session", `lock cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - // A transient provider-error pause intentionally leaves the paused badge - // visible so the user still has a resumable auto-mode signal on screen. - if (!s.paused) { - ctx.ui.setStatus("sf-auto", undefined); - ctx.ui.setWidget("sf-progress", undefined); - ctx.ui.setFooter(undefined); - initHealthWidget(ctx); - } - // Restore CWD out of worktree back to original project root - if (s.originalBasePath) { - s.basePath = s.originalBasePath; - try { - process.chdir(s.basePath); - } - catch (err) { - /* best-effort */ - logWarning("engine", `chdir failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - } + s.currentUnit = null; + s.active = false; + deactivateSF(); + clearUnitTimeout(); + restoreProjectRootEnv(); + restoreMilestoneLockEnv(); + // Clear crash lock and release session lock so the next `/sf next` does + // not see a stale lock with the current PID and treat it as a "remote" + // session (which would cause it to SIGTERM itself). (#2730) + try { + if (lockBase()) clearLock(lockBase()); + if (lockBase()) releaseSessionLock(lockBase()); + } catch (err) { + /* best-effort — mirror stopAuto cleanup */ + logWarning( + "session", + `lock cleanup failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + // A transient provider-error pause intentionally leaves the paused badge + // visible so the user still has a resumable auto-mode signal on screen. + if (!s.paused) { + ctx.ui.setStatus("sf-auto", undefined); + ctx.ui.setWidget("sf-progress", undefined); + ctx.ui.setFooter(undefined); + initHealthWidget(ctx); + } + // Restore CWD out of worktree back to original project root + if (s.originalBasePath) { + s.basePath = s.originalBasePath; + try { + process.chdir(s.basePath); + } catch (err) { + /* best-effort */ + logWarning( + "engine", + `chdir failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + } } export async function stopAuto(ctx, pi, reason) { - if (!s.active && !s.paused) - return; - const loadedPreferences = loadEffectiveSFPreferences()?.preferences; - const reasonSuffix = reason ? ` — ${reason}` : ""; - try { - // ── Step 1: Timers and locks ── - try { - clearUnitTimeout(); - if (lockBase()) - clearLock(lockBase()); - if (lockBase()) - releaseSessionLock(lockBase()); - } - catch (e) { - debugLog("stop-cleanup-locks", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 1b: Flush queued follow-up messages (#3512) ── - // Late async notifications (async_job_result, sf-auto-wrapup) can trigger - // extra LLM turns after stop. Flush them the same way run-unit.ts does. - try { - const cmdCtxAny = s.cmdCtx; - if (typeof cmdCtxAny?.clearQueue === "function") { - cmdCtxAny.clearQueue(); - } - } - catch (e) { - debugLog("stop-cleanup-queue", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 2: Skill state ── - try { - clearSkillSnapshot(); - resetSkillTelemetry(); - } - catch (e) { - debugLog("stop-cleanup-skills", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 3: SIGTERM handler ── - try { - deregisterSigtermHandler(); - } - catch (e) { - debugLog("stop-cleanup-sigterm", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 4: Auto-worktree exit ── - // When the milestone is complete (has a SUMMARY), merge the worktree branch - // back to main so code isn't stranded on the worktree branch (#2317). - // For incomplete milestones, preserve the branch for later resumption. - // - // Skip if phases.ts already merged this milestone — avoids the double - // mergeAndExit that fails because the branch was already deleted (#2645). - try { - if (s.currentMilestoneId && !s.milestoneMergedInPhases) { - const notifyCtx = ctx - ? { notify: ctx.ui.notify.bind(ctx.ui) } - : { notify: () => { } }; - const resolver = buildResolver(); - // Check if the milestone is complete. DB status is the authoritative - // signal — only a successful sf_complete_milestone call flips it to - // "complete" (tools/complete-milestone.ts). SUMMARY file presence is - // NOT sufficient: a blocker placeholder stub or a partial write can - // leave a file behind without the milestone actually being done, - // which previously caused stopAuto to merge a failed milestone and - // emit a misleading metadata-only merge warning (#4175). - // DB-unavailable projects fall back to SUMMARY-file presence. - let milestoneComplete = false; - try { - if (isDbAvailable()) { - const dbRow = getMilestone(s.currentMilestoneId); - milestoneComplete = dbRow?.status === "complete"; - } - else { - const summaryPath = resolveMilestoneFile(s.originalBasePath || s.basePath, s.currentMilestoneId, "SUMMARY"); - if (!summaryPath) { - // Also check in the worktree path (SUMMARY may not be synced yet) - const wtSummaryPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "SUMMARY"); - milestoneComplete = wtSummaryPath !== null; - } - else { - milestoneComplete = true; - } - } - } - catch (err) { - // Non-fatal — fall through to preserveBranch path - logWarning("engine", `milestone summary check failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - if (milestoneComplete) { - // Milestone is complete — merge worktree branch back to main - resolver.mergeAndExit(s.currentMilestoneId, notifyCtx); - } - else { - // Milestone still in progress — preserve branch for later resumption - resolver.exitMilestone(s.currentMilestoneId, notifyCtx, { - preserveBranch: true, - }); - } - } - } - catch (e) { - debugLog("stop-cleanup-worktree", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 5: Rebuild state while DB is still open (#3599) ── - // rebuildState() calls deriveState() which needs the DB for authoritative - // state. Previously this ran after closeDatabase(), forcing a filesystem - // fallback that could disagree with the DB-backed dispatch decisions — - // a split-brain where dispatch says "blocked" but STATE.md shows work. - if (s.basePath) { - try { - await rebuildState(s.basePath); - } - catch (e) { - debugLog("stop-rebuild-state-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - } - // ── Step 6: DB cleanup ── - if (isDbAvailable()) { - try { - const { closeDatabase } = await import("./sf-db.js"); - closeDatabase(); - } - catch (e) { - debugLog("db-close-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - } - // ── Step 7: Restore basePath and chdir ── - try { - if (s.originalBasePath) { - s.basePath = s.originalBasePath; - try { - process.chdir(s.basePath); - } - catch (err) { - /* best-effort */ - logWarning("engine", `chdir failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - } - } - catch (e) { - debugLog("stop-cleanup-basepath", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 7b: Scaffold-keeper dispatch (ADR-021 Phase D) ── - // At session close, detect editing-drift docs and stage `.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 7c: Record-promoter dispatch (ADR-021 Phase D) ── - // At session close, scan docs/records/ for newly-actionable records and - // auto-promote them to milestone backlog. Fire-and-forget — must not - // block the cleanup path or break the stop sequence on failure. - try { - if (ctx && s.basePath) { - const { dispatchRecordPromoterFireAndForget } = await import("./record-promoter.js"); - dispatchRecordPromoterFireAndForget(s.basePath, ctx); - } - } - catch (e) { - debugLog("stop-cleanup-record-promoter", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 8: Ledger notification ── - try { - // Tag with structured metadata so headless-events.ts classifies via - // metadata.kind rather than text matching. blocking=true when the - // stop reason includes "blocked" (e.g. write-gate, guardrail block). - const isBlocked = reason !== undefined && reason.toLowerCase().includes("block"); - const stopMeta = { - kind: "terminal", - ...(isBlocked ? { blocking: true } : {}), - source: "workflow", - }; - const ledger = getLedger(); - if (ledger && ledger.units.length > 0) { - const totals = getProjectTotals(ledger.units); - ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`, "info", stopMeta); - } - else { - ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info", stopMeta); - } - } - catch (e) { - debugLog("stop-cleanup-ledger", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 9: Cmux sidebar / event log ── - try { - clearCmuxSidebar(loadedPreferences); - logCmuxEvent(loadedPreferences, `Auto-mode stopped${reasonSuffix || ""}.`, reason?.startsWith("Blocked:") ? "warning" : "info"); - } - catch (e) { - debugLog("stop-cleanup-cmux", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 10: Debug summary ── - try { - if (isDebugEnabled()) { - const logPath = writeDebugSummary(); - if (logPath) { - ctx?.ui.notify(`Debug log written → ${logPath}`, "info"); - } - } - } - catch (e) { - debugLog("stop-cleanup-debug", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 11: Reset metrics, routing, hooks ── - try { - resetMetrics(); - resetRoutingHistory(); - resetHookState(); - if (s.basePath) - clearPersistedHookState(s.basePath); - } - catch (e) { - debugLog("stop-cleanup-metrics", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 12: Remove paused-session metadata (#1383) ── - try { - const pausedPath = join(sfRoot(s.originalBasePath || s.basePath), "runtime", "paused-session.json"); - if (existsSync(pausedPath)) - unlinkSync(pausedPath); - } - catch (err) { - /* non-fatal */ - logWarning("engine", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - // ── Step 13: Restore original model (before reset clears IDs) ── - try { - if (pi && ctx && s.originalModelId && s.originalModelProvider) { - const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId); - if (original) - await pi.setModel(original); - } - } - catch (e) { - debugLog("stop-cleanup-model", { - error: e instanceof Error ? e.message : String(e), - }); - } - // ── Step 14: Unblock pending unitPromise (#1799) ── - // resolveAgentEnd unblocks autoLoop's `await unitPromise` so it can see - // s.active === false and exit cleanly. Without this, autoLoop hangs - // forever and the interactive loop is blocked. - try { - resolveAgentEnd({ messages: [] }); - _resetPendingResolve(); - } - catch (e) { - debugLog("stop-cleanup-pending-resolve", { - error: e instanceof Error ? e.message : String(e), - }); - } - } - finally { - // ── Critical invariants: these MUST execute regardless of errors ── - // Browser teardown — prevent orphaned Chrome processes across retries (#1733) - try { - const { getBrowser } = await import("../browser-tools/state.js"); - if (getBrowser()) { - const { closeBrowser } = await import("../browser-tools/lifecycle.js"); - await closeBrowser(); - } - } - catch (err) { - /* non-fatal: browser-tools may not be loaded */ - logWarning("engine", `browser teardown failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - // External cleanup (not covered by session reset) - clearInFlightTools(); - clearSliceProgressCache(); - clearActivityLogState(); - setLevelChangeCallback(null); - resetProactiveHealing(); - // UI cleanup - ctx?.ui.setStatus("sf-auto", undefined); - ctx?.ui.setWidget("sf-progress", undefined); - ctx?.ui.setFooter(undefined); - if (ctx) - initHealthWidget(ctx); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - // #4764 — telemetry: record the exit reason and whether the current milestone - // was merged before we entered stopAuto. This is the producer-side signal for - // the #4761 orphan class: milestoneMerged=false + currentMilestoneId present - // is exactly the pattern that strands work. - try { - const { emitAutoExit } = await import("./worktree-telemetry.js"); - // Normalize the free-form reason to a closed set so the telemetry - // aggregator buckets stably. Raw detail is preserved in the phases.ts - // notification and the notify'd error string. - const rawReason = reason ?? "stop"; - const normalizedReason = rawReason.startsWith("Blocked:") - ? "blocked" - : rawReason.startsWith("Merge conflict") - ? "merge-conflict" - : rawReason.startsWith("Merge error") || - rawReason.startsWith("Merge failed") - ? "merge-failed" - : rawReason.startsWith("slice-merge-conflict") - ? "slice-merge-conflict" - : rawReason === "All milestones complete" - ? "all-complete" - : rawReason === "No active milestone" - ? "no-active-milestone" - : rawReason === "stop" || rawReason === "pause" - ? rawReason - : "other"; - emitAutoExit(s.originalBasePath || s.basePath, { - reason: normalizedReason, - milestoneId: s.currentMilestoneId ?? undefined, - milestoneMerged: s.milestoneMergedInPhases === true, - }); - } - catch (err) { - logWarning("engine", `auto-exit telemetry failed: ${err instanceof Error ? err.message : String(err)}`); - } - // Drop the active-tool baseline so a subsequent /sf auto run on the - // same `pi` instance recaptures from the live tool set rather than - // restoring this session's snapshot and silently undoing any tool - // changes the user made between sessions (#4959 / CodeRabbit). - if (pi) - clearToolBaseline(pi); - // Reset all session state in one call - s.reset(); - } + if (!s.active && !s.paused) return; + const loadedPreferences = loadEffectiveSFPreferences()?.preferences; + const reasonSuffix = reason ? ` — ${reason}` : ""; + try { + // ── Step 1: Timers and locks ── + try { + clearUnitTimeout(); + if (lockBase()) clearLock(lockBase()); + if (lockBase()) releaseSessionLock(lockBase()); + } catch (e) { + debugLog("stop-cleanup-locks", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 1b: Flush queued follow-up messages (#3512) ── + // Late async notifications (async_job_result, sf-auto-wrapup) can trigger + // extra LLM turns after stop. Flush them the same way run-unit.ts does. + try { + const cmdCtxAny = s.cmdCtx; + if (typeof cmdCtxAny?.clearQueue === "function") { + cmdCtxAny.clearQueue(); + } + } catch (e) { + debugLog("stop-cleanup-queue", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 2: Skill state ── + try { + clearSkillSnapshot(); + resetSkillTelemetry(); + } catch (e) { + debugLog("stop-cleanup-skills", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 3: SIGTERM handler ── + try { + deregisterSigtermHandler(); + } catch (e) { + debugLog("stop-cleanup-sigterm", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 4: Auto-worktree exit ── + // When the milestone is complete (has a SUMMARY), merge the worktree branch + // back to main so code isn't stranded on the worktree branch (#2317). + // For incomplete milestones, preserve the branch for later resumption. + // + // Skip if phases.ts already merged this milestone — avoids the double + // mergeAndExit that fails because the branch was already deleted (#2645). + try { + if (s.currentMilestoneId && !s.milestoneMergedInPhases) { + const notifyCtx = ctx + ? { notify: ctx.ui.notify.bind(ctx.ui) } + : { notify: () => {} }; + const resolver = buildResolver(); + // Check if the milestone is complete. DB status is the authoritative + // signal — only a successful sf_complete_milestone call flips it to + // "complete" (tools/complete-milestone.ts). SUMMARY file presence is + // NOT sufficient: a blocker placeholder stub or a partial write can + // leave a file behind without the milestone actually being done, + // which previously caused stopAuto to merge a failed milestone and + // emit a misleading metadata-only merge warning (#4175). + // DB-unavailable projects fall back to SUMMARY-file presence. + let milestoneComplete = false; + try { + if (isDbAvailable()) { + const dbRow = getMilestone(s.currentMilestoneId); + milestoneComplete = dbRow?.status === "complete"; + } else { + const summaryPath = resolveMilestoneFile( + s.originalBasePath || s.basePath, + s.currentMilestoneId, + "SUMMARY", + ); + if (!summaryPath) { + // Also check in the worktree path (SUMMARY may not be synced yet) + const wtSummaryPath = resolveMilestoneFile( + s.basePath, + s.currentMilestoneId, + "SUMMARY", + ); + milestoneComplete = wtSummaryPath !== null; + } else { + milestoneComplete = true; + } + } + } catch (err) { + // Non-fatal — fall through to preserveBranch path + logWarning( + "engine", + `milestone summary check failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + if (milestoneComplete) { + // Milestone is complete — merge worktree branch back to main + resolver.mergeAndExit(s.currentMilestoneId, notifyCtx); + } else { + // Milestone still in progress — preserve branch for later resumption + resolver.exitMilestone(s.currentMilestoneId, notifyCtx, { + preserveBranch: true, + }); + } + } + } catch (e) { + debugLog("stop-cleanup-worktree", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 5: Rebuild state while DB is still open (#3599) ── + // rebuildState() calls deriveState() which needs the DB for authoritative + // state. Previously this ran after closeDatabase(), forcing a filesystem + // fallback that could disagree with the DB-backed dispatch decisions — + // a split-brain where dispatch says "blocked" but STATE.md shows work. + if (s.basePath) { + try { + await rebuildState(s.basePath); + } catch (e) { + debugLog("stop-rebuild-state-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + } + // ── Step 6: DB cleanup ── + if (isDbAvailable()) { + try { + const { closeDatabase } = await import("./sf-db.js"); + closeDatabase(); + } catch (e) { + debugLog("db-close-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + } + // ── Step 7: Restore basePath and chdir ── + try { + if (s.originalBasePath) { + s.basePath = s.originalBasePath; + try { + process.chdir(s.basePath); + } catch (err) { + /* best-effort */ + logWarning( + "engine", + `chdir failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + } + } catch (e) { + debugLog("stop-cleanup-basepath", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 7b: Scaffold-keeper dispatch (ADR-021 Phase D) ── + // At session close, detect editing-drift docs and stage `.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 7c: Record-promoter dispatch (ADR-021 Phase D) ── + // At session close, scan docs/records/ for newly-actionable records and + // auto-promote them to milestone backlog. Fire-and-forget — must not + // block the cleanup path or break the stop sequence on failure. + try { + if (ctx && s.basePath) { + const { dispatchRecordPromoterFireAndForget } = await import( + "./record-promoter.js" + ); + dispatchRecordPromoterFireAndForget(s.basePath, ctx); + } + } catch (e) { + debugLog("stop-cleanup-record-promoter", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 8: Ledger notification ── + try { + // Tag with structured metadata so headless-events.ts classifies via + // metadata.kind rather than text matching. blocking=true when the + // stop reason includes "blocked" (e.g. write-gate, guardrail block). + const isBlocked = + reason !== undefined && reason.toLowerCase().includes("block"); + const stopMeta = { + kind: "terminal", + ...(isBlocked ? { blocking: true } : {}), + source: "workflow", + }; + const ledger = getLedger(); + if (ledger && ledger.units.length > 0) { + const totals = getProjectTotals(ledger.units); + ctx?.ui.notify( + `Auto-mode stopped${reasonSuffix}. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`, + "info", + stopMeta, + ); + } else { + ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info", stopMeta); + } + } catch (e) { + debugLog("stop-cleanup-ledger", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 9: Cmux sidebar / event log ── + try { + clearCmuxSidebar(loadedPreferences); + logCmuxEvent( + loadedPreferences, + `Auto-mode stopped${reasonSuffix || ""}.`, + reason?.startsWith("Blocked:") ? "warning" : "info", + ); + } catch (e) { + debugLog("stop-cleanup-cmux", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 10: Debug summary ── + try { + if (isDebugEnabled()) { + const logPath = writeDebugSummary(); + if (logPath) { + ctx?.ui.notify(`Debug log written → ${logPath}`, "info"); + } + } + } catch (e) { + debugLog("stop-cleanup-debug", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 11: Reset metrics, routing, hooks ── + try { + resetMetrics(); + resetRoutingHistory(); + resetHookState(); + if (s.basePath) clearPersistedHookState(s.basePath); + } catch (e) { + debugLog("stop-cleanup-metrics", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 12: Remove paused-session metadata (#1383) ── + try { + const pausedPath = join( + sfRoot(s.originalBasePath || s.basePath), + "runtime", + "paused-session.json", + ); + if (existsSync(pausedPath)) unlinkSync(pausedPath); + } catch (err) { + /* non-fatal */ + logWarning( + "engine", + `file unlink failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + // ── Step 13: Restore original model (before reset clears IDs) ── + try { + if (pi && ctx && s.originalModelId && s.originalModelProvider) { + const original = ctx.modelRegistry.find( + s.originalModelProvider, + s.originalModelId, + ); + if (original) await pi.setModel(original); + } + } catch (e) { + debugLog("stop-cleanup-model", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 14: Unblock pending unitPromise (#1799) ── + // resolveAgentEnd unblocks autoLoop's `await unitPromise` so it can see + // s.active === false and exit cleanly. Without this, autoLoop hangs + // forever and the interactive loop is blocked. + try { + resolveAgentEnd({ messages: [] }); + _resetPendingResolve(); + } catch (e) { + debugLog("stop-cleanup-pending-resolve", { + error: e instanceof Error ? e.message : String(e), + }); + } + } finally { + // ── Critical invariants: these MUST execute regardless of errors ── + // Browser teardown — prevent orphaned Chrome processes across retries (#1733) + try { + const { getBrowser } = await import("../browser-tools/state.js"); + if (getBrowser()) { + const { closeBrowser } = await import("../browser-tools/lifecycle.js"); + await closeBrowser(); + } + } catch (err) { + /* non-fatal: browser-tools may not be loaded */ + logWarning( + "engine", + `browser teardown failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + // External cleanup (not covered by session reset) + clearInFlightTools(); + clearSliceProgressCache(); + clearActivityLogState(); + setLevelChangeCallback(null); + resetProactiveHealing(); + // UI cleanup + ctx?.ui.setStatus("sf-auto", undefined); + ctx?.ui.setWidget("sf-progress", undefined); + ctx?.ui.setFooter(undefined); + if (ctx) initHealthWidget(ctx); + restoreProjectRootEnv(); + restoreMilestoneLockEnv(); + // #4764 — telemetry: record the exit reason and whether the current milestone + // was merged before we entered stopAuto. This is the producer-side signal for + // the #4761 orphan class: milestoneMerged=false + currentMilestoneId present + // is exactly the pattern that strands work. + try { + const { emitAutoExit } = await import("./worktree-telemetry.js"); + // Normalize the free-form reason to a closed set so the telemetry + // aggregator buckets stably. Raw detail is preserved in the phases.ts + // notification and the notify'd error string. + const rawReason = reason ?? "stop"; + const normalizedReason = rawReason.startsWith("Blocked:") + ? "blocked" + : rawReason.startsWith("Merge conflict") + ? "merge-conflict" + : rawReason.startsWith("Merge error") || + rawReason.startsWith("Merge failed") + ? "merge-failed" + : rawReason.startsWith("slice-merge-conflict") + ? "slice-merge-conflict" + : rawReason === "All milestones complete" + ? "all-complete" + : rawReason === "No active milestone" + ? "no-active-milestone" + : rawReason === "stop" || rawReason === "pause" + ? rawReason + : "other"; + emitAutoExit(s.originalBasePath || s.basePath, { + reason: normalizedReason, + milestoneId: s.currentMilestoneId ?? undefined, + milestoneMerged: s.milestoneMergedInPhases === true, + }); + } catch (err) { + logWarning( + "engine", + `auto-exit telemetry failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // Drop the active-tool baseline so a subsequent /sf auto run on the + // same `pi` instance recaptures from the live tool set rather than + // restoring this session's snapshot and silently undoing any tool + // changes the user made between sessions (#4959 / CodeRabbit). + if (pi) clearToolBaseline(pi); + // Reset all session state in one call + s.reset(); + } } /** * Pause auto-mode without destroying state. Context is preserved. @@ -986,112 +1126,134 @@ export async function stopAuto(ctx, pi, reason) { * from disk state. Called when the user presses Escape during auto-mode. */ export async function pauseAuto(ctx, _pi, _errorContext) { - if (!s.active) - return; - clearUnitTimeout(); - // Flush queued follow-up messages (#3512). - // Late async notifications (async_job_result, sf-auto-wrapup) can trigger - // extra LLM turns after pause. Flush them the same way run-unit.ts does. - try { - const cmdCtxAny = s.cmdCtx; - if (typeof cmdCtxAny?.clearQueue === "function") { - cmdCtxAny.clearQueue(); - } - } - catch (e) { - debugLog("pause-cleanup-queue", { - error: e instanceof Error ? e.message : String(e), - }); - } - // Unblock any pending unit promise so the auto-loop is not orphaned. - // Pass errorContext so runUnitPhase can distinguish user-initiated pause - // from provider-error pause and avoid hard-stopping (#2762). - resolveAgentEndCancelled(_errorContext); - s.pausedSessionFile = normalizeSessionFilePath(ctx?.sessionManager?.getSessionFile() ?? null); - // Persist paused-session metadata so resume survives /exit (#1383). - // The fresh-start bootstrap checks for this file and restores worktree context. - try { - const pausedMeta = { - milestoneId: s.currentMilestoneId, - worktreePath: isInAutoWorktree(s.basePath) ? s.basePath : null, - originalBasePath: s.originalBasePath, - stepMode: s.stepMode, - pausedAt: new Date().toISOString(), - sessionFile: s.pausedSessionFile, - unitType: s.currentUnit?.type ?? undefined, - unitId: s.currentUnit?.id ?? undefined, - activeEngineId: s.activeEngineId, - activeRunDir: s.activeRunDir, - autoStartTime: s.autoStartTime, - milestoneLock: s.sessionMilestoneLock ?? undefined, - }; - const runtimeDir = join(sfRoot(s.originalBasePath || s.basePath), "runtime"); - mkdirSync(runtimeDir, { recursive: true }); - writeFileSync(join(runtimeDir, "paused-session.json"), JSON.stringify(pausedMeta, null, 2), "utf-8"); - } - catch (err) { - // Non-fatal — resume will still work via full bootstrap, just without worktree context - logWarning("engine", `paused-session file write failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - // Close out the current unit so its runtime record doesn't stay at "dispatched" - if (s.currentUnit && ctx) { - try { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } - catch (err) { - // Non-fatal — best-effort closeout on pause - logWarning("engine", `unit closeout on pause failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - s.currentUnit = null; - } - if (lockBase()) { - releaseSessionLock(lockBase()); - clearLock(lockBase()); - } - deregisterSigtermHandler(); - // Unblock pending unitPromise so autoLoop exits cleanly (#1799) - resolveAgentEnd({ messages: [] }); - _resetPendingResolve(); - s.active = false; - s.paused = true; - deactivateSF(); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - s.pendingVerificationRetry = null; - s.verificationRetryCount.clear(); - ctx?.ui.setStatus("sf-auto", "paused"); - ctx?.ui.setWidget("sf-progress", undefined); - ctx?.ui.setFooter(undefined); - if (ctx) - initHealthWidget(ctx); - const resumeCmd = s.stepMode ? "/sf next" : "/sf autonomous"; - ctx?.ui.notify(`${s.stepMode ? "Step" : "Autonomous"} mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, "info", { kind: "terminal", blocking: true, source: "workflow" }); + if (!s.active) return; + clearUnitTimeout(); + // Flush queued follow-up messages (#3512). + // Late async notifications (async_job_result, sf-auto-wrapup) can trigger + // extra LLM turns after pause. Flush them the same way run-unit.ts does. + try { + const cmdCtxAny = s.cmdCtx; + if (typeof cmdCtxAny?.clearQueue === "function") { + cmdCtxAny.clearQueue(); + } + } catch (e) { + debugLog("pause-cleanup-queue", { + error: e instanceof Error ? e.message : String(e), + }); + } + // Unblock any pending unit promise so the auto-loop is not orphaned. + // Pass errorContext so runUnitPhase can distinguish user-initiated pause + // from provider-error pause and avoid hard-stopping (#2762). + resolveAgentEndCancelled(_errorContext); + s.pausedSessionFile = normalizeSessionFilePath( + ctx?.sessionManager?.getSessionFile() ?? null, + ); + // Persist paused-session metadata so resume survives /exit (#1383). + // The fresh-start bootstrap checks for this file and restores worktree context. + try { + const pausedMeta = { + milestoneId: s.currentMilestoneId, + worktreePath: isInAutoWorktree(s.basePath) ? s.basePath : null, + originalBasePath: s.originalBasePath, + stepMode: s.stepMode, + pausedAt: new Date().toISOString(), + sessionFile: s.pausedSessionFile, + unitType: s.currentUnit?.type ?? undefined, + unitId: s.currentUnit?.id ?? undefined, + activeEngineId: s.activeEngineId, + activeRunDir: s.activeRunDir, + autoStartTime: s.autoStartTime, + milestoneLock: s.sessionMilestoneLock ?? undefined, + }; + const runtimeDir = join( + sfRoot(s.originalBasePath || s.basePath), + "runtime", + ); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync( + join(runtimeDir, "paused-session.json"), + JSON.stringify(pausedMeta, null, 2), + "utf-8", + ); + } catch (err) { + // Non-fatal — resume will still work via full bootstrap, just without worktree context + logWarning( + "engine", + `paused-session file write failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + // Close out the current unit so its runtime record doesn't stay at "dispatched" + if (s.currentUnit && ctx) { + try { + await closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + ); + } catch (err) { + // Non-fatal — best-effort closeout on pause + logWarning( + "engine", + `unit closeout on pause failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + s.currentUnit = null; + } + if (lockBase()) { + releaseSessionLock(lockBase()); + clearLock(lockBase()); + } + deregisterSigtermHandler(); + // Unblock pending unitPromise so autoLoop exits cleanly (#1799) + resolveAgentEnd({ messages: [] }); + _resetPendingResolve(); + s.active = false; + s.paused = true; + deactivateSF(); + restoreProjectRootEnv(); + restoreMilestoneLockEnv(); + s.pendingVerificationRetry = null; + s.verificationRetryCount.clear(); + ctx?.ui.setStatus("sf-auto", "paused"); + ctx?.ui.setWidget("sf-progress", undefined); + ctx?.ui.setFooter(undefined); + if (ctx) initHealthWidget(ctx); + const resumeCmd = s.stepMode ? "/sf next" : "/sf autonomous"; + ctx?.ui.notify( + `${s.stepMode ? "Step" : "Autonomous"} mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, + "info", + { kind: "terminal", blocking: true, source: "workflow" }, + ); } /** * Build a WorktreeResolverDeps from auto.ts private scope. * Shared by buildResolver() and buildLoopDeps(). */ function buildResolverDeps() { - return { - isInAutoWorktree, - shouldUseWorktreeIsolation, - getIsolationMode, - mergeMilestoneToMain, - syncWorktreeStateBack, - teardownAutoWorktree, - createAutoWorktree, - enterAutoWorktree, - getAutoWorktreePath, - autoCommitCurrentBranch, - getCurrentBranch, - autoWorktreeBranch, - resolveMilestoneFile, - readFileSync: (path, encoding) => readFileSync(path, encoding), - GitServiceImpl: GitServiceImpl, - loadEffectiveSFPreferences: loadEffectiveSFPreferences, - invalidateAllCaches, - captureIntegrationBranch, - }; + return { + isInAutoWorktree, + shouldUseWorktreeIsolation, + getIsolationMode, + mergeMilestoneToMain, + syncWorktreeStateBack, + teardownAutoWorktree, + createAutoWorktree, + enterAutoWorktree, + getAutoWorktreePath, + autoCommitCurrentBranch, + getCurrentBranch, + autoWorktreeBranch, + resolveMilestoneFile, + readFileSync: (path, encoding) => readFileSync(path, encoding), + GitServiceImpl: GitServiceImpl, + loadEffectiveSFPreferences: loadEffectiveSFPreferences, + invalidateAllCaches, + captureIntegrationBranch, + }; } /** * Build a WorktreeResolver wrapping the current session. @@ -1099,433 +1261,533 @@ function buildResolverDeps() { * Used by stopAuto(), resume path, and buildLoopDeps(). */ function buildResolver() { - return new WorktreeResolver(s, buildResolverDeps()); + return new WorktreeResolver(s, buildResolverDeps()); } /** * Build the LoopDeps object from auto.ts private scope. * This bundles all private functions that autoLoop needs without exporting them. */ function buildLoopDeps() { - // Initialize the unified rule registry with converted dispatch rules. - // Must happen before LoopDeps is assembled so facade functions - // (resolveDispatch, runPreDispatchHooks, etc.) delegate to the registry. - initRegistry(convertDispatchRules(DISPATCH_RULES)); - return { - lockBase, - buildSnapshotOpts, - stopAuto, - pauseAuto, - clearUnitTimeout, - updateProgressWidget, - syncCmuxSidebar, - logCmuxEvent, - // State and cache - invalidateAllCaches, - deriveState, - rebuildState, - loadEffectiveSFPreferences, - // Pre-dispatch health gate - preDispatchHealthGate, - // Worktree sync - syncProjectRootToWorktree, - // Resource version guard - checkResourcesStale, - // Session lock - validateSessionLock: getSessionLockStatus, - updateSessionLock, - handleLostSessionLock, - // Milestone transition - sendDesktopNotification, - setActiveMilestoneId, - pruneQueueOrder, - isInAutoWorktree, - shouldUseWorktreeIsolation, - mergeMilestoneToMain, - teardownAutoWorktree, - createAutoWorktree, - captureIntegrationBranch, - getIsolationMode, - getCurrentBranch, - autoWorktreeBranch, - resolveMilestoneFile, - reconcileMergeState, - // Budget/context/secrets - getLedger, - getProjectTotals, - formatCost, - getBudgetAlertLevel, - getNewBudgetAlertLevel, - getBudgetEnforcementAction, - getManifestStatus, - collectSecretsFromManifest, - // Dispatch - resolveDispatch, - runPreDispatchHooks, - getPriorSliceCompletionBlocker, - getMainBranch, - // Unit closeout + runtime records - closeoutUnit, - autoCommitUnit, - recordOutcome, - writeLock, - captureAvailableSkills, - ensurePreconditions, - updateSliceProgressCache, - // Model selection + supervision - selectAndApplyModel, - resolveModelId, - startUnitSupervision, - // Prompt helpers - getDeepDiagnostic: (basePath) => { - const mid = readActiveMilestoneId(basePath); - const wtPath = mid ? getAutoWorktreePath(basePath, mid) : undefined; - return getDeepDiagnostic(basePath, wtPath ?? undefined); - }, - isDbAvailable, - reorderForCaching, - // Filesystem - existsSync, - readFileSync: (path, encoding) => readFileSync(path, encoding), - atomicWriteSync, - // Git - GitServiceImpl: GitServiceImpl, - // WorktreeResolver - resolver: buildResolver(), - // Post-unit processing - postUnitPreVerification, - runPostUnitVerification, - postUnitPostVerification, - // Session manager - getSessionFile: (ctx) => { - try { - return ctx.sessionManager?.getSessionFile() ?? ""; - } - catch { - return ""; - } - }, - // Journal - emitJournalEvent: (entry) => _emitJournalEvent(s.basePath, entry), - }; + // Initialize the unified rule registry with converted dispatch rules. + // Must happen before LoopDeps is assembled so facade functions + // (resolveDispatch, runPreDispatchHooks, etc.) delegate to the registry. + initRegistry(convertDispatchRules(DISPATCH_RULES)); + return { + lockBase, + buildSnapshotOpts, + stopAuto, + pauseAuto, + clearUnitTimeout, + updateProgressWidget, + syncCmuxSidebar, + logCmuxEvent, + // State and cache + invalidateAllCaches, + deriveState, + rebuildState, + loadEffectiveSFPreferences, + // Pre-dispatch health gate + preDispatchHealthGate, + // Worktree sync + syncProjectRootToWorktree, + // Resource version guard + checkResourcesStale, + // Session lock + validateSessionLock: getSessionLockStatus, + updateSessionLock, + handleLostSessionLock, + // Milestone transition + sendDesktopNotification, + setActiveMilestoneId, + pruneQueueOrder, + isInAutoWorktree, + shouldUseWorktreeIsolation, + mergeMilestoneToMain, + teardownAutoWorktree, + createAutoWorktree, + captureIntegrationBranch, + getIsolationMode, + getCurrentBranch, + autoWorktreeBranch, + resolveMilestoneFile, + reconcileMergeState, + // Budget/context/secrets + getLedger, + getProjectTotals, + formatCost, + getBudgetAlertLevel, + getNewBudgetAlertLevel, + getBudgetEnforcementAction, + getManifestStatus, + collectSecretsFromManifest, + // Dispatch + resolveDispatch, + runPreDispatchHooks, + getPriorSliceCompletionBlocker, + getMainBranch, + // Unit closeout + runtime records + closeoutUnit, + autoCommitUnit, + recordOutcome, + writeLock, + captureAvailableSkills, + ensurePreconditions, + updateSliceProgressCache, + // Model selection + supervision + selectAndApplyModel, + resolveModelId, + startUnitSupervision, + // Prompt helpers + getDeepDiagnostic: (basePath) => { + const mid = readActiveMilestoneId(basePath); + const wtPath = mid ? getAutoWorktreePath(basePath, mid) : undefined; + return getDeepDiagnostic(basePath, wtPath ?? undefined); + }, + isDbAvailable, + reorderForCaching, + // Filesystem + existsSync, + readFileSync: (path, encoding) => readFileSync(path, encoding), + atomicWriteSync, + // Git + GitServiceImpl: GitServiceImpl, + // WorktreeResolver + resolver: buildResolver(), + // Post-unit processing + postUnitPreVerification, + runPostUnitVerification, + postUnitPostVerification, + // Session manager + getSessionFile: (ctx) => { + try { + return ctx.sessionManager?.getSessionFile() ?? ""; + } catch { + return ""; + } + }, + // Journal + emitJournalEvent: (entry) => _emitJournalEvent(s.basePath, entry), + }; +} +async function runStartupDoctorFix(ctx, basePath) { + try { + const report = await runSFDoctor(basePath, { fix: true }); + if (report.fixesApplied.length > 0) { + ctx.ui.notify( + `Startup doctor: applied ${report.fixesApplied.length} fix(es).`, + "info", + ); + } + return report; + } catch (e) { + debugLog("startup-doctor-failed", { + error: e instanceof Error ? e.message : String(e), + }); + return null; + } } export async function startAuto(ctx, pi, base, verboseMode, options) { - if (s.active) { - debugLog("startAuto", { phase: "already-active", skipping: true }); - return; - } - // On a *fresh* start, drop any stale active-tool baseline left by a prior - // auto session that didn't run stopAuto cleanly. Skip on resume: pauseAuto - // leaves the last provider-trimmed active tools in place, so clearing here - // would let the next selectAndApplyModel recapture that already-narrowed - // set as the new baseline — exactly the cross-unit poisoning this PR is - // fixing (#4959 / CodeRabbit Major). The pre-pause baseline survives in - // the WeakMap keyed by `pi`. - if (!s.paused) - clearToolBaseline(pi); - const requestedStepMode = options?.step ?? false; - const interruptedAssessment = options?.interrupted ?? null; - // Pin full-autonomy on the session up-front. The branches below that set - // stepMode never override fullAutonomy — it carries through resume paths, - // fresh starts, and crash recovery so the milestone-complete code path can - // consult it without re-reading command-line options. - s.fullAutonomy = options?.fullAutonomy === true; - // Default: agent CAN ask the user. Autonomous mode flips this off so the - // agent must self-resolve via code/web/lookup. - s.canAskUser = options?.canAskUser !== false; - if (options?.milestoneLock !== undefined) { - s.sessionMilestoneLock = options.milestoneLock ?? null; - } - if (s.sessionMilestoneLock) { - captureMilestoneLockEnv(s.sessionMilestoneLock); - } - // Escape stale worktree cwd from a previous milestone (#608). - base = escapeStaleWorktree(base); - const startupFixes = healAutoStartupRuntime(base); - for (const fix of startupFixes) { - ctx.ui.notify(`Startup self-heal: ${fix}.`, "info"); - } - const freshStartAssessment = interruptedAssessment ?? (await assessInterruptedSession(base)); - if (freshStartAssessment.classification === "running") { - const pid = freshStartAssessment.lock?.pid; - ctx.ui.notify(pid - ? `Another auto-mode session (PID ${pid}) appears to be running.\nStop it with \`kill ${pid}\` before starting a new session.` - : "Another auto-mode session appears to be running.", "error"); - return; - } - // If resuming from paused state, just re-activate and dispatch next unit. - // Check persisted paused-session first (#1383) — survives /exit. - if (!s.paused) { - try { - const meta = freshStartAssessment.pausedSession ?? readPausedSessionMetadata(base); - const pausedPath = join(sfRoot(base), "runtime", "paused-session.json"); - if (meta?.activeEngineId && meta.activeEngineId !== "dev") { - // Custom workflow resume — restore engine state - s.activeEngineId = meta.activeEngineId; - s.activeRunDir = meta.activeRunDir ?? null; - s.originalBasePath = meta.originalBasePath || base; - s.stepMode = meta.stepMode ?? requestedStepMode; - s.autoStartTime = meta.autoStartTime || Date.now(); - s.sessionMilestoneLock = meta.milestoneLock ?? null; - s.paused = true; - try { - unlinkSync(pausedPath); - } - catch (e) { - if (e.code !== "ENOENT") { - logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); - } - } - ctx.ui.notify(`Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`, "info"); - } - else if (meta?.milestoneId) { - const shouldResumePausedSession = freshStartAssessment.classification === "recoverable" && - (freshStartAssessment.hasResumableDiskState || - !!freshStartAssessment.recoveryPrompt || - !!freshStartAssessment.lock); - if (shouldResumePausedSession) { - // Validate the milestone still exists and isn't already complete (#1664). - const mDir = resolveMilestonePath(base, meta.milestoneId); - const summaryFile = resolveMilestoneFile(base, meta.milestoneId, "SUMMARY"); - if (!mDir || summaryFile) { - try { - unlinkSync(pausedPath); - } - catch (err) { - if (err.code !== "ENOENT") { - logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - } - ctx.ui.notify(`Paused milestone ${meta.milestoneId} is ${!mDir ? "missing" : "already complete"}. Starting fresh.`, "info"); - } - else { - s.currentMilestoneId = meta.milestoneId; - s.originalBasePath = meta.originalBasePath || base; - s.stepMode = meta.stepMode ?? requestedStepMode; - s.pausedSessionFile = normalizeSessionFilePath(meta.sessionFile ?? null); - s.pausedUnitType = meta.unitType ?? null; - s.pausedUnitId = meta.unitId ?? null; - s.autoStartTime = meta.autoStartTime || Date.now(); - s.sessionMilestoneLock = meta.milestoneLock ?? null; - s.paused = true; - try { - unlinkSync(pausedPath); - } - catch (e) { - if (e.code !== "ENOENT") { - logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); - } - } - ctx.ui.notify(`Resuming paused session for ${meta.milestoneId}${meta.worktreePath && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`, "info"); - try { - const minutesAgo = Math.round((Date.now() - new Date(meta.pausedAt ?? 0).getTime()) / 60000); - ctx.ui.notify(`Resumed paused session: ${meta.unitType ?? "unit"} ${meta.unitId ?? ""} (paused ${minutesAgo} min ago)`, "info", { - kind: "notice", - blocking: false, - dedupe_key: "auto-resume", - source: "auto", - }); - } - catch { - // notify failure must not block startup - } - } - } - else if (existsSync(pausedPath)) { - try { - unlinkSync(pausedPath); - } - catch (e) { - if (e.code !== "ENOENT") { - logWarning("session", `stale pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); - } - } - } - } - } - catch (err) { - // Malformed or missing — proceed with fresh bootstrap - logWarning("session", `paused-session restore failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - // Guard against zero/missing autoStartTime after resume (#3585) - if (!s.autoStartTime || s.autoStartTime <= 0) - s.autoStartTime = Date.now(); - } - if (s.sessionMilestoneLock) { - captureMilestoneLockEnv(s.sessionMilestoneLock); - } - if (!s.paused) { - s.stepMode = requestedStepMode; - } - if (freshStartAssessment.lock) { - // Emit a synthetic unit-end for any unit-start that has no closing event. - // This closes the journal gap reported in #3348 where the worker wrote side - // effects (SUMMARY.md, DB updates) but died before emitting unit-end. - emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock); - clearLock(base); - } - if (!s.paused) { - s.pendingCrashRecovery = - freshStartAssessment.classification === "recoverable" - ? freshStartAssessment.recoveryPrompt - : null; - if (freshStartAssessment.classification === "recoverable" && - freshStartAssessment.lock) { - const info = formatCrashInfo(freshStartAssessment.lock); - if (freshStartAssessment.recoveryToolCallCount > 0) { - ctx.ui.notify(`${info}\nRecovered ${freshStartAssessment.recoveryToolCallCount} tool calls from crashed session. Resuming with full context.`, "warning"); - } - else if (freshStartAssessment.hasResumableDiskState) { - ctx.ui.notify(`${info}\nResuming from disk state.`, "warning"); - } - } - } - if (s.paused) { - const resumeLock = acquireSessionLock(base); - if (!resumeLock.acquired) { - // Reset paused state so isAutoPaused() doesn't stick true after lock failure. - // Pause file is preserved on disk for retry — not deleted. - s.paused = false; - const resumeReason = resumeLock - .reason; - ctx.ui.notify(`Cannot resume: ${resumeReason}`, "error"); - return; - } - // Preserve the paused session path for recovery synthesis before clearing - // mutable resume state. The file can be unlinked from runtime metadata, but - // the provider JSONL must remain available for synthesizeCrashRecovery(). - const resumeSessionFile = s.pausedSessionFile; - // Clear mutable resume metadata without deleting the provider session JSONL: - // synthesizeCrashRecovery() still needs that trace to avoid restarting blind. - s.pausedSessionFile = null; - s.paused = false; - s.active = true; - s.verbose = verboseMode; - s.stepMode = requestedStepMode; - s.cmdCtx = ctx; - s.basePath = base; - // Ensure the workflow-logger audit log is pinned to the project root - // even when auto-mode is entered via a path that bypasses the - // bootstrap/dynamic-tools ensureDbOpen() → setLogBasePath() chain - // (e.g. /clear resume, hot-reload). - setLogBasePath(base); - s.unitDispatchCount.clear(); - s.unitLifetimeDispatches.clear(); - if (!getLedger()) - initMetrics(base); - if (s.currentMilestoneId) - setActiveMilestoneId(base, s.currentMilestoneId); - // Re-register health level notification callback lost across process restart - setLevelChangeCallback((_from, to, summary) => { - const level = to === "red" ? "error" : to === "yellow" ? "warning" : "info"; - ctx.ui.notify(summary, level); - }); - // ── Auto-worktree: re-enter worktree on resume ── - if (s.currentMilestoneId && - shouldUseWorktreeIsolation() && - s.originalBasePath && - !isInAutoWorktree(s.basePath) && - !detectWorktreeName(s.basePath) && - !detectWorktreeName(s.originalBasePath)) { - buildResolver().enterMilestone(s.currentMilestoneId, { - notify: ctx.ui.notify.bind(ctx.ui), - }); - } - registerSigtermHandler(lockBase()); - ctx.ui.setStatus("sf-auto", s.stepMode ? "next" : "auto"); - ctx.ui.setFooter(hideFooter); - ctx.ui.notify(s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); - restoreHookState(s.basePath); - // Re-sync managed resources on resume so long-lived auto sessions pick up - // bundled extension updates before resume-time verification/state logic runs. - // SF_PKG_ROOT is set by loader.ts and points to the sf-run package root. - // The relative import ("../../../resource-loader.js") only works from the source - // tree; deployed extensions live at ~/.sf/agent/extensions/sf/ where the - // relative path resolves to ~/.sf/agent/resource-loader.js which doesn't exist. - // Using SF_PKG_ROOT constructs a correct absolute path in both contexts (#3949). - const agentDir = process.env.SF_CODING_AGENT_DIR || - join(process.env.SF_HOME || homedir(), ".sf", "agent"); - const pkgRoot = process.env.SF_PKG_ROOT; - const resourceLoaderPath = pkgRoot - ? pathToFileURL(join(pkgRoot, "dist", "resource-loader.js")).href - : new URL("../../../resource-loader.js", import.meta.url).href; - const { initResources } = await import(resourceLoaderPath); - initResources(agentDir); - // Open the project DB before rebuild/derive so resume uses DB-backed - // state instead of falling back to stale markdown parsing (#2940). - await openProjectDbIfPresent(s.basePath); - try { - await rebuildState(s.basePath); - syncCmuxSidebar(loadEffectiveSFPreferences()?.preferences, await deriveState(s.basePath)); - } - catch (e) { - debugLog("resume-rebuild-state-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - try { - const report = await runSFDoctor(s.basePath, { fix: true }); - if (report.fixesApplied.length > 0) { - ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info"); - } - } - catch (e) { - debugLog("resume-doctor-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - invalidateAllCaches(); - if (resumeSessionFile) { - const activityDir = join(sfRoot(s.basePath), "activity"); - const recovery = synthesizeCrashRecovery(s.basePath, s.currentUnit?.type ?? s.pausedUnitType ?? "unknown", s.currentUnit?.id ?? s.pausedUnitId ?? "unknown", resumeSessionFile ?? undefined, activityDir); - if (recovery && recovery.trace.toolCallCount > 0) { - s.pendingCrashRecovery = recovery.prompt; - ctx.ui.notify(`Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`, "info"); - } - } - updateSessionLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown"); - writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown"); - logCmuxEvent(loadEffectiveSFPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); - captureProjectRootEnv(s.originalBasePath || s.basePath); - await runAutoLoopWithUok({ - ctx, - pi, - s, - deps: buildLoopDeps(), - runKernelLoop: runUokKernelLoop, - runLegacyLoop: autoLoop, - }); - cleanupAfterLoopExit(ctx); - return; - } - // ── Fresh start path — delegated to auto-start.ts ── - const bootstrapDeps = { - shouldUseWorktreeIsolation, - registerSigtermHandler, - lockBase, - buildResolver, - }; - const ready = await bootstrapAutoSession(s, ctx, pi, base, verboseMode, requestedStepMode, bootstrapDeps, freshStartAssessment); - if (!ready) - return; - captureProjectRootEnv(s.originalBasePath || s.basePath); - try { - syncCmuxSidebar(loadEffectiveSFPreferences()?.preferences, await deriveState(s.basePath)); - } - catch (err) { - // Best-effort only — sidebar sync must never block auto-mode startup - logWarning("engine", `cmux sync failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - logCmuxEvent(loadEffectiveSFPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress"); - // Dispatch the first unit - await runAutoLoopWithUok({ - ctx, - pi, - s, - deps: buildLoopDeps(), - runKernelLoop: runUokKernelLoop, - runLegacyLoop: autoLoop, - }); - cleanupAfterLoopExit(ctx); + if (s.active) { + debugLog("startAuto", { phase: "already-active", skipping: true }); + return; + } + // On a *fresh* start, drop any stale active-tool baseline left by a prior + // auto session that didn't run stopAuto cleanly. Skip on resume: pauseAuto + // leaves the last provider-trimmed active tools in place, so clearing here + // would let the next selectAndApplyModel recapture that already-narrowed + // set as the new baseline — exactly the cross-unit poisoning this PR is + // fixing (#4959 / CodeRabbit Major). The pre-pause baseline survives in + // the WeakMap keyed by `pi`. + if (!s.paused) clearToolBaseline(pi); + const requestedStepMode = options?.step ?? false; + const interruptedAssessment = options?.interrupted ?? null; + // Pin full-autonomy on the session up-front. The branches below that set + // stepMode never override fullAutonomy — it carries through resume paths, + // fresh starts, and crash recovery so the milestone-complete code path can + // consult it without re-reading command-line options. + s.fullAutonomy = options?.fullAutonomy === true; + // Default: agent CAN ask the user. Autonomous mode flips this off so the + // agent must self-resolve via code/web/lookup. + s.canAskUser = options?.canAskUser !== false; + if (options?.milestoneLock !== undefined) { + s.sessionMilestoneLock = options.milestoneLock ?? null; + } + if (s.sessionMilestoneLock) { + captureMilestoneLockEnv(s.sessionMilestoneLock); + } + // Escape stale worktree cwd from a previous milestone (#608). + base = escapeStaleWorktree(base); + const startupFixes = healAutoStartupRuntime(base); + for (const fix of startupFixes) { + ctx.ui.notify(`Startup self-heal: ${fix}.`, "info"); + } + const freshStartAssessment = + interruptedAssessment ?? (await assessInterruptedSession(base)); + if (freshStartAssessment.classification === "running") { + const pid = freshStartAssessment.lock?.pid; + ctx.ui.notify( + pid + ? `Another auto-mode session (PID ${pid}) appears to be running.\nStop it with \`kill ${pid}\` before starting a new session.` + : "Another auto-mode session appears to be running.", + "error", + ); + return; + } + await runStartupDoctorFix(ctx, base); + // If resuming from paused state, just re-activate and dispatch next unit. + // Check persisted paused-session first (#1383) — survives /exit. + if (!s.paused) { + try { + const meta = + freshStartAssessment.pausedSession ?? readPausedSessionMetadata(base); + const pausedPath = join(sfRoot(base), "runtime", "paused-session.json"); + if (meta?.activeEngineId && meta.activeEngineId !== "dev") { + // Custom workflow resume — restore engine state + s.activeEngineId = meta.activeEngineId; + s.activeRunDir = meta.activeRunDir ?? null; + s.originalBasePath = meta.originalBasePath || base; + s.stepMode = meta.stepMode ?? requestedStepMode; + s.autoStartTime = meta.autoStartTime || Date.now(); + s.sessionMilestoneLock = meta.milestoneLock ?? null; + s.paused = true; + try { + unlinkSync(pausedPath); + } catch (e) { + if (e.code !== "ENOENT") { + logWarning( + "session", + `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, + { file: "auto.ts" }, + ); + } + } + ctx.ui.notify( + `Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`, + "info", + ); + } else if (meta?.milestoneId) { + const shouldResumePausedSession = + freshStartAssessment.classification === "recoverable" && + (freshStartAssessment.hasResumableDiskState || + !!freshStartAssessment.recoveryPrompt || + !!freshStartAssessment.lock); + if (shouldResumePausedSession) { + // Validate the milestone still exists and isn't already complete (#1664). + const mDir = resolveMilestonePath(base, meta.milestoneId); + const summaryFile = resolveMilestoneFile( + base, + meta.milestoneId, + "SUMMARY", + ); + if (!mDir || summaryFile) { + try { + unlinkSync(pausedPath); + } catch (err) { + if (err.code !== "ENOENT") { + logWarning( + "session", + `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + } + ctx.ui.notify( + `Paused milestone ${meta.milestoneId} is ${!mDir ? "missing" : "already complete"}. Starting fresh.`, + "info", + ); + } else { + s.currentMilestoneId = meta.milestoneId; + s.originalBasePath = meta.originalBasePath || base; + s.stepMode = meta.stepMode ?? requestedStepMode; + s.pausedSessionFile = normalizeSessionFilePath( + meta.sessionFile ?? null, + ); + s.pausedUnitType = meta.unitType ?? null; + s.pausedUnitId = meta.unitId ?? null; + s.autoStartTime = meta.autoStartTime || Date.now(); + s.sessionMilestoneLock = meta.milestoneLock ?? null; + s.paused = true; + try { + unlinkSync(pausedPath); + } catch (e) { + if (e.code !== "ENOENT") { + logWarning( + "session", + `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, + { file: "auto.ts" }, + ); + } + } + ctx.ui.notify( + `Resuming paused session for ${meta.milestoneId}${meta.worktreePath && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`, + "info", + ); + try { + const minutesAgo = Math.round( + (Date.now() - new Date(meta.pausedAt ?? 0).getTime()) / 60000, + ); + ctx.ui.notify( + `Resumed paused session: ${meta.unitType ?? "unit"} ${meta.unitId ?? ""} (paused ${minutesAgo} min ago)`, + "info", + { + kind: "notice", + blocking: false, + dedupe_key: "auto-resume", + source: "auto", + }, + ); + } catch { + // notify failure must not block startup + } + } + } else if (existsSync(pausedPath)) { + try { + unlinkSync(pausedPath); + } catch (e) { + if (e.code !== "ENOENT") { + logWarning( + "session", + `stale pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, + { file: "auto.ts" }, + ); + } + } + } + } + } catch (err) { + // Malformed or missing — proceed with fresh bootstrap + logWarning( + "session", + `paused-session restore failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + // Guard against zero/missing autoStartTime after resume (#3585) + if (!s.autoStartTime || s.autoStartTime <= 0) s.autoStartTime = Date.now(); + } + if (s.sessionMilestoneLock) { + captureMilestoneLockEnv(s.sessionMilestoneLock); + } + if (!s.paused) { + s.stepMode = requestedStepMode; + } + if (freshStartAssessment.lock) { + // Emit a synthetic unit-end for any unit-start that has no closing event. + // This closes the journal gap reported in #3348 where the worker wrote side + // effects (SUMMARY.md, DB updates) but died before emitting unit-end. + emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock); + clearLock(base); + } + if (!s.paused) { + s.pendingCrashRecovery = + freshStartAssessment.classification === "recoverable" + ? freshStartAssessment.recoveryPrompt + : null; + if ( + freshStartAssessment.classification === "recoverable" && + freshStartAssessment.lock + ) { + const info = formatCrashInfo(freshStartAssessment.lock); + if (freshStartAssessment.recoveryToolCallCount > 0) { + ctx.ui.notify( + `${info}\nRecovered ${freshStartAssessment.recoveryToolCallCount} tool calls from crashed session. Resuming with full context.`, + "warning", + ); + } else if (freshStartAssessment.hasResumableDiskState) { + ctx.ui.notify(`${info}\nResuming from disk state.`, "warning"); + } + } + } + if (s.paused) { + const resumeLock = acquireSessionLock(base); + if (!resumeLock.acquired) { + // Reset paused state so isAutoPaused() doesn't stick true after lock failure. + // Pause file is preserved on disk for retry — not deleted. + s.paused = false; + const resumeReason = resumeLock.reason; + ctx.ui.notify(`Cannot resume: ${resumeReason}`, "error"); + return; + } + // Preserve the paused session path for recovery synthesis before clearing + // mutable resume state. The file can be unlinked from runtime metadata, but + // the provider JSONL must remain available for synthesizeCrashRecovery(). + const resumeSessionFile = s.pausedSessionFile; + // Clear mutable resume metadata without deleting the provider session JSONL: + // synthesizeCrashRecovery() still needs that trace to avoid restarting blind. + s.pausedSessionFile = null; + s.paused = false; + s.active = true; + s.verbose = verboseMode; + s.stepMode = requestedStepMode; + s.cmdCtx = ctx; + s.basePath = base; + // Ensure the workflow-logger audit log is pinned to the project root + // even when auto-mode is entered via a path that bypasses the + // bootstrap/dynamic-tools ensureDbOpen() → setLogBasePath() chain + // (e.g. /clear resume, hot-reload). + setLogBasePath(base); + s.unitDispatchCount.clear(); + s.unitLifetimeDispatches.clear(); + if (!getLedger()) initMetrics(base); + if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId); + // Re-register health level notification callback lost across process restart + setLevelChangeCallback((_from, to, summary) => { + const level = + to === "red" ? "error" : to === "yellow" ? "warning" : "info"; + ctx.ui.notify(summary, level); + }); + // ── Auto-worktree: re-enter worktree on resume ── + if ( + s.currentMilestoneId && + shouldUseWorktreeIsolation() && + s.originalBasePath && + !isInAutoWorktree(s.basePath) && + !detectWorktreeName(s.basePath) && + !detectWorktreeName(s.originalBasePath) + ) { + buildResolver().enterMilestone(s.currentMilestoneId, { + notify: ctx.ui.notify.bind(ctx.ui), + }); + } + registerSigtermHandler(lockBase()); + ctx.ui.setStatus("sf-auto", s.stepMode ? "next" : "auto"); + ctx.ui.setFooter(hideFooter); + ctx.ui.notify( + s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", + "info", + ); + restoreHookState(s.basePath); + // Re-sync managed resources on resume so long-lived auto sessions pick up + // bundled extension updates before resume-time verification/state logic runs. + // SF_PKG_ROOT is set by loader.ts and points to the sf-run package root. + // The relative import ("../../../resource-loader.js") only works from the source + // tree; deployed extensions live at ~/.sf/agent/extensions/sf/ where the + // relative path resolves to ~/.sf/agent/resource-loader.js which doesn't exist. + // Using SF_PKG_ROOT constructs a correct absolute path in both contexts (#3949). + const agentDir = + process.env.SF_CODING_AGENT_DIR || + join(process.env.SF_HOME || homedir(), ".sf", "agent"); + const pkgRoot = process.env.SF_PKG_ROOT; + const resourceLoaderPath = pkgRoot + ? pathToFileURL(join(pkgRoot, "dist", "resource-loader.js")).href + : new URL("../../../resource-loader.js", import.meta.url).href; + const { initResources } = await import(resourceLoaderPath); + initResources(agentDir); + // Open the project DB before rebuild/derive so resume uses DB-backed + // state instead of falling back to stale markdown parsing (#2940). + await openProjectDbIfPresent(s.basePath); + try { + await rebuildState(s.basePath); + syncCmuxSidebar( + loadEffectiveSFPreferences()?.preferences, + await deriveState(s.basePath), + ); + } catch (e) { + debugLog("resume-rebuild-state-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + try { + const report = await runSFDoctor(s.basePath, { fix: true }); + if (report.fixesApplied.length > 0) { + ctx.ui.notify( + `Resume: applied ${report.fixesApplied.length} fix(es) to state.`, + "info", + ); + } + } catch (e) { + debugLog("resume-doctor-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + invalidateAllCaches(); + if (resumeSessionFile) { + const activityDir = join(sfRoot(s.basePath), "activity"); + const recovery = synthesizeCrashRecovery( + s.basePath, + s.currentUnit?.type ?? s.pausedUnitType ?? "unknown", + s.currentUnit?.id ?? s.pausedUnitId ?? "unknown", + resumeSessionFile ?? undefined, + activityDir, + ); + if (recovery && recovery.trace.toolCallCount > 0) { + s.pendingCrashRecovery = recovery.prompt; + ctx.ui.notify( + `Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`, + "info", + ); + } + } + updateSessionLock( + lockBase(), + "resuming", + s.currentMilestoneId ?? "unknown", + ); + writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown"); + logCmuxEvent( + loadEffectiveSFPreferences()?.preferences, + s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", + "progress", + ); + captureProjectRootEnv(s.originalBasePath || s.basePath); + await runAutoLoopWithUok({ + ctx, + pi, + s, + deps: buildLoopDeps(), + runKernelLoop: runUokKernelLoop, + runLegacyLoop: autoLoop, + }); + cleanupAfterLoopExit(ctx); + return; + } + // ── Fresh start path — delegated to auto-start.ts ── + const bootstrapDeps = { + shouldUseWorktreeIsolation, + registerSigtermHandler, + lockBase, + buildResolver, + }; + const ready = await bootstrapAutoSession( + s, + ctx, + pi, + base, + verboseMode, + requestedStepMode, + bootstrapDeps, + freshStartAssessment, + ); + if (!ready) return; + captureProjectRootEnv(s.originalBasePath || s.basePath); + try { + syncCmuxSidebar( + loadEffectiveSFPreferences()?.preferences, + await deriveState(s.basePath), + ); + } catch (err) { + // Best-effort only — sidebar sync must never block auto-mode startup + logWarning( + "engine", + `cmux sync failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + logCmuxEvent( + loadEffectiveSFPreferences()?.preferences, + requestedStepMode ? "Step-mode started." : "Auto-mode started.", + "progress", + ); + // Dispatch the first unit + await runAutoLoopWithUok({ + ctx, + pi, + s, + deps: buildLoopDeps(), + runKernelLoop: runUokKernelLoop, + runLegacyLoop: autoLoop, + }); + cleanupAfterLoopExit(ctx); } // ─── Agent End Handler ──────────────────────────────────────────────────────── /** @@ -1538,33 +1800,41 @@ export async function startAuto(ctx, pi, base, verboseMode, options) { * can continue. */ export async function handleAgentEnd(_ctx, _pi) { - if (!s.active || !s.cmdCtx) { - // Even when inactive, resolve any pending promise so the loop is unblocked. - resolveAgentEndCancelled(); - return; - } - clearUnitTimeout(); - resolveAgentEnd({ messages: [] }); + if (!s.active || !s.cmdCtx) { + // Even when inactive, resolve any pending promise so the loop is unblocked. + resolveAgentEndCancelled(); + return; + } + clearUnitTimeout(); + resolveAgentEnd({ messages: [] }); } // describeNextUnit is imported from auto-dashboard.ts and re-exported export { describeNextUnit } from "./auto-dashboard.js"; + /** Thin wrapper: delegates to auto-dashboard.ts, passing state accessors. */ function updateProgressWidget(ctx, unitType, unitId, state) { - const badge = s.currentUnitRouting?.tier - ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? - undefined) - : undefined; - _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors, badge); + const badge = s.currentUnitRouting?.tier + ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? + undefined) + : undefined; + _updateProgressWidget( + ctx, + unitType, + unitId, + state, + widgetStateAccessors, + badge, + ); } /** State accessors for the widget — closures over module globals. */ const widgetStateAccessors = { - getAutoStartTime: () => s.autoStartTime, - isStepMode: () => s.stepMode, - getCmdCtx: () => s.cmdCtx, - getBasePath: () => s.basePath, - isVerbose: () => s.verbose, - isSessionSwitching: isSessionSwitchInFlight, - getCurrentDispatchedModelId: () => s.currentDispatchedModelId, + getAutoStartTime: () => s.autoStartTime, + isStepMode: () => s.stepMode, + getCmdCtx: () => s.cmdCtx, + getBasePath: () => s.basePath, + isVerbose: () => s.verbose, + isSessionSwitching: isSessionSwitchInFlight, + getCurrentDispatchedModelId: () => s.currentDispatchedModelId, }; // ─── Preconditions ──────────────────────────────────────────────────────────── /** @@ -1572,101 +1842,128 @@ const widgetStateAccessors = { * dispatching a unit. The LLM should never need to mkdir or git checkout. */ function ensurePreconditions(_unitType, unitId, base, _state) { - const { milestone: mid, slice: sid } = parseUnitId(unitId); - const mDir = resolveMilestonePath(base, mid); - if (!mDir) { - const newDir = join(milestonesDir(base), mid); - mkdirSync(join(newDir, "slices"), { recursive: true }); - } - if (sid !== undefined) { - const mDirResolved = resolveMilestonePath(base, mid); - if (mDirResolved) { - const slicesDir = join(mDirResolved, "slices"); - const sDir = resolveDir(slicesDir, sid); - if (!sDir) { - mkdirSync(join(slicesDir, sid, "tasks"), { recursive: true }); - } - const resolvedSliceDir = resolveDir(slicesDir, sid) ?? sid; - const tasksDir = join(slicesDir, resolvedSliceDir, "tasks"); - if (!existsSync(tasksDir)) { - mkdirSync(tasksDir, { recursive: true }); - } - } - } + const { milestone: mid, slice: sid } = parseUnitId(unitId); + const mDir = resolveMilestonePath(base, mid); + if (!mDir) { + const newDir = join(milestonesDir(base), mid); + mkdirSync(join(newDir, "slices"), { recursive: true }); + } + if (sid !== undefined) { + const mDirResolved = resolveMilestonePath(base, mid); + if (mDirResolved) { + const slicesDir = join(mDirResolved, "slices"); + const sDir = resolveDir(slicesDir, sid); + if (!sDir) { + mkdirSync(join(slicesDir, sid, "tasks"), { recursive: true }); + } + const resolvedSliceDir = resolveDir(slicesDir, sid) ?? sid; + const tasksDir = join(slicesDir, resolvedSliceDir, "tasks"); + if (!existsSync(tasksDir)) { + mkdirSync(tasksDir, { recursive: true }); + } + } + } } -export async function dispatchHookUnit(ctx, pi, hookName, triggerUnitType, triggerUnitId, hookPrompt, hookModel, targetBasePath) { - if (!s.active) { - s.active = true; - s.stepMode = true; - s.cmdCtx = ctx; - s.basePath = targetBasePath; - s.autoStartTime = Date.now(); - s.currentUnit = null; - s.pendingQuickTasks = []; - } - const hookUnitType = `hook/${hookName}`; - const hookStartedAt = Date.now(); - s.currentUnit = { - type: triggerUnitType, - id: triggerUnitId, - startedAt: hookStartedAt, - }; - const result = await s.cmdCtx.newSession(); - if (result.cancelled) { - await stopAuto(ctx, pi); - return false; - } - s.currentUnit = { - type: hookUnitType, - id: triggerUnitId, - startedAt: hookStartedAt, - }; - if (hookModel) { - const availableModels = ctx.modelRegistry.getAvailable(); - const match = resolveModelId(hookModel, availableModels, ctx.model?.provider); - if (match) { - try { - await pi.setModel(match); - } - catch (err) { - /* non-fatal */ - logWarning("dispatch", `hook model set failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - } - else { - ctx.ui.notify(`Hook model "${hookModel}" not found in available models. Falling back to current session model. ` + - `Ensure the model is defined in models.json and has auth configured.`, "warning"); - } - } - const sessionFile = normalizeSessionFilePath(ctx.sessionManager.getSessionFile()); - writeLock(lockBase(), hookUnitType, triggerUnitId, sessionFile ?? undefined); - clearUnitTimeout(); - const supervisor = resolveAutoSupervisorConfig(); - const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) - return; - ctx.ui.notify(`Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, "warning"); - resetHookState(); - await pauseAuto(ctx, pi); - }, hookHardTimeoutMs); - ctx.ui.setStatus("sf-auto", s.stepMode ? "next" : "auto"); - ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); - // Ensure cwd matches basePath before hook dispatch (#1389) - try { - if (process.cwd() !== s.basePath) - process.chdir(s.basePath); - } - catch (err) { - logWarning("engine", `chdir failed before hook dispatch: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - debugLog("dispatchHookUnit", { - phase: "send-message", - promptLength: hookPrompt.length, - }); - pi.sendMessage({ customType: "sf-auto", content: hookPrompt, display: true }, { triggerTurn: true }); - return true; +export async function dispatchHookUnit( + ctx, + pi, + hookName, + triggerUnitType, + triggerUnitId, + hookPrompt, + hookModel, + targetBasePath, +) { + if (!s.active) { + s.active = true; + s.stepMode = true; + s.cmdCtx = ctx; + s.basePath = targetBasePath; + s.autoStartTime = Date.now(); + s.currentUnit = null; + s.pendingQuickTasks = []; + } + const hookUnitType = `hook/${hookName}`; + const hookStartedAt = Date.now(); + s.currentUnit = { + type: triggerUnitType, + id: triggerUnitId, + startedAt: hookStartedAt, + }; + const result = await s.cmdCtx.newSession(); + if (result.cancelled) { + await stopAuto(ctx, pi); + return false; + } + s.currentUnit = { + type: hookUnitType, + id: triggerUnitId, + startedAt: hookStartedAt, + }; + if (hookModel) { + const availableModels = ctx.modelRegistry.getAvailable(); + const match = resolveModelId( + hookModel, + availableModels, + ctx.model?.provider, + ); + if (match) { + try { + await pi.setModel(match); + } catch (err) { + /* non-fatal */ + logWarning( + "dispatch", + `hook model set failed: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + } else { + ctx.ui.notify( + `Hook model "${hookModel}" not found in available models. Falling back to current session model. ` + + `Ensure the model is defined in models.json and has auth configured.`, + "warning", + ); + } + } + const sessionFile = normalizeSessionFilePath( + ctx.sessionManager.getSessionFile(), + ); + writeLock(lockBase(), hookUnitType, triggerUnitId, sessionFile ?? undefined); + clearUnitTimeout(); + const supervisor = resolveAutoSupervisorConfig(); + const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; + ctx.ui.notify( + `Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, + "warning", + ); + resetHookState(); + await pauseAuto(ctx, pi); + }, hookHardTimeoutMs); + ctx.ui.setStatus("sf-auto", s.stepMode ? "next" : "auto"); + ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); + // Ensure cwd matches basePath before hook dispatch (#1389) + try { + if (process.cwd() !== s.basePath) process.chdir(s.basePath); + } catch (err) { + logWarning( + "engine", + `chdir failed before hook dispatch: ${err instanceof Error ? err.message : String(err)}`, + { file: "auto.ts" }, + ); + } + debugLog("dispatchHookUnit", { + phase: "send-message", + promptLength: hookPrompt.length, + }); + pi.sendMessage( + { customType: "sf-auto", content: hookPrompt, display: true }, + { triggerTurn: true }, + ); + return true; } export { resolveExpectedArtifactPath } from "./auto-artifact-paths.js"; // Re-export recovery functions for external consumers diff --git a/src/resources/extensions/sf/doctor-engine-checks.js b/src/resources/extensions/sf/doctor-engine-checks.js index a49cb0164..2c2302616 100644 --- a/src/resources/extensions/sf/doctor-engine-checks.js +++ b/src/resources/extensions/sf/doctor-engine-checks.js @@ -1,10 +1,96 @@ -import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; +import { existsSync, readdirSync, renameSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { milestonesDir, resolveMilestoneFile } from "./paths.js"; import { _getAdapter, getAllMilestones, isDbAvailable } from "./sf-db.js"; import { deriveState } from "./state.js"; import { readEvents } from "./workflow-events.js"; import { renderAllProjections } from "./workflow-projections.js"; + +const LEGACY_MILESTONE_DIR_RE = /^(M\d+)-.+$/; +const LEGACY_SLICE_DIR_RE = /^(S\d+)-.+$/; + +function legacyBareId(name, pattern) { + const match = name.match(pattern); + return match?.[1] ?? null; +} + +function normalizeLegacyDirectory( + parentDir, + entry, + pattern, + unitPrefix, + issues, + fixesApplied, + shouldFix, +) { + const bareId = legacyBareId(entry, pattern); + if (!bareId) return; + const sourcePath = join(parentDir, entry); + const targetPath = join(parentDir, bareId); + const conflict = existsSync(targetPath); + issues.push({ + severity: conflict ? "warning" : "info", + code: "legacy_plan_slug_directory", + scope: "project", + unitId: `${unitPrefix}/${entry}`, + message: conflict + ? `Legacy plan directory ${sourcePath} should be renamed to ${targetPath}, but the target already exists. Merge manually before running doctor fix.` + : `Legacy plan directory ${sourcePath} should be renamed to bare ID directory ${targetPath}.`, + file: sourcePath, + fixable: !conflict, + }); + if (conflict || !shouldFix?.("legacy_plan_slug_directory")) return; + try { + renameSync(sourcePath, targetPath); + fixesApplied.push( + `renamed legacy plan directory ${sourcePath} -> ${targetPath}`, + ); + } catch { + fixesApplied.push( + `failed to rename legacy plan directory ${sourcePath} -> ${targetPath}`, + ); + } +} + +export function normalizeLegacyPlanSlugDirectories( + basePath, + issues, + fixesApplied, + shouldFix, +) { + const msDir = milestonesDir(basePath); + if (!existsSync(msDir)) return; + for (const milestoneEntry of readdirSync(msDir, { withFileTypes: true })) { + if (!milestoneEntry.isDirectory()) continue; + const milestonePath = join(msDir, milestoneEntry.name); + const slicesPath = join(milestonePath, "slices"); + if (existsSync(slicesPath)) { + for (const sliceEntry of readdirSync(slicesPath, { + withFileTypes: true, + })) { + if (!sliceEntry.isDirectory()) continue; + normalizeLegacyDirectory( + slicesPath, + sliceEntry.name, + LEGACY_SLICE_DIR_RE, + milestoneEntry.name, + issues, + fixesApplied, + shouldFix, + ); + } + } + normalizeLegacyDirectory( + msDir, + milestoneEntry.name, + LEGACY_MILESTONE_DIR_RE, + "milestone", + issues, + fixesApplied, + shouldFix, + ); + } +} /** * Check SF engine health: database constraints, projection drift, and corruption. * @@ -12,237 +98,258 @@ import { renderAllProjections } from "./workflow-projections.js"; * Re-renders stale markdown projections when event log is newer than cached files. * Non-fatal: issues are reported but never auto-fixed. */ -export async function checkEngineHealth(basePath, issues, fixesApplied, shouldFix) { - const dbPath = join(basePath, ".sf", "sf.db"); - if (!isDbAvailable() && existsSync(dbPath)) { - issues.push({ - severity: "warning", - code: "db_unavailable", - scope: "project", - unitId: "project", - message: "Database unavailable — using filesystem state derivation (degraded mode). State queries may be slower and less reliable.", - file: ".sf/sf.db", - fixable: false, - }); - } - // ── DB constraint violation detection (full doctor only, not pre-dispatch per D-10) ── - try { - if (isDbAvailable()) { - const adapter = _getAdapter(); - // a. Orphaned tasks (task.slice_id points to non-existent slice) - try { - const orphanedTasks = adapter - .prepare(`SELECT t.id, t.slice_id, t.milestone_id +export async function checkEngineHealth( + basePath, + issues, + fixesApplied, + shouldFix, +) { + const dbPath = join(basePath, ".sf", "sf.db"); + if (!isDbAvailable() && existsSync(dbPath)) { + issues.push({ + severity: "warning", + code: "db_unavailable", + scope: "project", + unitId: "project", + message: + "Database unavailable — using filesystem state derivation (degraded mode). State queries may be slower and less reliable.", + file: ".sf/sf.db", + fixable: false, + }); + } + try { + normalizeLegacyPlanSlugDirectories( + basePath, + issues, + fixesApplied, + shouldFix, + ); + } catch { + // Non-fatal — legacy directory normalization must never block doctor. + } + // ── DB constraint violation detection (full doctor only, not pre-dispatch per D-10) ── + try { + if (isDbAvailable()) { + const adapter = _getAdapter(); + // a. Orphaned tasks (task.slice_id points to non-existent slice) + try { + const orphanedTasks = adapter + .prepare(`SELECT t.id, t.slice_id, t.milestone_id FROM tasks t LEFT JOIN slices s ON t.milestone_id = s.milestone_id AND t.slice_id = s.id WHERE s.id IS NULL`) - .all(); - for (const row of orphanedTasks) { - issues.push({ - severity: "error", - code: "db_orphaned_task", - scope: "task", - unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, - message: `Task ${row.id} references slice ${row.slice_id} in milestone ${row.milestone_id} but no such slice exists in the database`, - fixable: false, - }); - } - } - catch { - // Non-fatal — orphaned task check failed - } - // b. Orphaned slices (slice.milestone_id points to non-existent milestone) - try { - const orphanedSlices = adapter - .prepare(`SELECT s.id, s.milestone_id + .all(); + for (const row of orphanedTasks) { + issues.push({ + severity: "error", + code: "db_orphaned_task", + scope: "task", + unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, + message: `Task ${row.id} references slice ${row.slice_id} in milestone ${row.milestone_id} but no such slice exists in the database`, + fixable: false, + }); + } + } catch { + // Non-fatal — orphaned task check failed + } + // b. Orphaned slices (slice.milestone_id points to non-existent milestone) + try { + const orphanedSlices = adapter + .prepare(`SELECT s.id, s.milestone_id FROM slices s LEFT JOIN milestones m ON s.milestone_id = m.id WHERE m.id IS NULL`) - .all(); - for (const row of orphanedSlices) { - issues.push({ - severity: "error", - code: "db_orphaned_slice", - scope: "slice", - unitId: `${row.milestone_id}/${row.id}`, - message: `Slice ${row.id} references milestone ${row.milestone_id} but no such milestone exists in the database`, - fixable: false, - }); - } - } - catch { - // Non-fatal — orphaned slice check failed - } - // c. Tasks marked complete without summaries - try { - const doneTasks = adapter - .prepare(`SELECT id, slice_id, milestone_id FROM tasks + .all(); + for (const row of orphanedSlices) { + issues.push({ + severity: "error", + code: "db_orphaned_slice", + scope: "slice", + unitId: `${row.milestone_id}/${row.id}`, + message: `Slice ${row.id} references milestone ${row.milestone_id} but no such milestone exists in the database`, + fixable: false, + }); + } + } catch { + // Non-fatal — orphaned slice check failed + } + // c. Tasks marked complete without summaries + try { + const doneTasks = adapter + .prepare(`SELECT id, slice_id, milestone_id FROM tasks WHERE status = 'done' AND (summary IS NULL OR summary = '')`) - .all(); - for (const row of doneTasks) { - issues.push({ - severity: "warning", - code: "db_done_task_no_summary", - scope: "task", - unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, - message: `Task ${row.id} is marked done but has no summary in the database`, - fixable: false, - }); - } - } - catch { - // Non-fatal — done-task-no-summary check failed - } - // d. Duplicate entity IDs (safety check) - try { - const dupMilestones = adapter - .prepare("SELECT id, COUNT(*) as cnt FROM milestones GROUP BY id HAVING cnt > 1") - .all(); - for (const row of dupMilestones) { - issues.push({ - severity: "error", - code: "db_duplicate_id", - scope: "milestone", - unitId: row.id, - message: `Duplicate milestone ID "${row.id}" appears ${row.cnt} times in the database`, - fixable: false, - }); - } - const dupSlices = adapter - .prepare("SELECT id, milestone_id, COUNT(*) as cnt FROM slices GROUP BY id, milestone_id HAVING cnt > 1") - .all(); - for (const row of dupSlices) { - issues.push({ - severity: "error", - code: "db_duplicate_id", - scope: "slice", - unitId: `${row.milestone_id}/${row.id}`, - message: `Duplicate slice ID "${row.id}" in milestone ${row.milestone_id} appears ${row.cnt} times`, - fixable: false, - }); - } - const dupTasks = adapter - .prepare("SELECT id, slice_id, milestone_id, COUNT(*) as cnt FROM tasks GROUP BY id, slice_id, milestone_id HAVING cnt > 1") - .all(); - for (const row of dupTasks) { - issues.push({ - severity: "error", - code: "db_duplicate_id", - scope: "task", - unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, - message: `Duplicate task ID "${row.id}" in slice ${row.slice_id} appears ${row.cnt} times`, - fixable: false, - }); - } - } - catch { - // Non-fatal — duplicate ID check failed - } - } - } - catch { - // Non-fatal — DB constraint checks failed entirely - } - // ── Orphaned milestone directories ───────────────────────────────────── - // Detect .sf/milestones/* directories that have no corresponding DB row. - // These are leftover from manual cleanup, failed deletions, or DB resets. - // When DB is available, DB is authoritative. When DB is unavailable, - // fall back to filesystem-derived registry (roadmap-based discovery). - try { - const msDir = milestonesDir(basePath); - if (existsSync(msDir)) { - const validMilestoneIds = new Set(); - if (isDbAvailable()) { - // DB-authoritative: only DB rows count as valid - for (const m of getAllMilestones()) { - validMilestoneIds.add(m.id); - } - } - else { - // No DB: fall back to filesystem registry - const state = await deriveState(basePath); - for (const m of state.registry) { - validMilestoneIds.add(m.id); - } - } - for (const entry of readdirSync(msDir)) { - const fullPath = join(msDir, entry); - try { - if (!statSync(fullPath).isDirectory()) - continue; - } - catch { - continue; - } - // Extract milestone ID from directory name (handles M001, M001-r5jzab, etc.) - const milestoneId = entry.split("-")[0]; - if (!milestoneId) - continue; - if (!validMilestoneIds.has(milestoneId) && - !validMilestoneIds.has(entry)) { - issues.push({ - severity: "warning", - code: "orphaned_milestone_directory", - scope: "project", - unitId: entry, - message: `Milestone directory ${fullPath} exists on disk but has no corresponding database entry or roadmap. It may be leftover from manual cleanup or a DB reset.`, - fixable: true, - }); - if (shouldFix?.("orphaned_milestone_directory")) { - try { - rmSync(fullPath, { recursive: true, force: true }); - fixesApplied.push(`removed orphaned milestone directory ${fullPath}`); - } - catch { - fixesApplied.push(`failed to remove orphaned milestone directory ${fullPath}`); - } - } - } - } - } - } - catch { - // Non-fatal — orphaned milestone directory check failed - } - // ── Projection drift detection ────────────────────────────────────────── - // If the DB is available, check whether markdown projections are stale - // relative to the event log and re-render them. - try { - if (isDbAvailable()) { - const eventLogPath = join(basePath, ".sf", "event-log.jsonl"); - const events = readEvents(eventLogPath); - if (events.length > 0) { - const lastEventTs = new Date(events[events.length - 1].ts).getTime(); - const state = await deriveState(basePath); - for (const milestone of state.registry) { - if (milestone.status === "complete") - continue; - const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP"); - if (!roadmapPath || !existsSync(roadmapPath)) { - try { - await renderAllProjections(basePath, milestone.id); - fixesApplied.push(`re-rendered missing projections for ${milestone.id}`); - } - catch { - // Non-fatal — projection re-render failed - } - continue; - } - const projectionMtime = statSync(roadmapPath).mtimeMs; - if (lastEventTs > projectionMtime) { - try { - await renderAllProjections(basePath, milestone.id); - fixesApplied.push(`re-rendered stale projections for ${milestone.id}`); - } - catch { - // Non-fatal — projection re-render failed - } - } - } - } - } - } - catch { - // Non-fatal — projection drift check must never block doctor - } + .all(); + for (const row of doneTasks) { + issues.push({ + severity: "warning", + code: "db_done_task_no_summary", + scope: "task", + unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, + message: `Task ${row.id} is marked done but has no summary in the database`, + fixable: false, + }); + } + } catch { + // Non-fatal — done-task-no-summary check failed + } + // d. Duplicate entity IDs (safety check) + try { + const dupMilestones = adapter + .prepare( + "SELECT id, COUNT(*) as cnt FROM milestones GROUP BY id HAVING cnt > 1", + ) + .all(); + for (const row of dupMilestones) { + issues.push({ + severity: "error", + code: "db_duplicate_id", + scope: "milestone", + unitId: row.id, + message: `Duplicate milestone ID "${row.id}" appears ${row.cnt} times in the database`, + fixable: false, + }); + } + const dupSlices = adapter + .prepare( + "SELECT id, milestone_id, COUNT(*) as cnt FROM slices GROUP BY id, milestone_id HAVING cnt > 1", + ) + .all(); + for (const row of dupSlices) { + issues.push({ + severity: "error", + code: "db_duplicate_id", + scope: "slice", + unitId: `${row.milestone_id}/${row.id}`, + message: `Duplicate slice ID "${row.id}" in milestone ${row.milestone_id} appears ${row.cnt} times`, + fixable: false, + }); + } + const dupTasks = adapter + .prepare( + "SELECT id, slice_id, milestone_id, COUNT(*) as cnt FROM tasks GROUP BY id, slice_id, milestone_id HAVING cnt > 1", + ) + .all(); + for (const row of dupTasks) { + issues.push({ + severity: "error", + code: "db_duplicate_id", + scope: "task", + unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, + message: `Duplicate task ID "${row.id}" in slice ${row.slice_id} appears ${row.cnt} times`, + fixable: false, + }); + } + } catch { + // Non-fatal — duplicate ID check failed + } + } + } catch { + // Non-fatal — DB constraint checks failed entirely + } + // ── Orphaned milestone directories ───────────────────────────────────── + // Detect .sf/milestones/* directories that have no corresponding DB row. + // These are leftover from manual cleanup, failed deletions, or DB resets. + // When DB is available, DB is authoritative. When DB is unavailable, + // fall back to filesystem-derived registry (roadmap-based discovery). + try { + const msDir = milestonesDir(basePath); + if (existsSync(msDir)) { + const validMilestoneIds = new Set(); + if (isDbAvailable()) { + // DB-authoritative: only DB rows count as valid + for (const m of getAllMilestones()) { + validMilestoneIds.add(m.id); + } + } else { + // No DB: fall back to filesystem registry + const state = await deriveState(basePath); + for (const m of state.registry) { + validMilestoneIds.add(m.id); + } + } + for (const entry of readdirSync(msDir)) { + const fullPath = join(msDir, entry); + try { + if (!statSync(fullPath).isDirectory()) continue; + } catch { + continue; + } + // Extract milestone ID from directory name (handles M001, M001-r5jzab, etc.) + const milestoneId = entry.split("-")[0]; + if (!milestoneId) continue; + if ( + !validMilestoneIds.has(milestoneId) && + !validMilestoneIds.has(entry) + ) { + issues.push({ + severity: "warning", + code: "orphaned_milestone_directory", + scope: "project", + unitId: entry, + message: `Milestone directory ${fullPath} exists on disk but has no corresponding database entry or roadmap. It may be leftover from manual cleanup or a DB reset.`, + fixable: true, + }); + if (shouldFix?.("orphaned_milestone_directory")) { + try { + rmSync(fullPath, { recursive: true, force: true }); + fixesApplied.push( + `removed orphaned milestone directory ${fullPath}`, + ); + } catch { + fixesApplied.push( + `failed to remove orphaned milestone directory ${fullPath}`, + ); + } + } + } + } + } + } catch { + // Non-fatal — orphaned milestone directory check failed + } + // ── Projection drift detection ────────────────────────────────────────── + // If the DB is available, check whether markdown projections are stale + // relative to the event log and re-render them. + try { + if (isDbAvailable()) { + const eventLogPath = join(basePath, ".sf", "event-log.jsonl"); + const events = readEvents(eventLogPath); + if (events.length > 0) { + const lastEventTs = new Date(events[events.length - 1].ts).getTime(); + const state = await deriveState(basePath); + for (const milestone of state.registry) { + if (milestone.status === "complete") continue; + const roadmapPath = resolveMilestoneFile( + basePath, + milestone.id, + "ROADMAP", + ); + if (!roadmapPath || !existsSync(roadmapPath)) { + try { + await renderAllProjections(basePath, milestone.id); + fixesApplied.push( + `re-rendered missing projections for ${milestone.id}`, + ); + } catch { + // Non-fatal — projection re-render failed + } + continue; + } + const projectionMtime = statSync(roadmapPath).mtimeMs; + if (lastEventTs > projectionMtime) { + try { + await renderAllProjections(basePath, milestone.id); + fixesApplied.push( + `re-rendered stale projections for ${milestone.id}`, + ); + } catch { + // Non-fatal — projection re-render failed + } + } + } + } + } + } catch { + // Non-fatal — projection drift check must never block doctor + } } diff --git a/src/resources/extensions/sf/doctor-environment.js b/src/resources/extensions/sf/doctor-environment.js index cc0edca05..dd8d4cb66 100644 --- a/src/resources/extensions/sf/doctor-environment.js +++ b/src/resources/extensions/sf/doctor-environment.js @@ -8,9 +8,10 @@ * These checks complement the existing git/runtime health checks and * integrate into the doctor pipeline via checkEnvironmentHealth(). */ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; + // ── Constants ────────────────────────────────────────────────────────────── /** Default dev server ports to scan for conflicts. */ const DEFAULT_DEV_PORTS = [3000, 3001, 4000, 5000, 5173, 8000, 8080, 8888]; @@ -18,6 +19,7 @@ const DEFAULT_DEV_PORTS = [3000, 3001, 4000, 5000, 5173, 8000, 8080, 8888]; const MIN_DISK_BYTES = 500 * 1024 * 1024; /** Timeout for external commands (ms). */ const CMD_TIMEOUT = 5_000; +const INSTALL_TIMEOUT = 10 * 60_000; // ── Helpers ──────────────────────────────────────────────────────────────── /** Worktree sentinel — path segment that marks an auto-worktree directory. */ const WORKTREE_PATH_SEGMENT = `${join(".sf", "worktrees")}/`; @@ -30,32 +32,52 @@ const WORKTREE_PATH_SEGMENT = `${join(".sf", "worktrees")}/`; * 2. `.sf/worktrees/` segment in basePath */ function resolveWorktreeProjectRoot(basePath) { - const envRoot = process.env.SF_WORKTREE; - if (envRoot) - return envRoot; - const normalised = basePath.replace(/\\/g, "/"); - const idx = normalised.indexOf(WORKTREE_PATH_SEGMENT.replace(/\\/g, "/")); - if (idx === -1) - return null; - // Everything before `.sf/worktrees/` is the project root - return basePath.slice(0, idx); + const envRoot = process.env.SF_WORKTREE; + if (envRoot) return envRoot; + const normalised = basePath.replace(/\\/g, "/"); + const idx = normalised.indexOf(WORKTREE_PATH_SEGMENT.replace(/\\/g, "/")); + if (idx === -1) return null; + // Everything before `.sf/worktrees/` is the project root + return basePath.slice(0, idx); } function tryExec(cmd, cwd) { - try { - return execSync(cmd, { - cwd, - timeout: CMD_TIMEOUT, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - } - catch { - return null; - } + try { + return execSync(cmd, { + cwd, + timeout: CMD_TIMEOUT, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + } catch { + return null; + } } function commandExists(name, cwd) { - const whichCmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`; - return tryExec(whichCmd, cwd) !== null; + const whichCmd = + process.platform === "win32" ? `where ${name}` : `command -v ${name}`; + return tryExec(whichCmd, cwd) !== null; +} +function detectNodePackageManager(basePath) { + if (existsSync(join(basePath, "package-lock.json"))) + return { name: "npm", args: ["install"], command: "npm install" }; + if (existsSync(join(basePath, "pnpm-lock.yaml"))) + return { name: "pnpm", args: ["install"], command: "pnpm install" }; + if (existsSync(join(basePath, "yarn.lock"))) + return { name: "yarn", args: ["install"], command: "yarn install" }; + if ( + existsSync(join(basePath, "bun.lock")) || + existsSync(join(basePath, "bun.lockb")) + ) + return { name: "bun", args: ["install"], command: "bun install" }; + return null; +} +function runNodeInstall(basePath, manager) { + execFileSync(manager.name, manager.args, { + cwd: basePath, + timeout: INSTALL_TIMEOUT, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); } // ── Individual Checks ────────────────────────────────────────────────────── /** @@ -68,48 +90,47 @@ function commandExists(name, cwd) { * Returns null when the project has no Python signals (not a Python repo). */ function checkPythonEnvironment(basePath) { - const hasPyproject = existsSync(join(basePath, "pyproject.toml")); - const hasRequirements = existsSync(join(basePath, "requirements.txt")); - if (!hasPyproject && !hasRequirements) - return null; - const hasUvLock = existsSync(join(basePath, "uv.lock")); - const hasPoetryLock = existsSync(join(basePath, "poetry.lock")); - const hasPdmLock = existsSync(join(basePath, "pdm.lock")); - let manager = null; - let installHint = ""; - if (hasUvLock) { - manager = "uv"; - installHint = "Install: curl -LsSf https://astral.sh/uv/install.sh | sh"; - } - else if (hasPoetryLock) { - manager = "poetry"; - installHint = "Install: curl -sSL https://install.python-poetry.org | python3 -"; - } - else if (hasPdmLock) { - manager = "pdm"; - installHint = "Install: curl -sSL https://pdm-project.org/install-pdm.py | python3 -"; - } - if (!manager) { - return { - name: "python_env", - status: "ok", - message: "Python project (no lockfile detected)", - }; - } - const version = tryExec(`${manager} --version`, basePath); - if (!version) { - return { - name: "python_env", - status: "warning", - message: `${manager} not found in PATH (project uses ${manager}.lock)`, - detail: installHint, - }; - } - return { - name: "python_env", - status: "ok", - message: `Python project (${manager}: ${version})`, - }; + const hasPyproject = existsSync(join(basePath, "pyproject.toml")); + const hasRequirements = existsSync(join(basePath, "requirements.txt")); + if (!hasPyproject && !hasRequirements) return null; + const hasUvLock = existsSync(join(basePath, "uv.lock")); + const hasPoetryLock = existsSync(join(basePath, "poetry.lock")); + const hasPdmLock = existsSync(join(basePath, "pdm.lock")); + let manager = null; + let installHint = ""; + if (hasUvLock) { + manager = "uv"; + installHint = "Install: curl -LsSf https://astral.sh/uv/install.sh | sh"; + } else if (hasPoetryLock) { + manager = "poetry"; + installHint = + "Install: curl -sSL https://install.python-poetry.org | python3 -"; + } else if (hasPdmLock) { + manager = "pdm"; + installHint = + "Install: curl -sSL https://pdm-project.org/install-pdm.py | python3 -"; + } + if (!manager) { + return { + name: "python_env", + status: "ok", + message: "Python project (no lockfile detected)", + }; + } + const version = tryExec(`${manager} --version`, basePath); + if (!version) { + return { + name: "python_env", + status: "warning", + message: `${manager} not found in PATH (project uses ${manager}.lock)`, + detail: installHint, + }; + } + return { + name: "python_env", + status: "ok", + message: `Python project (${manager}: ${version})`, + }; } /** * Recommend installing sift on large repos where code intelligence quality @@ -120,474 +141,465 @@ function checkPythonEnvironment(basePath) { * already on PATH. */ function checkSiftAvailable(basePath) { - let fileCount = 0; - try { - // Lazy import — scanProjectFiles walks the filesystem, only do this - // when called by the doctor pipeline. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { scanProjectFiles } = require("./detection.js"); - fileCount = scanProjectFiles(basePath).length; - } - catch { - return null; - } - const SIFT_RECOMMENDED_THRESHOLD = 5000; - if (fileCount < SIFT_RECOMMENDED_THRESHOLD) - return null; - if (commandExists("sift", basePath)) { - return { - name: "sift_available", - status: "ok", - message: `sift on PATH (recommended for ${fileCount}-file repo)`, - }; - } - return { - name: "sift_available", - status: "warning", - message: `sift not installed (recommended for repos > ${SIFT_RECOMMENDED_THRESHOLD} files; this repo has ${fileCount})`, - detail: "Install: cargo install --git https://github.com/rupurt/sift", - }; + let fileCount = 0; + try { + // Lazy import — scanProjectFiles walks the filesystem, only do this + // when called by the doctor pipeline. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { scanProjectFiles } = require("./detection.js"); + fileCount = scanProjectFiles(basePath).length; + } catch { + return null; + } + const SIFT_RECOMMENDED_THRESHOLD = 5000; + if (fileCount < SIFT_RECOMMENDED_THRESHOLD) return null; + if (commandExists("sift", basePath)) { + return { + name: "sift_available", + status: "ok", + message: `sift on PATH (recommended for ${fileCount}-file repo)`, + }; + } + return { + name: "sift_available", + status: "warning", + message: `sift not installed (recommended for repos > ${SIFT_RECOMMENDED_THRESHOLD} files; this repo has ${fileCount})`, + detail: "Install: cargo install --git https://github.com/rupurt/sift", + }; } /** * Check that Node.js version meets the project's engines requirement. */ function checkNodeVersion(basePath) { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) - return null; - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const required = pkg.engines?.node; - if (!required) - return null; - const currentVersion = tryExec("node --version", basePath); - if (!currentVersion) { - return { - name: "node_version", - status: "error", - message: "Node.js not found in PATH", - }; - } - // Parse semver requirement (handles >=X.Y.Z format) - const reqMatch = required.match(/>=?\s*(\d+)(?:\.(\d+))?/); - if (!reqMatch) - return null; - const reqMajor = parseInt(reqMatch[1], 10); - const reqMinor = parseInt(reqMatch[2] ?? "0", 10); - const curMatch = currentVersion.match(/v?(\d+)\.(\d+)/); - if (!curMatch) - return null; - const curMajor = parseInt(curMatch[1], 10); - const curMinor = parseInt(curMatch[2], 10); - if (curMajor < reqMajor || (curMajor === reqMajor && curMinor < reqMinor)) { - return { - name: "node_version", - status: "warning", - message: `Node.js ${currentVersion} does not meet requirement "${required}"`, - detail: `Current: ${currentVersion}, Required: ${required}`, - }; - } - return { - name: "node_version", - status: "ok", - message: `Node.js ${currentVersion}`, - }; - } - catch { - return null; - } + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) return null; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const required = pkg.engines?.node; + if (!required) return null; + const currentVersion = tryExec("node --version", basePath); + if (!currentVersion) { + return { + name: "node_version", + status: "error", + message: "Node.js not found in PATH", + }; + } + // Parse semver requirement (handles >=X.Y.Z format) + const reqMatch = required.match(/>=?\s*(\d+)(?:\.(\d+))?/); + if (!reqMatch) return null; + const reqMajor = parseInt(reqMatch[1], 10); + const reqMinor = parseInt(reqMatch[2] ?? "0", 10); + const curMatch = currentVersion.match(/v?(\d+)\.(\d+)/); + if (!curMatch) return null; + const curMajor = parseInt(curMatch[1], 10); + const curMinor = parseInt(curMatch[2], 10); + if (curMajor < reqMajor || (curMajor === reqMajor && curMinor < reqMinor)) { + return { + name: "node_version", + status: "warning", + message: `Node.js ${currentVersion} does not meet requirement "${required}"`, + detail: `Current: ${currentVersion}, Required: ${required}`, + }; + } + return { + name: "node_version", + status: "ok", + message: `Node.js ${currentVersion}`, + }; + } catch { + return null; + } } /** * Check if node_modules exists and is not stale vs the lockfile. */ function checkDependenciesInstalled(basePath) { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) - return null; - const nodeModules = join(basePath, "node_modules"); - if (!existsSync(nodeModules)) { - // In auto-worktrees node_modules is absent by design — the worktree - // symlinks to (or expects) the project root's copy. Fall back to - // checking the project root before reporting an error (#2303). - const projectRoot = resolveWorktreeProjectRoot(basePath); - if (projectRoot && existsSync(join(projectRoot, "node_modules"))) { - return { - name: "dependencies", - status: "ok", - message: "Dependencies installed (project root)", - }; - } - return { - name: "dependencies", - status: "error", - message: "node_modules missing — run npm install", - }; - } - // Check if lockfile is newer than the last install. - // - // Each package manager writes a metadata marker inside node_modules on - // every install. Comparing the lockfile mtime against the marker is - // reliable; comparing against the node_modules *directory* mtime is not, - // because directory mtime only changes when entries are added or removed - // — not when files inside it are updated. (#1974) - const lockfiles = [ - { lock: "package-lock.json", markers: ["node_modules/.package-lock.json"] }, - { lock: "yarn.lock", markers: ["node_modules/.yarn-integrity"] }, - { lock: "pnpm-lock.yaml", markers: ["node_modules/.modules.yaml"] }, - ]; - for (const { lock, markers } of lockfiles) { - const lockPath = join(basePath, lock); - if (!existsSync(lockPath)) - continue; - try { - const lockMtime = statSync(lockPath).mtimeMs; - // Prefer the package manager's marker file; fall back to directory mtime - // only when no marker exists (e.g., manually created node_modules). - let installMtime = 0; - for (const marker of markers) { - const markerPath = join(basePath, marker); - if (existsSync(markerPath)) { - installMtime = Math.max(installMtime, statSync(markerPath).mtimeMs); - } - } - if (installMtime === 0) { - installMtime = statSync(nodeModules).mtimeMs; - } - if (lockMtime > installMtime) { - return { - name: "dependencies", - status: "warning", - message: `${lock} is newer than node_modules — dependencies may be stale`, - detail: `Run npm install / yarn / pnpm install to update`, - }; - } - } - catch { - // stat failed — skip - } - } - return { - name: "dependencies", - status: "ok", - message: "Dependencies installed", - }; + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) return null; + const nodeModules = join(basePath, "node_modules"); + if (!existsSync(nodeModules)) { + // In auto-worktrees node_modules is absent by design — the worktree + // symlinks to (or expects) the project root's copy. Fall back to + // checking the project root before reporting an error (#2303). + const projectRoot = resolveWorktreeProjectRoot(basePath); + if (projectRoot && existsSync(join(projectRoot, "node_modules"))) { + return { + name: "dependencies", + status: "ok", + message: "Dependencies installed (project root)", + }; + } + return { + name: "dependencies", + status: "error", + message: "node_modules missing — run npm install", + fixCode: "env_dependencies", + fixCommand: detectNodePackageManager(basePath)?.command ?? "npm install", + }; + } + // Check if lockfile is newer than the last install. + // + // Each package manager writes a metadata marker inside node_modules on + // every install. Comparing the lockfile mtime against the marker is + // reliable; comparing against the node_modules *directory* mtime is not, + // because directory mtime only changes when entries are added or removed + // — not when files inside it are updated. (#1974) + const lockfiles = [ + { lock: "package-lock.json", markers: ["node_modules/.package-lock.json"] }, + { lock: "yarn.lock", markers: ["node_modules/.yarn-integrity"] }, + { lock: "pnpm-lock.yaml", markers: ["node_modules/.modules.yaml"] }, + ]; + for (const { lock, markers } of lockfiles) { + const lockPath = join(basePath, lock); + if (!existsSync(lockPath)) continue; + try { + const lockMtime = statSync(lockPath).mtimeMs; + // Prefer the package manager's marker file; fall back to directory mtime + // only when no marker exists (e.g., manually created node_modules). + let installMtime = 0; + for (const marker of markers) { + const markerPath = join(basePath, marker); + if (existsSync(markerPath)) { + installMtime = Math.max(installMtime, statSync(markerPath).mtimeMs); + } + } + if (installMtime === 0) { + installMtime = statSync(nodeModules).mtimeMs; + } + if (lockMtime > installMtime) { + return { + name: "dependencies", + status: "warning", + message: `${lock} is newer than node_modules — dependencies may be stale`, + detail: `Run npm install / yarn / pnpm install to update`, + fixCode: "env_dependencies", + fixCommand: + detectNodePackageManager(basePath)?.command ?? "npm install", + }; + } + } catch { + // stat failed — skip + } + } + return { + name: "dependencies", + status: "ok", + message: "Dependencies installed", + }; } /** * Check for .env.example files without corresponding .env files. */ function checkEnvFiles(basePath) { - const examplePath = join(basePath, ".env.example"); - if (!existsSync(examplePath)) - return null; - const envPath = join(basePath, ".env"); - const envLocalPath = join(basePath, ".env.local"); - if (!existsSync(envPath) && !existsSync(envLocalPath)) { - return { - name: "env_file", - status: "warning", - message: ".env.example exists but no .env or .env.local found", - detail: "Copy .env.example to .env and fill in values", - }; - } - return { - name: "env_file", - status: "ok", - message: "Environment file present", - }; + const examplePath = join(basePath, ".env.example"); + if (!existsSync(examplePath)) return null; + const envPath = join(basePath, ".env"); + const envLocalPath = join(basePath, ".env.local"); + if (!existsSync(envPath) && !existsSync(envLocalPath)) { + return { + name: "env_file", + status: "warning", + message: ".env.example exists but no .env or .env.local found", + detail: "Copy .env.example to .env and fill in values", + }; + } + return { + name: "env_file", + status: "ok", + message: "Environment file present", + }; } /** * Check for port conflicts on common dev server ports. * Only checks ports that appear in package.json scripts. */ function checkPortConflicts(basePath) { - // Only run on macOS/Linux — lsof is not available on Windows - if (process.platform === "win32") - return []; - const results = []; - // Try to detect ports from package.json scripts - const portsToCheck = new Set(); - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) { - // No package.json — this isn't a Node.js project. Skip port checks - // entirely to avoid false positives from system services (e.g., macOS - // AirPlay Receiver on port 5000). (#1381) - return []; - } - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const scripts = pkg.scripts ?? {}; - const scriptText = Object.values(scripts).join(" "); - // Look for --port NNNN, -p NNNN, PORT=NNNN patterns - // Anchor more tightly: require whitespace or start-of-string for --port/-p, - // and require whitespace or = for PORT=, avoid IPv6 colons. - const portMatches = scriptText.matchAll(/(?:^|\s)(?:--port\s+|-p\s+)(\d{4,5})\b|(?:^|[\s=])PORT=(\d{4,5})(?:\s|$)/gm); - for (const m of portMatches) { - const port = parseInt(m[1] || m[2], 10); - if (port >= 1024 && port <= 65535) - portsToCheck.add(port); - } - } - catch { - // parse failed — skip port checks rather than using defaults - return []; - } - // If no ports found in scripts, check common defaults. - // Filter out port 5000 on macOS — AirPlay Receiver uses it by default (#1381). - if (portsToCheck.size === 0) { - for (const p of DEFAULT_DEV_PORTS) { - if (p === 5000 && process.platform === "darwin") - continue; - portsToCheck.add(p); - } - } - for (const port of portsToCheck) { - const result = tryExec(`lsof -i :${port} -sTCP:LISTEN -t`, basePath); - if (result && result.length > 0) { - // Get process name - const nameResult = tryExec(`lsof -i :${port} -sTCP:LISTEN -F cn | head -2`, basePath); - // Parse lsof -F cn output: lines like "c" and "n" - // Use field mode to reliably extract process name from COMMAND field. - // Defensive: if the first 'c' line is missing, scan all lines. - let processName = "unknown"; - if (nameResult) { - const cLine = nameResult - .split("\n") - .find((line) => line.startsWith("c")); - if (cLine !== undefined) { - processName = cLine.substring(1); - } - } - results.push({ - name: "port_conflict", - status: "warning", - message: `Port ${port} is already in use by ${processName} (PID ${result.split("\n")[0]})`, - detail: `Kill the process or use a different port`, - }); - } - } - return results; + // Only run on macOS/Linux — lsof is not available on Windows + if (process.platform === "win32") return []; + const results = []; + // Try to detect ports from package.json scripts + const portsToCheck = new Set(); + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) { + // No package.json — this isn't a Node.js project. Skip port checks + // entirely to avoid false positives from system services (e.g., macOS + // AirPlay Receiver on port 5000). (#1381) + return []; + } + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const scripts = pkg.scripts ?? {}; + const scriptText = Object.values(scripts).join(" "); + // Look for --port NNNN, -p NNNN, PORT=NNNN patterns + // Anchor more tightly: require whitespace or start-of-string for --port/-p, + // and require whitespace or = for PORT=, avoid IPv6 colons. + const portMatches = scriptText.matchAll( + /(?:^|\s)(?:--port\s+|-p\s+)(\d{4,5})\b|(?:^|[\s=])PORT=(\d{4,5})(?:\s|$)/gm, + ); + for (const m of portMatches) { + const port = parseInt(m[1] || m[2], 10); + if (port >= 1024 && port <= 65535) portsToCheck.add(port); + } + } catch { + // parse failed — skip port checks rather than using defaults + return []; + } + // If no ports found in scripts, check common defaults. + // Filter out port 5000 on macOS — AirPlay Receiver uses it by default (#1381). + if (portsToCheck.size === 0) { + for (const p of DEFAULT_DEV_PORTS) { + if (p === 5000 && process.platform === "darwin") continue; + portsToCheck.add(p); + } + } + for (const port of portsToCheck) { + const result = tryExec(`lsof -i :${port} -sTCP:LISTEN -t`, basePath); + if (result && result.length > 0) { + // Get process name + const nameResult = tryExec( + `lsof -i :${port} -sTCP:LISTEN -F cn | head -2`, + basePath, + ); + // Parse lsof -F cn output: lines like "c" and "n" + // Use field mode to reliably extract process name from COMMAND field. + // Defensive: if the first 'c' line is missing, scan all lines. + let processName = "unknown"; + if (nameResult) { + const cLine = nameResult + .split("\n") + .find((line) => line.startsWith("c")); + if (cLine !== undefined) { + processName = cLine.substring(1); + } + } + results.push({ + name: "port_conflict", + status: "warning", + message: `Port ${port} is already in use by ${processName} (PID ${result.split("\n")[0]})`, + detail: `Kill the process or use a different port`, + }); + } + } + return results; } /** * Check available disk space on the working directory partition. */ function checkDiskSpace(basePath) { - // Only run on macOS/Linux - if (process.platform === "win32") - return null; - const dfOutput = tryExec(`df -k "${basePath}" | tail -1`, basePath); - if (!dfOutput) - return null; - try { - // df output: filesystem blocks used avail capacity mount - const parts = dfOutput.split(/\s+/); - const availKB = parseInt(parts[3], 10); - if (Number.isNaN(availKB)) - return null; - const availBytes = availKB * 1024; - const availMB = Math.round(availBytes / (1024 * 1024)); - const availGB = (availBytes / (1024 * 1024 * 1024)).toFixed(1); - if (availBytes < MIN_DISK_BYTES) { - return { - name: "disk_space", - status: "error", - message: `Low disk space: ${availMB}MB free`, - detail: `Free up space — builds and git operations may fail`, - }; - } - if (availBytes < MIN_DISK_BYTES * 4) { - return { - name: "disk_space", - status: "warning", - message: `Disk space getting low: ${availGB}GB free`, - }; - } - return { name: "disk_space", status: "ok", message: `${availGB}GB free` }; - } - catch { - return null; - } + // Only run on macOS/Linux + if (process.platform === "win32") return null; + const dfOutput = tryExec(`df -k "${basePath}" | tail -1`, basePath); + if (!dfOutput) return null; + try { + // df output: filesystem blocks used avail capacity mount + const parts = dfOutput.split(/\s+/); + const availKB = parseInt(parts[3], 10); + if (Number.isNaN(availKB)) return null; + const availBytes = availKB * 1024; + const availMB = Math.round(availBytes / (1024 * 1024)); + const availGB = (availBytes / (1024 * 1024 * 1024)).toFixed(1); + if (availBytes < MIN_DISK_BYTES) { + return { + name: "disk_space", + status: "error", + message: `Low disk space: ${availMB}MB free`, + detail: `Free up space — builds and git operations may fail`, + }; + } + if (availBytes < MIN_DISK_BYTES * 4) { + return { + name: "disk_space", + status: "warning", + message: `Disk space getting low: ${availGB}GB free`, + }; + } + return { name: "disk_space", status: "ok", message: `${availGB}GB free` }; + } catch { + return null; + } } /** * Check if Docker is available when project has a Dockerfile. */ function checkDocker(basePath) { - const hasDockerfile = existsSync(join(basePath, "Dockerfile")) || - existsSync(join(basePath, "docker-compose.yml")) || - existsSync(join(basePath, "docker-compose.yaml")) || - existsSync(join(basePath, "compose.yml")) || - existsSync(join(basePath, "compose.yaml")); - if (!hasDockerfile) - return null; - if (!commandExists("docker", basePath)) { - return { - name: "docker", - status: "warning", - message: "Project has Docker files but docker is not installed", - }; - } - const info = tryExec("docker info --format '{{.ServerVersion}}'", basePath); - if (!info) { - return { - name: "docker", - status: "warning", - message: "Docker is installed but daemon is not running", - detail: "Start Docker Desktop or the docker daemon", - }; - } - return { name: "docker", status: "ok", message: `Docker ${info}` }; + const hasDockerfile = + existsSync(join(basePath, "Dockerfile")) || + existsSync(join(basePath, "docker-compose.yml")) || + existsSync(join(basePath, "docker-compose.yaml")) || + existsSync(join(basePath, "compose.yml")) || + existsSync(join(basePath, "compose.yaml")); + if (!hasDockerfile) return null; + if (!commandExists("docker", basePath)) { + return { + name: "docker", + status: "warning", + message: "Project has Docker files but docker is not installed", + }; + } + const info = tryExec("docker info --format '{{.ServerVersion}}'", basePath); + if (!info) { + return { + name: "docker", + status: "warning", + message: "Docker is installed but daemon is not running", + detail: "Start Docker Desktop or the docker daemon", + }; + } + return { name: "docker", status: "ok", message: `Docker ${info}` }; } /** * Check for common project tools that should be available. */ function checkProjectTools(basePath) { - const results = []; - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) - return results; - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const allDeps = { - ...(pkg.dependencies ?? {}), - ...(pkg.devDependencies ?? {}), - }; - // Check for package manager - const packageManager = pkg.packageManager; - if (packageManager) { - const managerName = packageManager.split("@")[0]; - if (managerName && - managerName !== "npm" && - !commandExists(managerName, basePath)) { - results.push({ - name: "package_manager", - status: "warning", - message: `Project requires ${managerName} but it's not installed`, - detail: `Install with: npm install -g ${managerName}`, - }); - } - } - // Check for TypeScript if it's a dependency - if (allDeps["typescript"] && - !existsSync(join(basePath, "node_modules", ".bin", "tsc"))) { - results.push({ - name: "typescript", - status: "warning", - message: "TypeScript is a dependency but tsc is not available (run npm install)", - }); - } - // Check for Python if pyproject.toml or requirements.txt exists - if (existsSync(join(basePath, "pyproject.toml")) || - existsSync(join(basePath, "requirements.txt"))) { - if (!commandExists("python3", basePath) && - !commandExists("python", basePath)) { - results.push({ - name: "python", - status: "warning", - message: "Project has Python config but python is not installed", - }); - } - } - // Check for Rust if Cargo.toml exists - if (existsSync(join(basePath, "Cargo.toml"))) { - if (!commandExists("cargo", basePath)) { - results.push({ - name: "cargo", - status: "warning", - message: "Project has Cargo.toml but cargo is not installed", - }); - } - } - // Check for Go if go.mod exists - if (existsSync(join(basePath, "go.mod"))) { - if (!commandExists("go", basePath)) { - results.push({ - name: "go", - status: "warning", - message: "Project has go.mod but go is not installed", - }); - } - } - } - catch { - // parse failed — skip - } - return results; + const results = []; + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) return results; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const allDeps = { + ...(pkg.dependencies ?? {}), + ...(pkg.devDependencies ?? {}), + }; + // Check for package manager + const packageManager = pkg.packageManager; + if (packageManager) { + const managerName = packageManager.split("@")[0]; + if ( + managerName && + managerName !== "npm" && + !commandExists(managerName, basePath) + ) { + results.push({ + name: "package_manager", + status: "warning", + message: `Project requires ${managerName} but it's not installed`, + detail: `Install with: npm install -g ${managerName}`, + }); + } + } + // Check for TypeScript if it's a dependency + if ( + allDeps["typescript"] && + !existsSync(join(basePath, "node_modules", ".bin", "tsc")) + ) { + results.push({ + name: "typescript", + status: "warning", + message: + "TypeScript is a dependency but tsc is not available (run npm install)", + }); + } + // Check for Python if pyproject.toml or requirements.txt exists + if ( + existsSync(join(basePath, "pyproject.toml")) || + existsSync(join(basePath, "requirements.txt")) + ) { + if ( + !commandExists("python3", basePath) && + !commandExists("python", basePath) + ) { + results.push({ + name: "python", + status: "warning", + message: "Project has Python config but python is not installed", + }); + } + } + // Check for Rust if Cargo.toml exists + if (existsSync(join(basePath, "Cargo.toml"))) { + if (!commandExists("cargo", basePath)) { + results.push({ + name: "cargo", + status: "warning", + message: "Project has Cargo.toml but cargo is not installed", + }); + } + } + // Check for Go if go.mod exists + if (existsSync(join(basePath, "go.mod"))) { + if (!commandExists("go", basePath)) { + results.push({ + name: "go", + status: "warning", + message: "Project has go.mod but go is not installed", + }); + } + } + } catch { + // parse failed — skip + } + return results; } /** * Check git remote reachability. */ function checkGitRemote(basePath) { - // Only check if it's a git repo with a remote - const remote = tryExec("git remote get-url origin", basePath); - if (!remote) - return null; - // Quick connectivity check with short timeout - const result = tryExec("git ls-remote --exit-code -h origin HEAD", basePath); - if (result === null) { - return { - name: "git_remote", - status: "warning", - message: "Git remote 'origin' is unreachable", - detail: `Remote: ${remote}`, - }; - } - return { name: "git_remote", status: "ok", message: "Git remote reachable" }; + // Only check if it's a git repo with a remote + const remote = tryExec("git remote get-url origin", basePath); + if (!remote) return null; + // Quick connectivity check with short timeout + const result = tryExec("git ls-remote --exit-code -h origin HEAD", basePath); + if (result === null) { + return { + name: "git_remote", + status: "warning", + message: "Git remote 'origin' is unreachable", + detail: `Remote: ${remote}`, + }; + } + return { name: "git_remote", status: "ok", message: "Git remote reachable" }; } /** * Check if the project build passes (opt-in slow check, use --build flag). * Runs npm run build and reports failure as env_build. */ function checkBuildHealth(basePath) { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) - return null; - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const buildScript = pkg.scripts?.build; - if (!buildScript) - return null; - const result = tryExec("npm run build 2>&1", basePath); - if (result === null) { - return { - name: "build", - status: "error", - message: "Build failed — npm run build exited non-zero", - detail: "Fix build errors before dispatching work", - }; - } - return { name: "build", status: "ok", message: "Build passes" }; - } - catch { - return null; - } + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) return null; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const buildScript = pkg.scripts?.build; + if (!buildScript) return null; + const result = tryExec("npm run build 2>&1", basePath); + if (result === null) { + return { + name: "build", + status: "error", + message: "Build failed — npm run build exited non-zero", + detail: "Fix build errors before dispatching work", + }; + } + return { name: "build", status: "ok", message: "Build passes" }; + } catch { + return null; + } } /** * Check if tests pass (opt-in slow check, use --test flag). * Runs npm test and reports failures as env_test. */ function checkTestHealth(basePath) { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) - return null; - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const testScript = pkg.scripts?.test; - // Skip if no test script or the default placeholder - if (!testScript || testScript.includes("no test specified")) - return null; - const result = tryExec("npm test 2>&1", basePath); - if (result === null) { - return { - name: "test", - status: "warning", - message: "Tests failing — npm test exited non-zero", - detail: "Fix failing tests before shipping", - }; - } - return { name: "test", status: "ok", message: "Tests pass" }; - } - catch { - return null; - } + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) return null; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const testScript = pkg.scripts?.test; + // Skip if no test script or the default placeholder + if (!testScript || testScript.includes("no test specified")) return null; + const result = tryExec("npm test 2>&1", basePath); + if (result === null) { + return { + name: "test", + status: "warning", + message: "Tests failing — npm test exited non-zero", + detail: "Fix failing tests before shipping", + }; + } + return { name: "test", status: "ok", message: "Tests pass" }; + } catch { + return null; + } } // ── Public API ───────────────────────────────────────────────────────────── /** @@ -595,129 +607,155 @@ function checkTestHealth(basePath) { * integration with the doctor pipeline. */ export function runEnvironmentChecks(basePath) { - const results = []; - const nodeCheck = checkNodeVersion(basePath); - if (nodeCheck) - results.push(nodeCheck); - const pythonCheck = checkPythonEnvironment(basePath); - if (pythonCheck) - results.push(pythonCheck); - const siftCheck = checkSiftAvailable(basePath); - if (siftCheck) - results.push(siftCheck); - const depsCheck = checkDependenciesInstalled(basePath); - if (depsCheck) - results.push(depsCheck); - const envCheck = checkEnvFiles(basePath); - if (envCheck) - results.push(envCheck); - results.push(...checkPortConflicts(basePath)); - const diskCheck = checkDiskSpace(basePath); - if (diskCheck) - results.push(diskCheck); - const dockerCheck = checkDocker(basePath); - if (dockerCheck) - results.push(dockerCheck); - results.push(...checkProjectTools(basePath)); - // Git remote check can be slow — only run on explicit doctor invocation - // (not on pre-dispatch gate) - return results; + const results = []; + const nodeCheck = checkNodeVersion(basePath); + if (nodeCheck) results.push(nodeCheck); + const pythonCheck = checkPythonEnvironment(basePath); + if (pythonCheck) results.push(pythonCheck); + const siftCheck = checkSiftAvailable(basePath); + if (siftCheck) results.push(siftCheck); + const depsCheck = checkDependenciesInstalled(basePath); + if (depsCheck) results.push(depsCheck); + const envCheck = checkEnvFiles(basePath); + if (envCheck) results.push(envCheck); + results.push(...checkPortConflicts(basePath)); + const diskCheck = checkDiskSpace(basePath); + if (diskCheck) results.push(diskCheck); + const dockerCheck = checkDocker(basePath); + if (dockerCheck) results.push(dockerCheck); + results.push(...checkProjectTools(basePath)); + // Git remote check can be slow — only run on explicit doctor invocation + // (not on pre-dispatch gate) + return results; } /** * Run environment checks with git remote check included. * Use this for explicit /sf doctor invocations, not pre-dispatch gates. */ export function runFullEnvironmentChecks(basePath) { - const results = runEnvironmentChecks(basePath); - const remoteCheck = checkGitRemote(basePath); - if (remoteCheck) - results.push(remoteCheck); - return results; + const results = runEnvironmentChecks(basePath); + const remoteCheck = checkGitRemote(basePath); + if (remoteCheck) results.push(remoteCheck); + return results; } /** * Run slow opt-in checks (build and/or test). * These are never run on the pre-dispatch gate — only on explicit /sf doctor --build/--test. */ export function runSlowEnvironmentChecks(basePath, options) { - const results = []; - if (options?.includeBuild) { - const buildCheck = checkBuildHealth(basePath); - if (buildCheck) - results.push(buildCheck); - } - if (options?.includeTests) { - const testCheck = checkTestHealth(basePath); - if (testCheck) - results.push(testCheck); - } - return results; + const results = []; + if (options?.includeBuild) { + const buildCheck = checkBuildHealth(basePath); + if (buildCheck) results.push(buildCheck); + } + if (options?.includeTests) { + const testCheck = checkTestHealth(basePath); + if (testCheck) results.push(testCheck); + } + return results; } /** * Convert environment check results to DoctorIssue format for the doctor pipeline. */ export function environmentResultsToDoctorIssues(results) { - return results - .filter((r) => r.status !== "ok") - .map((r) => ({ - severity: r.status === "error" ? "error" : "warning", - code: `env_${r.name}`, - scope: "project", - unitId: "environment", - message: r.detail ? `${r.message} — ${r.detail}` : r.message, - fixable: false, - })); + return results + .filter((r) => r.status !== "ok") + .map((r) => ({ + severity: r.status === "error" ? "error" : "warning", + code: `env_${r.name}`, + scope: "project", + unitId: "environment", + message: r.detail ? `${r.message} — ${r.detail}` : r.message, + fixable: r.fixCode === `env_${r.name}`, + })); +} +export function applyEnvironmentFixes(basePath, results, options) { + const shouldFix = options?.shouldFix; + const fixesApplied = options?.fixesApplied; + if (!shouldFix || !fixesApplied) return false; + const dependencyIssue = results.find( + (r) => + r.name === "dependencies" && + r.status !== "ok" && + r.fixCode === "env_dependencies", + ); + if (!dependencyIssue || !shouldFix("env_dependencies")) return false; + const manager = detectNodePackageManager(basePath); + if (!manager) { + fixesApplied.push("dependencies: skipped install (no supported lockfile)"); + return false; + } + if (!commandExists(manager.name, basePath)) { + fixesApplied.push( + `dependencies: skipped ${manager.command} (${manager.name} not found)`, + ); + return false; + } + try { + runNodeInstall(basePath, manager); + fixesApplied.push(`dependencies: ran ${manager.command}`); + return true; + } catch { + fixesApplied.push(`dependencies: ${manager.command} failed`); + return false; + } } /** * Integration point for the doctor pipeline. Runs environment checks * and appends issues to the provided array. */ export async function checkEnvironmentHealth(basePath, issues, options) { - const results = options?.includeRemote - ? runFullEnvironmentChecks(basePath) - : runEnvironmentChecks(basePath); - if (options?.includeBuild || options?.includeTests) { - results.push(...runSlowEnvironmentChecks(basePath, options)); - } - issues.push(...environmentResultsToDoctorIssues(results)); + let results = options?.includeRemote + ? runFullEnvironmentChecks(basePath) + : runEnvironmentChecks(basePath); + if (options?.includeBuild || options?.includeTests) { + results.push(...runSlowEnvironmentChecks(basePath, options)); + } + if (applyEnvironmentFixes(basePath, results, options)) { + results = options?.includeRemote + ? runFullEnvironmentChecks(basePath) + : runEnvironmentChecks(basePath); + if (options?.includeBuild || options?.includeTests) { + results.push(...runSlowEnvironmentChecks(basePath, options)); + } + } + issues.push(...environmentResultsToDoctorIssues(results)); } /** * Check if emoji icons should be rendered. * Respects NO_COLOR env var and CI detection. */ function shouldShowEmojis() { - // NO_COLOR disables all color and emoji output - if (process.env.NO_COLOR) - return false; - // CI environments often don't support emoji rendering - if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) - return false; - return true; + // NO_COLOR disables all color and emoji output + if (process.env.NO_COLOR) return false; + // CI environments often don't support emoji rendering + if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) return false; + return true; } /** * Format environment check results for display. */ export function formatEnvironmentReport(results) { - if (results.length === 0) - return "No environment checks applicable."; - const lines = []; - lines.push("Environment Health:"); - const useEmojis = shouldShowEmojis(); - for (const r of results) { - const icon = useEmojis - ? r.status === "ok" - ? "\u2705" - : r.status === "warning" - ? "\u26A0\uFE0F" - : "\uD83D\uDED1" - : r.status === "ok" - ? "\u2713" - : r.status === "warning" - ? "\u26A0" - : "\u2717"; - lines.push(` ${icon} ${r.message}`); - if (r.detail && r.status !== "ok") { - lines.push(` ${r.detail}`); - } - } - return lines.join("\n"); + if (results.length === 0) return "No environment checks applicable."; + const lines = []; + lines.push("Environment Health:"); + const useEmojis = shouldShowEmojis(); + for (const r of results) { + const icon = useEmojis + ? r.status === "ok" + ? "\u2705" + : r.status === "warning" + ? "\u26A0\uFE0F" + : "\uD83D\uDED1" + : r.status === "ok" + ? "\u2713" + : r.status === "warning" + ? "\u26A0" + : "\u2717"; + lines.push(` ${icon} ${r.message}`); + if (r.detail && r.status !== "ok") { + lines.push(` ${r.detail}`); + } + } + return lines.join("\n"); } diff --git a/src/resources/extensions/sf/doctor-git-checks.js b/src/resources/extensions/sf/doctor-git-checks.js index 3fb129437..e4786d58d 100644 --- a/src/resources/extensions/sf/doctor-git-checks.js +++ b/src/resources/extensions/sf/doctor-git-checks.js @@ -1,16 +1,48 @@ -import { existsSync, readdirSync, realpathSync, rmSync, statSync, } from "node:fs"; +import { + existsSync, + readdirSync, + realpathSync, + rmSync, + statSync, +} from "node:fs"; import { join, sep } from "node:path"; import { loadFile } from "./files.js"; import { abortAndReset } from "./git-self-heal.js"; -import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch, } from "./git-service.js"; -import { nativeAddTracked, nativeBranchDelete, nativeBranchList, nativeCommit, nativeGetCurrentBranch, nativeHasChanges, nativeIsRepo, nativeLastCommitEpoch, nativeLsFiles, nativeRmCached, nativeWorktreeList, nativeWorktreeRemove, } from "./native-git-bridge.js"; +import { + RUNTIME_EXCLUSION_PATHS, + resolveMilestoneIntegrationBranch, + writeIntegrationBranch, +} from "./git-service.js"; +import { + nativeAddTracked, + nativeBranchDelete, + nativeBranchList, + nativeCommit, + nativeGetCurrentBranch, + nativeHasChanges, + nativeIsRepo, + nativeLastCommitEpoch, + nativeLsFiles, + nativeRmCached, + nativeWorktreeList, + nativeWorktreeRemove, +} from "./native-git-bridge.js"; import { parseRoadmap } from "./parsers.js"; import { resolveMilestoneFile } from "./paths.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; import { getMilestoneSlices, isDbAvailable } from "./sf-db.js"; +import { + formatProtectedSnapshotDeletionMessage, + listProtectedSnapshotDeletions, +} from "./snapshot-safety.js"; import { deriveState, isMilestoneComplete } from "./state.js"; import { getAllWorktreeHealth } from "./worktree-health.js"; -import { listWorktrees, resolveGitDir, worktreesDir, } from "./worktree-manager.js"; +import { + listWorktrees, + resolveGitDir, + worktreesDir, +} from "./worktree-manager.js"; + /** * Returns true if the directory contains only doctor artifacts * (e.g. `.sf/doctor-history.jsonl`). These dirs are created by @@ -18,480 +50,503 @@ import { listWorktrees, resolveGitDir, worktreesDir, } from "./worktree-manager. * and should not be flagged as orphaned worktrees (#3105). */ function isDoctorArtifactOnly(dirPath) { - try { - const entries = readdirSync(dirPath); - // Empty dir — not a doctor artifact, still orphaned - if (entries.length === 0) - return false; - // Only a .sf subdirectory - if (entries.length === 1 && entries[0] === ".sf") { - const sfEntries = readdirSync(join(dirPath, ".sf")); - return (sfEntries.length <= 1 && - sfEntries.every((e) => e === "doctor-history.jsonl")); - } - return false; - } - catch { - return false; - } + try { + const entries = readdirSync(dirPath); + // Empty dir — not a doctor artifact, still orphaned + if (entries.length === 0) return false; + // Only a .sf subdirectory + if (entries.length === 1 && entries[0] === ".sf") { + const sfEntries = readdirSync(join(dirPath, ".sf")); + return ( + sfEntries.length <= 1 && + sfEntries.every((e) => e === "doctor-history.jsonl") + ); + } + return false; + } catch { + return false; + } } -export async function checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode = "none") { - // Degrade gracefully if not a git repo - if (!nativeIsRepo(basePath)) { - return; // Not a git repo — skip all git health checks - } - const gitDir = resolveGitDir(basePath); - // ── Orphaned auto-worktrees & Stale milestone branches ──────────────── - // These checks only apply in worktree/branch modes — skip in none mode - // where no milestone worktrees or branches are created. - if (isolationMode !== "none") { - try { - const worktrees = listWorktrees(basePath); - const milestoneWorktrees = worktrees.filter((wt) => wt.branch.startsWith("milestone/")); - // Load roadmap state once for cross-referencing - const state = await deriveState(basePath); - for (const wt of milestoneWorktrees) { - // Extract milestone ID from branch name "milestone/M001" → "M001" - const milestoneId = wt.branch.replace(/^milestone\//, ""); - const milestoneEntry = state.registry.find((m) => m.id === milestoneId); - // Check if milestone is complete via roadmap - let isComplete = false; - if (milestoneEntry) { - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestoneId); - isComplete = - dbSlices.length > 0 && - dbSlices.every((s) => s.status === "complete"); - } - else { - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - const roadmapContent = roadmapPath - ? await loadFile(roadmapPath) - : null; - if (roadmapContent) { - const roadmap = parseRoadmap(roadmapContent); - isComplete = isMilestoneComplete(roadmap); - } - } - // When DB unavailable and no roadmap, isComplete stays false - } - if (isComplete) { - issues.push({ - severity: "warning", - code: "orphaned_auto_worktree", - scope: "milestone", - unitId: milestoneId, - message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`, - fixable: true, - }); - if (shouldFix("orphaned_auto_worktree")) { - // If cwd is inside the worktree, chdir out first — matching the - // pattern in removeWorktree() (#1946). Without this, git cannot - // remove the worktree and the doctor enters a deadlock where it - // detects the orphan every run but never cleans it up. - const cwd = process.cwd(); - if (wt.path === cwd || cwd.startsWith(wt.path + sep)) { - try { - process.chdir(basePath); - } - catch { - fixesApplied.push(`skipped removing worktree at ${wt.path} (cannot chdir to basePath)`); - continue; - } - } - try { - nativeWorktreeRemove(basePath, wt.path, true); - fixesApplied.push(`removed orphaned worktree ${wt.path}`); - } - catch { - fixesApplied.push(`failed to remove worktree ${wt.path}`); - } - } - } - } - // ── Stale milestone branches ───────────────────────────────────────── - try { - const branches = nativeBranchList(basePath, "milestone/*"); - if (branches.length > 0) { - const worktreeBranches = new Set(milestoneWorktrees.map((wt) => wt.branch)); - for (const branch of branches) { - // Skip branches that have a worktree (handled above) - if (worktreeBranches.has(branch)) - continue; - const milestoneId = branch.replace(/^milestone\//, ""); - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - let branchMilestoneComplete = false; - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestoneId); - branchMilestoneComplete = - dbSlices.length > 0 && - dbSlices.every((s) => s.status === "complete"); - } - else { - const roadmapContent = roadmapPath - ? await loadFile(roadmapPath) - : null; - if (!roadmapContent) - continue; - const roadmap = parseRoadmap(roadmapContent); - branchMilestoneComplete = isMilestoneComplete(roadmap); - } - if (branchMilestoneComplete) { - issues.push({ - severity: "info", - code: "stale_milestone_branch", - scope: "milestone", - unitId: milestoneId, - message: `Branch ${branch} exists for completed milestone ${milestoneId}`, - fixable: true, - }); - if (shouldFix("stale_milestone_branch")) { - try { - nativeBranchDelete(basePath, branch, true); - fixesApplied.push(`deleted stale branch ${branch}`); - } - catch { - fixesApplied.push(`failed to delete branch ${branch}`); - } - } - } - } - } - } - catch { - // git branch list failed — skip stale branch check - } - } - catch { - // listWorktrees or deriveState failed — skip worktree/branch checks - } - } // end isolationMode !== "none" - // ── Corrupt merge state ──────────────────────────────────────────────── - try { - const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"]; - const mergeStateDirs = ["rebase-apply", "rebase-merge"]; - const found = []; - for (const f of mergeStateFiles) { - if (existsSync(join(gitDir, f))) - found.push(f); - } - for (const d of mergeStateDirs) { - if (existsSync(join(gitDir, d))) - found.push(d); - } - if (found.length > 0) { - issues.push({ - severity: "error", - code: "corrupt_merge_state", - scope: "project", - unitId: "project", - message: `Corrupt merge/rebase state detected: ${found.join(", ")}`, - fixable: true, - }); - if (shouldFix("corrupt_merge_state")) { - const result = abortAndReset(basePath); - fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`); - } - } - } - catch { - // Can't check .git dir — skip - } - // ── Tracked runtime files ────────────────────────────────────────────── - try { - const trackedPaths = []; - for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - try { - const files = nativeLsFiles(basePath, exclusion); - if (files.length > 0) { - trackedPaths.push(...files); - } - } - catch { - // Individual ls-files can fail — continue - } - } - if (trackedPaths.length > 0) { - issues.push({ - severity: "warning", - code: "tracked_runtime_files", - scope: "project", - unitId: "project", - message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`, - fixable: true, - }); - if (shouldFix("tracked_runtime_files")) { - try { - for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - nativeRmCached(basePath, [exclusion]); - } - fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`); - } - catch { - fixesApplied.push("failed to untrack runtime files"); - } - } - } - } - catch { - // git ls-files failed — skip - } - // ── Legacy slice branches ────────────────────────────────────────────── - try { - const branchList = nativeBranchList(basePath, "sf/*/*").filter((branch) => !branch.startsWith("sf/quick/")); - if (branchList.length > 0) { - issues.push({ - severity: "info", - code: "legacy_slice_branches", - scope: "project", - unitId: "project", - message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture).`, - fixable: true, - }); - if (shouldFix("legacy_slice_branches")) { - let deleted = 0; - for (const branch of branchList) { - try { - nativeBranchDelete(basePath, branch, true); - deleted++; - } - catch { - /* skip branches that can't be deleted */ - } - } - if (deleted > 0) { - fixesApplied.push(`deleted ${deleted} legacy slice branch(es)`); - } - } - } - } - catch { - // git branch list failed — skip - } - // ── Integration branch existence ────────────────────────────────────── - // For each active (non-complete) milestone, verify the stored integration - // branch still exists in git. A missing integration branch blocks merge-back - // and causes the next merge operation to fail silently. - try { - const state = await deriveState(basePath); - const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git ?? {}; - for (const milestone of state.registry) { - if (milestone.status === "complete") - continue; - const resolution = resolveMilestoneIntegrationBranch(basePath, milestone.id, gitPrefs); - if (!resolution.recordedBranch) - continue; // No stored branch — skip (not yet set) - if (resolution.status === "fallback" && resolution.effectiveBranch) { - issues.push({ - severity: "warning", - code: "integration_branch_missing", - scope: "milestone", - unitId: milestone.id, - message: resolution.reason, - fixable: true, - }); - if (shouldFix("integration_branch_missing")) { - writeIntegrationBranch(basePath, milestone.id, resolution.effectiveBranch); - fixesApplied.push(`updated integration branch for ${milestone.id} to "${resolution.effectiveBranch}"`); - } - continue; - } - if (resolution.status === "missing") { - issues.push({ - severity: "error", - code: "integration_branch_missing", - scope: "milestone", - unitId: milestone.id, - message: resolution.reason, - fixable: false, - }); - } - } - } - catch { - // Non-fatal — integration branch check failed - } - // ── Orphaned worktree directories ──────────────────────────────────── - // Worktree removal can fail after a branch delete, leaving a directory - // that is no longer registered with git. These orphaned dirs cause - // "already exists" errors when re-creating the same worktree name. - try { - const wtDir = worktreesDir(basePath); - if (existsSync(wtDir)) { - // Resolve symlinks and normalize separators so that symlinked .sf - // paths (e.g. ~/.sf/projects//worktrees/…) match the paths - // returned by `git worktree list`. - const normalizePath = (p) => { - try { - p = realpathSync(p); - } - catch { - /* path may not exist */ - } - return p.replaceAll("\\", "/"); - }; - const registeredPaths = new Set(nativeWorktreeList(basePath).map((entry) => normalizePath(entry.path))); - for (const entry of readdirSync(wtDir)) { - const fullPath = join(wtDir, entry); - try { - if (!statSync(fullPath).isDirectory()) - continue; - } - catch { - continue; - } - const normalizedFullPath = normalizePath(fullPath); - if (!registeredPaths.has(normalizedFullPath)) { - // Skip directories that only contain doctor artifacts (.sf/doctor-history.jsonl). - // appendDoctorHistory() can recreate these dirs during the audit itself, - // causing a circular false positive (#3105 Bug 1). - if (isDoctorArtifactOnly(fullPath)) - continue; - issues.push({ - severity: "warning", - code: "worktree_directory_orphaned", - scope: "project", - unitId: entry, - message: `Worktree directory ${fullPath} exists on disk but is not registered with git. Run "git worktree prune" or doctor --fix to remove it.`, - fixable: true, - }); - if (shouldFix("worktree_directory_orphaned")) { - try { - rmSync(fullPath, { recursive: true, force: true }); - fixesApplied.push(`removed orphaned worktree directory ${fullPath}`); - } - catch { - fixesApplied.push(`failed to remove orphaned worktree directory ${fullPath}`); - } - } - } - } - } - } - catch { - // Non-fatal — orphaned worktree directory check failed - } - // ── Stale uncommitted changes ──────────────────────────────────────────── - // If the working tree has uncommitted changes and the last commit was - // longer ago than the configured threshold, flag it and optionally - // auto-commit a safety snapshot so work isn't lost. - try { - const prefs = loadEffectiveSFPreferences()?.preferences ?? {}; - const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30; - if (thresholdMinutes > 0) { - const dirty = nativeHasChanges(basePath); - if (dirty) { - const branch = nativeGetCurrentBranch(basePath); - const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD"); - const nowEpoch = Math.floor(Date.now() / 1000); - const minutesSinceCommit = lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity; - if (minutesSinceCommit >= thresholdMinutes) { - const mins = Math.floor(minutesSinceCommit); - issues.push({ - severity: "warning", - code: "stale_uncommitted_changes", - scope: "project", - unitId: "project", - message: `Uncommitted changes detected with no commit in ${mins} minute${mins === 1 ? "" : "s"} (threshold: ${thresholdMinutes}m). Snapshotting tracked files.`, - fixable: true, - }); - if (shouldFix("stale_uncommitted_changes")) { - try { - nativeAddTracked(basePath); - const commitMsg = `sf snapshot: uncommitted changes after ${mins}m inactivity`; - const result = nativeCommit(basePath, commitMsg); - if (result) { - fixesApplied.push(`created sf snapshot after ${mins}m of uncommitted changes`); - } - else { - fixesApplied.push("sf snapshot skipped — nothing to commit after staging tracked files"); - } - } - catch { - fixesApplied.push("failed to create sf snapshot commit"); - } - } - } - } - } - } - catch { - // Non-fatal — stale commit check failed - } - // ── Worktree lifecycle checks ────────────────────────────────────────── - // Check SF-managed worktrees for: merged branches, stale work, dirty - // state, and unpushed commits. Only worktrees under .sf/worktrees/. - try { - const healthStatuses = getAllWorktreeHealth(basePath); - const cwd = process.cwd(); - for (const health of healthStatuses) { - const wt = health.worktree; - const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep); - // Branch fully merged into main — safe to remove - if (health.mergedIntoMain) { - issues.push({ - severity: "info", - code: "worktree_branch_merged", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" (branch ${wt.branch}) is fully merged into main${health.safeToRemove ? " — safe to remove" : ""}`, - fixable: health.safeToRemove, - }); - if (health.safeToRemove && - shouldFix("worktree_branch_merged") && - !isCwd) { - try { - const { removeWorktree } = await import("./worktree-manager.js"); - removeWorktree(basePath, wt.name, { - deleteBranch: true, - branch: wt.branch, - }); - fixesApplied.push(`removed merged worktree "${wt.name}" and deleted branch ${wt.branch}`); - } - catch { - fixesApplied.push(`failed to remove merged worktree "${wt.name}"`); - } - } - // If merged, skip the stale/dirty/unpushed checks — they're irrelevant - continue; - } - // Stale: no commits in N days, not merged - if (health.stale) { - const days = Math.floor(health.lastCommitAgeDays); - issues.push({ - severity: "warning", - code: "worktree_stale", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" has had no commits in ${days} day${days === 1 ? "" : "s"}`, - fixable: false, - }); - } - // Dirty: uncommitted changes in a worktree (only flag on stale worktrees to avoid noise) - if (health.dirty && health.stale) { - issues.push({ - severity: "warning", - code: "worktree_dirty", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" has ${health.dirtyFileCount} uncommitted file${health.dirtyFileCount === 1 ? "" : "s"} and is stale`, - fixable: false, - }); - } - // Unpushed: commits not on any remote (only flag on stale worktrees to avoid noise) - if (health.unpushedCommits > 0 && health.stale) { - issues.push({ - severity: "warning", - code: "worktree_unpushed", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" has ${health.unpushedCommits} unpushed commit${health.unpushedCommits === 1 ? "" : "s"}`, - fixable: false, - }); - } - } - } - catch { - // Non-fatal — worktree lifecycle check failed - } +export async function checkGitHealth( + basePath, + issues, + fixesApplied, + shouldFix, + isolationMode = "none", +) { + // Degrade gracefully if not a git repo + if (!nativeIsRepo(basePath)) { + return; // Not a git repo — skip all git health checks + } + const gitDir = resolveGitDir(basePath); + // ── Orphaned auto-worktrees & Stale milestone branches ──────────────── + // These checks only apply in worktree/branch modes — skip in none mode + // where no milestone worktrees or branches are created. + if (isolationMode !== "none") { + try { + const worktrees = listWorktrees(basePath); + const milestoneWorktrees = worktrees.filter((wt) => + wt.branch.startsWith("milestone/"), + ); + // Load roadmap state once for cross-referencing + const state = await deriveState(basePath); + for (const wt of milestoneWorktrees) { + // Extract milestone ID from branch name "milestone/M001" → "M001" + const milestoneId = wt.branch.replace(/^milestone\//, ""); + const milestoneEntry = state.registry.find((m) => m.id === milestoneId); + // Check if milestone is complete via roadmap + let isComplete = false; + if (milestoneEntry) { + if (isDbAvailable()) { + const dbSlices = getMilestoneSlices(milestoneId); + isComplete = + dbSlices.length > 0 && + dbSlices.every((s) => s.status === "complete"); + } else { + const roadmapPath = resolveMilestoneFile( + basePath, + milestoneId, + "ROADMAP", + ); + const roadmapContent = roadmapPath + ? await loadFile(roadmapPath) + : null; + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + isComplete = isMilestoneComplete(roadmap); + } + } + // When DB unavailable and no roadmap, isComplete stays false + } + if (isComplete) { + issues.push({ + severity: "warning", + code: "orphaned_auto_worktree", + scope: "milestone", + unitId: milestoneId, + message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`, + fixable: true, + }); + if (shouldFix("orphaned_auto_worktree")) { + // If cwd is inside the worktree, chdir out first — matching the + // pattern in removeWorktree() (#1946). Without this, git cannot + // remove the worktree and the doctor enters a deadlock where it + // detects the orphan every run but never cleans it up. + const cwd = process.cwd(); + if (wt.path === cwd || cwd.startsWith(wt.path + sep)) { + try { + process.chdir(basePath); + } catch { + fixesApplied.push( + `skipped removing worktree at ${wt.path} (cannot chdir to basePath)`, + ); + continue; + } + } + try { + nativeWorktreeRemove(basePath, wt.path, true); + fixesApplied.push(`removed orphaned worktree ${wt.path}`); + } catch { + fixesApplied.push(`failed to remove worktree ${wt.path}`); + } + } + } + } + // ── Stale milestone branches ───────────────────────────────────────── + try { + const branches = nativeBranchList(basePath, "milestone/*"); + if (branches.length > 0) { + const worktreeBranches = new Set( + milestoneWorktrees.map((wt) => wt.branch), + ); + for (const branch of branches) { + // Skip branches that have a worktree (handled above) + if (worktreeBranches.has(branch)) continue; + const milestoneId = branch.replace(/^milestone\//, ""); + const roadmapPath = resolveMilestoneFile( + basePath, + milestoneId, + "ROADMAP", + ); + let branchMilestoneComplete = false; + if (isDbAvailable()) { + const dbSlices = getMilestoneSlices(milestoneId); + branchMilestoneComplete = + dbSlices.length > 0 && + dbSlices.every((s) => s.status === "complete"); + } else { + const roadmapContent = roadmapPath + ? await loadFile(roadmapPath) + : null; + if (!roadmapContent) continue; + const roadmap = parseRoadmap(roadmapContent); + branchMilestoneComplete = isMilestoneComplete(roadmap); + } + if (branchMilestoneComplete) { + issues.push({ + severity: "info", + code: "stale_milestone_branch", + scope: "milestone", + unitId: milestoneId, + message: `Branch ${branch} exists for completed milestone ${milestoneId}`, + fixable: true, + }); + if (shouldFix("stale_milestone_branch")) { + try { + nativeBranchDelete(basePath, branch, true); + fixesApplied.push(`deleted stale branch ${branch}`); + } catch { + fixesApplied.push(`failed to delete branch ${branch}`); + } + } + } + } + } + } catch { + // git branch list failed — skip stale branch check + } + } catch { + // listWorktrees or deriveState failed — skip worktree/branch checks + } + } // end isolationMode !== "none" + // ── Corrupt merge state ──────────────────────────────────────────────── + try { + const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"]; + const mergeStateDirs = ["rebase-apply", "rebase-merge"]; + const found = []; + for (const f of mergeStateFiles) { + if (existsSync(join(gitDir, f))) found.push(f); + } + for (const d of mergeStateDirs) { + if (existsSync(join(gitDir, d))) found.push(d); + } + if (found.length > 0) { + issues.push({ + severity: "error", + code: "corrupt_merge_state", + scope: "project", + unitId: "project", + message: `Corrupt merge/rebase state detected: ${found.join(", ")}`, + fixable: true, + }); + if (shouldFix("corrupt_merge_state")) { + const result = abortAndReset(basePath); + fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`); + } + } + } catch { + // Can't check .git dir — skip + } + // ── Tracked runtime files ────────────────────────────────────────────── + try { + const trackedPaths = []; + for (const exclusion of RUNTIME_EXCLUSION_PATHS) { + try { + const files = nativeLsFiles(basePath, exclusion); + if (files.length > 0) { + trackedPaths.push(...files); + } + } catch { + // Individual ls-files can fail — continue + } + } + if (trackedPaths.length > 0) { + issues.push({ + severity: "warning", + code: "tracked_runtime_files", + scope: "project", + unitId: "project", + message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`, + fixable: true, + }); + if (shouldFix("tracked_runtime_files")) { + try { + for (const exclusion of RUNTIME_EXCLUSION_PATHS) { + nativeRmCached(basePath, [exclusion]); + } + fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`); + } catch { + fixesApplied.push("failed to untrack runtime files"); + } + } + } + } catch { + // git ls-files failed — skip + } + // ── Legacy slice branches ────────────────────────────────────────────── + try { + const branchList = nativeBranchList(basePath, "sf/*/*").filter( + (branch) => !branch.startsWith("sf/quick/"), + ); + if (branchList.length > 0) { + issues.push({ + severity: "info", + code: "legacy_slice_branches", + scope: "project", + unitId: "project", + message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture).`, + fixable: true, + }); + if (shouldFix("legacy_slice_branches")) { + let deleted = 0; + for (const branch of branchList) { + try { + nativeBranchDelete(basePath, branch, true); + deleted++; + } catch { + /* skip branches that can't be deleted */ + } + } + if (deleted > 0) { + fixesApplied.push(`deleted ${deleted} legacy slice branch(es)`); + } + } + } + } catch { + // git branch list failed — skip + } + // ── Integration branch existence ────────────────────────────────────── + // For each active (non-complete) milestone, verify the stored integration + // branch still exists in git. A missing integration branch blocks merge-back + // and causes the next merge operation to fail silently. + try { + const state = await deriveState(basePath); + const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git ?? {}; + for (const milestone of state.registry) { + if (milestone.status === "complete") continue; + const resolution = resolveMilestoneIntegrationBranch( + basePath, + milestone.id, + gitPrefs, + ); + if (!resolution.recordedBranch) continue; // No stored branch — skip (not yet set) + if (resolution.status === "fallback" && resolution.effectiveBranch) { + issues.push({ + severity: "warning", + code: "integration_branch_missing", + scope: "milestone", + unitId: milestone.id, + message: resolution.reason, + fixable: true, + }); + if (shouldFix("integration_branch_missing")) { + writeIntegrationBranch( + basePath, + milestone.id, + resolution.effectiveBranch, + ); + fixesApplied.push( + `updated integration branch for ${milestone.id} to "${resolution.effectiveBranch}"`, + ); + } + continue; + } + if (resolution.status === "missing") { + issues.push({ + severity: "error", + code: "integration_branch_missing", + scope: "milestone", + unitId: milestone.id, + message: resolution.reason, + fixable: false, + }); + } + } + } catch { + // Non-fatal — integration branch check failed + } + // ── Orphaned worktree directories ──────────────────────────────────── + // Worktree removal can fail after a branch delete, leaving a directory + // that is no longer registered with git. These orphaned dirs cause + // "already exists" errors when re-creating the same worktree name. + try { + const wtDir = worktreesDir(basePath); + if (existsSync(wtDir)) { + // Resolve symlinks and normalize separators so that symlinked .sf + // paths (e.g. ~/.sf/projects//worktrees/…) match the paths + // returned by `git worktree list`. + const normalizePath = (p) => { + try { + p = realpathSync(p); + } catch { + /* path may not exist */ + } + return p.replaceAll("\\", "/"); + }; + const registeredPaths = new Set( + nativeWorktreeList(basePath).map((entry) => normalizePath(entry.path)), + ); + for (const entry of readdirSync(wtDir)) { + const fullPath = join(wtDir, entry); + try { + if (!statSync(fullPath).isDirectory()) continue; + } catch { + continue; + } + const normalizedFullPath = normalizePath(fullPath); + if (!registeredPaths.has(normalizedFullPath)) { + // Skip directories that only contain doctor artifacts (.sf/doctor-history.jsonl). + // appendDoctorHistory() can recreate these dirs during the audit itself, + // causing a circular false positive (#3105 Bug 1). + if (isDoctorArtifactOnly(fullPath)) continue; + issues.push({ + severity: "warning", + code: "worktree_directory_orphaned", + scope: "project", + unitId: entry, + message: `Worktree directory ${fullPath} exists on disk but is not registered with git. Run "git worktree prune" or doctor --fix to remove it.`, + fixable: true, + }); + if (shouldFix("worktree_directory_orphaned")) { + try { + rmSync(fullPath, { recursive: true, force: true }); + fixesApplied.push( + `removed orphaned worktree directory ${fullPath}`, + ); + } catch { + fixesApplied.push( + `failed to remove orphaned worktree directory ${fullPath}`, + ); + } + } + } + } + } + } catch { + // Non-fatal — orphaned worktree directory check failed + } + // ── Stale uncommitted changes ──────────────────────────────────────────── + // If the working tree has uncommitted changes and the last commit was + // longer ago than the configured threshold, flag it and optionally + // auto-commit a safety snapshot so work isn't lost. + try { + const prefs = loadEffectiveSFPreferences()?.preferences ?? {}; + const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30; + if (thresholdMinutes > 0) { + const dirty = nativeHasChanges(basePath); + if (dirty) { + const branch = nativeGetCurrentBranch(basePath); + const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD"); + const nowEpoch = Math.floor(Date.now() / 1000); + const minutesSinceCommit = + lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity; + if (minutesSinceCommit >= thresholdMinutes) { + const mins = Math.floor(minutesSinceCommit); + issues.push({ + severity: "warning", + code: "stale_uncommitted_changes", + scope: "project", + unitId: "project", + message: `Uncommitted changes detected with no commit in ${mins} minute${mins === 1 ? "" : "s"} (threshold: ${thresholdMinutes}m). Snapshotting tracked files.`, + fixable: true, + }); + if (shouldFix("stale_uncommitted_changes")) { + const protectedDeletions = listProtectedSnapshotDeletions(basePath); + if (protectedDeletions.length > 0) { + fixesApplied.push( + formatProtectedSnapshotDeletionMessage(protectedDeletions), + ); + } else { + try { + nativeAddTracked(basePath); + const commitMsg = `sf snapshot: uncommitted changes after ${mins}m inactivity`; + const result = nativeCommit(basePath, commitMsg); + if (result) { + fixesApplied.push( + `created sf snapshot after ${mins}m of uncommitted changes`, + ); + } else { + fixesApplied.push( + "sf snapshot skipped — nothing to commit after staging tracked files", + ); + } + } catch { + fixesApplied.push("failed to create sf snapshot commit"); + } + } + } + } + } + } + } catch { + // Non-fatal — stale commit check failed + } + // ── Worktree lifecycle checks ────────────────────────────────────────── + // Check SF-managed worktrees for: merged branches, stale work, dirty + // state, and unpushed commits. Only worktrees under .sf/worktrees/. + try { + const healthStatuses = getAllWorktreeHealth(basePath); + const cwd = process.cwd(); + for (const health of healthStatuses) { + const wt = health.worktree; + const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep); + // Branch fully merged into main — safe to remove + if (health.mergedIntoMain) { + issues.push({ + severity: "info", + code: "worktree_branch_merged", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" (branch ${wt.branch}) is fully merged into main${health.safeToRemove ? " — safe to remove" : ""}`, + fixable: health.safeToRemove, + }); + if ( + health.safeToRemove && + shouldFix("worktree_branch_merged") && + !isCwd + ) { + try { + const { removeWorktree } = await import("./worktree-manager.js"); + removeWorktree(basePath, wt.name, { + deleteBranch: true, + branch: wt.branch, + }); + fixesApplied.push( + `removed merged worktree "${wt.name}" and deleted branch ${wt.branch}`, + ); + } catch { + fixesApplied.push(`failed to remove merged worktree "${wt.name}"`); + } + } + // If merged, skip the stale/dirty/unpushed checks — they're irrelevant + continue; + } + // Stale: no commits in N days, not merged + if (health.stale) { + const days = Math.floor(health.lastCommitAgeDays); + issues.push({ + severity: "warning", + code: "worktree_stale", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has had no commits in ${days} day${days === 1 ? "" : "s"}`, + fixable: false, + }); + } + // Dirty: uncommitted changes in a worktree (only flag on stale worktrees to avoid noise) + if (health.dirty && health.stale) { + issues.push({ + severity: "warning", + code: "worktree_dirty", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has ${health.dirtyFileCount} uncommitted file${health.dirtyFileCount === 1 ? "" : "s"} and is stale`, + fixable: false, + }); + } + // Unpushed: commits not on any remote (only flag on stale worktrees to avoid noise) + if (health.unpushedCommits > 0 && health.stale) { + issues.push({ + severity: "warning", + code: "worktree_unpushed", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has ${health.unpushedCommits} unpushed commit${health.unpushedCommits === 1 ? "" : "s"}`, + fixable: false, + }); + } + } + } catch { + // Non-fatal — worktree lifecycle check failed + } } diff --git a/src/resources/extensions/sf/doctor-proactive.js b/src/resources/extensions/sf/doctor-proactive.js index 84373fbc2..b7463363e 100644 --- a/src/resources/extensions/sf/doctor-proactive.js +++ b/src/resources/extensions/sf/doctor-proactive.js @@ -15,15 +15,31 @@ */ import { existsSync, rmSync } from "node:fs"; import { basename, dirname, join } from "node:path"; -import { clearLock, isLockProcessAlive, readCrashLock, } from "./crash-recovery.js"; +import { + clearLock, + isLockProcessAlive, + readCrashLock, +} from "./crash-recovery.js"; import { rebuildState } from "./doctor.js"; import { runEnvironmentChecks } from "./doctor-environment.js"; import { abortAndReset } from "./git-self-heal.js"; import { resolveMilestoneIntegrationBranch } from "./git-service.js"; -import { nativeAddTracked, nativeCommit, nativeGetCurrentBranch, nativeHasChanges, nativeIsRepo, nativeLastCommitEpoch, } from "./native-git-bridge.js"; +import { + nativeAddTracked, + nativeCommit, + nativeGetCurrentBranch, + nativeHasChanges, + nativeIsRepo, + nativeLastCommitEpoch, +} from "./native-git-bridge.js"; import { resolveSfRootFile, sfRoot } from "./paths.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; +import { + formatProtectedSnapshotDeletionMessage, + listProtectedSnapshotDeletions, +} from "./snapshot-safety.js"; import { deriveState } from "./state.js"; + /** In-memory health history for the current auto-mode session. */ let healthHistory = []; /** Count of consecutive units with unresolved errors. */ @@ -39,111 +55,119 @@ let onLevelChange = null; * Called once when auto-mode starts. Pass null to unregister. */ export function setLevelChangeCallback(cb) { - onLevelChange = cb; - previousProgressLevel = "green"; + onLevelChange = cb; + previousProgressLevel = "green"; } /** * Record a health snapshot after a doctor run. * Called from the post-unit hook in auto-post-unit.ts. */ -export function recordHealthSnapshot(errors, warnings, fixesApplied, issues, fixes, scope) { - healthUnitIndex++; - healthHistory.push({ - timestamp: Date.now(), - errors, - warnings, - fixesApplied, - unitIndex: healthUnitIndex, - issues: issues ?? [], - fixes: fixes ?? [], - scope, - }); - // Keep only the last 50 snapshots to bound memory - if (healthHistory.length > 50) { - healthHistory = healthHistory.slice(-50); - } - if (errors > 0) { - consecutiveErrorUnits++; - } - else { - consecutiveErrorUnits = 0; - } - // Detect progress level transitions and notify - if (onLevelChange) { - const newLevel = consecutiveErrorUnits >= 3 - ? "red" - : consecutiveErrorUnits >= 1 || getHealthTrend() === "degrading" - ? "yellow" - : "green"; - if (newLevel !== previousProgressLevel) { - const topIssue = (issues ?? []).find((i) => i.severity === "error") ?? (issues ?? [])[0]; - const detail = topIssue ? `: ${topIssue.message}` : ""; - onLevelChange(previousProgressLevel, newLevel, `Health ${previousProgressLevel} → ${newLevel}${detail}`); - previousProgressLevel = newLevel; - } - } +export function recordHealthSnapshot( + errors, + warnings, + fixesApplied, + issues, + fixes, + scope, +) { + healthUnitIndex++; + healthHistory.push({ + timestamp: Date.now(), + errors, + warnings, + fixesApplied, + unitIndex: healthUnitIndex, + issues: issues ?? [], + fixes: fixes ?? [], + scope, + }); + // Keep only the last 50 snapshots to bound memory + if (healthHistory.length > 50) { + healthHistory = healthHistory.slice(-50); + } + if (errors > 0) { + consecutiveErrorUnits++; + } else { + consecutiveErrorUnits = 0; + } + // Detect progress level transitions and notify + if (onLevelChange) { + const newLevel = + consecutiveErrorUnits >= 3 + ? "red" + : consecutiveErrorUnits >= 1 || getHealthTrend() === "degrading" + ? "yellow" + : "green"; + if (newLevel !== previousProgressLevel) { + const topIssue = + (issues ?? []).find((i) => i.severity === "error") ?? (issues ?? [])[0]; + const detail = topIssue ? `: ${topIssue.message}` : ""; + onLevelChange( + previousProgressLevel, + newLevel, + `Health ${previousProgressLevel} → ${newLevel}${detail}`, + ); + previousProgressLevel = newLevel; + } + } } /** * Get the current health trend. * Returns "improving", "stable", "degrading", or "unknown" (not enough data). */ export function getHealthTrend() { - if (healthHistory.length < 3) - return "unknown"; - const recent = healthHistory.slice(-5); - const older = healthHistory.slice(-10, -5); - if (older.length === 0) - return "unknown"; - const recentAvg = recent.reduce((sum, s) => sum + s.errors + s.warnings, 0) / recent.length; - const olderAvg = older.reduce((sum, s) => sum + s.errors + s.warnings, 0) / older.length; - const delta = recentAvg - olderAvg; - if (delta > 1) - return "degrading"; - if (delta < -1) - return "improving"; - return "stable"; + if (healthHistory.length < 3) return "unknown"; + const recent = healthHistory.slice(-5); + const older = healthHistory.slice(-10, -5); + if (older.length === 0) return "unknown"; + const recentAvg = + recent.reduce((sum, s) => sum + s.errors + s.warnings, 0) / recent.length; + const olderAvg = + older.reduce((sum, s) => sum + s.errors + s.warnings, 0) / older.length; + const delta = recentAvg - olderAvg; + if (delta > 1) return "degrading"; + if (delta < -1) return "improving"; + return "stable"; } /** * Get the number of consecutive units with unresolved errors. */ export function getConsecutiveErrorUnits() { - return consecutiveErrorUnits; + return consecutiveErrorUnits; } /** * Get health history for display (e.g., dashboard overlay). */ export function getHealthHistory() { - return healthHistory; + return healthHistory; } /** * Get the latest health issues from the most recent snapshot. * Returns issues from the last snapshot that had any, for real-time visibility. */ export function getLatestHealthIssues() { - for (let i = healthHistory.length - 1; i >= 0; i--) { - if (healthHistory[i].issues.length > 0) - return healthHistory[i].issues; - } - return []; + for (let i = healthHistory.length - 1; i >= 0; i--) { + if (healthHistory[i].issues.length > 0) return healthHistory[i].issues; + } + return []; } /** * Get the latest fixes applied from the most recent snapshot. */ export function getLatestHealthFixes() { - for (let i = healthHistory.length - 1; i >= 0; i--) { - if (healthHistory[i].fixes.length > 0) - return healthHistory[i].fixes; - } - return []; + for (let i = healthHistory.length - 1; i >= 0; i--) { + if (healthHistory[i].fixes.length > 0) return healthHistory[i].fixes; + } + return []; } /** * Reset health tracking state. Called on auto-mode start/stop. */ export function resetHealthTracking() { - healthHistory = []; - consecutiveErrorUnits = 0; - healthUnitIndex = 0; - previousProgressLevel = "green"; + healthHistory = []; + consecutiveErrorUnits = 0; + healthUnitIndex = 0; + previousProgressLevel = "green"; } /** * Clear stale auto runtime locks before startup decides whether to resume. @@ -155,33 +179,31 @@ export function resetHealthTracking() { * paused-session state. */ export function healAutoStartupRuntime(basePath) { - const fixesApplied = []; - try { - const lock = readCrashLock(basePath); - if (lock && !isLockProcessAlive(lock)) { - clearLock(basePath); - fixesApplied.push("cleared stale auto.lock before auto startup"); - } - } - catch { - // Non-fatal. - } - try { - const root = sfRoot(basePath); - const lockDir = join(dirname(root), `${basename(root)}.lock`); - if (existsSync(lockDir)) { - const lock = readCrashLock(basePath); - const lockHolderAlive = lock ? isLockProcessAlive(lock) : false; - if (!lockHolderAlive) { - rmSync(lockDir, { recursive: true, force: true }); - fixesApplied.push("removed stranded session lock directory"); - } - } - } - catch { - // Non-fatal. - } - return fixesApplied; + const fixesApplied = []; + try { + const lock = readCrashLock(basePath); + if (lock && !isLockProcessAlive(lock)) { + clearLock(basePath); + fixesApplied.push("cleared stale auto.lock before auto startup"); + } + } catch { + // Non-fatal. + } + try { + const root = sfRoot(basePath); + const lockDir = join(dirname(root), `${basename(root)}.lock`); + if (existsSync(lockDir)) { + const lock = readCrashLock(basePath); + const lockHolderAlive = lock ? isLockProcessAlive(lock) : false; + if (!lockHolderAlive) { + rmSync(lockDir, { recursive: true, force: true }); + fixesApplied.push("removed stranded session lock directory"); + } + } + } catch { + // Non-fatal. + } + return fixesApplied; } /** * Lightweight pre-dispatch health check. Runs fast checks that should @@ -193,142 +215,164 @@ export function healAutoStartupRuntime(basePath) { * Returns { proceed: true } if dispatch should continue. */ export async function preDispatchHealthGate(basePath) { - const issues = []; - const fixesApplied = []; - // ── Stale crash lock blocks dispatch ── - // If a stale lock exists, the crash recovery path should handle it, - // not a new dispatch. This prevents double-dispatch after crashes. - try { - const lock = readCrashLock(basePath); - if (lock && !isLockProcessAlive(lock)) { - // Auto-clear it since we're about to dispatch anyway - clearLock(basePath); - fixesApplied.push("cleared stale auto.lock before dispatch"); - } - } - catch { - // Non-fatal - } - // ── Corrupt merge/rebase state blocks dispatch ── - // Dispatching a unit with MERGE_HEAD present will cause git operations to fail. - try { - const gitDir = join(basePath, ".git"); - if (existsSync(gitDir)) { - const blockers = ["MERGE_HEAD", "rebase-apply", "rebase-merge"].filter((f) => existsSync(join(gitDir, f))); - if (blockers.length > 0) { - // Try to auto-heal - try { - const result = abortAndReset(basePath); - fixesApplied.push(`pre-dispatch: cleaned merge state (${result.cleaned.join(", ")})`); - } - catch { - issues.push(`Corrupt git state: ${blockers.join(", ")}. Run /sf doctor fix.`); - } - } - } - } - catch { - // Non-fatal - } - // ── STATE.md existence check ── - // If STATE.md is missing, attempt to rebuild it for the next unit's context. - // Non-blocking — fresh worktrees won't have it until the first unit completes (#889). - try { - const stateFile = resolveSfRootFile(basePath, "STATE"); - const milestonesDir = join(sfRoot(basePath), "milestones"); - if (existsSync(milestonesDir) && !existsSync(stateFile)) { - try { - await rebuildState(basePath); - fixesApplied.push("rebuilt missing STATE.md before dispatch"); - } - catch { - // Rebuild failed — non-blocking, dispatch continues - fixesApplied.push("STATE.md missing — will rebuild after first unit completes"); - } - } - } - catch { - // Non-fatal — dispatch continues without STATE.md if rebuild fails - } - // ── Integration branch existence check ── - // If the active milestone's recorded integration branch no longer exists in - // git, the merge-back at the end of the milestone will fail. Block dispatch - // now to surface this before work is lost. - try { - if (nativeIsRepo(basePath)) { - const state = await deriveState(basePath); - if (state.activeMilestone) { - const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git ?? {}; - const resolution = resolveMilestoneIntegrationBranch(basePath, state.activeMilestone.id, gitPrefs); - if (resolution.status === "fallback" && resolution.effectiveBranch) { - fixesApplied.push(`using fallback integration branch "${resolution.effectiveBranch}" for milestone ${state.activeMilestone.id}; recorded "${resolution.recordedBranch}" no longer exists`); - } - else if (resolution.recordedBranch && - resolution.status === "missing") { - issues.push(`${resolution.reason} Restore the branch or update the integration branch before dispatching. Run /sf doctor for details.`); - } - } - } - } - catch { - // Non-fatal — dispatch continues if state/branch check fails - } - // ── Stale uncommitted changes — auto-snapshot before dispatch ── - // If the working tree is dirty and no commit has happened recently, - // create a safety snapshot so work isn't lost if the next unit crashes. - try { - if (nativeIsRepo(basePath)) { - const prefs = loadEffectiveSFPreferences()?.preferences ?? {}; - const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30; - if (thresholdMinutes > 0 && nativeHasChanges(basePath)) { - const branch = nativeGetCurrentBranch(basePath); - const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD"); - const nowEpoch = Math.floor(Date.now() / 1000); - const minutesSinceCommit = lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity; - if (minutesSinceCommit >= thresholdMinutes) { - const mins = Math.floor(minutesSinceCommit); - try { - nativeAddTracked(basePath); - const commitMsg = `sf snapshot: pre-dispatch, uncommitted changes after ${mins}m inactivity`; - const result = nativeCommit(basePath, commitMsg); - if (result) { - fixesApplied.push(`pre-dispatch: created sf snapshot after ${mins}m of uncommitted changes`); - } - } - catch { - // Non-blocking — snapshot failed but dispatch can continue - fixesApplied.push("pre-dispatch: sf snapshot failed"); - } - } - } - } - } - catch { - // Non-fatal - } - // ── Disk space check ── - // Catches low-disk conditions before dispatch rather than letting the unit - // fail mid-execution with ENOSPC (which wastes a full LLM turn). - try { - const envResults = runEnvironmentChecks(basePath); - const diskError = envResults.find((r) => r.name === "disk_space" && r.status === "error"); - if (diskError) { - issues.push(`${diskError.message}${diskError.detail ? ` — ${diskError.detail}` : ""}`); - } - } - catch { - // Non-fatal — dispatch continues if env check fails - } - // If we had critical issues that couldn't be auto-healed, block dispatch - if (issues.length > 0) { - return { - proceed: false, - reason: `Pre-dispatch health check failed:\n${issues.map((i) => ` - ${i}`).join("\n")}\nRun /sf doctor fix to resolve.`, - issues, - fixesApplied, - }; - } - return { proceed: true, issues, fixesApplied }; + const issues = []; + const fixesApplied = []; + // ── Stale crash lock blocks dispatch ── + // If a stale lock exists, the crash recovery path should handle it, + // not a new dispatch. This prevents double-dispatch after crashes. + try { + const lock = readCrashLock(basePath); + if (lock && !isLockProcessAlive(lock)) { + // Auto-clear it since we're about to dispatch anyway + clearLock(basePath); + fixesApplied.push("cleared stale auto.lock before dispatch"); + } + } catch { + // Non-fatal + } + // ── Corrupt merge/rebase state blocks dispatch ── + // Dispatching a unit with MERGE_HEAD present will cause git operations to fail. + try { + const gitDir = join(basePath, ".git"); + if (existsSync(gitDir)) { + const blockers = ["MERGE_HEAD", "rebase-apply", "rebase-merge"].filter( + (f) => existsSync(join(gitDir, f)), + ); + if (blockers.length > 0) { + // Try to auto-heal + try { + const result = abortAndReset(basePath); + fixesApplied.push( + `pre-dispatch: cleaned merge state (${result.cleaned.join(", ")})`, + ); + } catch { + issues.push( + `Corrupt git state: ${blockers.join(", ")}. Run /sf doctor fix.`, + ); + } + } + } + } catch { + // Non-fatal + } + // ── STATE.md existence check ── + // If STATE.md is missing, attempt to rebuild it for the next unit's context. + // Non-blocking — fresh worktrees won't have it until the first unit completes (#889). + try { + const stateFile = resolveSfRootFile(basePath, "STATE"); + const milestonesDir = join(sfRoot(basePath), "milestones"); + if (existsSync(milestonesDir) && !existsSync(stateFile)) { + try { + await rebuildState(basePath); + fixesApplied.push("rebuilt missing STATE.md before dispatch"); + } catch { + // Rebuild failed — non-blocking, dispatch continues + fixesApplied.push( + "STATE.md missing — will rebuild after first unit completes", + ); + } + } + } catch { + // Non-fatal — dispatch continues without STATE.md if rebuild fails + } + // ── Integration branch existence check ── + // If the active milestone's recorded integration branch no longer exists in + // git, the merge-back at the end of the milestone will fail. Block dispatch + // now to surface this before work is lost. + try { + if (nativeIsRepo(basePath)) { + const state = await deriveState(basePath); + if (state.activeMilestone) { + const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git ?? {}; + const resolution = resolveMilestoneIntegrationBranch( + basePath, + state.activeMilestone.id, + gitPrefs, + ); + if (resolution.status === "fallback" && resolution.effectiveBranch) { + fixesApplied.push( + `using fallback integration branch "${resolution.effectiveBranch}" for milestone ${state.activeMilestone.id}; recorded "${resolution.recordedBranch}" no longer exists`, + ); + } else if ( + resolution.recordedBranch && + resolution.status === "missing" + ) { + issues.push( + `${resolution.reason} Restore the branch or update the integration branch before dispatching. Run /sf doctor for details.`, + ); + } + } + } + } catch { + // Non-fatal — dispatch continues if state/branch check fails + } + // ── Stale uncommitted changes — auto-snapshot before dispatch ── + // If the working tree is dirty and no commit has happened recently, + // create a safety snapshot so work isn't lost if the next unit crashes. + try { + if (nativeIsRepo(basePath)) { + const prefs = loadEffectiveSFPreferences()?.preferences ?? {}; + const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30; + if (thresholdMinutes > 0 && nativeHasChanges(basePath)) { + const branch = nativeGetCurrentBranch(basePath); + const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD"); + const nowEpoch = Math.floor(Date.now() / 1000); + const minutesSinceCommit = + lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity; + if (minutesSinceCommit >= thresholdMinutes) { + const mins = Math.floor(minutesSinceCommit); + const protectedDeletions = listProtectedSnapshotDeletions(basePath); + if (protectedDeletions.length > 0) { + issues.push( + formatProtectedSnapshotDeletionMessage(protectedDeletions), + ); + } else { + try { + nativeAddTracked(basePath); + const commitMsg = `sf snapshot: pre-dispatch, uncommitted changes after ${mins}m inactivity`; + const result = nativeCommit(basePath, commitMsg); + if (result) { + fixesApplied.push( + `pre-dispatch: created sf snapshot after ${mins}m of uncommitted changes`, + ); + } + } catch { + // Non-blocking — snapshot failed but dispatch can continue + fixesApplied.push("pre-dispatch: sf snapshot failed"); + } + } + } + } + } + } catch { + // Non-fatal + } + // ── Disk space check ── + // Catches low-disk conditions before dispatch rather than letting the unit + // fail mid-execution with ENOSPC (which wastes a full LLM turn). + try { + const envResults = runEnvironmentChecks(basePath); + const diskError = envResults.find( + (r) => r.name === "disk_space" && r.status === "error", + ); + if (diskError) { + issues.push( + `${diskError.message}${diskError.detail ? ` — ${diskError.detail}` : ""}`, + ); + } + } catch { + // Non-fatal — dispatch continues if env check fails + } + // If we had critical issues that couldn't be auto-healed, block dispatch + if (issues.length > 0) { + return { + proceed: false, + reason: `Pre-dispatch health check failed:\n${issues.map((i) => ` - ${i}`).join("\n")}\nRun /sf doctor fix to resolve.`, + issues, + fixesApplied, + }; + } + return { proceed: true, issues, fixesApplied }; } // ── Auto-Heal Escalation ────────────────────────────────────────────────── /** Threshold: escalate to LLM heal after this many consecutive error units. */ @@ -343,96 +387,100 @@ let escalationTriggered = false; * escalation is not needed. */ export function checkHealEscalation(errors, unresolvedIssues) { - if (escalationTriggered) { - return { - shouldEscalate: false, - reason: "already escalated this session", - issues: [], - }; - } - if (consecutiveErrorUnits < ESCALATION_THRESHOLD) { - return { - shouldEscalate: false, - reason: `${consecutiveErrorUnits}/${ESCALATION_THRESHOLD} consecutive error units`, - issues: [], - }; - } - if (errors === 0) { - return { - shouldEscalate: false, - reason: "no errors to escalate", - issues: [], - }; - } - const trend = getHealthTrend(); - if (trend === "improving") { - return { - shouldEscalate: false, - reason: "health is improving — deferring escalation", - issues: [], - }; - } - escalationTriggered = true; - return { - shouldEscalate: true, - reason: `${consecutiveErrorUnits} consecutive units with unresolved errors (trend: ${trend})`, - issues: unresolvedIssues, - }; + if (escalationTriggered) { + return { + shouldEscalate: false, + reason: "already escalated this session", + issues: [], + }; + } + if (consecutiveErrorUnits < ESCALATION_THRESHOLD) { + return { + shouldEscalate: false, + reason: `${consecutiveErrorUnits}/${ESCALATION_THRESHOLD} consecutive error units`, + issues: [], + }; + } + if (errors === 0) { + return { + shouldEscalate: false, + reason: "no errors to escalate", + issues: [], + }; + } + const trend = getHealthTrend(); + if (trend === "improving") { + return { + shouldEscalate: false, + reason: "health is improving — deferring escalation", + issues: [], + }; + } + escalationTriggered = true; + return { + shouldEscalate: true, + reason: `${consecutiveErrorUnits} consecutive units with unresolved errors (trend: ${trend})`, + issues: unresolvedIssues, + }; } /** * Reset escalation state. Called on auto-mode start/stop. */ export function resetEscalation() { - escalationTriggered = false; + escalationTriggered = false; } /** * Format a health summary for display in the auto-mode dashboard. * Human-readable with full words, not abbreviations. */ export function formatHealthSummary() { - if (healthHistory.length === 0) - return "No health data yet."; - const latest = healthHistory[healthHistory.length - 1]; - const trend = getHealthTrend(); - const trendLabel = trend === "improving" - ? "improving" - : trend === "degrading" - ? "degrading" - : trend === "stable" - ? "stable" - : "unknown"; - const totalFixes = healthHistory.reduce((sum, s) => sum + s.fixesApplied, 0); - const parts = []; - // Error/warning summary - if (latest.errors === 0 && latest.warnings === 0) { - parts.push("No issues"); - } - else { - const counts = []; - if (latest.errors > 0) - counts.push(`${latest.errors} error${latest.errors > 1 ? "s" : ""}`); - if (latest.warnings > 0) - counts.push(`${latest.warnings} warning${latest.warnings > 1 ? "s" : ""}`); - parts.push(counts.join(", ")); - } - parts.push(`trend ${trendLabel}`); - if (totalFixes > 0) { - parts.push(`${totalFixes} fix${totalFixes > 1 ? "es" : ""} applied`); - } - if (consecutiveErrorUnits > 0) { - parts.push(`${consecutiveErrorUnits} of ${ESCALATION_THRESHOLD} consecutive errors before escalation`); - } - // Include top issue from latest snapshot - if (latest.issues.length > 0) { - const topIssue = latest.issues.find((i) => i.severity === "error") ?? latest.issues[0]; - parts.push(`latest: ${topIssue.message}`); - } - return parts.join(" · "); + if (healthHistory.length === 0) return "No health data yet."; + const latest = healthHistory[healthHistory.length - 1]; + const trend = getHealthTrend(); + const trendLabel = + trend === "improving" + ? "improving" + : trend === "degrading" + ? "degrading" + : trend === "stable" + ? "stable" + : "unknown"; + const totalFixes = healthHistory.reduce((sum, s) => sum + s.fixesApplied, 0); + const parts = []; + // Error/warning summary + if (latest.errors === 0 && latest.warnings === 0) { + parts.push("No issues"); + } else { + const counts = []; + if (latest.errors > 0) + counts.push(`${latest.errors} error${latest.errors > 1 ? "s" : ""}`); + if (latest.warnings > 0) + counts.push( + `${latest.warnings} warning${latest.warnings > 1 ? "s" : ""}`, + ); + parts.push(counts.join(", ")); + } + parts.push(`trend ${trendLabel}`); + if (totalFixes > 0) { + parts.push(`${totalFixes} fix${totalFixes > 1 ? "es" : ""} applied`); + } + if (consecutiveErrorUnits > 0) { + parts.push( + `${consecutiveErrorUnits} of ${ESCALATION_THRESHOLD} consecutive errors before escalation`, + ); + } + // Include top issue from latest snapshot + if (latest.issues.length > 0) { + const topIssue = + latest.issues.find((i) => i.severity === "error") ?? latest.issues[0]; + parts.push(`latest: ${topIssue.message}`); + } + return parts.join(" · "); } /** * Reset all proactive healing state. Called on auto-mode start/stop. */ export function resetProactiveHealing() { - resetHealthTracking(); - resetEscalation(); + resetHealthTracking(); + resetEscalation(); } diff --git a/src/resources/extensions/sf/doctor.js b/src/resources/extensions/sf/doctor.js index 86694c101..94e480792 100644 --- a/src/resources/extensions/sf/doctor.js +++ b/src/resources/extensions/sf/doctor.js @@ -1,307 +1,344 @@ -import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, +} from "node:fs"; import { join } from "node:path"; import { invalidateAllCaches } from "./cache.js"; -import { checkEngineHealth, checkGitHealth, checkGlobalHealth, checkRuntimeHealth, } from "./doctor-checks.js"; +import { + checkEngineHealth, + checkGitHealth, + checkGlobalHealth, + checkRuntimeHealth, +} from "./doctor-checks.js"; import { checkEnvironmentHealth } from "./doctor-environment.js"; import { runProviderChecks } from "./doctor-providers.js"; import { GLOBAL_STATE_CODES } from "./doctor-types.js"; -import { countMustHavesMentionedInSummary, loadFile, parseSummary, parseTaskPlanMustHaves, saveFile, } from "./files.js"; +import { + countMustHavesMentionedInSummary, + loadFile, + parseSummary, + parseTaskPlanMustHaves, + saveFile, +} from "./files.js"; import { parsePlan, parseRoadmap } from "./parsers.js"; -import { milestonesDir, relMilestoneFile, relMilestonePath, relSfRootFile, relSliceFile, relSlicePath, relTaskFile, resolveMilestoneFile, resolveMilestonePath, resolveSfRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, sfRoot, } from "./paths.js"; -import { loadEffectiveSFPreferences, } from "./preferences.js"; -import { readAllSelfFeedback, recordSelfFeedback, } from "./self-feedback.js"; +import { + milestonesDir, + relMilestoneFile, + relMilestonePath, + relSfRootFile, + relSliceFile, + relSlicePath, + relTaskFile, + resolveMilestoneFile, + resolveMilestonePath, + resolveSfRootFile, + resolveSliceFile, + resolveSlicePath, + resolveTaskFile, + resolveTasksDir, + sfRoot, +} from "./paths.js"; +import { loadEffectiveSFPreferences } from "./preferences.js"; +import { readAllSelfFeedback, recordSelfFeedback } from "./self-feedback.js"; import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js"; import { deriveState, isMilestoneComplete } from "./state.js"; import { isClosedStatus } from "./status-guards.js"; import { parseUnitId } from "./unit-id.js"; + // ─── Flow Audit Implementation ──────────────────────────────────────────── const DEFAULT_STALE_PROGRESS_MS = 20 * 60 * 1000; const DEFAULT_OPTIONAL_CHILD_BUDGET_MS = 30 * 60 * 1000; const REPEATED_FAILURE_THRESHOLD = 3; const FLOW_AUDIT_ROLLUP_KIND = "flow-audit:repeated-milestone-failure"; function parseEpochMs(value, fallbackMs) { - if (typeof value === "number" && Number.isFinite(value)) { - return value < 10_000_000_000 ? value * 1000 : value; - } - if (typeof value === "string" && value.trim()) { - const parsed = new Date(value).getTime(); - if (Number.isFinite(parsed)) - return parsed; - } - return fallbackMs; + if (typeof value === "number" && Number.isFinite(value)) { + return value < 10_000_000_000 ? value * 1000 : value; + } + if (typeof value === "string" && value.trim()) { + const parsed = new Date(value).getTime(); + if (Number.isFinite(parsed)) return parsed; + } + return fallbackMs; } function formatIso(ms) { - if (ms === undefined || !Number.isFinite(ms)) - return undefined; - return new Date(ms).toISOString(); + if (ms === undefined || !Number.isFinite(ms)) return undefined; + return new Date(ms).toISOString(); } function minutes(ms) { - return Math.max(0, Math.round(ms / 60_000)); + return Math.max(0, Math.round(ms / 60_000)); } function readJsonFile(path) { - try { - if (!existsSync(path)) - return null; - return JSON.parse(readFileSync(path, "utf8")); - } - catch { - return null; - } + try { + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } } function readRuntimeUnits(runtimeUnitsDir) { - if (!existsSync(runtimeUnitsDir)) - return []; - const records = []; - try { - for (const file of readdirSync(runtimeUnitsDir)) { - if (!file.endsWith(".json")) - continue; - const record = readJsonFile(join(runtimeUnitsDir, file)); - if (record) - records.push(record); - } - } - catch { - // Runtime audit must stay best-effort. - } - return records; + if (!existsSync(runtimeUnitsDir)) return []; + const records = []; + try { + for (const file of readdirSync(runtimeUnitsDir)) { + if (!file.endsWith(".json")) continue; + const record = readJsonFile(join(runtimeUnitsDir, file)); + if (record) records.push(record); + } + } catch { + // Runtime audit must stay best-effort. + } + return records; } function parsePsOutput(psOutput) { - const rows = []; - for (const line of psOutput.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) - continue; - const match = trimmed.match(/^(\d+)\s+(\d+)(?:\s+(\d+))?\s+(.+)$/); - if (!match) - continue; - const pid = Number.parseInt(match[1], 10); - const ppid = Number.parseInt(match[2], 10); - if (!Number.isFinite(pid) || !Number.isFinite(ppid)) - continue; - const elapsedSeconds = match[3] === undefined ? undefined : Number.parseInt(match[3], 10); - rows.push({ - pid, - ppid, - ageMs: elapsedSeconds !== undefined && Number.isFinite(elapsedSeconds) - ? elapsedSeconds * 1000 - : undefined, - cmd: match[4], - }); - } - return rows; + const rows = []; + for (const line of psOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(\d+)\s+(\d+)(?:\s+(\d+))?\s+(.+)$/); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + const ppid = Number.parseInt(match[2], 10); + if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue; + const elapsedSeconds = + match[3] === undefined ? undefined : Number.parseInt(match[3], 10); + rows.push({ + pid, + ppid, + ageMs: + elapsedSeconds !== undefined && Number.isFinite(elapsedSeconds) + ? elapsedSeconds * 1000 + : undefined, + cmd: match[4], + }); + } + return rows; } async function readPsRows(options) { - if (options.psOutput !== undefined) - return parsePsOutput(options.psOutput); - if (process.platform === "win32") - return []; - try { - const { execSync } = await import("node:child_process"); - const psOutput = execSync("ps -eo pid,ppid,etimes,cmd --no-headers", { - encoding: "utf8", - timeout: 5000, - }); - return parsePsOutput(psOutput); - } - catch { - return []; - } + if (options.psOutput !== undefined) return parsePsOutput(options.psOutput); + if (process.platform === "win32") return []; + try { + const { execSync } = await import("node:child_process"); + const psOutput = execSync("ps -eo pid,ppid,etimes,cmd --no-headers", { + encoding: "utf8", + timeout: 5000, + }); + return parsePsOutput(psOutput); + } catch { + return []; + } } function classifyProcess(row) { - const cmd = row.cmd.toLowerCase(); - if (cmd.includes("sift") || cmd.includes("warmup")) - return "warmup"; - if (row.ppid === 1 && cmd.includes("next-server")) - return "orphan"; - if (cmd.includes("next-server") || - cmd.includes("vite") || - cmd.includes("turbopack")) { - return "background"; - } - if ((cmd.includes("node") || cmd.includes("sf-run") || cmd.includes("codex")) && - (cmd.includes(" sf") || - cmd.includes("/sf") || - cmd.includes("dist/loader") || - cmd.includes("tool-session") || - cmd.includes("headless"))) { - return "active-session"; - } - return "unknown"; + const cmd = row.cmd.toLowerCase(); + if (cmd.includes("sift") || cmd.includes("warmup")) return "warmup"; + if (row.ppid === 1 && cmd.includes("next-server")) return "orphan"; + if ( + cmd.includes("next-server") || + cmd.includes("vite") || + cmd.includes("turbopack") + ) { + return "background"; + } + if ( + (cmd.includes("node") || cmd.includes("sf-run") || cmd.includes("codex")) && + (cmd.includes(" sf") || + cmd.includes("/sf") || + cmd.includes("dist/loader") || + cmd.includes("tool-session") || + cmd.includes("headless")) + ) { + return "active-session"; + } + return "unknown"; } function isOptionalChild(classification) { - return (classification === "warmup" || - classification === "background" || - classification === "orphan"); + return ( + classification === "warmup" || + classification === "background" || + classification === "orphan" + ); } function shouldIncludeProcess(row, classification, activePid) { - if (classification !== "unknown") - return true; - if (activePid === undefined) - return false; - return row.pid === activePid || row.ppid === activePid; + if (classification !== "unknown") return true; + if (activePid === undefined) return false; + return row.pid === activePid || row.ppid === activePid; } function readRecentErrors(runtimeRoot) { - const notificationsPath = join(runtimeRoot, "notifications.jsonl"); - if (!existsSync(notificationsPath)) - return []; - const errors = []; - try { - const lines = readFileSync(notificationsPath, "utf8") - .split("\n") - .filter((l) => l.trim()); - for (const line of lines.slice(-20)) { - try { - const entry = JSON.parse(line); - const message = entry.message ?? entry.text ?? ""; - if (entry.severity === "error" || - message.toLowerCase().includes("error") || - message.toLowerCase().includes("failed")) { - errors.push(message || "Unknown error"); - } - } - catch { - // skip malformed notification rows - } - } - } - catch { - // non-fatal - } - return errors; + const notificationsPath = join(runtimeRoot, "notifications.jsonl"); + if (!existsSync(notificationsPath)) return []; + const errors = []; + try { + const lines = readFileSync(notificationsPath, "utf8") + .split("\n") + .filter((l) => l.trim()); + for (const line of lines.slice(-20)) { + try { + const entry = JSON.parse(line); + const message = entry.message ?? entry.text ?? ""; + if ( + entry.severity === "error" || + message.toLowerCase().includes("error") || + message.toLowerCase().includes("failed") + ) { + errors.push(message || "Unknown error"); + } + } catch { + // skip malformed notification rows + } + } + } catch { + // non-fatal + } + return errors; } function buildLoopEvidence(basePath, unitType, unitId) { - if (unitType !== "execute-task") - return undefined; - const { milestone, slice, task } = parseUnitId(unitId); - if (!milestone || !slice || !task) - return undefined; - const planPath = resolveSliceFile(basePath, milestone, slice, "PLAN"); - if (!planPath || !existsSync(planPath)) - return undefined; - const completedPriorTasks = []; - const missingSummaries = []; - try { - const plan = parsePlan(readFileSync(planPath, "utf8")); - const currentIndex = plan.tasks.findIndex((t) => t.id === task); - if (currentIndex > 0) { - for (const prior of plan.tasks.slice(0, currentIndex)) { - if (prior.done) - completedPriorTasks.push(prior.id); - } - } - if (!resolveTaskFile(basePath, milestone, slice, task, "SUMMARY")) { - missingSummaries.push(`${milestone}/${slice}/${task} task SUMMARY`); - } - const allTasksDone = plan.tasks.length > 0 && plan.tasks.every((t) => t.done); - if (allTasksDone && - !resolveSliceFile(basePath, milestone, slice, "SUMMARY")) { - missingSummaries.push(`${milestone}/${slice} slice SUMMARY`); - } - } - catch { - return undefined; - } - return { - milestoneId: milestone, - sliceId: slice, - taskId: task, - completedPriorTasks, - missingSummaries, - }; + if (unitType !== "execute-task") return undefined; + const { milestone, slice, task } = parseUnitId(unitId); + if (!milestone || !slice || !task) return undefined; + const planPath = resolveSliceFile(basePath, milestone, slice, "PLAN"); + if (!planPath || !existsSync(planPath)) return undefined; + const completedPriorTasks = []; + const missingSummaries = []; + try { + const plan = parsePlan(readFileSync(planPath, "utf8")); + const currentIndex = plan.tasks.findIndex((t) => t.id === task); + if (currentIndex > 0) { + for (const prior of plan.tasks.slice(0, currentIndex)) { + if (prior.done) completedPriorTasks.push(prior.id); + } + } + if (!resolveTaskFile(basePath, milestone, slice, task, "SUMMARY")) { + missingSummaries.push(`${milestone}/${slice}/${task} task SUMMARY`); + } + const allTasksDone = + plan.tasks.length > 0 && plan.tasks.every((t) => t.done); + if ( + allTasksDone && + !resolveSliceFile(basePath, milestone, slice, "SUMMARY") + ) { + missingSummaries.push(`${milestone}/${slice} slice SUMMARY`); + } + } catch { + return undefined; + } + return { + milestoneId: milestone, + sliceId: slice, + taskId: task, + completedPriorTasks, + missingSummaries, + }; } function collectRunawayHistory(runtimeUnits, feedback, milestoneId) { - const history = []; - for (const unit of runtimeUnits) { - const pause = unit.runawayGuardPause; - if (!pause) - continue; - const id = pause.unitId ?? unit.unitId ?? "unknown"; - if (milestoneId && !id.startsWith(`${milestoneId}/`)) - continue; - history.push(pause.reason ?? `Runaway guard paused ${id}`); - } - for (const entry of feedback) { - if (entry.resolvedAt) - continue; - if (milestoneId && entry.occurredIn?.milestone !== milestoneId) - continue; - if (entry.kind.includes("runaway") || - entry.summary.toLowerCase().includes("runaway")) { - history.push(`${entry.kind}: ${entry.summary}`); - } - } - return Array.from(new Set(history)).slice(-10); + const history = []; + for (const unit of runtimeUnits) { + const pause = unit.runawayGuardPause; + if (!pause) continue; + const id = pause.unitId ?? unit.unitId ?? "unknown"; + if (milestoneId && !id.startsWith(`${milestoneId}/`)) continue; + history.push(pause.reason ?? `Runaway guard paused ${id}`); + } + for (const entry of feedback) { + if (entry.resolvedAt) continue; + if (milestoneId && entry.occurredIn?.milestone !== milestoneId) continue; + if ( + entry.kind.includes("runaway") || + entry.summary.toLowerCase().includes("runaway") + ) { + history.push(`${entry.kind}: ${entry.summary}`); + } + } + return Array.from(new Set(history)).slice(-10); } -function maybeRecordRepeatedFailureRollup(basePath, milestoneId, feedback, options) { - if (!milestoneId || options.recordSelfFeedback === false) - return undefined; - const failures = feedback.filter((e) => !e.resolvedAt && - e.occurredIn?.milestone === milestoneId && - e.kind !== FLOW_AUDIT_ROLLUP_KIND); - if (failures.length < REPEATED_FAILURE_THRESHOLD) - return undefined; - const openRollup = feedback.find((e) => !e.resolvedAt && - e.kind === FLOW_AUDIT_ROLLUP_KIND && - e.occurredIn?.milestone === milestoneId); - if (openRollup) { - return { - filed: false, - milestoneId, - count: failures.length, - entryId: openRollup.id, - }; - } - const evidence = failures - .slice(-8) - .map((e) => `[${e.id}] ${e.kind} ${[ - e.occurredIn?.milestone, - e.occurredIn?.slice, - e.occurredIn?.task, - ] - .filter(Boolean) - .join("/")}: ${e.summary}`) - .join("\n"); - const recorded = recordSelfFeedback({ - kind: FLOW_AUDIT_ROLLUP_KIND, - severity: "high", - summary: `${failures.length} unresolved flow failures on ${milestoneId} need one recovery fix`, - evidence, - suggestedFix: "Fix the shared milestone-flow failure instead of filing one item per failed unit. Use the flow audit evidence to repair stale dispatch, missing summary, runaway, or child-process handling.", - acceptanceCriteria: "AC1: flow audit reports the active milestone/unit and session pointer. AC2: stale dispatched unit with no progress is flagged. AC3: runaway history and child-process hang evidence are preserved. AC4: repeated same-milestone failures stay deduplicated into one open item.", - source: "detector", - occurredIn: { milestone: milestoneId, unitType: "flow-audit" }, - }, basePath); - if (!recorded) - return undefined; - return { - filed: true, - milestoneId, - count: failures.length, - entryId: recorded.entry.id, - }; +function maybeRecordRepeatedFailureRollup( + basePath, + milestoneId, + feedback, + options, +) { + if (!milestoneId || options.recordSelfFeedback === false) return undefined; + const failures = feedback.filter( + (e) => + !e.resolvedAt && + e.occurredIn?.milestone === milestoneId && + e.kind !== FLOW_AUDIT_ROLLUP_KIND, + ); + if (failures.length < REPEATED_FAILURE_THRESHOLD) return undefined; + const openRollup = feedback.find( + (e) => + !e.resolvedAt && + e.kind === FLOW_AUDIT_ROLLUP_KIND && + e.occurredIn?.milestone === milestoneId, + ); + if (openRollup) { + return { + filed: false, + milestoneId, + count: failures.length, + entryId: openRollup.id, + }; + } + const evidence = failures + .slice(-8) + .map( + (e) => + `[${e.id}] ${e.kind} ${[ + e.occurredIn?.milestone, + e.occurredIn?.slice, + e.occurredIn?.task, + ] + .filter(Boolean) + .join("/")}: ${e.summary}`, + ) + .join("\n"); + const recorded = recordSelfFeedback( + { + kind: FLOW_AUDIT_ROLLUP_KIND, + severity: "high", + summary: `${failures.length} unresolved flow failures on ${milestoneId} need one recovery fix`, + evidence, + suggestedFix: + "Fix the shared milestone-flow failure instead of filing one item per failed unit. Use the flow audit evidence to repair stale dispatch, missing summary, runaway, or child-process handling.", + acceptanceCriteria: + "AC1: flow audit reports the active milestone/unit and session pointer. AC2: stale dispatched unit with no progress is flagged. AC3: runaway history and child-process hang evidence are preserved. AC4: repeated same-milestone failures stay deduplicated into one open item.", + source: "detector", + occurredIn: { milestone: milestoneId, unitType: "flow-audit" }, + }, + basePath, + ); + if (!recorded) return undefined; + return { + filed: true, + milestoneId, + count: failures.length, + entryId: recorded.entry.id, + }; } function chooseRecommendedAction(args) { - if (args.staleDispatchedUnits.length > 0) { - const unit = args.staleDispatchedUnits[0]; - const session = args.sessionPointer?.sessionFile - ? ` ${args.sessionPointer.sessionFile}` - : args.sessionPointer?.sessionId - ? ` ${args.sessionPointer.sessionId}` - : ""; - return `Inspect session${session} for ${unit.unitType} ${unit.unitId}; if no new output exists, stop/requeue the stale dispatched unit before continuing.`; - } - const overBudgetOptional = args.childProcesses.find((p) => p.nonBlocking && p.overBudget); - if (overBudgetOptional) { - return `Optional ${overBudgetOptional.classification} child pid ${overBudgetOptional.pid} is over budget; it is non-blocking, or rerun with --kill-children to terminate it.`; - } - if (args.lastErrors.length > 0) { - return "Review recent errors before dispatching another unit."; - } - if (args.activeMilestone && !args.activeUnit) { - return `Dispatch or resume the next unit for ${args.activeMilestone.id}.`; - } - return "No flow-auditor action needed."; + if (args.staleDispatchedUnits.length > 0) { + const unit = args.staleDispatchedUnits[0]; + const session = args.sessionPointer?.sessionFile + ? ` ${args.sessionPointer.sessionFile}` + : args.sessionPointer?.sessionId + ? ` ${args.sessionPointer.sessionId}` + : ""; + return `Inspect session${session} for ${unit.unitType} ${unit.unitId}; if no new output exists, stop/requeue the stale dispatched unit before continuing.`; + } + const overBudgetOptional = args.childProcesses.find( + (p) => p.nonBlocking && p.overBudget, + ); + if (overBudgetOptional) { + return `Optional ${overBudgetOptional.classification} child pid ${overBudgetOptional.pid} is over budget; it is non-blocking, or rerun with --kill-children to terminate it.`; + } + if (args.lastErrors.length > 0) { + return "Review recent errors before dispatching another unit."; + } + if (args.activeMilestone && !args.activeUnit) { + return `Dispatch or resume the next unit for ${args.activeMilestone.id}.`; + } + return "No flow-auditor action needed."; } /** * Run a flow audit: inspect active unit state, auto.lock, runtime artifacts, @@ -314,213 +351,289 @@ function chooseRecommendedAction(args) { * Consumer: `/sf doctor flow` command and session_start startup health sweep. */ export async function runFlowAudit(basePath, options = {}) { - const nowMs = options.nowMs ?? Date.now(); - const staleProgressMs = options.staleProgressMs ?? DEFAULT_STALE_PROGRESS_MS; - const optionalChildBudgetMs = options.optionalChildBudgetMs ?? DEFAULT_OPTIONAL_CHILD_BUDGET_MS; - const runtimeRoot = sfRoot(basePath); - const warnings = []; - const recommendations = []; - const childProcesses = []; - const lastErrors = readRecentErrors(runtimeRoot); - const staleDispatchedUnits = []; - let sessionPointer; - let activeMilestone; - const autoLockPath = join(runtimeRoot, "auto.lock"); - let activeUnit; - let activePid; - const lockData = readJsonFile(autoLockPath); - if (lockData) { - if (lockData.unitType && lockData.unitId) { - const startedAtMs = parseEpochMs(lockData.startedAt, nowMs); - const parsed = parseUnitId(lockData.unitId); - activeMilestone = { id: parsed.milestone }; - activePid = - typeof lockData.pid === "number" && Number.isFinite(lockData.pid) - ? lockData.pid - : undefined; - activeUnit = { - unitType: lockData.unitType, - unitId: lockData.unitId, - phase: lockData.phase ?? "unknown", - startedAt: formatIso(startedAtMs) ?? new Date(nowMs).toISOString(), - ageMs: Math.max(0, nowMs - startedAtMs), - progressAgeMs: Math.max(0, nowMs - startedAtMs), - }; - if (lockData.sessionId || lockData.sessionFile) { - sessionPointer = { - sessionId: lockData.sessionId, - sessionFile: lockData.sessionFile, - source: "auto.lock", - }; - } - } - } - else if (existsSync(autoLockPath)) { - warnings.push("Could not parse .sf/auto.lock"); - } - const runtimeUnits = readRuntimeUnits(join(runtimeRoot, "runtime", "units")); - let dispatchedCount = 0; - for (const unit of runtimeUnits) { - if (unit.phase === "dispatched") - dispatchedCount++; - if (!unit.unitType || !unit.unitId) - continue; - const progressBaseMs = parseEpochMs(unit.lastProgressAt ?? unit.updatedAt ?? unit.startedAt, nowMs); - const progressAgeMs = Math.max(0, nowMs - progressBaseMs); - const lastProgressAt = formatIso(progressBaseMs); - const stale = unit.phase === "dispatched" && progressAgeMs > staleProgressMs; - if (stale) { - // False-positive guard: if the expected artifact already exists, the unit - // completed successfully but its runtime record was not cleared (#sf-moqv5o7h-vaabu6). - const parsed = parseUnitId(unit.unitId); - let artifactExists = false; - if (unit.unitType === "complete-slice" && parsed.milestone && parsed.slice) { - artifactExists = !!resolveSliceFile(basePath, parsed.milestone, parsed.slice, "SUMMARY"); - } - else if (unit.unitType === "execute-task" && parsed.milestone && parsed.slice && parsed.task) { - artifactExists = !!resolveTaskFile(basePath, parsed.milestone, parsed.slice, parsed.task, "SUMMARY"); - } - else if (unit.unitType === "complete-milestone" && parsed.milestone) { - artifactExists = !!resolveMilestoneFile(basePath, parsed.milestone, "SUMMARY"); - } - else if ((unit.unitType === "plan-slice" || unit.unitType === "replan-slice") && parsed.milestone && parsed.slice) { - artifactExists = !!resolveSliceFile(basePath, parsed.milestone, parsed.slice, "PLAN"); - } - else if (unit.unitType === "plan-milestone" && parsed.milestone) { - artifactExists = !!resolveMilestoneFile(basePath, parsed.milestone, "ROADMAP"); - } - if (!artifactExists) { - staleDispatchedUnits.push({ - unitType: unit.unitType, - unitId: unit.unitId, - phase: unit.phase ?? "unknown", - progressAgeMs, - lastProgressAt, - }); - warnings.push(`Unit ${unit.unitId} has no progress for ${minutes(progressAgeMs)} minutes (phase=${unit.phase}).`); - } - } - if (activeUnit && - unit.unitType === activeUnit.unitType && - unit.unitId === activeUnit.unitId) { - activeUnit.phase = unit.phase ?? activeUnit.phase; - activeUnit.progressAgeMs = progressAgeMs; - activeUnit.lastProgressAt = lastProgressAt; - if (!sessionPointer && (unit.sessionId || unit.sessionFile)) { - sessionPointer = { - sessionId: unit.sessionId, - sessionFile: unit.sessionFile, - source: "runtime-unit", - }; - } - } - } - if (dispatchedCount > 1) { - warnings.push(`${dispatchedCount} units are in dispatched phase simultaneously.`); - } - const psRows = await readPsRows(options); - for (const row of psRows) { - const classification = classifyProcess(row); - if (!shouldIncludeProcess(row, classification, activePid)) - continue; - const nonBlocking = isOptionalChild(classification); - const overBudget = nonBlocking && - row.ageMs !== undefined && - row.ageMs > optionalChildBudgetMs; - let action = nonBlocking ? "non-blocking" : "observe"; - let killed = false; - let killError; - if (overBudget) { - warnings.push(`${classification} child pid ${row.pid} is over budget (${minutes(row.ageMs ?? 0)} minutes).`); - if (options.killOverBudgetChildren) { - action = "kill"; - try { - if (options.killProcess) - options.killProcess(row.pid); - else - process.kill(row.pid, "SIGTERM"); - killed = true; - } - catch (err) { - killError = err instanceof Error ? err.message : String(err); - warnings.push(`Failed to kill over-budget ${classification} child pid ${row.pid}: ${killError}`); - } - } - } - childProcesses.push({ - pid: row.pid, - ppid: row.ppid, - cmd: row.cmd, - classification, - ageMs: row.ageMs, - nonBlocking, - overBudget, - action, - killed: killed || undefined, - killError, - }); - } - try { - const state = await deriveState(basePath); - if (state.activeMilestone) { - activeMilestone = { - id: state.activeMilestone.id, - title: state.activeMilestone.title, - phase: state.phase, - }; - } - if (state.activeMilestone && !activeUnit) { - recommendations.push(`No active unit detected, but milestone ${state.activeMilestone.id} is active. Consider dispatching the next unit.`); - } - } - catch { - // State derivation is useful context but not required for the audit. - } - const loopEvidence = activeUnit && - buildLoopEvidence(basePath, activeUnit.unitType, activeUnit.unitId); - if (loopEvidence?.completedPriorTasks.length && - loopEvidence.missingSummaries.length) { - warnings.push(`${loopEvidence.milestoneId}/${loopEvidence.sliceId} has ${loopEvidence.completedPriorTasks.length} completed prior tasks but missing final summary evidence for ${loopEvidence.missingSummaries.join(", ")}.`); - } - const feedback = readAllSelfFeedback(basePath); - const milestoneId = activeMilestone?.id; - const runawayHistory = collectRunawayHistory(runtimeUnits, feedback, milestoneId); - const repeatedFailureRollup = maybeRecordRepeatedFailureRollup(basePath, milestoneId, feedback, options); - if (repeatedFailureRollup?.filed) { - recommendations.push(`Filed ${FLOW_AUDIT_ROLLUP_KIND} for ${milestoneId} after ${repeatedFailureRollup.count} repeated failures.`); - } - const recommendedAction = chooseRecommendedAction({ - activeUnit, - sessionPointer, - staleDispatchedUnits, - childProcesses, - lastErrors, - activeMilestone, - }); - if (!recommendations.includes(recommendedAction)) { - recommendations.unshift(recommendedAction); - } - return { - ok: warnings.length === 0 && - lastErrors.length === 0 && - staleDispatchedUnits.length === 0, - activeMilestone, - activeUnit, - sessionPointer, - recommendations, - recommendedAction, - warnings, - childProcesses, - lastErrors, - staleDispatchedUnits, - runawayHistory, - loopEvidence, - repeatedFailureRollup, - }; + const nowMs = options.nowMs ?? Date.now(); + const staleProgressMs = options.staleProgressMs ?? DEFAULT_STALE_PROGRESS_MS; + const optionalChildBudgetMs = + options.optionalChildBudgetMs ?? DEFAULT_OPTIONAL_CHILD_BUDGET_MS; + const runtimeRoot = sfRoot(basePath); + const warnings = []; + const recommendations = []; + const childProcesses = []; + const lastErrors = readRecentErrors(runtimeRoot); + const staleDispatchedUnits = []; + let sessionPointer; + let activeMilestone; + const autoLockPath = join(runtimeRoot, "auto.lock"); + let activeUnit; + let activePid; + const lockData = readJsonFile(autoLockPath); + if (lockData) { + if (lockData.unitType && lockData.unitId) { + const startedAtMs = parseEpochMs(lockData.startedAt, nowMs); + const parsed = parseUnitId(lockData.unitId); + activeMilestone = { id: parsed.milestone }; + activePid = + typeof lockData.pid === "number" && Number.isFinite(lockData.pid) + ? lockData.pid + : undefined; + activeUnit = { + unitType: lockData.unitType, + unitId: lockData.unitId, + phase: lockData.phase ?? "unknown", + startedAt: formatIso(startedAtMs) ?? new Date(nowMs).toISOString(), + ageMs: Math.max(0, nowMs - startedAtMs), + progressAgeMs: Math.max(0, nowMs - startedAtMs), + }; + if (lockData.sessionId || lockData.sessionFile) { + sessionPointer = { + sessionId: lockData.sessionId, + sessionFile: lockData.sessionFile, + source: "auto.lock", + }; + } + } + } else if (existsSync(autoLockPath)) { + warnings.push("Could not parse .sf/auto.lock"); + } + const runtimeUnits = readRuntimeUnits(join(runtimeRoot, "runtime", "units")); + let dispatchedCount = 0; + for (const unit of runtimeUnits) { + if (unit.phase === "dispatched") dispatchedCount++; + if (!unit.unitType || !unit.unitId) continue; + const progressBaseMs = parseEpochMs( + unit.lastProgressAt ?? unit.updatedAt ?? unit.startedAt, + nowMs, + ); + const progressAgeMs = Math.max(0, nowMs - progressBaseMs); + const lastProgressAt = formatIso(progressBaseMs); + const stale = + unit.phase === "dispatched" && progressAgeMs > staleProgressMs; + if (stale) { + // False-positive guard: if the expected artifact already exists, the unit + // completed successfully but its runtime record was not cleared (#sf-moqv5o7h-vaabu6). + const parsed = parseUnitId(unit.unitId); + let artifactExists = false; + if ( + unit.unitType === "complete-slice" && + parsed.milestone && + parsed.slice + ) { + artifactExists = !!resolveSliceFile( + basePath, + parsed.milestone, + parsed.slice, + "SUMMARY", + ); + } else if ( + unit.unitType === "execute-task" && + parsed.milestone && + parsed.slice && + parsed.task + ) { + artifactExists = !!resolveTaskFile( + basePath, + parsed.milestone, + parsed.slice, + parsed.task, + "SUMMARY", + ); + } else if (unit.unitType === "complete-milestone" && parsed.milestone) { + artifactExists = !!resolveMilestoneFile( + basePath, + parsed.milestone, + "SUMMARY", + ); + } else if ( + (unit.unitType === "plan-slice" || unit.unitType === "replan-slice") && + parsed.milestone && + parsed.slice + ) { + artifactExists = !!resolveSliceFile( + basePath, + parsed.milestone, + parsed.slice, + "PLAN", + ); + } else if (unit.unitType === "plan-milestone" && parsed.milestone) { + artifactExists = !!resolveMilestoneFile( + basePath, + parsed.milestone, + "ROADMAP", + ); + } + if (!artifactExists) { + staleDispatchedUnits.push({ + unitType: unit.unitType, + unitId: unit.unitId, + phase: unit.phase ?? "unknown", + progressAgeMs, + lastProgressAt, + }); + warnings.push( + `Unit ${unit.unitId} has no progress for ${minutes(progressAgeMs)} minutes (phase=${unit.phase}).`, + ); + } + } + if ( + activeUnit && + unit.unitType === activeUnit.unitType && + unit.unitId === activeUnit.unitId + ) { + activeUnit.phase = unit.phase ?? activeUnit.phase; + activeUnit.progressAgeMs = progressAgeMs; + activeUnit.lastProgressAt = lastProgressAt; + if (!sessionPointer && (unit.sessionId || unit.sessionFile)) { + sessionPointer = { + sessionId: unit.sessionId, + sessionFile: unit.sessionFile, + source: "runtime-unit", + }; + } + } + } + if (dispatchedCount > 1) { + warnings.push( + `${dispatchedCount} units are in dispatched phase simultaneously.`, + ); + } + const psRows = await readPsRows(options); + for (const row of psRows) { + const classification = classifyProcess(row); + if (!shouldIncludeProcess(row, classification, activePid)) continue; + const nonBlocking = isOptionalChild(classification); + const overBudget = + nonBlocking && + row.ageMs !== undefined && + row.ageMs > optionalChildBudgetMs; + let action = nonBlocking ? "non-blocking" : "observe"; + let killed = false; + let killError; + if (overBudget) { + warnings.push( + `${classification} child pid ${row.pid} is over budget (${minutes(row.ageMs ?? 0)} minutes).`, + ); + if (options.killOverBudgetChildren) { + action = "kill"; + try { + if (options.killProcess) options.killProcess(row.pid); + else process.kill(row.pid, "SIGTERM"); + killed = true; + } catch (err) { + killError = err instanceof Error ? err.message : String(err); + warnings.push( + `Failed to kill over-budget ${classification} child pid ${row.pid}: ${killError}`, + ); + } + } + } + childProcesses.push({ + pid: row.pid, + ppid: row.ppid, + cmd: row.cmd, + classification, + ageMs: row.ageMs, + nonBlocking, + overBudget, + action, + killed: killed || undefined, + killError, + }); + } + try { + const state = await deriveState(basePath); + if (state.activeMilestone) { + activeMilestone = { + id: state.activeMilestone.id, + title: state.activeMilestone.title, + phase: state.phase, + }; + } + if (state.activeMilestone && !activeUnit) { + recommendations.push( + `No active unit detected, but milestone ${state.activeMilestone.id} is active. Consider dispatching the next unit.`, + ); + } + } catch { + // State derivation is useful context but not required for the audit. + } + const loopEvidence = + activeUnit && + buildLoopEvidence(basePath, activeUnit.unitType, activeUnit.unitId); + if ( + loopEvidence?.completedPriorTasks.length && + loopEvidence.missingSummaries.length + ) { + warnings.push( + `${loopEvidence.milestoneId}/${loopEvidence.sliceId} has ${loopEvidence.completedPriorTasks.length} completed prior tasks but missing final summary evidence for ${loopEvidence.missingSummaries.join(", ")}.`, + ); + } + const feedback = readAllSelfFeedback(basePath); + const milestoneId = activeMilestone?.id; + const runawayHistory = collectRunawayHistory( + runtimeUnits, + feedback, + milestoneId, + ); + const repeatedFailureRollup = maybeRecordRepeatedFailureRollup( + basePath, + milestoneId, + feedback, + options, + ); + if (repeatedFailureRollup?.filed) { + recommendations.push( + `Filed ${FLOW_AUDIT_ROLLUP_KIND} for ${milestoneId} after ${repeatedFailureRollup.count} repeated failures.`, + ); + } + const recommendedAction = chooseRecommendedAction({ + activeUnit, + sessionPointer, + staleDispatchedUnits, + childProcesses, + lastErrors, + activeMilestone, + }); + if (!recommendations.includes(recommendedAction)) { + recommendations.unshift(recommendedAction); + } + return { + ok: + warnings.length === 0 && + lastErrors.length === 0 && + staleDispatchedUnits.length === 0, + activeMilestone, + activeUnit, + sessionPointer, + recommendations, + recommendedAction, + warnings, + childProcesses, + lastErrors, + staleDispatchedUnits, + runawayHistory, + loopEvidence, + repeatedFailureRollup, + }; } -export { formatEnvironmentReport, runEnvironmentChecks, runFullEnvironmentChecks, } from "./doctor-environment.js"; -export { filterDoctorIssues, formatDoctorIssuesForPrompt, formatDoctorReport, formatDoctorReportJson, summarizeDoctorIssues, } from "./doctor-format.js"; -export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, } from "./progress-score.js"; +export { + formatEnvironmentReport, + runEnvironmentChecks, + runFullEnvironmentChecks, +} from "./doctor-environment.js"; +export { + filterDoctorIssues, + formatDoctorIssuesForPrompt, + formatDoctorReport, + formatDoctorReportJson, + summarizeDoctorIssues, +} from "./doctor-format.js"; +export { + computeProgressScore, + computeProgressScoreWithContext, + formatProgressLine, + formatProgressReport, +} from "./progress-score.js"; + /** * Characters that are used as delimiters in SF state management documents * and should not appear in milestone or slice titles. @@ -543,53 +656,51 @@ const TITLE_DELIMITER_RE = /[\u2014\u2013/]/; // em dash, en dash, forward slash * @returns error description or null if title is safe */ export function validateTitle(title) { - if (TITLE_DELIMITER_RE.test(title)) { - const found = []; - if (/[\u2014\u2013]/.test(title)) - found.push("em/en dash (\u2014 or \u2013)"); - if (/\//.test(title)) - found.push("forward slash (/)"); - return `title contains ${found.join(" and ")}, which conflict with SF state document delimiters`; - } - return null; + if (TITLE_DELIMITER_RE.test(title)) { + const found = []; + if (/[\u2014\u2013]/.test(title)) + found.push("em/en dash (\u2014 or \u2013)"); + if (/\//.test(title)) found.push("forward slash (/)"); + return `title contains ${found.join(" and ")}, which conflict with SF state document delimiters`; + } + return null; } function validatePreferenceShape(preferences) { - const issues = []; - const listFields = [ - "always_use_skills", - "prefer_skills", - "avoid_skills", - "custom_instructions", - ]; - for (const field of listFields) { - const value = preferences[field]; - if (value !== undefined && !Array.isArray(value)) { - issues.push(`${field} must be a list`); - } - } - if (preferences.skill_rules !== undefined) { - if (!Array.isArray(preferences.skill_rules)) { - issues.push("skill_rules must be a list"); - } - else { - for (const [index, rule] of preferences.skill_rules.entries()) { - if (!rule || typeof rule !== "object") { - issues.push(`skill_rules[${index}] must be an object`); - continue; - } - if (typeof rule.when !== "string") { - issues.push(`skill_rules[${index}].when must be a string`); - } - for (const key of ["use", "prefer", "avoid"]) { - const value = rule[key]; - if (value !== undefined && !Array.isArray(value)) { - issues.push(`skill_rules[${index}].${key} must be a list`); - } - } - } - } - } - return issues; + const issues = []; + const listFields = [ + "always_use_skills", + "prefer_skills", + "avoid_skills", + "custom_instructions", + ]; + for (const field of listFields) { + const value = preferences[field]; + if (value !== undefined && !Array.isArray(value)) { + issues.push(`${field} must be a list`); + } + } + if (preferences.skill_rules !== undefined) { + if (!Array.isArray(preferences.skill_rules)) { + issues.push("skill_rules must be a list"); + } else { + for (const [index, rule] of preferences.skill_rules.entries()) { + if (!rule || typeof rule !== "object") { + issues.push(`skill_rules[${index}] must be an object`); + continue; + } + if (typeof rule.when !== "string") { + issues.push(`skill_rules[${index}].when must be a string`); + } + for (const key of ["use", "prefer", "avoid"]) { + const value = rule[key]; + if (value !== undefined && !Array.isArray(value)) { + issues.push(`skill_rules[${index}].${key} must be a list`); + } + } + } + } + } + return issues; } /** * Build STATE.md markdown from derived project state. @@ -598,61 +709,60 @@ function validatePreferenceShape(preferences) { * recent decisions, blockers, and next action. Exported for pre-dispatch rebuild (#3475). */ export function buildStateMarkdown(state) { - const lines = []; - lines.push("# SF State", ""); - const activeMilestone = state.activeMilestone - ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` - : "None"; - const activeSlice = state.activeSlice - ? `${state.activeSlice.id}: ${state.activeSlice.title}` - : "None"; - lines.push(`**Active Milestone:** ${activeMilestone}`); - lines.push(`**Active Slice:** ${activeSlice}`); - lines.push(`**Phase:** ${state.phase}`); - if (state.requirements) { - lines.push(`**Requirements Status:** ${state.requirements.active} active \u00b7 ${state.requirements.validated} validated \u00b7 ${state.requirements.deferred} deferred \u00b7 ${state.requirements.outOfScope} out of scope`); - } - lines.push(""); - lines.push("## Milestone Registry"); - for (const entry of state.registry) { - const glyph = entry.status === "complete" - ? "\u2705" - : entry.status === "active" - ? "\uD83D\uDD04" - : entry.status === "parked" - ? "\u23F8\uFE0F" - : "\u2B1C"; - lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`); - } - lines.push(""); - lines.push("## Recent Decisions"); - if (state.recentDecisions.length > 0) { - for (const decision of state.recentDecisions) - lines.push(`- ${decision}`); - } - else { - lines.push("- None recorded"); - } - lines.push(""); - lines.push("## Blockers"); - if (state.blockers.length > 0) { - for (const blocker of state.blockers) - lines.push(`- ${blocker}`); - } - else { - lines.push("- None"); - } - lines.push(""); - lines.push("## Next Action"); - lines.push(state.nextAction || "None"); - lines.push(""); - return lines.join("\n"); + const lines = []; + lines.push("# SF State", ""); + const activeMilestone = state.activeMilestone + ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` + : "None"; + const activeSlice = state.activeSlice + ? `${state.activeSlice.id}: ${state.activeSlice.title}` + : "None"; + lines.push(`**Active Milestone:** ${activeMilestone}`); + lines.push(`**Active Slice:** ${activeSlice}`); + lines.push(`**Phase:** ${state.phase}`); + if (state.requirements) { + lines.push( + `**Requirements Status:** ${state.requirements.active} active \u00b7 ${state.requirements.validated} validated \u00b7 ${state.requirements.deferred} deferred \u00b7 ${state.requirements.outOfScope} out of scope`, + ); + } + lines.push(""); + lines.push("## Milestone Registry"); + for (const entry of state.registry) { + const glyph = + entry.status === "complete" + ? "\u2705" + : entry.status === "active" + ? "\uD83D\uDD04" + : entry.status === "parked" + ? "\u23F8\uFE0F" + : "\u2B1C"; + lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`); + } + lines.push(""); + lines.push("## Recent Decisions"); + if (state.recentDecisions.length > 0) { + for (const decision of state.recentDecisions) lines.push(`- ${decision}`); + } else { + lines.push("- None recorded"); + } + lines.push(""); + lines.push("## Blockers"); + if (state.blockers.length > 0) { + for (const blocker of state.blockers) lines.push(`- ${blocker}`); + } else { + lines.push("- None"); + } + lines.push(""); + lines.push("## Next Action"); + lines.push(state.nextAction || "None"); + lines.push(""); + return lines.join("\n"); } async function updateStateFile(basePath, fixesApplied) { - const state = await deriveState(basePath); - const path = resolveSfRootFile(basePath, "STATE"); - await saveFile(path, buildStateMarkdown(state)); - fixesApplied.push(`updated ${path}`); + const state = await deriveState(basePath); + const path = resolveSfRootFile(basePath, "STATE"); + await saveFile(path, buildStateMarkdown(state)); + fixesApplied.push(`updated ${path}`); } /** * Rebuild STATE.md from current disk state. @@ -661,68 +771,70 @@ async function updateStateFile(basePath, fixesApplied) { * and rewrites STATE.md. Called from auto-mode post-hooks and doctor recovery paths. */ export async function rebuildState(basePath) { - invalidateAllCaches(); - const state = await deriveState(basePath); - const path = resolveSfRootFile(basePath, "STATE"); - await saveFile(path, buildStateMarkdown(state)); + invalidateAllCaches(); + const state = await deriveState(basePath); + const path = resolveSfRootFile(basePath, "STATE"); + await saveFile(path, buildStateMarkdown(state)); } function matchesScope(unitId, scope) { - if (!scope) - return true; - return unitId === scope || unitId.startsWith(`${scope}/`); + if (!scope) return true; + return unitId === scope || unitId.startsWith(`${scope}/`); } function auditRequirements(content) { - if (!content) - return []; - const issues = []; - const blocks = content.split(/^###\s+/m).slice(1); - for (const block of blocks) { - const idMatch = block.match(/^(R\d+)/); - if (!idMatch) - continue; - const requirementId = idMatch[1]; - const status = block - .match(/^-\s+Status:\s+(.+)$/m)?.[1] - ?.trim() - .toLowerCase() ?? ""; - const owner = block - .match(/^-\s+Primary owning slice:\s+(.+)$/m)?.[1] - ?.trim() - .toLowerCase() ?? ""; - const notes = block - .match(/^-\s+Notes:\s+(.+)$/m)?.[1] - ?.trim() - .toLowerCase() ?? ""; - if (status === "active" && - (!owner || owner === "none" || owner === "none yet")) { - // #4414: Downgrade to warning. A newly-created requirement has - // primary_owner='' by default until the planning agent wires it to - // a slice via sf_requirement_update. Flagging as error during normal - // planning is noisy — the real failure is when it persists past - // milestone completion, which is covered by other audits. - issues.push({ - severity: "warning", - code: "active_requirement_missing_owner", - scope: "project", - unitId: requirementId, - message: `${requirementId} is Active but has no primary owning slice`, - file: relSfRootFile("REQUIREMENTS"), - fixable: false, - }); - } - if (status === "blocked" && !notes) { - issues.push({ - severity: "warning", - code: "blocked_requirement_missing_reason", - scope: "project", - unitId: requirementId, - message: `${requirementId} is Blocked but has no reason in Notes`, - file: relSfRootFile("REQUIREMENTS"), - fixable: false, - }); - } - } - return issues; + if (!content) return []; + const issues = []; + const blocks = content.split(/^###\s+/m).slice(1); + for (const block of blocks) { + const idMatch = block.match(/^(R\d+)/); + if (!idMatch) continue; + const requirementId = idMatch[1]; + const status = + block + .match(/^-\s+Status:\s+(.+)$/m)?.[1] + ?.trim() + .toLowerCase() ?? ""; + const owner = + block + .match(/^-\s+Primary owning slice:\s+(.+)$/m)?.[1] + ?.trim() + .toLowerCase() ?? ""; + const notes = + block + .match(/^-\s+Notes:\s+(.+)$/m)?.[1] + ?.trim() + .toLowerCase() ?? ""; + if ( + status === "active" && + (!owner || owner === "none" || owner === "none yet") + ) { + // #4414: Downgrade to warning. A newly-created requirement has + // primary_owner='' by default until the planning agent wires it to + // a slice via sf_requirement_update. Flagging as error during normal + // planning is noisy — the real failure is when it persists past + // milestone completion, which is covered by other audits. + issues.push({ + severity: "warning", + code: "active_requirement_missing_owner", + scope: "project", + unitId: requirementId, + message: `${requirementId} is Active but has no primary owning slice`, + file: relSfRootFile("REQUIREMENTS"), + fixable: false, + }); + } + if (status === "blocked" && !notes) { + issues.push({ + severity: "warning", + code: "blocked_requirement_missing_reason", + scope: "project", + unitId: requirementId, + message: `${requirementId} is Blocked but has no reason in Notes`, + file: relSfRootFile("REQUIREMENTS"), + fixable: false, + }); + } + } + return issues; } /** * Select the doctor scope (milestone or milestone/slice). @@ -734,119 +846,116 @@ function auditRequirements(content) { * @returns scope ID (e.g., "M001" or "M001/S01") or undefined */ export async function selectDoctorScope(basePath, requestedScope) { - if (requestedScope) - return requestedScope; - const state = await deriveState(basePath); - if (state.activeMilestone?.id && state.activeSlice?.id) { - return `${state.activeMilestone.id}/${state.activeSlice.id}`; - } - if (state.activeMilestone?.id) { - return state.activeMilestone.id; - } - const milestonesPath = milestonesDir(basePath); - if (!existsSync(milestonesPath)) - return undefined; - for (const milestone of state.registry) { - const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP"); - const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; - if (!roadmapContent) - continue; - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestone.id); - const allDone = dbSlices.length > 0 && dbSlices.every((s) => s.status === "complete"); - if (!allDone) - return milestone.id; - } - else { - const roadmap = parseRoadmap(roadmapContent); - if (!isMilestoneComplete(roadmap)) - return milestone.id; - } - } - return state.registry[0]?.id; + if (requestedScope) return requestedScope; + const state = await deriveState(basePath); + if (state.activeMilestone?.id && state.activeSlice?.id) { + return `${state.activeMilestone.id}/${state.activeSlice.id}`; + } + if (state.activeMilestone?.id) { + return state.activeMilestone.id; + } + const milestonesPath = milestonesDir(basePath); + if (!existsSync(milestonesPath)) return undefined; + for (const milestone of state.registry) { + const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP"); + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (!roadmapContent) continue; + if (isDbAvailable()) { + const dbSlices = getMilestoneSlices(milestone.id); + const allDone = + dbSlices.length > 0 && dbSlices.every((s) => s.status === "complete"); + if (!allDone) return milestone.id; + } else { + const roadmap = parseRoadmap(roadmapContent); + if (!isMilestoneComplete(roadmap)) return milestone.id; + } + } + return state.registry[0]?.id; } // ── Helper: circular dependency detection ────────────────────────────────── function detectCircularDependencies(slices) { - const known = new Set(slices.map((s) => s.id)); - const adj = new Map(); - for (const s of slices) - adj.set(s.id, s.depends.filter((d) => known.has(d))); - const state = new Map(); - for (const s of slices) - state.set(s.id, "unvisited"); - const cycles = []; - function dfs(id, path) { - const st = state.get(id); - if (st === "done") - return; - if (st === "visiting") { - cycles.push([...path.slice(path.indexOf(id)), id]); - return; - } - state.set(id, "visiting"); - for (const dep of adj.get(id) ?? []) - dfs(dep, [...path, id]); - state.set(id, "done"); - } - for (const s of slices) - if (state.get(s.id) === "unvisited") - dfs(s.id, []); - return cycles; + const known = new Set(slices.map((s) => s.id)); + const adj = new Map(); + for (const s of slices) + adj.set( + s.id, + s.depends.filter((d) => known.has(d)), + ); + const state = new Map(); + for (const s of slices) state.set(s.id, "unvisited"); + const cycles = []; + function dfs(id, path) { + const st = state.get(id); + if (st === "done") return; + if (st === "visiting") { + cycles.push([...path.slice(path.indexOf(id)), id]); + return; + } + state.set(id, "visiting"); + for (const dep of adj.get(id) ?? []) dfs(dep, [...path, id]); + state.set(id, "done"); + } + for (const s of slices) if (state.get(s.id) === "unvisited") dfs(s.id, []); + return cycles; } async function appendDoctorHistory(basePath, report) { - try { - const historyPath = join(sfRoot(basePath), "doctor-history.jsonl"); - const errorCount = report.issues.filter((i) => i.severity === "error").length; - const warningCount = report.issues.filter((i) => i.severity === "warning").length; - const issueDetails = report.issues - .filter((i) => i.severity === "error" || i.severity === "warning") - .slice(0, 10) // cap to keep JSONL lines bounded - .map((i) => ({ - severity: i.severity, - code: i.code, - message: i.message, - unitId: i.unitId, - })); - // Human-readable one-line summary - const summaryParts = []; - if (report.ok) { - summaryParts.push("Clean"); - } - else { - const counts = []; - if (errorCount > 0) - counts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`); - if (warningCount > 0) - counts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`); - summaryParts.push(counts.join(", ")); - } - if (report.fixesApplied.length > 0) { - summaryParts.push(`${report.fixesApplied.length} fixed`); - } - if (issueDetails.length > 0) { - const topIssue = issueDetails.find((i) => i.severity === "error") ?? issueDetails[0]; - summaryParts.push(topIssue.message); - } - const entry = JSON.stringify({ - ts: new Date().toISOString(), - ok: report.ok, - errors: errorCount, - warnings: warningCount, - fixes: report.fixesApplied.length, - codes: [...new Set(report.issues.map((i) => i.code))], - issues: issueDetails.length > 0 ? issueDetails : undefined, - fixDescriptions: report.fixesApplied.length > 0 ? report.fixesApplied : undefined, - scope: report.scope, - summary: summaryParts.join(" · "), - }); - const existing = existsSync(historyPath) - ? readFileSync(historyPath, "utf-8") - : ""; - await saveFile(historyPath, existing + entry + "\n"); - } - catch { - /* non-fatal */ - } + try { + const historyPath = join(sfRoot(basePath), "doctor-history.jsonl"); + const errorCount = report.issues.filter( + (i) => i.severity === "error", + ).length; + const warningCount = report.issues.filter( + (i) => i.severity === "warning", + ).length; + const issueDetails = report.issues + .filter((i) => i.severity === "error" || i.severity === "warning") + .slice(0, 10) // cap to keep JSONL lines bounded + .map((i) => ({ + severity: i.severity, + code: i.code, + message: i.message, + unitId: i.unitId, + })); + // Human-readable one-line summary + const summaryParts = []; + if (report.ok) { + summaryParts.push("Clean"); + } else { + const counts = []; + if (errorCount > 0) + counts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`); + if (warningCount > 0) + counts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`); + summaryParts.push(counts.join(", ")); + } + if (report.fixesApplied.length > 0) { + summaryParts.push(`${report.fixesApplied.length} fixed`); + } + if (issueDetails.length > 0) { + const topIssue = + issueDetails.find((i) => i.severity === "error") ?? issueDetails[0]; + summaryParts.push(topIssue.message); + } + const entry = JSON.stringify({ + ts: new Date().toISOString(), + ok: report.ok, + errors: errorCount, + warnings: warningCount, + fixes: report.fixesApplied.length, + codes: [...new Set(report.issues.map((i) => i.code))], + issues: issueDetails.length > 0 ? issueDetails : undefined, + fixDescriptions: + report.fixesApplied.length > 0 ? report.fixesApplied : undefined, + scope: report.scope, + summary: summaryParts.join(" · "), + }); + const existing = existsSync(historyPath) + ? readFileSync(historyPath, "utf-8") + : ""; + await saveFile(historyPath, existing + entry + "\n"); + } catch { + /* non-fatal */ + } } /** * Read the last N doctor history entries from the log. @@ -858,21 +967,19 @@ async function appendDoctorHistory(basePath, report) { * @returns history entries, most-recent first */ export async function readDoctorHistory(basePath, lastN = 50) { - try { - const historyPath = join(sfRoot(basePath), "doctor-history.jsonl"); - if (!existsSync(historyPath)) - return []; - const lines = readFileSync(historyPath, "utf-8") - .split("\n") - .filter((l) => l.trim()); - return lines - .slice(-lastN) - .reverse() - .map((l) => JSON.parse(l)); - } - catch { - return []; - } + try { + const historyPath = join(sfRoot(basePath), "doctor-history.jsonl"); + if (!existsSync(historyPath)) return []; + const lines = readFileSync(historyPath, "utf-8") + .split("\n") + .filter((l) => l.trim()); + return lines + .slice(-lastN) + .reverse() + .map((l) => JSON.parse(l)); + } catch { + return []; + } } /** * Run the SF doctor health check suite across git, runtime, environment, and state layers. @@ -886,539 +993,594 @@ export async function readDoctorHistory(basePath, lastN = 50) { * @returns comprehensive report with issues, fixes applied, and per-domain timing */ export async function runSFDoctor(basePath, options) { - const issues = []; - const fixesApplied = []; - const fix = options?.fix === true; - const dryRun = options?.dryRun === true; - const fixLevel = options?.fixLevel ?? "all"; - // Issue codes that represent completion state transitions — creating summary - // stubs, marking slices/milestones done in the roadmap. These belong to the - // dispatch lifecycle (complete-slice, complete-milestone units), not to - // mechanical post-hook bookkeeping. When fixLevel is "task", these are - // detected and reported but never auto-fixed. - /** Whether a given issue code should be auto-fixed at the current fixLevel. */ - const shouldFix = (code) => { - if (!fix || dryRun) - return false; - if (fixLevel === "task" && GLOBAL_STATE_CODES.has(code)) - return false; - return true; - }; - const prefs = loadEffectiveSFPreferences(); - if (prefs) { - const prefIssues = validatePreferenceShape(prefs.preferences); - for (const issue of prefIssues) { - issues.push({ - severity: "warning", - code: "invalid_preferences", - scope: "project", - unitId: "project", - message: `SF preferences invalid: ${issue}`, - file: prefs.path, - fixable: false, - }); - } - } - // Git health checks — timed - const t0git = Date.now(); - const isolationMode = options?.isolationMode ?? - (prefs?.preferences?.git?.isolation === "worktree" - ? "worktree" - : prefs?.preferences?.git?.isolation === "branch" - ? "branch" - : "none"); - await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode); - const gitMs = Date.now() - t0git; - // Runtime health checks — timed - const t0runtime = Date.now(); - await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix); - const runtimeMs = Date.now() - t0runtime; - // Global health checks — cross-project state (e.g. orphaned project state dirs) - await checkGlobalHealth(issues, fixesApplied, shouldFix); - // Environment health checks — timed - const t0env = Date.now(); - await checkEnvironmentHealth(basePath, issues, { - includeRemote: !options?.scope, - includeBuild: options?.includeBuild, - includeTests: options?.includeTests, - }); - const envMs = Date.now() - t0env; - // Engine health checks — DB constraints and projection drift - await checkEngineHealth(basePath, issues, fixesApplied, shouldFix); - const milestonesPath = milestonesDir(basePath); - if (!existsSync(milestonesPath)) { - const report = { - ok: issues.every((i) => i.severity !== "error"), - basePath, - issues, - fixesApplied, - timing: { - git: gitMs, - runtime: runtimeMs, - environment: envMs, - sfState: 0, - }, - }; - await appendDoctorHistory(basePath, report); - return report; - } - const requirementsPath = resolveSfRootFile(basePath, "REQUIREMENTS"); - const requirementsContent = await loadFile(requirementsPath); - issues.push(...auditRequirements(requirementsContent)); - const t0state = Date.now(); - const state = await deriveState(basePath); - // Provider / auth health checks — only relevant when there is active work to dispatch. - // Skipped for idle projects (no active milestone) to avoid noise in environments - // where CI/test runners have no API key configured. - if (state.activeMilestone) { - try { - const providerResults = runProviderChecks(); - for (const result of providerResults) { - if (!result.required) - continue; - if (result.status === "error") { - issues.push({ - severity: "warning", - code: "provider_key_missing", - scope: "project", - unitId: "project", - message: result.message + (result.detail ? ` — ${result.detail}` : ""), - fixable: false, - }); - } - else if (result.status === "warning") { - issues.push({ - severity: "warning", - code: "provider_key_backedoff", - scope: "project", - unitId: "project", - message: result.message + (result.detail ? ` — ${result.detail}` : ""), - fixable: false, - }); - } - } - } - catch { - // Non-fatal — provider check failure should not block other checks - } - } - for (const milestone of state.registry) { - const milestoneId = milestone.id; - const milestonePath = resolveMilestonePath(basePath, milestoneId); - if (!milestonePath) - continue; - // Validate milestone title for delimiter characters that break state documents. - const milestoneTitleIssue = validateTitle(milestone.title); - if (milestoneTitleIssue) { - const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - let wasFixed = false; - if (shouldFix("delimiter_in_title") && roadmapFile) { - try { - const raw = readFileSync(roadmapFile, "utf-8"); - // Replace em/en dashes with " - " in the H1 title line only - const sanitized = raw.replace(/^(# .*)$/m, (line) => line.replace(/[\u2014\u2013]/g, "-")); - if (sanitized !== raw) { - await saveFile(roadmapFile, sanitized); - fixesApplied.push(`sanitized delimiter characters in ${milestoneId} title`); - wasFixed = true; - } - } - catch { - /* non-fatal — report the warning below */ - } - } - if (!wasFixed) { - issues.push({ - severity: "warning", - code: "delimiter_in_title", - scope: "milestone", - unitId: milestoneId, - message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: true, - }); - } - } - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; - if (!roadmapContent) - continue; - let slices; - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestoneId); - slices = dbSlices.map((s) => ({ - id: s.id, - title: s.title, - done: isClosedStatus(s.status), - pending: s.status === "pending", - skipped: s.status === "skipped", - risk: (s.risk || "medium"), - depends: s.depends, - demo: s.demo, - })); - } - else { - const activeMilestoneId = state.activeMilestone?.id; - const activeSliceId = state.activeSlice?.id; - slices = parseRoadmap(roadmapContent).slices.map((s) => ({ - ...s, - // Legacy roadmaps only encode done vs not-done. For doctor's - // missing-directory checks, treat every undone slice except the - // current active slice as effectively pending/unstarted. - pending: !s.done && - (milestoneId !== activeMilestoneId || s.id !== activeSliceId), - })); - } - // Wrap in Roadmap-compatible shape for detectCircularDependencies - const roadmap = { slices }; - // ── Circular dependency detection ────────────────────────────────────── - for (const cycle of detectCircularDependencies(roadmap.slices)) { - issues.push({ - severity: "error", - code: "circular_slice_dependency", - scope: "milestone", - unitId: milestoneId, - message: `Circular dependency detected: ${cycle.join(" → ")}`, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: false, - }); - } - // ── Orphaned slice directories ───────────────────────────────────────── - try { - const slicesDir = join(milestonePath, "slices"); - if (existsSync(slicesDir)) { - const knownSliceIds = new Set(roadmap.slices.map((s) => s.id)); - for (const entry of readdirSync(slicesDir)) { - try { - if (!lstatSync(join(slicesDir, entry)).isDirectory()) - continue; - } - catch { - continue; - } - if (!knownSliceIds.has(entry)) { - issues.push({ - severity: "warning", - code: "orphaned_slice_directory", - scope: "milestone", - unitId: milestoneId, - message: `Directory "${entry}" exists in ${milestoneId}/slices/ but is not referenced in the roadmap`, - file: `${relMilestonePath(basePath, milestoneId)}/slices/${entry}`, - fixable: false, - }); - } - } - } - } - catch { - /* non-fatal */ - } - for (const slice of roadmap.slices) { - const unitId = `${milestoneId}/${slice.id}`; - if (options?.scope && - !matchesScope(unitId, options.scope) && - options.scope !== milestoneId) - continue; - // Validate slice title for delimiter characters. - const sliceTitleIssue = validateTitle(slice.title); - if (sliceTitleIssue) { - // Slice titles live inside the roadmap H1/checkbox lines — the milestone-level - // fix above already sanitizes the roadmap file. For slices we only report, because - // the title comes from the checkbox text and requires careful regex to fix safely. - issues.push({ - severity: "warning", - code: "delimiter_in_title", - scope: "slice", - unitId, - message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: false, - }); - } - // Check for unresolvable dependency IDs - const knownSliceIds = new Set(roadmap.slices.map((s) => s.id)); - for (const dep of slice.depends) { - if (!knownSliceIds.has(dep)) { - issues.push({ - severity: "warning", - code: "unresolvable_dependency", - scope: "slice", - unitId, - message: `Slice ${unitId} depends on "${dep}" which is not a slice ID in this roadmap. This permanently blocks the slice. Use comma-separated IDs: \`depends:[S01,S02]\``, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: false, - }); - } - } - const slicePath = resolveSlicePath(basePath, milestoneId, slice.id); - if (!slicePath) { - // Pending slices haven't been planned yet — directories are created - // lazily by ensurePreconditions() at dispatch time. Skipped slices are - // intentionally allowed to remain summary-less and directory-less. - if (slice.pending || slice.skipped) - continue; - const expectedPath = relSlicePath(basePath, milestoneId, slice.id); - issues.push({ - severity: slice.done ? "warning" : "error", - code: "missing_slice_dir", - scope: "slice", - unitId, - message: slice.done - ? `Missing slice directory for ${unitId} (slice is complete — cosmetic only)` - : `Missing slice directory for ${unitId}`, - file: expectedPath, - fixable: true, - }); - if (fix) { - const absoluteSliceDir = join(milestonePath, "slices", slice.id); - mkdirSync(absoluteSliceDir, { recursive: true }); - fixesApplied.push(`created ${absoluteSliceDir}`); - } - continue; - } - const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id); - if (!tasksDir) { - // Pending slices haven't been planned yet — tasks/ is created on demand. - // Skipped slices may legitimately never create tasks/. - if (slice.pending || slice.skipped) - continue; - issues.push({ - severity: slice.done ? "warning" : "error", - code: "missing_tasks_dir", - scope: "slice", - unitId, - message: slice.done - ? `Missing tasks directory for ${unitId} (slice is complete \u2014 cosmetic only)` - : `Missing tasks directory for ${unitId}`, - file: relSlicePath(basePath, milestoneId, slice.id), - fixable: true, - }); - if (fix) { - mkdirSync(join(slicePath, "tasks"), { recursive: true }); - fixesApplied.push(`created ${join(slicePath, "tasks")}`); - } - } - const planPath = resolveSliceFile(basePath, milestoneId, slice.id, "PLAN"); - const planContent = planPath ? await loadFile(planPath) : null; - // Normalize plan tasks: prefer DB, fall back to parsers - let plan = null; - if (isDbAvailable()) { - const dbTasks = getSliceTasks(milestoneId, slice.id); - if (dbTasks.length > 0) { - plan = { - tasks: dbTasks.map((t) => ({ - id: t.id, - done: t.status === "complete" || t.status === "done", - title: t.title, - estimate: t.estimate || undefined, - })), - }; - } - } - if (!plan && planContent) { - plan = parsePlan(planContent); - } - if (!plan) { - if (!slice.done) { - issues.push({ - severity: "warning", - code: "missing_slice_plan", - scope: "slice", - unitId, - message: `Slice ${unitId} has no plan file`, - file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), - fixable: false, - }); - } - continue; - } - // ── Duplicate task IDs ─────────────────────────────────────────────── - const taskIdCounts = new Map(); - for (const task of plan.tasks) - taskIdCounts.set(task.id, (taskIdCounts.get(task.id) ?? 0) + 1); - for (const [taskId, count] of taskIdCounts) { - if (count > 1) { - issues.push({ - severity: "error", - code: "duplicate_task_id", - scope: "slice", - unitId, - message: `Task ID "${taskId}" appears ${count} times in ${slice.id}-PLAN.md — duplicate IDs cause dispatch failures`, - file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), - fixable: false, - }); - } - } - // ── Task files on disk not in plan ──────────────────────────────────── - try { - if (tasksDir) { - const planTaskIds = new Set(plan.tasks.map((t) => t.id)); - for (const f of readdirSync(tasksDir)) { - if (!f.endsWith("-SUMMARY.md")) - continue; - const diskTaskId = f.replace(/-SUMMARY\.md$/, ""); - if (!planTaskIds.has(diskTaskId)) { - issues.push({ - severity: "info", - code: "task_file_not_in_plan", - scope: "slice", - unitId, - message: `Task summary "${f}" exists on disk but "${diskTaskId}" is not in ${slice.id}-PLAN.md`, - file: relTaskFile(basePath, milestoneId, slice.id, diskTaskId, "SUMMARY"), - fixable: false, - }); - } - } - } - } - catch { - /* non-fatal */ - } - let allTasksDone = plan.tasks.length > 0; - for (const task of plan.tasks) { - const taskUnitId = `${unitId}/${task.id}`; - const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"); - const hasSummary = !!(summaryPath && (await loadFile(summaryPath))); - // Must-have verification - if (task.done && hasSummary) { - const taskPlanPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "PLAN"); - if (taskPlanPath) { - const taskPlanContent = await loadFile(taskPlanPath); - if (taskPlanContent) { - const mustHaves = parseTaskPlanMustHaves(taskPlanContent); - if (mustHaves.length > 0) { - const summaryContent = await loadFile(summaryPath); - const mentionedCount = summaryContent - ? countMustHavesMentionedInSummary(mustHaves, summaryContent) - : 0; - if (mentionedCount < mustHaves.length) { - issues.push({ - severity: "warning", - code: "task_done_must_haves_not_verified", - scope: "task", - unitId: taskUnitId, - message: `Task ${task.id} has ${mustHaves.length} must-haves but summary addresses only ${mentionedCount}`, - file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), - fixable: false, - }); - } - } - } - } - } - // ── Future timestamp check ───────────────────────────────────── - if (task.done && hasSummary && summaryPath) { - try { - const rawSummary = await loadFile(summaryPath); - const m = rawSummary?.match(/^completed_at:\s*(.+)$/m); - if (m) { - const ts = new Date(m[1].trim()); - if (!Number.isNaN(ts.getTime()) && - ts.getTime() > Date.now() + 24 * 60 * 60 * 1000) { - issues.push({ - severity: "warning", - code: "future_timestamp", - scope: "task", - unitId: taskUnitId, - message: `Task ${task.id} has completed_at "${m[1].trim()}" which is more than 24h in the future`, - file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), - fixable: false, - }); - } - } - } - catch { - /* non-fatal */ - } - } - allTasksDone = allTasksDone && task.done; - } - // Blocker-without-replan detection - // Skip when all tasks are done — the blocker was implicitly resolved - // within the task and the slice is not stuck (#3105 Bug 2). - const replanPath = resolveSliceFile(basePath, milestoneId, slice.id, "REPLAN"); - if (!replanPath && !allTasksDone) { - for (const task of plan.tasks) { - if (!task.done) - continue; - const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"); - if (!summaryPath) - continue; - const summaryContent = await loadFile(summaryPath); - if (!summaryContent) - continue; - const summary = parseSummary(summaryContent); - if (summary.frontmatter.blocker_discovered) { - issues.push({ - severity: "warning", - code: "blocker_discovered_no_replan", - scope: "slice", - unitId, - message: `Task ${task.id} reported blocker_discovered but no REPLAN.md exists for ${slice.id} \u2014 slice may be stuck`, - file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), - fixable: false, - }); - break; - } - } - } - // ── Stale REPLAN: exists but all tasks done ──────────────────────── - if (replanPath && allTasksDone) { - issues.push({ - severity: "info", - code: "stale_replan_file", - scope: "slice", - unitId, - message: `${slice.id} has a REPLAN.md but all tasks are done — REPLAN.md may be stale`, - file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), - fixable: false, - }); - } - } - // Milestone-level check: all slices done but no validation file - const milestoneComplete = roadmap.slices.length > 0 && roadmap.slices.every((s) => s.done); - if (milestoneComplete && - !resolveMilestoneFile(basePath, milestoneId, "VALIDATION") && - !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) { - issues.push({ - severity: "info", - code: "all_slices_done_missing_milestone_validation", - scope: "milestone", - unitId: milestoneId, - message: `All slices are done but ${milestoneId}-VALIDATION.md is missing \u2014 milestone is in validating-milestone phase`, - file: relMilestoneFile(basePath, milestoneId, "VALIDATION"), - fixable: false, - }); - } - // Milestone-level check: all slices done but no milestone summary - if (milestoneComplete && - !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) { - issues.push({ - severity: "warning", - code: "all_slices_done_missing_milestone_summary", - scope: "milestone", - unitId: milestoneId, - message: `All slices are done but ${milestoneId}-SUMMARY.md is missing \u2014 milestone is stuck in completing-milestone phase`, - file: relMilestoneFile(basePath, milestoneId, "SUMMARY"), - fixable: false, - }); - } - } - if (fix && !dryRun && fixesApplied.length > 0) { - await updateStateFile(basePath, fixesApplied); - } - const report = { - ok: issues.every((issue) => issue.severity !== "error"), - basePath, - issues, - fixesApplied, - timing: { - git: gitMs, - runtime: runtimeMs, - environment: envMs, - sfState: Math.max(0, Date.now() - t0state), - }, - }; - await appendDoctorHistory(basePath, report); - return report; + const issues = []; + const fixesApplied = []; + const fix = options?.fix === true; + const dryRun = options?.dryRun === true; + const fixLevel = options?.fixLevel ?? "all"; + // Issue codes that represent completion state transitions — creating summary + // stubs, marking slices/milestones done in the roadmap. These belong to the + // dispatch lifecycle (complete-slice, complete-milestone units), not to + // mechanical post-hook bookkeeping. When fixLevel is "task", these are + // detected and reported but never auto-fixed. + /** Whether a given issue code should be auto-fixed at the current fixLevel. */ + const shouldFix = (code) => { + if (!fix || dryRun) return false; + if (fixLevel === "task" && GLOBAL_STATE_CODES.has(code)) return false; + return true; + }; + const prefs = loadEffectiveSFPreferences(); + if (prefs) { + const prefIssues = validatePreferenceShape(prefs.preferences); + for (const issue of prefIssues) { + issues.push({ + severity: "warning", + code: "invalid_preferences", + scope: "project", + unitId: "project", + message: `SF preferences invalid: ${issue}`, + file: prefs.path, + fixable: false, + }); + } + } + // Git health checks — timed + const t0git = Date.now(); + const isolationMode = + options?.isolationMode ?? + (prefs?.preferences?.git?.isolation === "worktree" + ? "worktree" + : prefs?.preferences?.git?.isolation === "branch" + ? "branch" + : "none"); + await checkGitHealth( + basePath, + issues, + fixesApplied, + shouldFix, + isolationMode, + ); + const gitMs = Date.now() - t0git; + // Runtime health checks — timed + const t0runtime = Date.now(); + await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix); + const runtimeMs = Date.now() - t0runtime; + // Global health checks — cross-project state (e.g. orphaned project state dirs) + await checkGlobalHealth(issues, fixesApplied, shouldFix); + // Environment health checks — timed + const t0env = Date.now(); + await checkEnvironmentHealth(basePath, issues, { + includeRemote: !options?.scope, + includeBuild: options?.includeBuild, + includeTests: options?.includeTests, + shouldFix, + fixesApplied, + }); + const envMs = Date.now() - t0env; + // Engine health checks — DB constraints and projection drift + await checkEngineHealth(basePath, issues, fixesApplied, shouldFix); + const milestonesPath = milestonesDir(basePath); + if (!existsSync(milestonesPath)) { + const report = { + ok: issues.every((i) => i.severity !== "error"), + basePath, + issues, + fixesApplied, + timing: { + git: gitMs, + runtime: runtimeMs, + environment: envMs, + sfState: 0, + }, + }; + await appendDoctorHistory(basePath, report); + return report; + } + const requirementsPath = resolveSfRootFile(basePath, "REQUIREMENTS"); + const requirementsContent = await loadFile(requirementsPath); + issues.push(...auditRequirements(requirementsContent)); + const t0state = Date.now(); + const state = await deriveState(basePath); + // Provider / auth health checks — only relevant when there is active work to dispatch. + // Skipped for idle projects (no active milestone) to avoid noise in environments + // where CI/test runners have no API key configured. + if (state.activeMilestone) { + try { + const providerResults = runProviderChecks(); + for (const result of providerResults) { + if (!result.required) continue; + if (result.status === "error") { + issues.push({ + severity: "warning", + code: "provider_key_missing", + scope: "project", + unitId: "project", + message: + result.message + (result.detail ? ` — ${result.detail}` : ""), + fixable: false, + }); + } else if (result.status === "warning") { + issues.push({ + severity: "warning", + code: "provider_key_backedoff", + scope: "project", + unitId: "project", + message: + result.message + (result.detail ? ` — ${result.detail}` : ""), + fixable: false, + }); + } + } + } catch { + // Non-fatal — provider check failure should not block other checks + } + } + for (const milestone of state.registry) { + const milestoneId = milestone.id; + const milestonePath = resolveMilestonePath(basePath, milestoneId); + if (!milestonePath) continue; + // Validate milestone title for delimiter characters that break state documents. + const milestoneTitleIssue = validateTitle(milestone.title); + if (milestoneTitleIssue) { + const roadmapFile = resolveMilestoneFile( + basePath, + milestoneId, + "ROADMAP", + ); + let wasFixed = false; + if (shouldFix("delimiter_in_title") && roadmapFile) { + try { + const raw = readFileSync(roadmapFile, "utf-8"); + // Replace em/en dashes with " - " in the H1 title line only + const sanitized = raw.replace(/^(# .*)$/m, (line) => + line.replace(/[\u2014\u2013]/g, "-"), + ); + if (sanitized !== raw) { + await saveFile(roadmapFile, sanitized); + fixesApplied.push( + `sanitized delimiter characters in ${milestoneId} title`, + ); + wasFixed = true; + } + } catch { + /* non-fatal — report the warning below */ + } + } + if (!wasFixed) { + issues.push({ + severity: "warning", + code: "delimiter_in_title", + scope: "milestone", + unitId: milestoneId, + message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: true, + }); + } + } + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (!roadmapContent) continue; + let slices; + if (isDbAvailable()) { + const dbSlices = getMilestoneSlices(milestoneId); + slices = dbSlices.map((s) => ({ + id: s.id, + title: s.title, + done: isClosedStatus(s.status), + pending: s.status === "pending", + skipped: s.status === "skipped", + risk: s.risk || "medium", + depends: s.depends, + demo: s.demo, + })); + } else { + const activeMilestoneId = state.activeMilestone?.id; + const activeSliceId = state.activeSlice?.id; + slices = parseRoadmap(roadmapContent).slices.map((s) => ({ + ...s, + // Legacy roadmaps only encode done vs not-done. For doctor's + // missing-directory checks, treat every undone slice except the + // current active slice as effectively pending/unstarted. + pending: + !s.done && + (milestoneId !== activeMilestoneId || s.id !== activeSliceId), + })); + } + // Wrap in Roadmap-compatible shape for detectCircularDependencies + const roadmap = { slices }; + // ── Circular dependency detection ────────────────────────────────────── + for (const cycle of detectCircularDependencies(roadmap.slices)) { + issues.push({ + severity: "error", + code: "circular_slice_dependency", + scope: "milestone", + unitId: milestoneId, + message: `Circular dependency detected: ${cycle.join(" → ")}`, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: false, + }); + } + // ── Orphaned slice directories ───────────────────────────────────────── + try { + const slicesDir = join(milestonePath, "slices"); + if (existsSync(slicesDir)) { + const knownSliceIds = new Set(roadmap.slices.map((s) => s.id)); + for (const entry of readdirSync(slicesDir)) { + try { + if (!lstatSync(join(slicesDir, entry)).isDirectory()) continue; + } catch { + continue; + } + if (!knownSliceIds.has(entry)) { + issues.push({ + severity: "warning", + code: "orphaned_slice_directory", + scope: "milestone", + unitId: milestoneId, + message: `Directory "${entry}" exists in ${milestoneId}/slices/ but is not referenced in the roadmap`, + file: `${relMilestonePath(basePath, milestoneId)}/slices/${entry}`, + fixable: false, + }); + } + } + } + } catch { + /* non-fatal */ + } + for (const slice of roadmap.slices) { + const unitId = `${milestoneId}/${slice.id}`; + if ( + options?.scope && + !matchesScope(unitId, options.scope) && + options.scope !== milestoneId + ) + continue; + // Validate slice title for delimiter characters. + const sliceTitleIssue = validateTitle(slice.title); + if (sliceTitleIssue) { + // Slice titles live inside the roadmap H1/checkbox lines — the milestone-level + // fix above already sanitizes the roadmap file. For slices we only report, because + // the title comes from the checkbox text and requires careful regex to fix safely. + issues.push({ + severity: "warning", + code: "delimiter_in_title", + scope: "slice", + unitId, + message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: false, + }); + } + // Check for unresolvable dependency IDs + const knownSliceIds = new Set(roadmap.slices.map((s) => s.id)); + for (const dep of slice.depends) { + if (!knownSliceIds.has(dep)) { + issues.push({ + severity: "warning", + code: "unresolvable_dependency", + scope: "slice", + unitId, + message: `Slice ${unitId} depends on "${dep}" which is not a slice ID in this roadmap. This permanently blocks the slice. Use comma-separated IDs: \`depends:[S01,S02]\``, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: false, + }); + } + } + const slicePath = resolveSlicePath(basePath, milestoneId, slice.id); + if (!slicePath) { + // Pending slices haven't been planned yet — directories are created + // lazily by ensurePreconditions() at dispatch time. Skipped slices are + // intentionally allowed to remain summary-less and directory-less. + if (slice.pending || slice.skipped) continue; + const expectedPath = relSlicePath(basePath, milestoneId, slice.id); + issues.push({ + severity: slice.done ? "warning" : "error", + code: "missing_slice_dir", + scope: "slice", + unitId, + message: slice.done + ? `Missing slice directory for ${unitId} (slice is complete — cosmetic only)` + : `Missing slice directory for ${unitId}`, + file: expectedPath, + fixable: true, + }); + if (fix) { + const absoluteSliceDir = join(milestonePath, "slices", slice.id); + mkdirSync(absoluteSliceDir, { recursive: true }); + fixesApplied.push(`created ${absoluteSliceDir}`); + } + continue; + } + const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id); + if (!tasksDir) { + // Pending slices haven't been planned yet — tasks/ is created on demand. + // Skipped slices may legitimately never create tasks/. + if (slice.pending || slice.skipped) continue; + issues.push({ + severity: slice.done ? "warning" : "error", + code: "missing_tasks_dir", + scope: "slice", + unitId, + message: slice.done + ? `Missing tasks directory for ${unitId} (slice is complete \u2014 cosmetic only)` + : `Missing tasks directory for ${unitId}`, + file: relSlicePath(basePath, milestoneId, slice.id), + fixable: true, + }); + if (fix) { + mkdirSync(join(slicePath, "tasks"), { recursive: true }); + fixesApplied.push(`created ${join(slicePath, "tasks")}`); + } + } + const planPath = resolveSliceFile( + basePath, + milestoneId, + slice.id, + "PLAN", + ); + const planContent = planPath ? await loadFile(planPath) : null; + // Normalize plan tasks: prefer DB, fall back to parsers + let plan = null; + if (isDbAvailable()) { + const dbTasks = getSliceTasks(milestoneId, slice.id); + if (dbTasks.length > 0) { + plan = { + tasks: dbTasks.map((t) => ({ + id: t.id, + done: t.status === "complete" || t.status === "done", + title: t.title, + estimate: t.estimate || undefined, + })), + }; + } + } + if (!plan && planContent) { + plan = parsePlan(planContent); + } + if (!plan) { + if (!slice.done) { + issues.push({ + severity: "warning", + code: "missing_slice_plan", + scope: "slice", + unitId, + message: `Slice ${unitId} has no plan file`, + file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), + fixable: false, + }); + } + continue; + } + // ── Duplicate task IDs ─────────────────────────────────────────────── + const taskIdCounts = new Map(); + for (const task of plan.tasks) + taskIdCounts.set(task.id, (taskIdCounts.get(task.id) ?? 0) + 1); + for (const [taskId, count] of taskIdCounts) { + if (count > 1) { + issues.push({ + severity: "error", + code: "duplicate_task_id", + scope: "slice", + unitId, + message: `Task ID "${taskId}" appears ${count} times in ${slice.id}-PLAN.md — duplicate IDs cause dispatch failures`, + file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), + fixable: false, + }); + } + } + // ── Task files on disk not in plan ──────────────────────────────────── + try { + if (tasksDir) { + const planTaskIds = new Set(plan.tasks.map((t) => t.id)); + for (const f of readdirSync(tasksDir)) { + if (!f.endsWith("-SUMMARY.md")) continue; + const diskTaskId = f.replace(/-SUMMARY\.md$/, ""); + if (!planTaskIds.has(diskTaskId)) { + issues.push({ + severity: "info", + code: "task_file_not_in_plan", + scope: "slice", + unitId, + message: `Task summary "${f}" exists on disk but "${diskTaskId}" is not in ${slice.id}-PLAN.md`, + file: relTaskFile( + basePath, + milestoneId, + slice.id, + diskTaskId, + "SUMMARY", + ), + fixable: false, + }); + } + } + } + } catch { + /* non-fatal */ + } + let allTasksDone = plan.tasks.length > 0; + for (const task of plan.tasks) { + const taskUnitId = `${unitId}/${task.id}`; + const summaryPath = resolveTaskFile( + basePath, + milestoneId, + slice.id, + task.id, + "SUMMARY", + ); + const hasSummary = !!(summaryPath && (await loadFile(summaryPath))); + // Must-have verification + if (task.done && hasSummary) { + const taskPlanPath = resolveTaskFile( + basePath, + milestoneId, + slice.id, + task.id, + "PLAN", + ); + if (taskPlanPath) { + const taskPlanContent = await loadFile(taskPlanPath); + if (taskPlanContent) { + const mustHaves = parseTaskPlanMustHaves(taskPlanContent); + if (mustHaves.length > 0) { + const summaryContent = await loadFile(summaryPath); + const mentionedCount = summaryContent + ? countMustHavesMentionedInSummary(mustHaves, summaryContent) + : 0; + if (mentionedCount < mustHaves.length) { + issues.push({ + severity: "warning", + code: "task_done_must_haves_not_verified", + scope: "task", + unitId: taskUnitId, + message: `Task ${task.id} has ${mustHaves.length} must-haves but summary addresses only ${mentionedCount}`, + file: relTaskFile( + basePath, + milestoneId, + slice.id, + task.id, + "SUMMARY", + ), + fixable: false, + }); + } + } + } + } + } + // ── Future timestamp check ───────────────────────────────────── + if (task.done && hasSummary && summaryPath) { + try { + const rawSummary = await loadFile(summaryPath); + const m = rawSummary?.match(/^completed_at:\s*(.+)$/m); + if (m) { + const ts = new Date(m[1].trim()); + if ( + !Number.isNaN(ts.getTime()) && + ts.getTime() > Date.now() + 24 * 60 * 60 * 1000 + ) { + issues.push({ + severity: "warning", + code: "future_timestamp", + scope: "task", + unitId: taskUnitId, + message: `Task ${task.id} has completed_at "${m[1].trim()}" which is more than 24h in the future`, + file: relTaskFile( + basePath, + milestoneId, + slice.id, + task.id, + "SUMMARY", + ), + fixable: false, + }); + } + } + } catch { + /* non-fatal */ + } + } + allTasksDone = allTasksDone && task.done; + } + // Blocker-without-replan detection + // Skip when all tasks are done — the blocker was implicitly resolved + // within the task and the slice is not stuck (#3105 Bug 2). + const replanPath = resolveSliceFile( + basePath, + milestoneId, + slice.id, + "REPLAN", + ); + if (!replanPath && !allTasksDone) { + for (const task of plan.tasks) { + if (!task.done) continue; + const summaryPath = resolveTaskFile( + basePath, + milestoneId, + slice.id, + task.id, + "SUMMARY", + ); + if (!summaryPath) continue; + const summaryContent = await loadFile(summaryPath); + if (!summaryContent) continue; + const summary = parseSummary(summaryContent); + if (summary.frontmatter.blocker_discovered) { + issues.push({ + severity: "warning", + code: "blocker_discovered_no_replan", + scope: "slice", + unitId, + message: `Task ${task.id} reported blocker_discovered but no REPLAN.md exists for ${slice.id} \u2014 slice may be stuck`, + file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), + fixable: false, + }); + break; + } + } + } + // ── Stale REPLAN: exists but all tasks done ──────────────────────── + if (replanPath && allTasksDone) { + issues.push({ + severity: "info", + code: "stale_replan_file", + scope: "slice", + unitId, + message: `${slice.id} has a REPLAN.md but all tasks are done — REPLAN.md may be stale`, + file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), + fixable: false, + }); + } + } + // Milestone-level check: all slices done but no validation file + const milestoneComplete = + roadmap.slices.length > 0 && roadmap.slices.every((s) => s.done); + if ( + milestoneComplete && + !resolveMilestoneFile(basePath, milestoneId, "VALIDATION") && + !resolveMilestoneFile(basePath, milestoneId, "SUMMARY") + ) { + issues.push({ + severity: "info", + code: "all_slices_done_missing_milestone_validation", + scope: "milestone", + unitId: milestoneId, + message: `All slices are done but ${milestoneId}-VALIDATION.md is missing \u2014 milestone is in validating-milestone phase`, + file: relMilestoneFile(basePath, milestoneId, "VALIDATION"), + fixable: false, + }); + } + // Milestone-level check: all slices done but no milestone summary + if ( + milestoneComplete && + !resolveMilestoneFile(basePath, milestoneId, "SUMMARY") + ) { + issues.push({ + severity: "warning", + code: "all_slices_done_missing_milestone_summary", + scope: "milestone", + unitId: milestoneId, + message: `All slices are done but ${milestoneId}-SUMMARY.md is missing \u2014 milestone is stuck in completing-milestone phase`, + file: relMilestoneFile(basePath, milestoneId, "SUMMARY"), + fixable: false, + }); + } + } + if (fix && !dryRun && fixesApplied.length > 0) { + await updateStateFile(basePath, fixesApplied); + } + const report = { + ok: issues.every((issue) => issue.severity !== "error"), + basePath, + issues, + fixesApplied, + timing: { + git: gitMs, + runtime: runtimeMs, + environment: envMs, + sfState: Math.max(0, Date.now() - t0state), + }, + }; + await appendDoctorHistory(basePath, report); + return report; } diff --git a/src/resources/extensions/sf/snapshot-safety.js b/src/resources/extensions/sf/snapshot-safety.js new file mode 100644 index 000000000..0c5317353 --- /dev/null +++ b/src/resources/extensions/sf/snapshot-safety.js @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; +import { GIT_NO_PROMPT_ENV } from "./git-constants.js"; + +const PROTECTED_SNAPSHOT_DELETE_PATHS = [ + ":(glob)src/resources/extensions/**/*.d.ts", +]; + +/** + * List protected source files that are currently deleted. + * + * Purpose: keep automated stale-work snapshots from committing suspicious loss + * of hand-written extension declaration files. + * + * Consumer: pre-dispatch and doctor git stale-change snapshot checks. + */ +export function listProtectedSnapshotDeletions(basePath, opts = {}) { + const args = ["diff", "--name-only", "--diff-filter=D"]; + if (opts.cached === true) args.push("--cached"); + args.push("--", ...PROTECTED_SNAPSHOT_DELETE_PATHS); + try { + const out = execFileSync("git", args, { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + }).trim(); + return out ? out.split("\n").filter(Boolean) : []; + } catch { + return []; + } +} + +/** + * Format a blocking diagnostic for protected snapshot deletions. + * + * Purpose: explain why SF refused an automated snapshot and name the files that + * need an explicit human or task-level decision. + * + * Consumer: pre-dispatch and doctor git stale-change snapshot checks. + */ +export function formatProtectedSnapshotDeletionMessage(paths) { + const shown = paths.slice(0, 8); + const suffix = + paths.length > shown.length + ? `, and ${paths.length - shown.length} more` + : ""; + return `Protected declaration deletions detected; refusing automated snapshot: ${shown.join(", ")}${suffix}`; +} diff --git a/src/resources/extensions/sf/tests/auto-startup-doctor.test.mjs b/src/resources/extensions/sf/tests/auto-startup-doctor.test.mjs new file mode 100644 index 000000000..162cb1a0d --- /dev/null +++ b/src/resources/extensions/sf/tests/auto-startup-doctor.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, test } from "vitest"; + +const autoSource = readFileSync( + join(process.cwd(), "src/resources/extensions/sf/auto.js"), + "utf-8", +); + +describe("auto startup doctor", () => { + test("startAuto_when_session_not_running_runs_startup_doctor_fix_before_resume_dispatch", () => { + const runningGuard = autoSource.indexOf('classification === "running"'); + const doctorCall = autoSource.indexOf( + "await runStartupDoctorFix(ctx, base)", + ); + const pausedResume = autoSource.indexOf("// If resuming from paused state"); + + assert.ok(runningGuard !== -1, "running-session guard must exist"); + assert.ok(doctorCall !== -1, "fresh startup must run doctor fix"); + assert.ok(pausedResume !== -1, "paused resume marker must exist"); + assert.ok( + runningGuard < doctorCall, + "doctor must not run while another session is active", + ); + assert.ok( + doctorCall < pausedResume, + "doctor must run before resume/dispatch decisions", + ); + }); +}); diff --git a/src/resources/extensions/sf/tests/doctor-environment-fix.test.mjs b/src/resources/extensions/sf/tests/doctor-environment-fix.test.mjs new file mode 100644 index 000000000..34bb63f15 --- /dev/null +++ b/src/resources/extensions/sf/tests/doctor-environment-fix.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, test } from "vitest"; +import { + applyEnvironmentFixes, + environmentResultsToDoctorIssues, + runEnvironmentChecks, +} from "../doctor-environment.js"; + +const tmpDirs = []; + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-env-fix-")); + tmpDirs.push(dir); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ scripts: {} }, null, 2), + ); + writeFileSync( + join(dir, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3 }, null, 2), + ); + return dir; +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("doctor environment dependency fixes", () => { + test("environmentResultsToDoctorIssues_when_node_modules_missing_marks_dependencies_fixable", () => { + const project = makeProject(); + const issues = environmentResultsToDoctorIssues( + runEnvironmentChecks(project), + ); + const deps = issues.find((issue) => issue.code === "env_dependencies"); + + assert.equal(deps?.fixable, true); + assert.match(deps?.message ?? "", /node_modules missing/); + }); + + test("applyEnvironmentFixes_when_fix_enabled_runs_detected_package_manager", () => { + const project = makeProject(); + const fakeBin = mkdtempSync(join(tmpdir(), "sf-env-bin-")); + tmpDirs.push(fakeBin); + writeFileSync( + join(fakeBin, "npm"), + "#!/usr/bin/env sh\nmkdir -p node_modules\nprintf '{}\\n' > node_modules/.package-lock.json\n", + { mode: 0o755 }, + ); + + const results = runEnvironmentChecks(project); + const fixesApplied = []; + const originalPath = process.env.PATH; + process.env.PATH = `${fakeBin}:${originalPath ?? ""}`; + try { + const fixed = applyEnvironmentFixes(project, results, { + shouldFix: (code) => code === "env_dependencies", + fixesApplied, + }); + + assert.equal(fixed, true); + assert.equal( + existsSync(join(project, "node_modules", ".package-lock.json")), + true, + ); + assert.deepEqual(fixesApplied, ["dependencies: ran npm install"]); + } finally { + process.env.PATH = originalPath; + } + }); + + test("applyEnvironmentFixes_when_fix_disabled_does_not_install", () => { + const project = makeProject(); + mkdirSync(join(project, "node_modules"), { recursive: true }); + rmSync(join(project, "node_modules"), { recursive: true, force: true }); + const results = runEnvironmentChecks(project); + const fixesApplied = []; + + const fixed = applyEnvironmentFixes(project, results, { + shouldFix: () => false, + fixesApplied, + }); + + assert.equal(fixed, false); + assert.deepEqual(fixesApplied, []); + assert.equal(existsSync(join(project, "node_modules")), false); + }); +}); diff --git a/src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs b/src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs new file mode 100644 index 000000000..5ef8e18c7 --- /dev/null +++ b/src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, test } from "vitest"; +import { normalizeLegacyPlanSlugDirectories } from "../doctor-engine-checks.js"; + +const tmpDirs = []; + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-plan-dir-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf", "milestones"), { recursive: true }); + return dir; +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("doctor plan directory normalization", () => { + test("normalizeLegacyPlanSlugDirectories_when_fix_enabled_renames_milestone_and_slice_dirs", () => { + const project = makeProject(); + const legacySlice = join( + project, + ".sf", + "milestones", + "M001-long-name", + "slices", + "S02-research-work", + ); + mkdirSync(legacySlice, { recursive: true }); + writeFileSync(join(legacySlice, "S02-RESEARCH.md"), "# Research\n"); + const issues = []; + const fixesApplied = []; + + normalizeLegacyPlanSlugDirectories( + project, + issues, + fixesApplied, + (code) => code === "legacy_plan_slug_directory", + ); + + assert.equal(existsSync(join(project, ".sf", "milestones", "M001")), true); + assert.equal( + existsSync(join(project, ".sf", "milestones", "M001", "slices", "S02")), + true, + ); + assert.equal( + existsSync(join(project, ".sf", "milestones", "M001-long-name")), + false, + ); + assert.equal( + existsSync( + join( + project, + ".sf", + "milestones", + "M001", + "slices", + "S02-research-work", + ), + ), + false, + ); + assert.equal( + issues.filter((issue) => issue.code === "legacy_plan_slug_directory") + .length, + 2, + ); + assert.equal(fixesApplied.length, 2); + }); + + test("normalizeLegacyPlanSlugDirectories_when_target_exists_reports_conflict_without_rename", () => { + const project = makeProject(); + mkdirSync(join(project, ".sf", "milestones", "M001"), { recursive: true }); + mkdirSync(join(project, ".sf", "milestones", "M001-long-name"), { + recursive: true, + }); + const issues = []; + const fixesApplied = []; + + normalizeLegacyPlanSlugDirectories( + project, + issues, + fixesApplied, + (code) => code === "legacy_plan_slug_directory", + ); + + const issue = issues.find( + (candidate) => candidate.code === "legacy_plan_slug_directory", + ); + assert.equal(issue?.fixable, false); + assert.match(issue?.message ?? "", /target already exists/); + assert.equal( + existsSync(join(project, ".sf", "milestones", "M001-long-name")), + true, + ); + assert.deepEqual(fixesApplied, []); + }); +}); diff --git a/src/resources/extensions/sf/tests/snapshot-safety.test.mjs b/src/resources/extensions/sf/tests/snapshot-safety.test.mjs new file mode 100644 index 000000000..f2d341fa2 --- /dev/null +++ b/src/resources/extensions/sf/tests/snapshot-safety.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, test } from "vitest"; +import { preDispatchHealthGate } from "../doctor-proactive.js"; +import { + formatProtectedSnapshotDeletionMessage, + listProtectedSnapshotDeletions, +} from "../snapshot-safety.js"; + +const tmpDirs = []; + +function git(cwd, args, env = {}) { + return execFileSync("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "SF Test", + GIT_AUTHOR_EMAIL: "sf-test@example.invalid", + GIT_COMMITTER_NAME: "SF Test", + GIT_COMMITTER_EMAIL: "sf-test@example.invalid", + ...env, + }, + }).trim(); +} + +function makeRepo() { + const dir = mkdtempSync(join(tmpdir(), "sf-snapshot-safety-")); + tmpDirs.push(dir); + git(dir, ["init", "-b", "main"]); + return dir; +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("snapshot safety", () => { + test("listProtectedSnapshotDeletions_when_extension_declaration_deleted_returns_path", () => { + const repo = makeRepo(); + const dts = join( + repo, + "src/resources/extensions/sf/code-intelligence.d.ts", + ); + mkdirSync(join(repo, "src/resources/extensions/sf"), { recursive: true }); + writeFileSync(dts, "export function codebaseSearch(): void;\n"); + git(repo, ["add", "."]); + git(repo, ["commit", "-m", "seed"]); + + rmSync(dts); + + assert.deepEqual(listProtectedSnapshotDeletions(repo), [ + "src/resources/extensions/sf/code-intelligence.d.ts", + ]); + }); + + test("listProtectedSnapshotDeletions_when_unrelated_declaration_deleted_ignores_path", () => { + const repo = makeRepo(); + const dts = join(repo, "src/generated/types.d.ts"); + mkdirSync(join(repo, "src/generated"), { recursive: true }); + writeFileSync(dts, "export type Generated = string;\n"); + git(repo, ["add", "."]); + git(repo, ["commit", "-m", "seed"]); + + rmSync(dts); + + assert.deepEqual(listProtectedSnapshotDeletions(repo), []); + }); + + test("formatProtectedSnapshotDeletionMessage_when_many_paths_limits_output", () => { + const paths = Array.from( + { length: 10 }, + (_, i) => `src/resources/extensions/sf/file-${i}.d.ts`, + ); + const message = formatProtectedSnapshotDeletionMessage(paths); + + assert.match(message, /refusing automated snapshot/); + assert.match(message, /file-0\.d\.ts/); + assert.match(message, /and 2 more/); + }); + + test("preDispatchHealthGate_when_protected_declaration_deleted_blocks_snapshot", async () => { + const repo = makeRepo(); + const dts = join( + repo, + "src/resources/extensions/sf/code-intelligence.d.ts", + ); + mkdirSync(join(repo, "src/resources/extensions/sf"), { recursive: true }); + writeFileSync(dts, "export function codebaseSearch(): void;\n"); + git(repo, ["add", "."]); + git(repo, ["commit", "-m", "seed"], { + GIT_AUTHOR_DATE: "2020-01-01T00:00:00Z", + GIT_COMMITTER_DATE: "2020-01-01T00:00:00Z", + }); + + rmSync(dts); + + const result = await preDispatchHealthGate(repo); + + assert.equal(result.proceed, false); + assert.match(result.reason, /Protected declaration deletions detected/); + assert.equal(git(repo, ["log", "--oneline"]).split("\n").length, 1); + }); +});