diff --git a/TODO.md b/TODO.md deleted file mode 100644 index b6dbda7f3..000000000 --- a/TODO.md +++ /dev/null @@ -1,2 +0,0 @@ -# TODO - diff --git a/src/headless-context.ts b/src/headless-context.ts index fbbdd86b1..88b6b7104 100644 --- a/src/headless-context.ts +++ b/src/headless-context.ts @@ -13,6 +13,7 @@ import { readFileSync, renameSync, statSync, + writeFileSync, } from "node:fs"; import { join, relative, resolve } from "node:path"; import { @@ -21,6 +22,7 @@ import { untrackRuntimeFiles, } from "./resources/extensions/sf/gitignore.js"; import { ensureAgenticDocsScaffold } from "./resources/extensions/sf/agentic-docs-scaffold.js"; +import { checkDocsScaffold, formatDocCheckReport } from "./resources/extensions/sf/doc-checker.js"; import { ensureSiftIndexWarmup } from "./resources/extensions/sf/code-intelligence.js"; import { nativeInit, @@ -301,9 +303,102 @@ function walkFiles( } // --------------------------------------------------------------------------- -// Project Bootstrap +// Serena MCP Auto-Enrollment // --------------------------------------------------------------------------- +/** + * Register the project in Serena's global config and add it to .sf/mcp.json. + * Called from bootstrapProject so every `sf init` or auto-bootstrap enrolls + * the repo in Serena MCP automatically — no extra flags needed. + * + * Uses `claude-code` context: disables tools SF already provides (read_file, + * execute_shell_command, etc.) so only Serena's unique symbol-level code + * intelligence tools are exposed via MCP. + * + * Availability check is deferred to first MCP connection — `uvx --from serena-agent` + * resolves lazily. If Serena is not installed, the MCP client will surface the + * error; the user can then run `uv tool install serena-agent`. + */ +function ensureSerenaMcp(basePath: string): void { + // 1. Register project path in ~/.serena/serena_config.yml (no-op if already present) + const serenaConfigPath = join(process.env.HOME ?? "/root", ".serena", "serena_config.yml"); + const projectPath = resolve(basePath); + if (existsSync(serenaConfigPath)) { + const content = readFileSync(serenaConfigPath, "utf-8"); + const lines = content.split("\n"); + // Check if project already registered + const projectRe = /^(\s*)-\s*(.+)$/; + let inProjects = false; + let alreadyListed = false; + for (const line of lines) { + if (line.trim() === "projects:") { + inProjects = true; + } else if (inProjects) { + if (line.trim().startsWith("- ")) { + if (line.trim().slice(2).trim() === projectPath) { + alreadyListed = true; + } + } else if (!line.trim().startsWith("-") && line.trim() !== "" && !line.startsWith("#") && !line.startsWith(" ")) { + // End of projects list (next top-level key) + break; + } + } + } + if (!alreadyListed) { + // Find the projects: line and add our path after it + const newLines: string[] = []; + for (const line of lines) { + newLines.push(line); + if (line.trim() === "projects:") { + newLines.push(`- ${projectPath}`); + } + } + writeFileSync(serenaConfigPath, newLines.join("\n"), "utf-8"); + } + } else { + // Create minimal global config with projects list + const serenaDir = join(serenaConfigPath, ".."); + mkdirSync(serenaDir, { recursive: true }); + writeFileSync( + serenaConfigPath, + `projects:\n- ${projectPath}\n`, + "utf-8", + ); + } + + // 2. Add/update serena MCP server in .sf/mcp.json + const sfDir = join(basePath, ".sf"); + const mcpPath = join(sfDir, "mcp.json"); + let mcpConfig: Record = {}; + if (existsSync(mcpPath)) { + try { + mcpConfig = JSON.parse(readFileSync(mcpPath, "utf-8")); + } catch { + // Corrupt JSON — overwrite below + } + } + // Avoid overwriting if already configured + const servers = (mcpConfig.mcpServers ?? mcpConfig.servers ?? {}) as Record; + if (!servers["serena"]) { + servers["serena"] = { + command: "uvx", + args: [ + "--from", + "serena-agent", + "serena", + "start-mcp-server", + "--transport", + "stdio", + "--project-from-cwd", + "--context", + "desktop-app", + ], + }; + mcpConfig.mcpServers = servers; + writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, "\t") + "\n", "utf-8"); + } +} + /** * Bootstrap .sf/ directory structure for headless new-milestone. * Mirrors the bootstrap logic from guided-flow.ts showSmartEntry(). @@ -332,5 +427,12 @@ export function bootstrapProject(basePath: string): void { ensurePreferences(basePath); ensureAgenticDocsScaffold(basePath); ensureSiftIndexWarmup(basePath); + ensureSerenaMcp(basePath); untrackRuntimeFiles(basePath); + + // Run scaffold check after init — surfaces which files need real content + const report = checkDocsScaffold(basePath); + if (report.summary.stub > 0 || report.summary.missing > 0) { + process.stderr.write(`\n${formatDocCheckReport(report)}\n`); + } } diff --git a/src/resources/extensions/sf/commands-bootstrap.ts b/src/resources/extensions/sf/commands-bootstrap.ts index 08bb66d0e..4ad61b518 100644 --- a/src/resources/extensions/sf/commands-bootstrap.ts +++ b/src/resources/extensions/sf/commands-bootstrap.ts @@ -327,6 +327,27 @@ function getGsdArgumentCompletions(prefix: string) { ); } + if (parts[0] === "triage" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "--source", desc: "Triage source (captures|todo)" }, + ], + "triage", + ); + } + + if (parts[0] === "triage" && parts[1] === "--source" && parts.length <= 3) { + return filterStartsWith( + partial, + [ + { cmd: "captures", desc: "Triage pending captures (default)" }, + { cmd: "todo", desc: "Triage repo-root TODO.md" }, + ], + "triage --source", + ); + } + if (parts[0] === "doctor" && parts.length <= 2) { return filterStartsWith( partial, diff --git a/src/resources/extensions/sf/commands-handlers.ts b/src/resources/extensions/sf/commands-handlers.ts index 1f577eca4..b9867e1f1 100644 --- a/src/resources/extensions/sf/commands-handlers.ts +++ b/src/resources/extensions/sf/commands-handlers.ts @@ -18,6 +18,7 @@ import { hasPendingCaptures, loadPendingCaptures, } from "./captures.js"; +import { buildTodoTriageLLMCall, triageTodoDump } from "./commands-todo.js"; import { projectRoot } from "./commands/context.js"; import { filterDoctorIssues, @@ -266,10 +267,50 @@ export async function handleCapture( } export async function handleTriage( + args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI, basePath: string, ): Promise { + const trimmed = args.trim(); + const sourceMatch = trimmed.match(/--source\s+(\S+)/); + const source = sourceMatch?.[1]; + + if (source === "todo") { + const llmCall = buildTodoTriageLLMCall(ctx); + if (!llmCall) { + ctx.ui.notify("No model available for TODO triage.", "warning"); + return; + } + + try { + const output = await triageTodoDump(basePath, llmCall, { + clear: !trimmed.includes("--no-clear"), + backlog: trimmed.includes("--backlog"), + }); + ctx.ui.notify( + [ + "TODO triage complete.", + `Report: ${output.markdownPath}`, + `Normalized inbox: ${output.normalizedJsonlPath}`, + `Eval candidates: ${output.evalJsonlPath}`, + `Eval candidate count: ${output.result.eval_candidates.length}`, + `Backlog items added: ${output.backlogItemsAdded}`, + output.backlogItemsAdded > 0 + ? "TODO.md was reset to the empty dump inbox." + : "TODO.md was left unchanged.", + ].join("\n"), + "info", + ); + } catch (err) { + ctx.ui.notify( + `TODO triage failed: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + return; + } + if (!hasPendingCaptures(basePath)) { ctx.ui.notify("No pending captures to triage.", "info"); return; diff --git a/src/resources/extensions/sf/commands-todo.ts b/src/resources/extensions/sf/commands-todo.ts index f26264755..259cc5145 100644 --- a/src/resources/extensions/sf/commands-todo.ts +++ b/src/resources/extensions/sf/commands-todo.ts @@ -360,7 +360,7 @@ function chooseTodoTriageModel(ctx: ExtensionCommandContext): Model | null } } -function buildTodoTriageLLMCall(ctx: ExtensionCommandContext): LLMCallFn | null { +export function buildTodoTriageLLMCall(ctx: ExtensionCommandContext): LLMCallFn | null { const model = chooseTodoTriageModel(ctx); if (!model) return null; const resolvedKeyPromise = ctx.modelRegistry diff --git a/src/resources/extensions/sf/commands/handlers/ops.ts b/src/resources/extensions/sf/commands/handlers/ops.ts index b844a4dfc..73c8043f0 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.ts +++ b/src/resources/extensions/sf/commands/handlers/ops.ts @@ -175,8 +175,13 @@ export async function handleOpsCommand( await handleCapture(trimmed.replace(/^capture\s*/, "").trim(), ctx); return true; } - if (trimmed === "triage") { - await handleTriage(ctx, pi, process.cwd()); + if (trimmed === "triage" || trimmed.startsWith("triage ")) { + await handleTriage( + trimmed.replace(/^triage\s*/, "").trim(), + ctx, + pi, + process.cwd(), + ); return true; } if (trimmed === "todo" || trimmed.startsWith("todo ")) { diff --git a/src/resources/extensions/sf/tests/commands-todo.test.ts b/src/resources/extensions/sf/tests/commands-todo.test.ts index 41a9c91ae..8102ceec7 100644 --- a/src/resources/extensions/sf/tests/commands-todo.test.ts +++ b/src/resources/extensions/sf/tests/commands-todo.test.ts @@ -17,13 +17,33 @@ import { test } from "node:test"; import { extractTodoDump, + handleTodo, parseTodoTriageResponse, triageTodoDump, } from "../commands-todo.ts"; +import { handleTriage } from "../commands-handlers.ts"; const fixedDate = new Date("2026-04-30T12:34:56.000Z"); const fixedLocalTimestamp = "20260430-143456"; +function makeMockCtx(opts: { model?: unknown; modelRegistry?: unknown } = {}) { + return { + ui: { notify: () => {} }, + model: opts.model !== undefined ? opts.model : { id: "test-model", provider: "test", name: "Test Model" }, + modelRegistry: opts.modelRegistry !== undefined ? opts.modelRegistry : { + getAvailable: () => [], + getApiKey: async () => undefined, + }, + } as any; +} + +function makeMockPi() { + return { + sendMessage: () => {}, + setModel: async () => true, + } as any; +} + test("extractTodoDump strips the empty inbox wrapper", () => { assert.equal( extractTodoDump("# TODO\n\nDump anything here.\n\n- keep this\n"), @@ -86,7 +106,7 @@ test("triageTodoDump writes report, eval JSONL, normalized inbox, and clears TOD docs_or_tests: ["test TODO triage command"], unclear_notes: [], }), - { date: fixedDate }, + { date: fixedDate, clear: false }, ); assert.equal( @@ -107,10 +127,6 @@ test("triageTodoDump writes report, eval JSONL, normalized inbox, and clears TOD output.normalizedJsonlPath, join(base, ".sf", "triage", "inbox", `${fixedLocalTimestamp}.jsonl`), ); - assert.equal( - readFileSync(join(base, "TODO.md"), "utf-8"), - "# TODO\n\nDump anything here.\n", - ); const evals = readFileSync(output.evalJsonlPath, "utf-8") .trim() @@ -136,6 +152,32 @@ test("triageTodoDump writes report, eval JSONL, normalized inbox, and clears TOD } }); +test("triageTodoDump removes TODO.md when clear is true", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-clear-")); + try { + writeFileSync(join(base, "TODO.md"), "# TODO\n\nclear me\n"); + + await triageTodoDump( + base, + async () => + JSON.stringify({ + summary: "Cleared.", + eval_candidates: [], + implementation_tasks: [], + memory_requirements: [], + harness_suggestions: [], + docs_or_tests: [], + unclear_notes: [], + }), + { date: fixedDate, clear: true }, + ); + + assert.equal(existsSync(join(base, "TODO.md")), false); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + test("triageTodoDump appends implementation tasks to backlog only when requested", async () => { const base = mkdtempSync(join(tmpdir(), "sf-todo-backlog-")); try { @@ -168,3 +210,134 @@ test("triageTodoDump appends implementation tasks to backlog only when requested rmSync(base, { recursive: true, force: true }); } }); + +test("triageTodoDump throws when TODO.md is missing", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-missing-")); + try { + await assert.rejects( + async () => + triageTodoDump( + base, + async () => "{}", + { date: fixedDate }, + ), + /no root TODO\.md found/i, + ); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("triageTodoDump throws when TODO.md has no dump content", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-empty-")); + try { + writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n"); + await assert.rejects( + async () => + triageTodoDump( + base, + async () => "{}", + { date: fixedDate }, + ), + /TODO\.md has no dump content to triage/i, + ); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("handleTriage with --source todo routes to todo triage path", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-triage-source-todo-")); + const notifications: Array<{ message: string; type: string }> = []; + try { + writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- test item\n"); + + const ctx = makeMockCtx(); + ctx.ui.notify = (message: string, type: string) => { + notifications.push({ message, type }); + }; + const pi = makeMockPi(); + + await handleTriage("--source todo", ctx, pi, base); + + // The LLM call will fail because completeSimple is not mocked, + // but the notification proves it routed to the todo triage path. + assert.equal(notifications.length, 1); + assert.equal(notifications[0].type, "warning"); + assert.ok(notifications[0].message.includes("TODO triage failed:")); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("handleTriage with --source todo warns when no model is available", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-triage-no-model-")); + const notifications: Array<{ message: string; type: string }> = []; + try { + writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- test item\n"); + + const ctx = makeMockCtx({ model: null, modelRegistry: { getAvailable: () => [] } }); + ctx.ui.notify = (message: string, type: string) => { + notifications.push({ message, type }); + }; + const pi = makeMockPi(); + + await handleTriage("--source todo", ctx, pi, base); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].type, "warning"); + assert.ok(notifications[0].message.includes("No model available for TODO triage.")); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("handleTriage without --source notifies when no pending captures exist", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-triage-captures-")); + const notifications: Array<{ message: string; type: string }> = []; + try { + const ctx = makeMockCtx(); + ctx.ui.notify = (message: string, type: string) => { + notifications.push({ message, type }); + }; + const pi = makeMockPi(); + + await handleTriage("", ctx, pi, base); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].type, "info"); + assert.ok(notifications[0].message.includes("No pending captures to triage.")); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("handleTodo removes empty TODO.md and notifies info", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-handle-empty-")); + const notifications: Array<{ message: string; type: string }> = []; + const originalProjectRoot = process.env.SF_PROJECT_ROOT; + try { + process.env.SF_PROJECT_ROOT = base; + writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n"); + + const ctx = makeMockCtx(); + ctx.ui.notify = (message: string, type: string) => { + notifications.push({ message, type }); + }; + const pi = makeMockPi(); + + await handleTodo("triage", ctx, pi); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].type, "info"); + assert.ok(notifications[0].message.includes("TODO.md was empty")); + assert.equal(existsSync(join(base, "TODO.md")), false); + } finally { + if (originalProjectRoot === undefined) { + delete process.env.SF_PROJECT_ROOT; + } else { + process.env.SF_PROJECT_ROOT = originalProjectRoot; + } + rmSync(base, { recursive: true, force: true }); + } +});