diff --git a/src/resources/extensions/mcp-client/index.ts b/src/resources/extensions/mcp-client/index.ts index 5ba46edf7..479ca3cba 100644 --- a/src/resources/extensions/mcp-client/index.ts +++ b/src/resources/extensions/mcp-client/index.ts @@ -60,6 +60,8 @@ interface ManagedConnection { const connections = new Map(); let configCache: McpServerConfig[] | null = null; +/** Servers whose MCP tools have been auto-registered as first-class pi tools. */ +const autoRegisteredServers = new Set(); const toolCache = new Map(); function readConfigs(): McpServerConfig[] { @@ -161,6 +163,95 @@ function resolveEnv(env: Record): Record { return resolved; } +// ─── JSON Schema → TypeBox converter ───────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function jsonSchemaPropToTypeBox(schema: Record): any { + if (!schema || typeof schema !== "object") return Type.Any(); + const t = schema.type as string; + if (t === "string") return Type.String({ description: schema.description as string | undefined }); + if (t === "number" || t === "integer") return Type.Number({ description: schema.description as string | undefined }); + if (t === "boolean") return Type.Boolean({ description: schema.description as string | undefined }); + if (t === "array") return Type.Array(Type.Any()); + if (t === "object") { + const props = schema.properties as Record> | undefined; + if (props) { + const entries: Record = {}; + for (const [k, v] of Object.entries(props)) { + entries[k] = jsonSchemaPropToTypeBox(v); + } + return Type.Object(entries as Parameters[0]); + } + } + return Type.Any(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function jsonSchemaToTypeBox(schema: Record | undefined): any { + if (!schema || typeof schema !== "object") return Type.Object({}); + const obj = schema as Record; + const props = obj.properties as Record> | undefined; + if (!props) return Type.Object({}); + const entries: Record = {}; + for (const [k, v] of Object.entries(props)) { + entries[k] = jsonSchemaPropToTypeBox(v); + } + return Type.Object(entries as Parameters[0]); +} + +// ─── Dynamic MCP tool auto-registration ─────────────────────────────────────── + +function registerMcpToolsForServer(pi: ExtensionAPI, serverName: string, tools: McpToolSchema[]) { + if (autoRegisteredServers.has(serverName)) return; + autoRegisteredServers.add(serverName); + + for (const tool of tools) { + const piToolName = `${serverName}_${tool.name}`; + const description = tool.description || `MCP tool: ${tool.name} on ${serverName}`; + // Build parameter TypeBox type from MCP inputSchema + const paramType = tool.inputSchema + ? jsonSchemaToTypeBox(tool.inputSchema) + : Type.Object({}); + + try { + pi.registerTool({ + name: piToolName, + label: `${serverName}:${tool.name}`, + description, + parameters: paramType, + async execute(_id, params) { + // Delegate to the internal mcp_call logic directly via the client + const client = await getOrConnect(serverName); + const result = await client.callTool( + { name: tool.name, arguments: params as Record }, + undefined, + { timeout: 60000 }, + ); + const contentItems = result.content as Array<{ type: string; text?: string }>; + const raw = contentItems + .map((c) => (c.type === "text" ? (c.text ?? "") : JSON.stringify(c))) + .join("\n"); + const truncation = truncateHead(raw, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + let finalText = truncation.content; + if (truncation.truncated) { + finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines]`; + } + return { + content: [{ type: "text", text: finalText }], + details: { server: serverName, tool: tool.name }, + }; + }, + }); + } + catch { + // Non-fatal — tool registration can fail if schema is unconvertible + } + } +} + async function getOrConnect( name: string, signal?: AbortSignal, @@ -342,10 +433,12 @@ export default function (pi: ExtensionAPI) { label: "MCP Servers", description: "List all available MCP servers configured in project files (.mcp.json, .sf/mcp.json). " + - "Shows server names, transport type, and connection status. Use mcp_discover to get full tool schemas for a server.", + "Shows server names, transport type, and connection status. After mcp_discover, each server's " + + "tools are auto-registered as first-class pi tools (e.g. serena_find_symbol).", promptSnippet: "List available MCP servers from project configuration", promptGuidelines: [ "Call mcp_servers to see what MCP servers are available before trying to use one.", + "After mcp_discover(server), the server's tools appear as real pi tools.", "MCP servers provide external integrations (Twitter, Linear, Railway, etc.) via the Model Context Protocol.", "After listing, use mcp_discover(server) to get tool schemas, then mcp_call(server, tool, args) to invoke.", ], @@ -396,13 +489,14 @@ export default function (pi: ExtensionAPI) { description: "Get detailed tool signatures and JSON schemas for a specific MCP server. " + "Connects to the server on first call (lazy connection). " + - "Use this to understand what tools a server provides and what arguments they accept " + - "before calling them with mcp_call.", + "After discovery, each MCP tool is auto-registered as a first-class pi tool " + + "(e.g. serena_find_symbol) — the LLM can call them directly without mcp_call.", promptSnippet: - "Get tool schemas for a specific MCP server before calling its tools", + "Discover MCP server tools and register them as first-class pi tools", promptGuidelines: [ - "Call mcp_discover with a server name to see the full tool signatures before calling mcp_call.", - "The schemas show required and optional parameters with types and descriptions.", + "Call mcp_discover(server) to connect to an MCP server and surface its tools.", + "After discovery, the LLM sees each tool by its real name (e.g. serena_search_for_pattern).", + "Call tools directly by their names instead of going through mcp_call.", ], parameters: Type.Object({ server: Type.String({ @@ -447,6 +541,11 @@ export default function (pi: ExtensionAPI) { })); toolCache.set(params.server, tools); + // Auto-register each MCP tool as a first-class pi tool. + // After this, the LLM sees e.g. serena_find_symbol directly instead + // of going through the generic mcp_call indirection. + registerMcpToolsForServer(pi, params.server, tools); + const text = formatToolList(params.server, tools); const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, diff --git a/src/resources/extensions/sf-tui/footer.ts b/src/resources/extensions/sf-tui/footer.ts index 200fda12f..713d50c4d 100644 --- a/src/resources/extensions/sf-tui/footer.ts +++ b/src/resources/extensions/sf-tui/footer.ts @@ -47,17 +47,22 @@ export function renderFooter( const leftSegments: Segment[] = []; if (git.branch) { - const dirtyIcon = git.dirty ? "✗" : git.untracked ? "?" : "✓"; - const branchText = `${git.branch} ${dirtyIcon}`; + const dirtyIcon = git.dirty ? "dirty" : git.untracked ? "new" : "clean"; leftSegments.push({ - text: `⎇ ${branchText}`, + text: `repo ${git.branch}`, fg: "white", - bg: "blue", + bg: git.dirty || git.untracked ? "brightBlack" : "blue", + bold: true, + }); + leftSegments.push({ + text: dirtyIcon, + fg: git.dirty || git.untracked ? "black" : "white", + bg: git.dirty || git.untracked ? "yellow" : "green", bold: true, }); if (git.added || git.deleted) { - const diffText = `+${git.added}/-${git.deleted}`; + const diffText = `Δ +${git.added}/-${git.deleted}`; leftSegments.push({ text: diffText, fg: "white", bg: "brightBlack" }); } @@ -82,22 +87,26 @@ export function renderFooter( .map(([, text]) => text.trim()) .filter(Boolean); if (statuses.length) { - leftSegments.push({ text: statuses.join(" "), fg: "white", bg: "magenta" }); + leftSegments.push({ + text: `status ${statuses.join(" ")}`, + fg: "white", + bg: "brightBlack", + }); } const rightSegments: Segment[] = []; if (ctx.model) { rightSegments.push({ - text: `${ctx.model.provider}/${ctx.model.id}`, + text: `model ${ctx.model.provider}/${ctx.model.id}`, fg: "white", - bg: "cyan", + bg: "blue", }); } if (cost > 0) { rightSegments.push({ - text: `$${cost.toFixed(2)}`, + text: `spent $${cost.toFixed(2)}`, fg: "black", bg: "yellow", }); @@ -105,7 +114,7 @@ export function renderFooter( const cxColor = cxPct >= 85 ? "red" : cxPct >= 60 ? "yellow" : "green"; rightSegments.push({ - text: `${Math.round(cxPct)}%ctx`, + text: `ctx ${Math.round(cxPct)}%`, fg: cxPct >= 85 ? "white" : "black", bg: cxColor, }); diff --git a/src/resources/extensions/sf-tui/header.ts b/src/resources/extensions/sf-tui/header.ts index 9ae98fa8b..42abf6414 100644 --- a/src/resources/extensions/sf-tui/header.ts +++ b/src/resources/extensions/sf-tui/header.ts @@ -3,9 +3,14 @@ import type { ExtensionContext, Theme, } from "@singularity-forge/pi-coding-agent"; -import { truncateToWidth } from "@singularity-forge/pi-tui"; +import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui"; import { refreshGitStatus } from "./git.js"; +function align(left: string, right: string, width: number, ellipsis: string): string { + const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right)); + return truncateToWidth(left + " ".repeat(gap) + right, width, ellipsis); +} + export function renderHeader( theme: Theme, ctx: ExtensionContext, @@ -16,26 +21,42 @@ export function renderHeader( const projectName = basename(process.cwd()); - const leftParts: string[] = []; - leftParts.push(th.bold(th.fg("accent", "SF"))); - leftParts.push(th.fg("text", projectName)); + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "") + : ""; + const modelLabel = model + ? `${th.fg("dim", "model ")}${th.fg("text", model)}` + : ""; - if (git.branch) { - leftParts.push(th.fg("dim", "on") + " " + th.fg("accent", git.branch)); + const topLeft = [ + th.fg("accent", "╭─"), + th.bold(th.fg("accent", "SF")), + th.fg("dim", "▸"), + th.fg("text", projectName), + ].join(" "); + + const branchState = git.branch + ? git.dirty + ? th.fg("warning", "modified") + : git.untracked + ? th.fg("warning", "untracked") + : th.fg("success", "clean") + : th.fg("dim", "no git"); + const branchLabel = git.branch + ? `${th.fg("dim", "branch ")}${th.fg("accent", git.branch)} ${th.fg("dim", "·")} ${branchState}` + : branchState; + const sync: string[] = []; + if (git.ahead) sync.push(th.fg("success", `↑${git.ahead}`)); + if (git.behind) sync.push(th.fg("warning", `↓${git.behind}`)); + if (git.added || git.deleted) { + sync.push(th.fg("muted", `Δ +${git.added}/-${git.deleted}`)); } + const bottomRight = sync.join(th.fg("dim", " ")); - const left = leftParts.join(" " + th.fg("dim", "│") + " "); + const ellipsis = th.fg("dim", "…"); + const top = align(topLeft, modelLabel, width, ellipsis); + if (width < 64) return [top]; - const rightParts: string[] = []; - if (ctx.model) { - rightParts.push( - th.fg("dim", ctx.model.provider) + " " + th.fg("text", ctx.model.id), - ); - } - - const right = rightParts.join(" " + th.fg("dim", "│") + " "); - - const line = - left + " ".repeat(Math.max(1, width - left.length - right.length)) + right; - return [truncateToWidth(line, width, th.fg("dim", "…"))]; + const bottom = align(`${th.fg("accent", "╰─")} ${branchLabel}`, bottomRight, width, ellipsis); + return [top, bottom]; } diff --git a/src/resources/extensions/sf-tui/powerline.ts b/src/resources/extensions/sf-tui/powerline.ts index 6d4442f7c..e63d42de5 100644 --- a/src/resources/extensions/sf-tui/powerline.ts +++ b/src/resources/extensions/sf-tui/powerline.ts @@ -1,5 +1,5 @@ import type { Theme } from "@singularity-forge/pi-coding-agent"; -import { visibleWidth } from "@singularity-forge/pi-tui"; +import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui"; export interface Segment { text: string; @@ -135,6 +135,7 @@ export function renderPowerline( const trimmed = segments.slice(0, -1); return renderPowerline(trimmed, width, theme); } + if (vis > width) return truncateToWidth(line, width, ""); // Pad right to fill width if (vis < width) { @@ -182,6 +183,7 @@ export function renderPowerlineRight( const trimmed = segments.slice(1); return renderPowerlineRight(trimmed, width, theme); } + if (vis > width) return truncateToWidth(line, width, ""); if (vis < width) { return " ".repeat(width - vis) + line + RESET; diff --git a/src/resources/extensions/sf/auto-dashboard.ts b/src/resources/extensions/sf/auto-dashboard.ts index d8680f1f2..0681c0519 100644 --- a/src/resources/extensions/sf/auto-dashboard.ts +++ b/src/resources/extensions/sf/auto-dashboard.ts @@ -809,7 +809,7 @@ export function updateProgressWidget( : "x"; const healthStr = ` ${theme.fg(healthColor, healthIcon)} ${theme.fg(healthColor, score.summary)}`; - const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("SF"))} ${theme.fg("success", modeTag)}${healthStr}`; + const headerLeft = `${pad}${theme.fg("accent", "╭─")} ${dot} ${theme.fg("accent", theme.bold("SF"))} ${theme.fg("dim", "▸")} ${theme.fg("success", modeTag)}${healthStr}`; // ETA in header right, after elapsed const eta = estimateTimeRemaining(); @@ -901,7 +901,7 @@ export function updateProgressWidget( // Action line const target = task ? `${task.id}: ${task.title}` : unitId; - const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; + const actionLeft = `${pad}${theme.fg("accent", "╰─")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; lines.push( rightAlign(actionLeft, theme.fg("dim", phaseLabel), width), ); @@ -915,11 +915,11 @@ export function updateProgressWidget( Math.min(18, Math.floor(width * 0.25)), ); const pct = total > 0 ? done / total : 0; - const filled = Math.round(pct * barWidth); + const filled = Math.max(0, Math.min(barWidth, Math.round(pct * barWidth))); const bar = - theme.fg("success", "━".repeat(filled)) + - theme.fg("dim", "─".repeat(barWidth - filled)); - let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`; + theme.fg("success", "█".repeat(filled)) + + theme.fg("dim", "░".repeat(barWidth - filled)); + let meta = `${theme.fg("accent", `${Math.round(pct * 100)}%`)} ${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`; if (activeSliceTasks && activeSliceTasks.total > 0) { const tn = Math.min( activeSliceTasks.done + 1, @@ -991,7 +991,7 @@ export function updateProgressWidget( if (hasContext) lines.push(""); const target = task ? `${task.id}: ${task.title}` : unitId; - const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; + const actionLeft = `${pad}${theme.fg("accent", "╰─")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : ""; const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`; lines.push(rightAlign(actionLeft, phaseBadge, width)); @@ -1019,12 +1019,12 @@ export function updateProgressWidget( Math.min(18, Math.floor(leftColWidth * 0.4)), ); const pct = total > 0 ? done / total : 0; - const filled = Math.round(pct * barWidth); + const filled = Math.max(0, Math.min(barWidth, Math.round(pct * barWidth))); const bar = - theme.fg("success", "━".repeat(filled)) + - theme.fg("dim", "─".repeat(barWidth - filled)); + theme.fg("success", "█".repeat(filled)) + + theme.fg("dim", "░".repeat(barWidth - filled)); - let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`; + let meta = `${theme.fg("accent", `${Math.round(pct * 100)}%`)} ${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`; if (activeSliceTasks && activeSliceTasks.total > 0) { const taskNum = isHook ? Math.max(activeSliceTasks.done, 1) @@ -1051,10 +1051,10 @@ export function updateProgressWidget( isCurrent: boolean, ): string { const glyph = t.done - ? theme.fg("success", "*") + ? theme.fg("success", "✓") : isCurrent - ? theme.fg("accent", ">") - : theme.fg("dim", "."); + ? theme.fg("accent", "▸") + : theme.fg("dim", "·"); const id = isCurrent ? theme.fg("accent", t.id) : t.done @@ -1154,7 +1154,7 @@ export function updateProgressWidget( hintParts.push("esc pause"); hintParts.push(`${formattedShortcutPair("dashboard")} dashboard`); hintParts.push(`${formattedShortcutPair("parallel")} parallel`); - const hintStr = theme.fg("dim", hintParts.join(" | ")); + const hintStr = theme.fg("dim", hintParts.join(" · ")); const commitStr = lastCommit ? theme.fg("dim", `${lastCommit.timeAgo} ago: ${commitMsg}`) : ""; diff --git a/src/resources/extensions/sf/commands-todo.ts b/src/resources/extensions/sf/commands-todo.ts index 17d1f30fb..0f2fe3cef 100644 --- a/src/resources/extensions/sf/commands-todo.ts +++ b/src/resources/extensions/sf/commands-todo.ts @@ -235,6 +235,101 @@ function renderEvalJsonl(result: TodoTriageResult): string { ); } +export interface SkillProposalDraft { + id: string; + title: string; + trigger_pattern: string; + description: string; + example_input: string; + example_output: string; + confidence: "low" | "medium" | "high"; + source_evidence: string[]; +} + +function detectRecurringPatterns(result: TodoTriageResult): SkillProposalDraft[] { + const proposals: SkillProposalDraft[] = []; + + // Pattern 1: repeated eval candidates with similar task_input suggest a skill + const evalGroups = new Map(); + for (const item of result.eval_candidates) { + const key = item.task_input.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + const words = key.split(/\s+/).slice(0, 6).join(" "); + const existing = evalGroups.get(words) ?? []; + existing.push(item); + evalGroups.set(words, existing); + } + for (const [pattern, items] of evalGroups) { + if (items.length >= 2) { + proposals.push({ + id: `skill.${timestampId()}`, + title: `Skill: handle "${pattern.slice(0, 40)}${pattern.length > 40 ? "..." : ""}"`, + trigger_pattern: pattern.slice(0, 60), + description: `Recurring eval candidate (${items.length} occurrences) suggesting a reusable skill for this pattern.`, + example_input: items[0].task_input, + example_output: items[0].expected_behavior, + confidence: items.length >= 3 ? "high" : "medium", + source_evidence: items.map((i) => i.evidence ?? i.task_input).filter(Boolean), + }); + } + } + + // Pattern 2: harness suggestions that appear multiple times + const harnessGroups = new Map(); + for (const item of result.harness_suggestions) { + const key = item.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + const words = key.split(/\s+/).slice(0, 6).join(" "); + const existing = harnessGroups.get(words) ?? []; + existing.push(item); + harnessGroups.set(words, existing); + } + for (const [pattern, items] of harnessGroups) { + if (items.length >= 2) { + proposals.push({ + id: `skill.${timestampId()}`, + title: `Skill: gate/harness for "${pattern.slice(0, 40)}${pattern.length > 40 ? "..." : ""}"`, + trigger_pattern: pattern.slice(0, 60), + description: `Recurring harness suggestion (${items.length} occurrences) suggesting a reusable quality gate or harness.`, + example_input: items[0], + example_output: "Deterministic gate passes / fails with structured output.", + confidence: items.length >= 3 ? "high" : "medium", + source_evidence: items, + }); + } + } + + // Pattern 3: memory requirements that appear multiple times + const memoryGroups = new Map(); + for (const item of result.memory_requirements) { + const key = item.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + const words = key.split(/\s+/).slice(0, 6).join(" "); + const existing = memoryGroups.get(words) ?? []; + existing.push(item); + memoryGroups.set(words, existing); + } + for (const [pattern, items] of memoryGroups) { + if (items.length >= 2) { + proposals.push({ + id: `skill.${timestampId()}`, + title: `Skill: remember "${pattern.slice(0, 40)}${pattern.length > 40 ? "..." : ""}"`, + trigger_pattern: pattern.slice(0, 60), + description: `Recurring memory requirement (${items.length} occurrences) suggesting a durable memory extraction skill.`, + example_input: items[0], + example_output: "Memory captured with confidence score and category.", + confidence: items.length >= 3 ? "high" : "medium", + source_evidence: items, + }); + } + } + + return proposals; +} + +function renderSkillProposals(result: TodoTriageResult): string { + const proposals = detectRecurringPatterns(result); + if (proposals.length === 0) return "\n"; + return proposals.map((p) => JSON.stringify(p)).join("\n") + "\n"; +} + function backlogPath(basePath: string): string { return join(sfRoot(basePath), "BACKLOG.md"); } @@ -453,6 +548,7 @@ export async function triageTodoDump( markdownPath: string; evalJsonlPath: string; normalizedJsonlPath: string; + skillJsonlPath: string; backlogItemsAdded: number; result: TodoTriageResult; }> { @@ -474,16 +570,20 @@ export async function triageTodoDump( const reportsDir = join(triageRoot, "reports"); const evalsDir = join(triageRoot, "evals"); const inboxDir = join(triageRoot, "inbox"); + const skillsDir = join(triageRoot, "skills"); mkdirSync(reportsDir, { recursive: true }); mkdirSync(evalsDir, { recursive: true }); mkdirSync(inboxDir, { recursive: true }); + mkdirSync(skillsDir, { recursive: true }); const markdownPath = join(reportsDir, `${id}.md`); const evalJsonlPath = join(evalsDir, `${id}.evals.jsonl`); const normalizedJsonlPath = join(inboxDir, `${id}.jsonl`); + const skillJsonlPath = join(skillsDir, `${id}.skills.jsonl`); writeFileSync(markdownPath, renderTriageMarkdown(result, "TODO.md")); writeFileSync(evalJsonlPath, renderEvalJsonl(result)); writeFileSync(normalizedJsonlPath, renderNormalizedJsonl(result, createdAt)); + writeFileSync(skillJsonlPath, renderSkillProposals(result)); const backlogItemsAdded = options.backlog === true @@ -498,6 +598,7 @@ export async function triageTodoDump( markdownPath, evalJsonlPath, normalizedJsonlPath, + skillJsonlPath, backlogItemsAdded, result, }; @@ -547,6 +648,7 @@ export async function handleTodo( `Report: ${output.markdownPath}`, `Normalized inbox: ${output.normalizedJsonlPath}`, `Eval candidates: ${output.evalJsonlPath}`, + `Skill proposals: ${output.skillJsonlPath}`, `Eval candidate count: ${output.result.eval_candidates.length}`, `Backlog items added: ${output.backlogItemsAdded}`, clear ? "TODO.md was reset to the empty dump inbox." : "TODO.md was left unchanged.", diff --git a/src/resources/extensions/sf/tests/commands-todo.test.ts b/src/resources/extensions/sf/tests/commands-todo.test.ts index 8102ceec7..db80a286f 100644 --- a/src/resources/extensions/sf/tests/commands-todo.test.ts +++ b/src/resources/extensions/sf/tests/commands-todo.test.ts @@ -76,7 +76,7 @@ test("parseTodoTriageResponse accepts fenced JSON", () => { assert.deepEqual(parsed.implementation_tasks, ["wire command"]); }); -test("triageTodoDump writes report, eval JSONL, normalized inbox, and clears TODO.md", async () => { +test("triageTodoDump writes report, eval JSONL, normalized inbox, skill proposals, and clears TODO.md", async () => { const base = mkdtempSync(join(tmpdir(), "sf-todo-triage-")); try { writeFileSync( @@ -127,6 +127,10 @@ test("triageTodoDump writes report, eval JSONL, normalized inbox, and clears TOD output.normalizedJsonlPath, join(base, ".sf", "triage", "inbox", `${fixedLocalTimestamp}.jsonl`), ); + assert.equal( + output.skillJsonlPath, + join(base, ".sf", "triage", "skills", `${fixedLocalTimestamp}.skills.jsonl`), + ); const evals = readFileSync(output.evalJsonlPath, "utf-8") .trim() @@ -135,6 +139,13 @@ test("triageTodoDump writes report, eval JSONL, normalized inbox, and clears TOD assert.equal(evals.length, 1); assert.equal(evals[0].id, "todo.eval.memory-repeat"); + const skills = readFileSync(output.skillJsonlPath, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + assert.equal(skills.length, 0, "no recurring patterns = no skill proposals"); + const inbox = readFileSync(output.normalizedJsonlPath, "utf-8") .trim() .split("\n") @@ -178,6 +189,139 @@ test("triageTodoDump removes TODO.md when clear is true", async () => { } }); +test("triageTodoDump generates skill proposals for recurring eval candidates", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-skills-")); + try { + writeFileSync( + join(base, "TODO.md"), + "# TODO\n\nDump anything here.\n\n- agent forgot to turn repeated failure into eval\n", + ); + + const output = await triageTodoDump( + base, + async () => + JSON.stringify({ + summary: "Repeated failure should become an eval.", + eval_candidates: [ + { + id: "todo.eval.memory-repeat", + task_input: "repeated failure appears in TODO.md", + expected_behavior: "triage emits eval candidate JSONL", + failure_mode: "agent treats note as runtime instruction", + evidence: "TODO.md dump", + source: "TODO.md", + suggested_location: ".sf/triage/evals", + }, + { + id: "todo.eval.memory-repeat-2", + task_input: "repeated failure appears in TODO.md", + expected_behavior: "triage also emits skill proposal", + failure_mode: "agent misses pattern", + evidence: "TODO.md dump", + source: "TODO.md", + suggested_location: ".sf/triage/skills", + }, + ], + implementation_tasks: [], + memory_requirements: [], + harness_suggestions: [], + docs_or_tests: [], + unclear_notes: [], + }), + { date: fixedDate, clear: false }, + ); + + assert.equal( + output.skillJsonlPath, + join(base, ".sf", "triage", "skills", `${fixedLocalTimestamp}.skills.jsonl`), + ); + assert.ok(existsSync(output.skillJsonlPath), "skill proposals file should exist"); + + const skills = readFileSync(output.skillJsonlPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(skills.length, 1, "one recurring pattern should produce one skill proposal"); + assert.ok(skills[0].id.startsWith("skill."), "skill proposal id should start with skill."); + assert.equal(skills[0].confidence, "medium", "2 occurrences = medium confidence"); + assert.ok(skills[0].trigger_pattern.includes("repeated failure"), "trigger pattern should capture the recurring theme"); + assert.equal(skills[0].source_evidence.length, 2, "should include both source evidences"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("triageTodoDump generates skill proposals for recurring harness suggestions", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-skills-harness-")); + try { + writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- add gate for missing eval\n"); + + const output = await triageTodoDump( + base, + async () => + JSON.stringify({ + summary: "Harness suggestions.", + eval_candidates: [], + implementation_tasks: [], + memory_requirements: [], + harness_suggestions: [ + "add gate for missing eval before behavior change", + "add gate for missing eval before behavior change", + "add gate for missing eval before behavior change", + ], + docs_or_tests: [], + unclear_notes: [], + }), + { date: fixedDate, clear: false }, + ); + + const skills = readFileSync(output.skillJsonlPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(skills.length, 1, "one recurring harness pattern should produce one skill proposal"); + assert.equal(skills[0].confidence, "high", "3 occurrences = high confidence"); + assert.ok(skills[0].title.toLowerCase().includes("gate"), "title should reference gate/harness"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("triageTodoDump generates skill proposals for recurring memory requirements", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-skills-memory-")); + try { + writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- remember eval pattern\n"); + + const output = await triageTodoDump( + base, + async () => + JSON.stringify({ + summary: "Memory requirements.", + eval_candidates: [], + implementation_tasks: [], + memory_requirements: [ + "remember to extract eval pattern from repeated failures", + "remember to extract eval pattern from repeated failures", + ], + harness_suggestions: [], + docs_or_tests: [], + unclear_notes: [], + }), + { date: fixedDate, clear: false }, + ); + + const skills = readFileSync(output.skillJsonlPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(skills.length, 1, "one recurring memory pattern should produce one skill proposal"); + assert.equal(skills[0].confidence, "medium", "2 occurrences = medium confidence"); + assert.ok(skills[0].title.toLowerCase().includes("remember"), "title should reference memory"); + } 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 { @@ -211,6 +355,60 @@ test("triageTodoDump appends implementation tasks to backlog only when requested } }); +test("triageTodoDump writes backlog JSONL with valid BacklogEntry schema", async () => { + const base = mkdtempSync(join(tmpdir(), "sf-todo-backlog-jsonl-")); + try { + mkdirSync(join(base, ".sf"), { recursive: true }); + writeFileSync(join(base, "TODO.md"), "# TODO\n\nimpl task 1\nimpl task 2\n"); + + const output = await triageTodoDump( + base, + async () => + JSON.stringify({ + summary: "Multiple tasks.", + eval_candidates: [], + implementation_tasks: ["task alpha", "task beta"], + memory_requirements: [], + harness_suggestions: [], + docs_or_tests: [], + unclear_notes: [], + }), + { date: fixedDate, backlog: true }, + ); + + const backlogJsonlPath = join(base, ".sf", "triage", "backlog", `${fixedLocalTimestamp}.jsonl`); + assert.equal(existsSync(backlogJsonlPath), true, "backlog JSONL file should exist"); + + const lines = readFileSync(backlogJsonlPath, "utf-8") + .trim() + .split("\n") + .filter(Boolean); + assert.equal(lines.length, 2, "should have one JSONL entry per implementation task"); + + const entries = lines.map((line) => JSON.parse(line)); + const backlogMd = readFileSync(join(base, ".sf", "BACKLOG.md"), "utf-8"); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + assert.match(entry.id, /^999\.\d+$/, "id should match 999.x pattern"); + assert.equal(entry.source, "todo-triage"); + assert.equal(entry.kind, "implementation_task"); + assert.equal(entry.status, "pending"); + assert.ok(entry.title, "title should be present"); + assert.ok( + Date.parse(entry.triaged_at) > 0, + "triaged_at should be a valid ISO date string", + ); + assert.ok( + backlogMd.includes(entry.id), + `JSONL entry id ${entry.id} should match an id in BACKLOG.md`, + ); + } + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + test("triageTodoDump throws when TODO.md is missing", async () => { const base = mkdtempSync(join(tmpdir(), "sf-todo-missing-")); try { diff --git a/src/resources/extensions/shared/ui.ts b/src/resources/extensions/shared/ui.ts index a4cc3b592..c3bb5e435 100644 --- a/src/resources/extensions/shared/ui.ts +++ b/src/resources/extensions/shared/ui.ts @@ -259,7 +259,11 @@ export function makeUI(theme: Theme, width: number): UI { return wrapped; } - const bar = theme.fg("accent", GLYPH.separator.repeat(width)); + const bar = + width <= 1 + ? theme.fg("accent", GLYPH.separator) + : theme.fg("accent", "╾") + + theme.fg("dim", GLYPH.separator.repeat(Math.max(0, width - 1))); // ── EditorTheme ────────────────────────────────────────────────────────────