feat: Added renderSkillProposal() to detect recurring patterns in triag…

- src/resources/extensions/sf/commands-todo.ts
- src/resources/extensions/sf/tests/commands-todo.test.ts

SF-Task: S03/T01
This commit is contained in:
Mikael Hugo 2026-04-30 19:31:40 +02:00
parent 30586f36f8
commit 69be7aeeaa
8 changed files with 488 additions and 53 deletions

View file

@ -60,6 +60,8 @@ interface ManagedConnection {
const connections = new Map<string, ManagedConnection>();
let configCache: McpServerConfig[] | null = null;
/** Servers whose MCP tools have been auto-registered as first-class pi tools. */
const autoRegisteredServers = new Set<string>();
const toolCache = new Map<string, McpToolSchema[]>();
function readConfigs(): McpServerConfig[] {
@ -161,6 +163,95 @@ function resolveEnv(env: Record<string, string>): Record<string, string> {
return resolved;
}
// ─── JSON Schema → TypeBox converter ─────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function jsonSchemaPropToTypeBox(schema: Record<string, unknown>): 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<string, Record<string, unknown>> | undefined;
if (props) {
const entries: Record<string, unknown> = {};
for (const [k, v] of Object.entries(props)) {
entries[k] = jsonSchemaPropToTypeBox(v);
}
return Type.Object(entries as Parameters<typeof Type.Object>[0]);
}
}
return Type.Any();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function jsonSchemaToTypeBox(schema: Record<string, unknown> | undefined): any {
if (!schema || typeof schema !== "object") return Type.Object({});
const obj = schema as Record<string, unknown>;
const props = obj.properties as Record<string, Record<string, unknown>> | undefined;
if (!props) return Type.Object({});
const entries: Record<string, unknown> = {};
for (const [k, v] of Object.entries(props)) {
entries[k] = jsonSchemaPropToTypeBox(v);
}
return Type.Object(entries as Parameters<typeof Type.Object>[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<string, unknown> },
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,

View file

@ -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,
});

View file

@ -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];
}

View file

@ -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;

View file

@ -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}`)
: "";

View file

@ -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<string, TodoEvalCandidate[]>();
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<string, string[]>();
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<string, string[]>();
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.",

View file

@ -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 {

View file

@ -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 ────────────────────────────────────────────────────────────