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:
parent
30586f36f8
commit
69be7aeeaa
8 changed files with 488 additions and 53 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
: "";
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue