3489 lines
130 KiB
TypeScript
3489 lines
130 KiB
TypeScript
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||
import { Type, type TSchema } from "typebox";
|
||
import { StringEnum } from "@mariozechner/pi-ai";
|
||
import {
|
||
TaskStore,
|
||
COLUMNS,
|
||
COLUMN_LABELS,
|
||
validateNodeOverrideChange,
|
||
type Task,
|
||
type InsightCategory,
|
||
type TaskPriority,
|
||
type InsightStatus,
|
||
type InsightRunStatus,
|
||
type InsightRunTrigger,
|
||
type ResearchRun,
|
||
type ResearchRunStatus,
|
||
RESEARCH_RUN_STATUSES,
|
||
isResearchExperimentalEnabled,
|
||
resolveResearchSettings,
|
||
canAgentTakeImplementationTaskForExplicitRouting,
|
||
formatRoleMismatchReason,
|
||
resolveAgentProvisioningPolicy,
|
||
TASK_PRIORITIES,
|
||
} from "@fusion/core";
|
||
import {
|
||
getGhErrorMessage,
|
||
isGhAuthenticated,
|
||
isGhAvailable,
|
||
runGhJsonAsync,
|
||
} from "@fusion/core/gh-cli";
|
||
import {
|
||
defaultGitOps,
|
||
ExperimentFinalizeBranchExistsError,
|
||
ExperimentFinalizeCherryPickConflictError,
|
||
ExperimentFinalizeMergeBaseError,
|
||
ExperimentFinalizeNoKeptRunsError,
|
||
ExperimentFinalizePlanError,
|
||
ExperimentFinalizeService,
|
||
ExperimentFinalizeStateError,
|
||
type FinalizePlanOverride,
|
||
fetchWebContent,
|
||
} from "@fusion/engine";
|
||
import { resolve, basename, extname, join } from "node:path";
|
||
import { readFile } from "node:fs/promises";
|
||
import { existsSync } from "node:fs";
|
||
import { spawn, type ChildProcess } from "node:child_process";
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────
|
||
|
||
const MIME_TYPES: Record<string, string> = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".webp": "image/webp",
|
||
".txt": "text/plain",
|
||
".log": "text/plain",
|
||
".json": "application/json",
|
||
".yaml": "text/yaml",
|
||
".yml": "text/yaml",
|
||
".toml": "text/x-toml",
|
||
".csv": "text/csv",
|
||
".xml": "application/xml",
|
||
};
|
||
|
||
function resolveProjectRoot(cwd: string): string {
|
||
let current = resolve(cwd);
|
||
while (true) {
|
||
if (existsSync(join(current, ".fusion"))) {
|
||
return current;
|
||
}
|
||
|
||
const parent = resolve(current, "..");
|
||
if (parent === current) {
|
||
return resolve(cwd);
|
||
}
|
||
current = parent;
|
||
}
|
||
}
|
||
|
||
/** Cache stores per project root to avoid re-init on every tool call. */
|
||
const storeCache = new Map<string, TaskStore>();
|
||
|
||
async function getStore(cwd: string): Promise<TaskStore> {
|
||
const projectRoot = resolveProjectRoot(cwd);
|
||
const existing = storeCache.get(projectRoot);
|
||
if (existing) return existing;
|
||
|
||
const store = new TaskStore(projectRoot);
|
||
await store.init();
|
||
storeCache.set(projectRoot, store);
|
||
return store;
|
||
}
|
||
|
||
function getFusionDir(cwd: string): string {
|
||
return join(resolveProjectRoot(cwd), ".fusion");
|
||
}
|
||
|
||
/**
|
||
* Validate an agent id supplied to task create/update tools.
|
||
* Returns null on success, or an error message describing why the id was rejected.
|
||
*
|
||
* Rejects unknown agents and ephemeral/runtime-managed agents — mirrors fn_delegate
|
||
* so callers can't park hallucinated or task-worker IDs in `task.assignedAgentId`.
|
||
*/
|
||
async function validateAssignableAgentId(
|
||
cwd: string,
|
||
agentId: string,
|
||
task?: Pick<Task, "id" | "column"> | null,
|
||
override = false,
|
||
): Promise<string | null> {
|
||
const { AgentStore, isEphemeralAgent } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) });
|
||
await agentStore.init();
|
||
const agent = await agentStore.getAgent(agentId);
|
||
if (!agent) {
|
||
return `Agent ${agentId} not found`;
|
||
}
|
||
if (isEphemeralAgent(agent)) {
|
||
return `Cannot assign task to ephemeral/runtime agent ${agentId}`;
|
||
}
|
||
if (task && !override && !canAgentTakeImplementationTaskForExplicitRouting(agent, task)) {
|
||
return formatRoleMismatchReason(agent, task);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeNullableStringInput(value: string | null | undefined): string | null | undefined {
|
||
if (value === undefined) {
|
||
return undefined;
|
||
}
|
||
if (value === null) {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = value.trim();
|
||
if (trimmed.length === 0 || trimmed.toLowerCase() === "null") {
|
||
return null;
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
const INSIGHT_CATEGORIES: InsightCategory[] = [
|
||
"quality",
|
||
"performance",
|
||
"architecture",
|
||
"security",
|
||
"reliability",
|
||
"ux",
|
||
"testability",
|
||
"documentation",
|
||
"dependency",
|
||
"workflow",
|
||
"other",
|
||
"features",
|
||
"competitive_analysis",
|
||
"research",
|
||
"trends",
|
||
];
|
||
|
||
const INSIGHT_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dismissed", "archived"];
|
||
const INSIGHT_RUN_STATUSES: InsightRunStatus[] = ["pending", "running", "completed", "failed", "cancelled"];
|
||
const INSIGHT_RUN_TRIGGERS: InsightRunTrigger[] = ["schedule", "manual", "task_completion", "merge_event", "api"];
|
||
|
||
function getTaskSourceAgentLabel(task: Pick<Task, "sourceMetadata" | "sourceAgentId">): string | undefined {
|
||
const metadataAgentName = task.sourceMetadata?.agentName;
|
||
if (typeof metadataAgentName === "string" && metadataAgentName.trim().length > 0) {
|
||
return metadataAgentName.trim();
|
||
}
|
||
|
||
if (typeof task.sourceAgentId === "string" && task.sourceAgentId.trim().length > 0) {
|
||
return task.sourceAgentId.trim();
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
function getTaskSourceLabel(task: Pick<Task, "sourceType" | "sourceMetadata" | "sourceAgentId" | "sourceParentTaskId">): string | undefined {
|
||
switch (task.sourceType) {
|
||
case "dashboard_ui":
|
||
return "Dashboard";
|
||
case "quick_chat":
|
||
return "Quick Chat";
|
||
case "chat_session":
|
||
return "Chat Session";
|
||
case "agent_heartbeat": {
|
||
const sourceAgent = getTaskSourceAgentLabel(task);
|
||
return sourceAgent ? `Agent (${sourceAgent})` : "Agent";
|
||
}
|
||
case "automation": {
|
||
const sourceAgent = getTaskSourceAgentLabel(task);
|
||
return sourceAgent ? `Automation (${sourceAgent})` : "Automation";
|
||
}
|
||
case "cron":
|
||
return "Scheduled Task";
|
||
case "workflow_step":
|
||
return "Workflow Step";
|
||
case "github_import": {
|
||
const issueUrl = task.sourceMetadata?.issueUrl;
|
||
return typeof issueUrl === "string" && issueUrl.length > 0
|
||
? `GitHub Import (${issueUrl})`
|
||
: "GitHub Import";
|
||
}
|
||
case "research": {
|
||
const findingLabel = task.sourceMetadata?.findingLabel;
|
||
if (typeof findingLabel === "string" && findingLabel.length > 0) {
|
||
return `Research (${findingLabel})`;
|
||
}
|
||
const runId = task.sourceMetadata?.runId;
|
||
return typeof runId === "string" && runId.length > 0
|
||
? `Research (${runId})`
|
||
: "Research";
|
||
}
|
||
case "task" + "_refine":
|
||
return task.sourceParentTaskId ? `Refinement of ${task.sourceParentTaskId}` : "Refinement";
|
||
case "task" + "_duplicate":
|
||
return task.sourceParentTaskId ? `Duplicate of ${task.sourceParentTaskId}` : "Duplicate";
|
||
case "cli":
|
||
return "CLI";
|
||
case "api":
|
||
return "API";
|
||
case "recovery":
|
||
return "Recovery";
|
||
default:
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function formatTaskLine(t: Task): string {
|
||
const label =
|
||
t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
|
||
const source = getTaskSourceLabel(t);
|
||
const sourceSuffix = source ? ` [via: ${source}]` : "";
|
||
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
|
||
const paused = t.paused ? " (paused)" : "";
|
||
return `${t.id} ${label}${sourceSuffix}${deps}${paused}`;
|
||
}
|
||
|
||
async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean; code?: string; message?: string }> {
|
||
const settings = await store.getSettings();
|
||
if (!isResearchExperimentalEnabled(settings)) {
|
||
return { ok: false, code: "feature-disabled", message: "Research tools are disabled. Enable experimentalFeatures.researchView first." };
|
||
}
|
||
|
||
const resolved = resolveResearchSettings(settings);
|
||
if (!resolved.enabled) {
|
||
return { ok: false, code: "feature-disabled", message: "Research is disabled in settings." };
|
||
}
|
||
|
||
const backend = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
|
||
const configured = backend === "builtin"
|
||
? true
|
||
: backend === "searxng"
|
||
? Boolean(settings.researchGlobalSearxngUrl)
|
||
: backend === "brave"
|
||
? Boolean(settings.researchGlobalBraveApiKey)
|
||
: backend === "google"
|
||
? Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx)
|
||
: backend === "tavily"
|
||
? Boolean(settings.researchGlobalTavilyApiKey)
|
||
: false;
|
||
|
||
if (!configured) {
|
||
return { ok: false, code: "missing-credentials", message: `Missing credentials for ${backend}. Add provider keys in Authentication and verify Research defaults.` };
|
||
}
|
||
|
||
return { ok: true };
|
||
}
|
||
|
||
const RESEARCH_RUN_TERMINAL_STATUSES = new Set<ResearchRunStatus>([
|
||
"completed",
|
||
"failed",
|
||
"cancelled",
|
||
"timed_out",
|
||
"retry_exhausted",
|
||
]);
|
||
|
||
function toResearchRunDetails(run: ResearchRun) {
|
||
return {
|
||
runId: run.id,
|
||
status: run.status,
|
||
query: run.query,
|
||
summary: run.results?.summary ?? null,
|
||
findings: run.results?.findings ?? [],
|
||
citations: run.results?.citations ?? [],
|
||
sourceCount: Array.isArray(run.sources) ? run.sources.length : 0,
|
||
error: run.error ?? null,
|
||
setup: null,
|
||
};
|
||
}
|
||
|
||
function isResearchRunTerminal(status: ResearchRunStatus): boolean {
|
||
return RESEARCH_RUN_TERMINAL_STATUSES.has(status);
|
||
}
|
||
|
||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||
if (ms <= 0) {
|
||
return;
|
||
}
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
signal?.removeEventListener("abort", onAbort);
|
||
resolve();
|
||
}, ms);
|
||
|
||
const onAbort = () => {
|
||
clearTimeout(timer);
|
||
reject(new Error("Operation aborted"));
|
||
};
|
||
|
||
if (signal?.aborted) {
|
||
onAbort();
|
||
return;
|
||
}
|
||
|
||
signal?.addEventListener("abort", onAbort, { once: true });
|
||
});
|
||
}
|
||
|
||
interface GitHubIssueApiResult {
|
||
number: number;
|
||
title: string;
|
||
body: string | null;
|
||
html_url: string;
|
||
labels?: Array<{ name: string }>;
|
||
pull_request?: unknown;
|
||
}
|
||
|
||
function ensureGhCliAuth(): void {
|
||
if (!isGhAvailable() || !isGhAuthenticated()) {
|
||
throw new Error("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
|
||
}
|
||
}
|
||
|
||
async function fetchGitHubIssuesViaGh(
|
||
owner: string,
|
||
repo: string,
|
||
options: { limit?: number; labels?: string[]; signal?: AbortSignal } = {},
|
||
): Promise<GitHubIssueApiResult[]> {
|
||
ensureGhCliAuth();
|
||
|
||
const queryParams = new URLSearchParams();
|
||
queryParams.append("state", "open");
|
||
queryParams.append("per_page", String(Math.min(options.limit ?? 30, 100)));
|
||
if (options.labels && options.labels.length > 0) {
|
||
queryParams.append("labels", options.labels.join(","));
|
||
}
|
||
|
||
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams.toString()}`;
|
||
|
||
try {
|
||
const issues = await runGhJsonAsync<GitHubIssueApiResult[]>(["api", path], { signal: options.signal });
|
||
return issues.filter((issue) => !issue.pull_request);
|
||
} catch (error) {
|
||
throw new Error(getGhErrorMessage(error));
|
||
}
|
||
}
|
||
|
||
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
|
||
return {
|
||
sourceIssue: {
|
||
provider: "github" as const,
|
||
repository: `${owner}/${repo}`,
|
||
externalIssueId: String(issue.number),
|
||
issueNumber: issue.number,
|
||
url: issue.html_url,
|
||
},
|
||
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
|
||
};
|
||
}
|
||
|
||
async function fetchGitHubIssueViaGh(
|
||
owner: string,
|
||
repo: string,
|
||
issueNumber: number,
|
||
options: { signal?: AbortSignal } = {},
|
||
): Promise<GitHubIssueApiResult> {
|
||
ensureGhCliAuth();
|
||
|
||
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
|
||
|
||
try {
|
||
return await runGhJsonAsync<GitHubIssueApiResult>(["api", path], { signal: options.signal });
|
||
} catch (error) {
|
||
throw new Error(getGhErrorMessage(error));
|
||
}
|
||
}
|
||
|
||
// ── Extension entry point ──────────────────────────────────────────
|
||
|
||
export default function kbExtension(pi: ExtensionAPI) {
|
||
// ── fn_task_create ───────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_create",
|
||
label: "fn: Create Task",
|
||
description:
|
||
"Create a new task on the Fusion task board. The task enters the planning column " +
|
||
"where the AI planning agent will plan it into a full prompt with steps, " +
|
||
"file scope, and acceptance criteria.",
|
||
promptSnippet: "Create a task on the Fusion AI-orchestrated task board",
|
||
promptGuidelines: [
|
||
"Use fn_task_create for task tracking — be descriptive so the planning agent can write a good plan.",
|
||
"Include the problem AND desired outcome. For bugs, describe current vs expected behavior.",
|
||
],
|
||
parameters: Type.Object({
|
||
description: Type.String({ description: "What needs to be done — be descriptive" }),
|
||
depends: Type.Optional(
|
||
Type.Array(Type.String(), {
|
||
description: "Task IDs this depends on (e.g. ['FN-001', 'FN-002'])",
|
||
}),
|
||
),
|
||
agentId: Type.Optional(
|
||
Type.String({
|
||
description: "Agent ID to assign this task to (e.g. 'agent-abc123')",
|
||
}),
|
||
),
|
||
priority: Type.Optional(
|
||
StringEnum([...TASK_PRIORITIES], { description: "Task priority (low, normal, high, urgent)" }) as unknown as TSchema,
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
|
||
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
|
||
|
||
if (normalizedAgentId !== undefined && normalizedAgentId !== null) {
|
||
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "todo" };
|
||
const error = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), normalizedAgentId, candidateTask);
|
||
if (error) {
|
||
return {
|
||
content: [{ type: "text", text: error }],
|
||
isError: true,
|
||
details: { error },
|
||
};
|
||
}
|
||
}
|
||
|
||
try {
|
||
const task = await store.createTask({
|
||
description: params.description.trim(),
|
||
dependencies: params.depends,
|
||
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
|
||
priority: params.priority as TaskPriority | undefined,
|
||
source: { sourceType: "api" },
|
||
});
|
||
|
||
const label =
|
||
task.description.length > 80
|
||
? task.description.slice(0, 80) + "…"
|
||
: task.description;
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text:
|
||
`Created ${task.id}: ${label}\n` +
|
||
`Column: triage\n` +
|
||
(task.dependencies.length
|
||
? `Dependencies: ${task.dependencies.join(", ")}\n`
|
||
: "") +
|
||
(task.assignedAgentId
|
||
? `Assigned to: ${task.assignedAgentId}\n`
|
||
: "") +
|
||
`Priority: ${task.priority}\n` +
|
||
`Path: .fusion/tasks/${task.id}/`,
|
||
},
|
||
],
|
||
details: {
|
||
taskId: task.id,
|
||
column: task.column,
|
||
dependencies: task.dependencies,
|
||
assignedAgentId: task.assignedAgentId,
|
||
priority: task.priority,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
|
||
return {
|
||
content: [{ type: "text", text: `ERROR: ${error.message}` }],
|
||
isError: true,
|
||
details: { error: error.message },
|
||
};
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
});
|
||
|
||
// ── fn_task_update ────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_update",
|
||
label: "fn: Update Task",
|
||
description:
|
||
"Update fields on an existing task. Supports modifying the title, " +
|
||
"description, dependencies, and assigned agent after task creation.",
|
||
promptSnippet: "Update fields on an existing Fusion task",
|
||
promptGuidelines: [
|
||
"Use fn_task_update to modify task title, description, dependencies, or assigned agent after creation.",
|
||
"At least one field must be provided to update.",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
||
title: Type.Optional(Type.String({ description: "New task title" })),
|
||
description: Type.Optional(Type.String({ description: "New task description" })),
|
||
depends: Type.Optional(
|
||
Type.Array(Type.String(), {
|
||
description: "New dependency list — replaces existing dependencies (e.g. ['FN-001', 'FN-002'])",
|
||
}),
|
||
),
|
||
agentId: Type.Optional(
|
||
Type.Union([
|
||
Type.String(),
|
||
Type.Null(),
|
||
], {
|
||
description: "Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123')",
|
||
}),
|
||
),
|
||
nodeId: Type.Optional(
|
||
Type.Union([Type.String(), Type.Null()], {
|
||
description: "Node ID override for this task, or null to clear",
|
||
}),
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
|
||
// Validate task exists
|
||
let task: Task;
|
||
try {
|
||
task = await store.getTask(params.id);
|
||
} catch {
|
||
return {
|
||
content: [{ type: "text", text: `Task ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Task not found" },
|
||
};
|
||
}
|
||
|
||
// Build update payload
|
||
const updates: Record<string, unknown> = {};
|
||
const updatedFields: string[] = [];
|
||
|
||
if (params.title !== undefined) {
|
||
updates.title = params.title.trim();
|
||
updatedFields.push("title");
|
||
}
|
||
if (params.description !== undefined) {
|
||
updates.description = params.description.trim();
|
||
updatedFields.push("description");
|
||
}
|
||
if (params.depends !== undefined) {
|
||
updates.dependencies = params.depends;
|
||
updatedFields.push("dependencies");
|
||
}
|
||
if (params.agentId !== undefined) {
|
||
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
|
||
if (typeof normalizedAgentId === "string") {
|
||
const error = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), normalizedAgentId, task);
|
||
if (error) {
|
||
return {
|
||
content: [{ type: "text", text: error }],
|
||
isError: true,
|
||
details: { error },
|
||
};
|
||
}
|
||
}
|
||
updates.assignedAgentId = normalizedAgentId;
|
||
updatedFields.push("agentId");
|
||
}
|
||
if (params.nodeId !== undefined) {
|
||
const normalizedNodeId = normalizeNullableStringInput(params.nodeId);
|
||
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null);
|
||
if (!validation.allowed) {
|
||
return {
|
||
content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],
|
||
isError: true,
|
||
details: { error: validation.reason },
|
||
};
|
||
}
|
||
updates.nodeId = normalizedNodeId;
|
||
updatedFields.push("nodeId");
|
||
}
|
||
|
||
if (updatedFields.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId." }],
|
||
isError: true,
|
||
details: { error: "No fields provided" },
|
||
};
|
||
}
|
||
|
||
await store.updateTask(params.id, updates);
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Updated ${params.id}: ${updatedFields.join(", ")}`,
|
||
},
|
||
],
|
||
details: { taskId: params.id, updatedFields },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_list ─────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_list",
|
||
label: "fn: List Tasks",
|
||
description: "List all tasks on the Fusion board, grouped by column.",
|
||
promptSnippet: "List all tasks on the Fusion board grouped by column",
|
||
parameters: Type.Object({
|
||
column: Type.Optional(
|
||
StringEnum([...COLUMNS] as unknown as string[], {
|
||
description: "Filter to a specific column",
|
||
}) as unknown as TSchema,
|
||
),
|
||
limit: Type.Optional(
|
||
Type.Number({
|
||
description: "Max tasks to show per column (default: 10)",
|
||
}),
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const tasks = await store.listTasks({ slim: true });
|
||
|
||
if (tasks.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: "No tasks yet." }],
|
||
details: { count: 0 },
|
||
};
|
||
}
|
||
|
||
const perColumn = params.limit ?? 10;
|
||
const lines: string[] = [];
|
||
for (const col of COLUMNS) {
|
||
if (params.column && params.column !== col) continue;
|
||
|
||
const colTasks = tasks.filter((t) => t.column === col);
|
||
if (colTasks.length === 0) continue;
|
||
|
||
lines.push(`${COLUMN_LABELS[col]} (${colTasks.length}):`);
|
||
const shown = colTasks.slice(0, perColumn);
|
||
for (const t of shown) {
|
||
lines.push(` ${formatTaskLine(t)}`);
|
||
}
|
||
const hidden = colTasks.length - shown.length;
|
||
if (hidden > 0) {
|
||
lines.push(` ... and ${hidden} more`);
|
||
}
|
||
lines.push("");
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
|
||
details: { count: tasks.length },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_show ─────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_show",
|
||
label: "fn: Show Task",
|
||
description: "Show full details for a task including steps, progress, and log entries.",
|
||
promptSnippet: "Show full details for a Fusion task",
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const task = await store.getTask(params.id);
|
||
|
||
const lines: string[] = [];
|
||
lines.push(`${task.id}: ${task.title || task.description}`);
|
||
lines.push(
|
||
`Column: ${COLUMN_LABELS[task.column]}` +
|
||
(task.size ? ` · Size: ${task.size}` : "") +
|
||
(task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""),
|
||
);
|
||
if (task.dependencies.length) {
|
||
lines.push(`Dependencies: ${task.dependencies.join(", ")}`);
|
||
}
|
||
const sourceLabel = getTaskSourceLabel(task);
|
||
if (sourceLabel) {
|
||
lines.push(`Created via: ${sourceLabel}`);
|
||
}
|
||
if (task.paused) lines.push("Status: PAUSED");
|
||
lines.push("");
|
||
|
||
// Steps
|
||
if (task.steps.length > 0) {
|
||
const done = task.steps.filter((s) => s.status === "done").length;
|
||
lines.push(`Steps (${done}/${task.steps.length}):`);
|
||
for (let i = 0; i < task.steps.length; i++) {
|
||
const s = task.steps[i];
|
||
const icon =
|
||
s.status === "done"
|
||
? "✓"
|
||
: s.status === "in-progress"
|
||
? "▸"
|
||
: s.status === "skipped"
|
||
? "–"
|
||
: " ";
|
||
const marker =
|
||
i === task.currentStep && s.status !== "done" ? " ◀" : "";
|
||
lines.push(` [${icon}] ${i}: ${s.name}${marker}`);
|
||
}
|
||
lines.push("");
|
||
}
|
||
|
||
// Prompt (truncated)
|
||
if (task.prompt) {
|
||
const promptPreview =
|
||
task.prompt.length > 500
|
||
? task.prompt.slice(0, 500) + "\n... (truncated)"
|
||
: task.prompt;
|
||
lines.push("Prompt:");
|
||
lines.push(promptPreview);
|
||
lines.push("");
|
||
}
|
||
|
||
// Recent log
|
||
if (task.log.length > 0) {
|
||
const recent = task.log.slice(-5);
|
||
lines.push(`Log (last ${recent.length}):`);
|
||
for (const l of recent) {
|
||
const ts = new Date(l.timestamp).toLocaleTimeString();
|
||
lines.push(
|
||
` ${ts} ${l.action}${l.outcome ? ` → ${l.outcome}` : ""}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
|
||
details: { task },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_attach ───────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_attach",
|
||
label: "fn: Attach File",
|
||
description:
|
||
"Attach a file to a task. Supports images (png, jpg, gif, webp) and " +
|
||
"text files (txt, log, json, yaml, yml, toml, csv, xml).",
|
||
promptSnippet: "Attach a file to a Fusion task",
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
||
path: Type.String({ description: "Path to the file to attach" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const filePath = resolve(ctx.cwd, params.path.replace(/^@/, ""));
|
||
const filename = basename(filePath);
|
||
const ext = extname(filename).toLowerCase();
|
||
const mimeType = MIME_TYPES[ext];
|
||
|
||
if (!mimeType) {
|
||
throw new Error(
|
||
`Unsupported file type: ${ext}. Supported: ${Object.keys(MIME_TYPES).join(", ")}`,
|
||
);
|
||
}
|
||
|
||
let content: Buffer;
|
||
try {
|
||
content = await readFile(filePath);
|
||
} catch {
|
||
throw new Error(`Cannot read file: ${params.path}`);
|
||
}
|
||
|
||
const store = await getStore(ctx.cwd);
|
||
const attachment = await store.addAttachment(params.id, filename, content, mimeType);
|
||
const sizeKB = (attachment.size / 1024).toFixed(1);
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text:
|
||
`Attached to ${params.id}: ${attachment.originalName} (${sizeKB} KB)\n` +
|
||
`Path: .fusion/tasks/${params.id}/attachments/${attachment.filename}`,
|
||
},
|
||
],
|
||
details: { taskId: params.id, attachment },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_pause ────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_pause",
|
||
label: "fn: Pause Task",
|
||
description:
|
||
"Pause a task — stops all automated agent and scheduler interaction for this task.",
|
||
promptSnippet: "Pause a Fusion task (stops automation)",
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const task = await store.pauseTask(params.id, true);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Paused ${task.id}` }],
|
||
details: { taskId: task.id },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_unpause ──────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_unpause",
|
||
label: "fn: Unpause Task",
|
||
description:
|
||
"Unpause a task — resumes automated agent and scheduler interaction.",
|
||
promptSnippet: "Unpause a Fusion task (resumes automation)",
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const task = await store.pauseTask(params.id, false);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Unpaused ${task.id}` }],
|
||
details: { taskId: task.id },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_retry ────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_retry",
|
||
label: "fn: Retry Task",
|
||
description:
|
||
"Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry.",
|
||
promptSnippet: "Retry a failed Fusion task (clears error, moves to todo or stays in in-review)",
|
||
promptGuidelines: [
|
||
"Use when a task has failed and needs to be retried",
|
||
"Only tasks in 'failed' or 'stuck-killed' state can be retried",
|
||
"In-review tasks with incomplete steps (pending/in-progress) move to todo with preserveProgress so execution can resume",
|
||
"In-review tasks with all steps done stay in in-review and reset merge retry state for auto-merge re-attempt",
|
||
"Tasks in other columns are moved to the todo column with error state cleared",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID to retry (e.g. FN-001). Must be in 'failed' state." }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
|
||
// Validate task exists
|
||
let task;
|
||
try {
|
||
task = await store.getTask(params.id);
|
||
} catch {
|
||
return {
|
||
content: [{ type: "text", text: `Task ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Task not found" },
|
||
};
|
||
}
|
||
|
||
// Validate task is in a retryable state
|
||
if (task.status !== 'failed' && task.status !== 'stuck-killed') {
|
||
return {
|
||
content: [{ type: "text", text: `Task ${params.id} is not in a retryable state (status: ${task.status || 'none'})` }],
|
||
isError: true,
|
||
details: { taskId: params.id, currentStatus: task.status },
|
||
};
|
||
}
|
||
|
||
// In-review retry: distinguish between execution failures and merge failures.
|
||
if (task.column === 'in-review') {
|
||
const hasIncompleteSteps = task.steps.some(
|
||
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
|
||
);
|
||
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
|
||
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
|
||
const isExecutionFailureInReview =
|
||
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
|
||
|
||
if (isExecutionFailureInReview) {
|
||
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0 });
|
||
await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)");
|
||
await store.moveTask(params.id, "todo", { preserveProgress: true });
|
||
return {
|
||
content: [{ type: "text", text: `Retried ${params.id} → todo (execution failure, preserving step progress)` }],
|
||
details: { taskId: params.id, newColumn: 'todo' },
|
||
};
|
||
}
|
||
|
||
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0, mergeRetries: 0 });
|
||
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)");
|
||
return {
|
||
content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared)` }],
|
||
details: { taskId: params.id, newColumn: 'in-review' },
|
||
};
|
||
}
|
||
|
||
// Clear failure state and move to todo for other columns
|
||
await store.updateTask(params.id, { status: null, error: null });
|
||
|
||
// Move to todo column
|
||
await store.moveTask(params.id, 'todo');
|
||
|
||
// Log the retry action
|
||
await store.logEntry(params.id, "Retry requested via Fusion extension", "Task reset to todo for retry");
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Retried ${params.id} → todo (failure state cleared)` }],
|
||
details: { taskId: params.id, newColumn: 'todo' },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_duplicate ─────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_duplicate",
|
||
label: "fn: Duplicate Task",
|
||
description:
|
||
"Duplicate an existing task, creating a fresh copy in planning. " +
|
||
"Copies the title and description but resets all execution state. " +
|
||
"The AI planning agent will replan the new task.",
|
||
promptSnippet: "Duplicate a Fusion task (creates copy in planning)",
|
||
promptGuidelines: [
|
||
"Use when a task needs to be re-done, split, or used as a template",
|
||
"The duplicated task will be placed in planning for replanning",
|
||
"Dependencies, attachments, and execution state are NOT copied",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Source task ID to duplicate (e.g. FN-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const newTask = await store.duplicateTask(params.id);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Duplicated ${params.id} → ${newTask.id}` }],
|
||
details: { sourceId: params.id, newTaskId: newTask.id },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_refine ──────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_refine",
|
||
label: "fn: Refine Task",
|
||
description:
|
||
"Request a refinement of a completed or in-review task. " +
|
||
"Creates a new follow-up task in planning that references the original task as a dependency. " +
|
||
"Use this when a done or in-review task needs additional work, improvements, or follow-up changes.",
|
||
promptSnippet: "Create a refinement task for follow-up work on a completed task",
|
||
promptGuidelines: [
|
||
"Use when a completed or in-review task needs follow-up work or improvements",
|
||
"The original task must be in 'done' or 'in-review' column",
|
||
"The refinement task will be created in planning and depend on the original task",
|
||
"Provide clear feedback about what needs to be refined or improved",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID to refine (e.g. FN-001). Must be in 'done' or 'in-review' column." }),
|
||
feedback: Type.String({
|
||
description: "Description of what needs to be refined or improved",
|
||
minLength: 1,
|
||
maxLength: 2000,
|
||
}),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const newTask = await store.refineTask(params.id, params.feedback);
|
||
|
||
return {
|
||
content: [
|
||
{ type: "text", text: `Created refinement ${newTask.id} for ${params.id}` },
|
||
],
|
||
details: { sourceId: params.id, newTaskId: newTask.id, feedback: params.feedback },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_archive ───────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_archive",
|
||
label: "fn: Archive Task",
|
||
description:
|
||
"Archive a done task (move from done → archived). " +
|
||
"Archived tasks are preserved for historical reference but moved out of the main board view.",
|
||
promptSnippet: "Archive a done Fusion task (moves to archived column)",
|
||
promptGuidelines: [
|
||
"Use to clean up old completed tasks from the done column",
|
||
"Only tasks in the 'done' column can be archived",
|
||
"Archived tasks can be unarchived later if needed",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID to archive (e.g. FN-001). Must be in 'done' column." }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const task = await store.archiveTask(params.id);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
|
||
details: { taskId: task.id, column: task.column },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_unarchive ─────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_unarchive",
|
||
label: "fn: Unarchive Task",
|
||
description:
|
||
"Unarchive an archived task (move from archived → done). " +
|
||
"Restores the task to the done column.",
|
||
promptSnippet: "Unarchive a Fusion task (restores to done column)",
|
||
promptGuidelines: [
|
||
"Use to restore an archived task back to the done column",
|
||
"Only tasks in the 'archived' column can be unarchived",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID to unarchive (e.g. FN-001). Must be in 'archived' column." }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const task = await store.unarchiveTask(params.id);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
|
||
details: { taskId: task.id, column: task.column },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_delete ─────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_delete",
|
||
label: "fn: Delete Task",
|
||
description:
|
||
"Permanently delete a task from the Fusion board. " +
|
||
"Tasks are deleted immediately and cannot be recovered.",
|
||
promptSnippet: "Delete a Fusion task",
|
||
promptGuidelines: [
|
||
"Use for cleaning up test tasks or tasks created in error",
|
||
"Tasks are permanently deleted and cannot be recovered",
|
||
"Consider archiving instead of deleting for completed work you may need to reference later",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const task = await store.deleteTask(params.id);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Deleted ${task.id}` }],
|
||
details: { taskId: task.id },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_import_github ─────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_import_github",
|
||
label: "fn: Import GitHub Issues",
|
||
description:
|
||
"Import GitHub issues as Fusion tasks. Fetches open issues from a repository " +
|
||
"and creates tasks in the planning column. Each task includes the issue title " +
|
||
"and body with a link to the source issue.",
|
||
promptSnippet: "Import GitHub issues as Fusion tasks",
|
||
promptGuidelines: [
|
||
"Use for syncing GitHub issue backlog to Fusion board",
|
||
"Uses gh CLI authentication (run 'gh auth login')",
|
||
"Use --limit to control how many issues to import (default: 30)",
|
||
"Use --labels to filter by specific labels",
|
||
],
|
||
parameters: Type.Object({
|
||
ownerRepo: Type.String({
|
||
description: "Repository in owner/repo format (e.g., 'dustinbyrne/fusion')",
|
||
pattern: "^[^/]+/[^/]+$",
|
||
}),
|
||
limit: Type.Optional(
|
||
Type.Number({
|
||
description: "Max issues to import (default: 30, max: 100)",
|
||
minimum: 1,
|
||
maximum: 100,
|
||
})
|
||
),
|
||
labels: Type.Optional(
|
||
Type.Array(Type.String(), {
|
||
description: "Label names to filter by",
|
||
})
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||
const [owner, repo] = params.ownerRepo.split("/");
|
||
const limit = params.limit ?? 30;
|
||
const labels = params.labels;
|
||
|
||
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
|
||
|
||
if (issues.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
|
||
details: { createdTasks: [], summary: `Imported 0 tasks from ${owner}/${repo}` },
|
||
};
|
||
}
|
||
|
||
const store = await getStore(ctx.cwd);
|
||
const existingTasks = await store.listTasks({ slim: true });
|
||
const createdTasks: Array<{ id: string; title: string }> = [];
|
||
|
||
for (const issue of issues) {
|
||
const sourceUrl = issue.html_url;
|
||
const alreadyImported = existingTasks.some((task) => task.description.includes(sourceUrl));
|
||
if (alreadyImported) {
|
||
continue;
|
||
}
|
||
|
||
const title = issue.title.slice(0, 200);
|
||
const body = issue.body?.trim() || "(no description)";
|
||
const description = `${body}\n\nSource: ${sourceUrl}`;
|
||
|
||
const source = buildGitHubIssueSource(owner, repo, issue);
|
||
const task = await store.createTask({
|
||
title: title || undefined,
|
||
description,
|
||
column: "triage",
|
||
dependencies: [],
|
||
sourceIssue: source.sourceIssue,
|
||
source: {
|
||
sourceType: "github_import",
|
||
sourceMetadata: source.sourceMetadata,
|
||
},
|
||
});
|
||
|
||
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
|
||
createdTasks.push({ id: task.id, title: task.title || issue.title });
|
||
|
||
existingTasks.push({ ...task, description });
|
||
}
|
||
|
||
const summary = `✓ Imported ${createdTasks.length} tasks from ${owner}/${repo}`;
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `${summary}\n\nCreated tasks:\n${createdTasks.map((task) => ` ${task.id}: ${task.title}`).join("\n") || " None"}`,
|
||
},
|
||
],
|
||
details: { createdTasks, summary },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_import_github_issue ───────────────────────────────────
|
||
// Import a single GitHub issue by its issue number
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_import_github_issue",
|
||
label: "fn: Import GitHub Issue",
|
||
description:
|
||
"Import a specific GitHub issue as a Fusion task. Fetches the issue by number " +
|
||
"and creates a single task in the planning column with the issue title and body.",
|
||
promptSnippet: "Import a specific GitHub issue as a Fusion task",
|
||
promptGuidelines: [
|
||
"Use for importing a single known issue by its number",
|
||
"Uses gh CLI authentication (run 'gh auth login')",
|
||
"Skips import if the issue is already imported (checks for existing Source URL)",
|
||
],
|
||
parameters: Type.Object({
|
||
owner: Type.String({
|
||
description: "Repository owner (e.g., 'dustinbyrne')",
|
||
}),
|
||
repo: Type.String({
|
||
description: "Repository name (e.g., 'fusion')",
|
||
}),
|
||
issueNumber: Type.Number({
|
||
description: "GitHub issue number to import",
|
||
minimum: 1,
|
||
}),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||
const { owner, repo, issueNumber } = params;
|
||
const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber, { signal });
|
||
|
||
if (issue.pull_request) {
|
||
throw new Error(`#${issueNumber} is a pull request, not an issue`);
|
||
}
|
||
|
||
// Check if already imported
|
||
const store = await getStore(ctx.cwd);
|
||
const existingTasks = await store.listTasks({ slim: true });
|
||
const sourceUrl = issue.html_url;
|
||
|
||
for (const task of existingTasks) {
|
||
if (task.description.includes(sourceUrl)) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Issue #${issueNumber} already imported as ${task.id}\nSource: ${sourceUrl}`,
|
||
},
|
||
],
|
||
details: { skipped: true, existingTaskId: task.id, sourceUrl },
|
||
};
|
||
}
|
||
}
|
||
|
||
// Create the task
|
||
const title = issue.title.slice(0, 200);
|
||
const body = issue.body?.trim() || "(no description)";
|
||
const description = `${body}\n\nSource: ${sourceUrl}`;
|
||
|
||
const source = buildGitHubIssueSource(owner, repo, issue);
|
||
const task = await store.createTask({
|
||
title: title || undefined,
|
||
description,
|
||
column: "triage",
|
||
dependencies: [],
|
||
sourceIssue: source.sourceIssue,
|
||
source: {
|
||
sourceType: "github_import",
|
||
sourceMetadata: source.sourceMetadata,
|
||
},
|
||
});
|
||
|
||
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Imported ${task.id} from GitHub\n${sourceUrl}`,
|
||
},
|
||
],
|
||
details: { taskId: task.id, sourceUrl },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_browse_github_issues ──────────────────────────────────
|
||
// Browse available GitHub issues before importing
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_browse_github_issues",
|
||
label: "fn: Browse GitHub Issues",
|
||
description:
|
||
"List open GitHub issues from a repository to browse before importing. " +
|
||
"Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue " +
|
||
"to import specific issues by number.",
|
||
promptSnippet: "Browse open GitHub issues in a repository",
|
||
promptGuidelines: [
|
||
"Use to preview available issues before importing",
|
||
"Returns a list you can reference when importing specific issues",
|
||
"Use --limit to control how many issues to show (default: 30)",
|
||
"Use --labels to filter by specific labels",
|
||
"Uses gh CLI authentication (run 'gh auth login')",
|
||
],
|
||
parameters: Type.Object({
|
||
owner: Type.String({
|
||
description: "Repository owner (e.g., 'dustinbyrne')",
|
||
}),
|
||
repo: Type.String({
|
||
description: "Repository name (e.g., 'fusion')",
|
||
}),
|
||
limit: Type.Optional(
|
||
Type.Number({
|
||
description: "Max issues to show (default: 30, max: 100)",
|
||
minimum: 1,
|
||
maximum: 100,
|
||
})
|
||
),
|
||
labels: Type.Optional(
|
||
Type.Array(Type.String(), {
|
||
description: "Label names to filter by",
|
||
})
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||
const { owner, repo, limit = 30, labels } = params;
|
||
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
|
||
|
||
if (issues.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
|
||
details: { count: 0, issues: [] },
|
||
};
|
||
}
|
||
|
||
// Check which issues are already imported
|
||
const store = await getStore(ctx.cwd);
|
||
const existingTasks = await store.listTasks({ slim: true });
|
||
const importedUrls = new Set<string>();
|
||
|
||
for (const task of existingTasks) {
|
||
const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
|
||
if (match) {
|
||
importedUrls.add(match[1]);
|
||
}
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
lines.push(`Found ${issues.length} open issues in ${owner}/${repo}:\n`);
|
||
|
||
for (const issue of issues) {
|
||
const isImported = importedUrls.has(issue.html_url);
|
||
const issueLabels = issue.labels ?? [];
|
||
const labelStr = issueLabels.length > 0 ? ` [${issueLabels.map((label) => label.name).join(", ")}]` : "";
|
||
const importedStr = isImported ? " ✓ Imported" : "";
|
||
lines.push(` #${issue.number}: ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${labelStr}${importedStr}`);
|
||
lines.push(` ${issue.html_url}`);
|
||
}
|
||
|
||
lines.push("\nUse fn_task_import_github_issue to import a specific issue by number.");
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: {
|
||
count: issues.length,
|
||
issues: issues.map((issue) => ({
|
||
number: issue.number,
|
||
title: issue.title,
|
||
url: issue.html_url,
|
||
labels: (issue.labels ?? []).map((label) => label.name),
|
||
imported: importedUrls.has(issue.html_url),
|
||
})),
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_task_plan ────────────────────────────────────────────────
|
||
// Create a task via AI-guided planning mode
|
||
|
||
pi.registerTool({
|
||
name: "fn_task_plan",
|
||
label: "fn: Plan Task",
|
||
description:
|
||
"Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task.",
|
||
promptSnippet: "Create a task via AI-guided planning mode",
|
||
promptGuidelines: [
|
||
"Use for breaking down vague ideas into actionable tasks",
|
||
"The AI will ask clarifying questions before creating the task",
|
||
],
|
||
parameters: Type.Object({
|
||
description: Type.Optional(
|
||
Type.String({
|
||
description: "Initial plan description (optional) — the AI will ask clarifying questions if not provided",
|
||
})
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||
// Import the planning function dynamically to avoid circular dependencies
|
||
const { runTaskPlan } = await import("./commands/task.js");
|
||
|
||
// Capture console output
|
||
const originalLog = console.log;
|
||
const originalError = console.error;
|
||
const logs: string[] = [];
|
||
|
||
console.log = (...args: unknown[]) => {
|
||
const line = args.map(String).join(" ");
|
||
logs.push(line);
|
||
originalLog.apply(console, args);
|
||
};
|
||
console.error = (...args: unknown[]) => {
|
||
const line = args.map(String).join(" ");
|
||
logs.push(line);
|
||
originalError.apply(console, args);
|
||
};
|
||
|
||
let taskId: string | undefined;
|
||
try {
|
||
taskId = await runTaskPlan(params.description, true); // Use --yes flag for non-interactive
|
||
} catch (err) {
|
||
console.error = originalError;
|
||
console.log = originalLog;
|
||
throw new Error(`Planning mode failed: ${err instanceof Error ? err.message : String(err)}`);
|
||
} finally {
|
||
console.error = originalError;
|
||
console.log = originalLog;
|
||
}
|
||
|
||
// Get summary line
|
||
const summaryLine = logs.find((l) => l.includes("✓ Created")) || "Task created";
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: summaryLine + (taskId ? `\n\nPlanning session completed. Task ${taskId} is now in planning and will be auto-planned by the AI planning agent.` : ""),
|
||
},
|
||
],
|
||
details: { taskId, logs },
|
||
};
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_web_fetch",
|
||
label: "fn: Web Fetch",
|
||
description: "Lightweight URL fetch (no JS rendering). Use agent-browser skill for JS-heavy pages.",
|
||
parameters: Type.Object({
|
||
url: Type.String({ description: "URL to fetch (http/https)" }),
|
||
prompt: Type.Optional(Type.String({ description: "Optional extraction hint for downstream summarization" })),
|
||
timeoutMs: Type.Optional(Type.Number({ description: "Timeout in milliseconds (default: 30000)" })),
|
||
maxBytes: Type.Optional(Type.Number({ description: "Max bytes to return (default: 512000)" })),
|
||
}),
|
||
promptSnippet: "Fetch and extract readable text from a webpage URL",
|
||
promptGuidelines: [
|
||
"Use for lightweight GET requests where JS rendering is not required.",
|
||
"For JS-rendered pages or complex browsing flows, use agent-browser skill instead.",
|
||
],
|
||
async execute(_toolCallId, params) {
|
||
const result = await fetchWebContent(params.url, {
|
||
timeoutMs: params.timeoutMs,
|
||
maxBytes: params.maxBytes,
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text",
|
||
text: [
|
||
`URL: ${result.finalUrl}`,
|
||
`Status: ${result.status}`,
|
||
`Content-Type: ${result.contentType}`,
|
||
params.prompt ? `Prompt: ${params.prompt}` : undefined,
|
||
result.title ? `Title: ${result.title}` : undefined,
|
||
"",
|
||
result.content,
|
||
result.truncated ? "\n[truncated to maxBytes]" : "",
|
||
].filter(Boolean).join("\n"),
|
||
}],
|
||
details: {
|
||
finalUrl: result.finalUrl,
|
||
status: result.status,
|
||
contentType: result.contentType,
|
||
title: result.title,
|
||
truncated: result.truncated,
|
||
bytesRead: result.bytesRead,
|
||
promptSnippet: params.prompt ? params.prompt.slice(0, 200) : undefined,
|
||
promptGuidelines: "Lightweight fetch only; for JS-rendered pages, use agent-browser skill.",
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── Research Tools ──────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_research_run",
|
||
label: "fn: Run Research",
|
||
description: "Cited-research pipeline: create a bounded search/fetch/synthesis run (not an autonomous experiment loop) and optionally wait for completion.",
|
||
parameters: Type.Object({
|
||
query: Type.String({ description: "Research query or question" }),
|
||
wait_for_completion: Type.Optional(Type.Boolean({ description: "Wait for the run to complete before returning (default: false)" })),
|
||
max_wait_ms: Type.Optional(Type.Number({ description: "Max wait time when wait_for_completion=true (default: 90000, capped by settings)" })),
|
||
}),
|
||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const availability = await getResearchAvailability(store);
|
||
if (!availability.ok) {
|
||
return {
|
||
content: [{ type: "text", text: availability.message! }],
|
||
details: { runId: null, status: "unavailable", summary: null, findings: [], citations: [], error: availability.message, setup: { code: availability.code, message: availability.message } },
|
||
};
|
||
}
|
||
|
||
const researchStore = store.getResearchStore();
|
||
const run = researchStore.createRun({
|
||
query: params.query,
|
||
topic: params.query,
|
||
providerConfig: {},
|
||
});
|
||
|
||
if (!params.wait_for_completion) {
|
||
return {
|
||
content: [{ type: "text", text: `Created research run ${run.id}. Start the project engine to process pending runs, then use fn_research_get.` }],
|
||
details: toResearchRunDetails(run),
|
||
};
|
||
}
|
||
|
||
const maxWaitMs = Number.isFinite(params.max_wait_ms)
|
||
? Math.max(0, params.max_wait_ms ?? 90_000)
|
||
: 90_000;
|
||
const pollIntervalMs = 2_000;
|
||
const deadline = Date.now() + maxWaitMs;
|
||
let latestRun = run;
|
||
|
||
while (Date.now() <= deadline) {
|
||
const current = researchStore.getRun(run.id);
|
||
if (!current) {
|
||
break;
|
||
}
|
||
|
||
latestRun = current;
|
||
if (isResearchRunTerminal(current.status)) {
|
||
return {
|
||
content: [{ type: "text", text: `Research run ${current.id} is ${current.status}.` }],
|
||
details: toResearchRunDetails(current),
|
||
};
|
||
}
|
||
|
||
await sleepWithSignal(pollIntervalMs, signal);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Research run ${latestRun.id} is ${latestRun.status}.` }],
|
||
details: toResearchRunDetails(latestRun),
|
||
};
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_research_list",
|
||
label: "fn: List Research Runs",
|
||
description: "Cited-research pipeline: list recent search/fetch/synthesis runs (not experiment-loop sessions).",
|
||
parameters: Type.Object({
|
||
status: Type.Optional(StringEnum([...RESEARCH_RUN_STATUSES], { description: "Filter by run status" }) as unknown as TSchema),
|
||
limit: Type.Optional(Type.Number({ description: "Max runs to return (default: 10)" })),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const availability = await getResearchAvailability(store);
|
||
if (!availability.ok) {
|
||
return {
|
||
content: [{ type: "text", text: availability.message! }],
|
||
details: { runs: [], setup: { code: availability.code, message: availability.message } },
|
||
};
|
||
}
|
||
|
||
const runs = store.getResearchStore().listRuns({ status: params.status as ResearchRunStatus | undefined, limit: params.limit ?? 10 });
|
||
const text = runs.length ? runs.map((run) => `- ${run.id} [${run.status}] ${run.query}`).join("\n") : "No research runs found.";
|
||
return { content: [{ type: "text", text }], details: { runs: runs.map(toResearchRunDetails) } };
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_research_get",
|
||
label: "fn: Get Research Run",
|
||
description: "Cited-research pipeline: get one run with structured findings and citations (not experiment-loop state).",
|
||
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const availability = await getResearchAvailability(store);
|
||
if (!availability.ok) {
|
||
return {
|
||
content: [{ type: "text", text: availability.message! }],
|
||
details: {
|
||
runId: params.id,
|
||
status: "unavailable",
|
||
summary: null,
|
||
findings: [],
|
||
citations: [],
|
||
error: availability.message,
|
||
setup: { code: availability.code, message: availability.message },
|
||
},
|
||
};
|
||
}
|
||
|
||
const run = store.getResearchStore().getRun(params.id);
|
||
if (!run) {
|
||
return {
|
||
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
|
||
details: {
|
||
runId: params.id,
|
||
status: "missing",
|
||
summary: null,
|
||
findings: [],
|
||
citations: [],
|
||
error: "not found",
|
||
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
|
||
},
|
||
};
|
||
}
|
||
return { content: [{ type: "text", text: `Research run ${run.id} is ${run.status}.` }], details: toResearchRunDetails(run) };
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_research_cancel",
|
||
label: "fn: Cancel Research Run",
|
||
description: "Cited-research pipeline: cancel an in-flight run; terminal runs return INVALID_TRANSITION (does not control experiment loops).",
|
||
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const availability = await getResearchAvailability(store);
|
||
if (!availability.ok) {
|
||
return {
|
||
content: [{ type: "text", text: availability.message! }],
|
||
isError: true,
|
||
details: {
|
||
runId: params.id,
|
||
status: "unavailable",
|
||
summary: null,
|
||
findings: [],
|
||
citations: [],
|
||
error: availability.message,
|
||
setup: { code: availability.code, message: availability.message },
|
||
},
|
||
};
|
||
}
|
||
|
||
const researchStore = store.getResearchStore();
|
||
const run = researchStore.getRun(params.id);
|
||
if (!run) {
|
||
return {
|
||
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
|
||
isError: true,
|
||
details: {
|
||
runId: params.id,
|
||
status: "missing",
|
||
summary: null,
|
||
findings: [],
|
||
citations: [],
|
||
error: "not found",
|
||
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
|
||
},
|
||
};
|
||
}
|
||
|
||
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
|
||
return {
|
||
content: [{ type: "text", text: `Research run ${params.id} cannot be cancelled from status ${run.status}.` }],
|
||
isError: true,
|
||
details: {
|
||
...toResearchRunDetails(run),
|
||
error: "invalid transition",
|
||
setup: { code: "INVALID_TRANSITION", message: "Cancel is only available for queued/running/cancelling/retry_waiting runs." },
|
||
},
|
||
};
|
||
}
|
||
|
||
const updated = researchStore.requestCancellation(params.id);
|
||
return {
|
||
content: [{ type: "text", text: `Requested cancellation for research run ${params.id} (status: ${updated.status}).` }],
|
||
details: toResearchRunDetails(updated),
|
||
};
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_research_retry",
|
||
label: "fn: Retry Research Run",
|
||
description: "Cited-research pipeline: retry a failed run when lifecycle marks it retryable (not an autonomous experiment loop retry).",
|
||
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const availability = await getResearchAvailability(store);
|
||
if (!availability.ok) {
|
||
return {
|
||
content: [{ type: "text", text: availability.message! }],
|
||
isError: true,
|
||
details: {
|
||
runId: params.id,
|
||
status: "unavailable",
|
||
summary: null,
|
||
findings: [],
|
||
citations: [],
|
||
error: availability.message,
|
||
setup: { code: availability.code, message: availability.message },
|
||
},
|
||
};
|
||
}
|
||
|
||
const researchStore = store.getResearchStore();
|
||
const run = researchStore.getRun(params.id);
|
||
if (!run) {
|
||
return {
|
||
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
|
||
isError: true,
|
||
details: {
|
||
runId: params.id,
|
||
status: "missing",
|
||
summary: null,
|
||
findings: [],
|
||
citations: [],
|
||
error: "not found",
|
||
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
|
||
},
|
||
};
|
||
}
|
||
const isRetryExhausted = run.status === "retry_exhausted" || run.lifecycle?.errorCode === "RETRY_EXHAUSTED";
|
||
if ((run.status !== "failed" && run.status !== "timed_out") || run.lifecycle?.retryable === false || isRetryExhausted) {
|
||
return {
|
||
content: [{ type: "text", text: `Research run ${params.id} is not retryable from status ${run.status}.` }],
|
||
isError: true,
|
||
details: {
|
||
...toResearchRunDetails(run),
|
||
error: "not retryable",
|
||
setup: { code: isRetryExhausted ? "RETRY_EXHAUSTED" : "INVALID_TRANSITION", message: "Retry is only available for failed/timed_out retryable runs." },
|
||
},
|
||
};
|
||
}
|
||
|
||
const retryRun = researchStore.createRetryRun(params.id);
|
||
return {
|
||
content: [{ type: "text", text: `Created retry run ${retryRun.id} from ${params.id}.` }],
|
||
details: toResearchRunDetails(retryRun),
|
||
};
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_experiment_finalize",
|
||
label: "fn: Finalize Experiment Session",
|
||
description: "Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git.",
|
||
parameters: Type.Object({
|
||
sessionId: Type.String({ description: "Experiment session ID" }),
|
||
integrationBranch: Type.Optional(Type.String({ description: "Integration branch to compute merge-base against (default: main)" })),
|
||
dryRun: Type.Optional(Type.Boolean({ description: "Preview plan only; do not create branches" })),
|
||
planOverride: Type.Optional(Type.Any({ description: "Optional plan override payload" })),
|
||
summary: Type.Optional(Type.String({ description: "Optional finalize summary" })),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
try {
|
||
const store = await getStore(ctx.cwd);
|
||
const sessionStore = store.getExperimentSessionStore();
|
||
const service = new ExperimentFinalizeService({
|
||
store: sessionStore,
|
||
git: defaultGitOps(resolveProjectRoot(ctx.cwd)),
|
||
});
|
||
|
||
if (params.dryRun) {
|
||
const plan = await service.previewPlan({
|
||
sessionId: params.sessionId,
|
||
integrationBranch: params.integrationBranch,
|
||
});
|
||
return {
|
||
content: [{ type: "text", text: `Finalize plan for ${plan.sessionId}: ${plan.groups.length} group(s), merge-base ${plan.mergeBaseCommit}.` }],
|
||
details: { plan },
|
||
};
|
||
}
|
||
|
||
const result = await service.finalize({
|
||
sessionId: params.sessionId,
|
||
integrationBranch: params.integrationBranch,
|
||
planOverride: params.planOverride as FinalizePlanOverride | undefined,
|
||
summary: params.summary,
|
||
});
|
||
return {
|
||
content: [{ type: "text", text: `Finalized ${result.sessionId}. Created ${result.branches.length} branch(es).` }],
|
||
details: { result },
|
||
};
|
||
} catch (error) {
|
||
if (
|
||
error instanceof ExperimentFinalizeStateError
|
||
|| error instanceof ExperimentFinalizeNoKeptRunsError
|
||
|| error instanceof ExperimentFinalizePlanError
|
||
|| error instanceof ExperimentFinalizeMergeBaseError
|
||
|| error instanceof ExperimentFinalizeBranchExistsError
|
||
|| error instanceof ExperimentFinalizeCherryPickConflictError
|
||
) {
|
||
return {
|
||
content: [{ type: "text", text: error.message }],
|
||
isError: true,
|
||
details: {
|
||
code: error.code,
|
||
...(error instanceof ExperimentFinalizeCherryPickConflictError
|
||
? { groupId: error.groupId, commit: error.commit, stderr: error.stderr }
|
||
: {}),
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
content: [{ type: "text", text: error instanceof Error ? error.message : "Unknown experiment finalize error" }],
|
||
isError: true,
|
||
details: { code: "INTERNAL_ERROR" },
|
||
};
|
||
}
|
||
},
|
||
});
|
||
|
||
// ── Insights Tools ──────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_insight_list",
|
||
label: "fn: List Insights",
|
||
description: "List persisted project insights with optional category/status filters.",
|
||
promptSnippet: "List persisted project insights",
|
||
parameters: Type.Object({
|
||
category: Type.Optional(
|
||
StringEnum([...INSIGHT_CATEGORIES], {
|
||
description: "Filter by insight category",
|
||
}) as unknown as TSchema,
|
||
),
|
||
status: Type.Optional(
|
||
StringEnum([...INSIGHT_STATUSES], {
|
||
description: "Filter by insight status",
|
||
}) as unknown as TSchema,
|
||
),
|
||
runId: Type.Optional(Type.String({ description: "Filter to insights linked to a specific run ID" })),
|
||
limit: Type.Optional(Type.Number({ description: "Max insights to return" })),
|
||
offset: Type.Optional(Type.Number({ description: "Number of rows to skip" })),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1)) {
|
||
return {
|
||
content: [{ type: "text", text: "Invalid limit. Provide an integer >= 1." }],
|
||
isError: true,
|
||
details: { error: "Invalid limit" },
|
||
};
|
||
}
|
||
if (params.offset !== undefined && (!Number.isInteger(params.offset) || params.offset < 0)) {
|
||
return {
|
||
content: [{ type: "text", text: "Invalid offset. Provide an integer >= 0." }],
|
||
isError: true,
|
||
details: { error: "Invalid offset" },
|
||
};
|
||
}
|
||
|
||
const store = await getStore(ctx.cwd);
|
||
const insightStore = store.getInsightStore();
|
||
const category = params.category as InsightCategory | undefined;
|
||
const status = params.status as InsightStatus | undefined;
|
||
const options = {
|
||
category,
|
||
status,
|
||
runId: params.runId,
|
||
limit: params.limit,
|
||
offset: params.offset,
|
||
};
|
||
const insights = insightStore.listInsights(options);
|
||
const count = insightStore.countInsights({
|
||
category,
|
||
status,
|
||
runId: params.runId,
|
||
});
|
||
|
||
if (insights.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: "No insights found for the provided filters." }],
|
||
details: { count, insights: [] },
|
||
};
|
||
}
|
||
|
||
const lines = [`Insights (${insights.length}/${count} shown):`];
|
||
for (const insight of insights) {
|
||
const title = insight.title.length > 80 ? `${insight.title.slice(0, 80)}…` : insight.title;
|
||
lines.push(` ${insight.id} [${insight.category}] [${insight.status}] ${title}`);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: { count, insights },
|
||
};
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_insight_show",
|
||
label: "fn: Show Insight",
|
||
description: "Show a single persisted insight by ID.",
|
||
promptSnippet: "Show full details for a persisted insight",
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Insight ID (e.g. INS-XXXXX)" }),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const insightStore = store.getInsightStore();
|
||
const insight = insightStore.getInsight(params.id);
|
||
|
||
if (!insight) {
|
||
return {
|
||
content: [{ type: "text", text: `Insight ${params.id} not found.` }],
|
||
isError: true,
|
||
details: { error: "Insight not found", id: params.id },
|
||
};
|
||
}
|
||
|
||
const lines = [
|
||
`${insight.id}: ${insight.title}`,
|
||
`Category: ${insight.category}`,
|
||
`Status: ${insight.status}`,
|
||
`Last run: ${insight.lastRunId ?? "none"}`,
|
||
`Created: ${insight.createdAt}`,
|
||
`Updated: ${insight.updatedAt}`,
|
||
];
|
||
|
||
if (insight.content) {
|
||
lines.push("", "Content:", insight.content.length > 500 ? `${insight.content.slice(0, 500)}\n... (truncated)` : insight.content);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: { insight },
|
||
};
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_insight_run_list",
|
||
label: "fn: List Insight Runs",
|
||
description: "List recent insight-generation runs with optional status/trigger filters.",
|
||
promptSnippet: "List recent insight-generation runs",
|
||
parameters: Type.Object({
|
||
status: Type.Optional(
|
||
StringEnum([...INSIGHT_RUN_STATUSES], {
|
||
description: "Filter by run status",
|
||
}) as unknown as TSchema,
|
||
),
|
||
trigger: Type.Optional(
|
||
StringEnum([...INSIGHT_RUN_TRIGGERS], {
|
||
description: "Filter by run trigger",
|
||
}) as unknown as TSchema,
|
||
),
|
||
limit: Type.Optional(Type.Number({ description: "Max runs to return" })),
|
||
offset: Type.Optional(Type.Number({ description: "Number of runs to skip" })),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1)) {
|
||
return {
|
||
content: [{ type: "text", text: "Invalid limit. Provide an integer >= 1." }],
|
||
isError: true,
|
||
details: { error: "Invalid limit" },
|
||
};
|
||
}
|
||
if (params.offset !== undefined && (!Number.isInteger(params.offset) || params.offset < 0)) {
|
||
return {
|
||
content: [{ type: "text", text: "Invalid offset. Provide an integer >= 0." }],
|
||
isError: true,
|
||
details: { error: "Invalid offset" },
|
||
};
|
||
}
|
||
|
||
const store = await getStore(ctx.cwd);
|
||
const insightStore = store.getInsightStore();
|
||
const status = params.status as InsightRunStatus | undefined;
|
||
const trigger = params.trigger as InsightRunTrigger | undefined;
|
||
const options = {
|
||
status,
|
||
trigger,
|
||
limit: params.limit,
|
||
offset: params.offset,
|
||
};
|
||
const runs = insightStore.listRuns(options);
|
||
const count = insightStore.countRuns({ status, trigger });
|
||
|
||
if (runs.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: "No insight runs found for the provided filters." }],
|
||
details: { count, runs: [] },
|
||
};
|
||
}
|
||
|
||
const lines = [`Insight runs (${runs.length}/${count} shown):`];
|
||
for (const run of runs) {
|
||
lines.push(
|
||
` ${run.id} [${run.status}] [${run.trigger}] created=${run.insightsCreated} updated=${run.insightsUpdated}`,
|
||
);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: { count, runs },
|
||
};
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "fn_insight_run_show",
|
||
label: "fn: Show Insight Run",
|
||
description: "Show a single insight-generation run by ID.",
|
||
promptSnippet: "Show full details for an insight-generation run",
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Insight run ID (e.g. INSR-XXXXX)" }),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const insightStore = store.getInsightStore();
|
||
const run = insightStore.getRun(params.id);
|
||
|
||
if (!run) {
|
||
return {
|
||
content: [{ type: "text", text: `Insight run ${params.id} not found.` }],
|
||
isError: true,
|
||
details: { error: "Insight run not found", id: params.id },
|
||
};
|
||
}
|
||
|
||
const lines = [
|
||
`${run.id}`,
|
||
`Trigger: ${run.trigger}`,
|
||
`Status: ${run.status}`,
|
||
`Insights: created ${run.insightsCreated}, updated ${run.insightsUpdated}`,
|
||
`Created: ${run.createdAt}`,
|
||
`Started: ${run.startedAt ?? "not started"}`,
|
||
`Completed: ${run.completedAt ?? "not completed"}`,
|
||
];
|
||
if (run.summary) lines.push(`Summary: ${run.summary}`);
|
||
if (run.error) lines.push(`Error: ${run.error}`);
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: { run },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── Mission Tools ───────────────────────────────────────────────
|
||
// Mission hierarchy management for multi-phase project planning
|
||
|
||
// ── fn_mission_create ───────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_mission_create",
|
||
label: "fn: Create Mission",
|
||
description:
|
||
"Create a new mission — a high-level objective that can span multiple milestones. " +
|
||
"Missions contain milestones that break down work into phases.",
|
||
promptSnippet: "Create a new mission for high-level project planning",
|
||
promptGuidelines: [
|
||
"Use for high-level project objectives that span multiple work phases",
|
||
"Missions are broken down into milestones → slices → features → tasks",
|
||
"Be descriptive so the mission purpose is clear",
|
||
],
|
||
parameters: Type.Object({
|
||
title: Type.String({ description: "Mission title — brief but descriptive" }),
|
||
description: Type.Optional(
|
||
Type.String({ description: "Detailed mission objectives and context" })
|
||
),
|
||
autoAdvance: Type.Optional(
|
||
Type.Boolean({ description: "Automatically activate the next pending slice when the current slice completes" })
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const mission = missionStore.createMission({
|
||
title: params.title.trim(),
|
||
description: params.description?.trim(),
|
||
});
|
||
|
||
if (params.autoAdvance !== undefined) {
|
||
missionStore.updateMission(mission.id, { autoAdvance: params.autoAdvance });
|
||
}
|
||
|
||
const createdMission = missionStore.getMission(mission.id)!;
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Created ${createdMission.id}: ${createdMission.title}\nStatus: ${createdMission.status}${createdMission.autoAdvance ? "\nAuto-advance: enabled" : ""}`,
|
||
},
|
||
],
|
||
details: {
|
||
missionId: createdMission.id,
|
||
title: createdMission.title,
|
||
status: createdMission.status,
|
||
autoAdvance: createdMission.autoAdvance ?? false,
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_mission_list ──────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_mission_list",
|
||
label: "fn: List Missions",
|
||
description: "List all missions with their current status.",
|
||
promptSnippet: "List all missions",
|
||
promptGuidelines: [
|
||
"Use to see all missions and their current status",
|
||
"Missions are grouped by status (active, planning, complete, etc.)",
|
||
"Drafts represent unfinished mission interview sessions; fn_mission_show does not work on draft IDs because no mission row exists yet",
|
||
"Use before fn_mission_show to find a specific mission ID",
|
||
],
|
||
parameters: Type.Object({
|
||
includeDrafts: Type.Optional(Type.Boolean({ description: "Include in-flight mission interview drafts (default: true)" })),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
const includeDrafts = params.includeDrafts ?? true;
|
||
|
||
const missions = missionStore.listMissions();
|
||
const drafts = includeDrafts
|
||
? (store.getDatabase()
|
||
.prepare(
|
||
`SELECT id, title, status, updatedAt
|
||
FROM ai_sessions
|
||
WHERE type = 'mission_interview'
|
||
AND status IN ('generating', 'awaiting_input', 'error')
|
||
AND COALESCE(archived, 0) = 0
|
||
ORDER BY updatedAt DESC`,
|
||
)
|
||
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
|
||
: [];
|
||
|
||
if (missions.length === 0 && drafts.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: "No missions yet." }],
|
||
details: { count: 0, drafts: [] },
|
||
};
|
||
}
|
||
|
||
const summary = {
|
||
planning: missions.filter((mission) => mission.status === "planning").length,
|
||
active: missions.filter((mission) => mission.status === "active").length,
|
||
blocked: missions.filter((mission) => mission.status === "blocked").length,
|
||
complete: missions.filter((mission) => mission.status === "complete").length,
|
||
archived: missions.filter((mission) => mission.status === "archived").length,
|
||
};
|
||
|
||
const lines: string[] = [];
|
||
lines.push(`Missions (${missions.length})`);
|
||
lines.push(
|
||
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}`,
|
||
);
|
||
lines.push("");
|
||
|
||
if (drafts.length > 0) {
|
||
lines.push(`Drafts (${drafts.length})`);
|
||
for (const draft of drafts) {
|
||
lines.push(` ◌ ${draft.id}: ${draft.title} (draft · interview ${draft.status})`);
|
||
}
|
||
lines.push("");
|
||
}
|
||
|
||
for (const mission of missions) {
|
||
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : mission.status === "blocked" ? "⚠" : "○";
|
||
const autoAdvance = mission.autoAdvance ? " · auto-advance" : "";
|
||
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status}${autoAdvance})`);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: {
|
||
count: missions.length,
|
||
missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })),
|
||
drafts: drafts.map((draft) => ({ id: draft.id, title: draft.title, status: draft.status, updatedAt: draft.updatedAt })),
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_mission_show ──────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_mission_show",
|
||
label: "fn: Show Mission",
|
||
description: "Show mission details with full hierarchy: milestones → slices → features.",
|
||
promptSnippet: "Show mission details with hierarchy",
|
||
promptGuidelines: [
|
||
"Use to see the full mission structure before planning work",
|
||
"Shows milestones, slices, and features in hierarchical order",
|
||
"Check slice status to see if features can be linked to tasks",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Mission ID (e.g., M-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const mission = missionStore.getMissionWithHierarchy(params.id);
|
||
if (!mission) {
|
||
return {
|
||
content: [{ type: "text", text: `Mission ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Mission not found" },
|
||
};
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
lines.push(`${mission.id}: ${mission.title}`);
|
||
lines.push(`Status: ${mission.status}`);
|
||
if (mission.description) {
|
||
lines.push(`Description: ${mission.description}`);
|
||
}
|
||
lines.push("");
|
||
|
||
if (mission.milestones.length === 0) {
|
||
lines.push("No milestones yet.");
|
||
} else {
|
||
lines.push("Milestones:");
|
||
for (const milestone of mission.milestones) {
|
||
const mIcon = milestone.status === "complete" ? "✓" : milestone.status === "active" ? "●" : "○";
|
||
lines.push(` ${mIcon} ${milestone.id}: ${milestone.title} (${milestone.status})`);
|
||
|
||
for (const slice of milestone.slices) {
|
||
const sIcon = slice.status === "complete" ? "✓" : slice.status === "active" ? "●" : "○";
|
||
lines.push(` ${sIcon} ${slice.id}: ${slice.title} (${slice.status})`);
|
||
|
||
for (const feature of slice.features) {
|
||
const fIcon = feature.status === "done" ? "✓" : feature.status === "in-progress" ? "▸" : feature.status === "triaged" ? "●" : "○";
|
||
const taskLink = feature.taskId ? ` → ${feature.taskId}` : "";
|
||
lines.push(` ${fIcon} ${feature.id}: ${feature.title} (${feature.status})${taskLink}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: { mission },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_mission_delete ───────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_mission_delete",
|
||
label: "fn: Delete Mission",
|
||
description: "Delete a mission and all its milestones, slices, and features. Cannot be undone.",
|
||
promptSnippet: "Delete a mission and all its contents",
|
||
promptGuidelines: [
|
||
"Use for cleaning up test missions or mistakenly created missions",
|
||
"Permanently deletes all milestones, slices, and features within the mission",
|
||
"Tasks linked to features are NOT deleted — only the feature links are removed",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Mission ID to delete (e.g., M-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const mission = missionStore.getMission(params.id);
|
||
if (!mission) {
|
||
return {
|
||
content: [{ type: "text", text: `Mission ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Mission not found" },
|
||
};
|
||
}
|
||
|
||
missionStore.deleteMission(params.id);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Deleted ${params.id}: "${mission.title}"` }],
|
||
details: { missionId: params.id, title: mission.title },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_milestone_add ────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_milestone_add",
|
||
label: "fn: Add Milestone",
|
||
description: "Add a milestone to a mission. Milestones represent phases of work.",
|
||
promptSnippet: "Add a milestone to a mission",
|
||
promptGuidelines: [
|
||
"Use to break down a mission into manageable phases",
|
||
"Milestones are ordered and contain slices (work units)",
|
||
],
|
||
parameters: Type.Object({
|
||
missionId: Type.String({ description: "Parent mission ID (e.g., M-001)" }),
|
||
title: Type.String({ description: "Milestone title" }),
|
||
description: Type.Optional(Type.String({ description: "Milestone description" })),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const mission = missionStore.getMission(params.missionId);
|
||
if (!mission) {
|
||
return {
|
||
content: [{ type: "text", text: `Mission ${params.missionId} not found` }],
|
||
isError: true,
|
||
details: { error: "Mission not found" },
|
||
};
|
||
}
|
||
|
||
const milestone = missionStore.addMilestone(params.missionId, {
|
||
title: params.title.trim(),
|
||
description: params.description?.trim(),
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{ type: "text", text: `Added ${milestone.id}: "${milestone.title}" to ${params.missionId}` },
|
||
],
|
||
details: { milestoneId: milestone.id, missionId: params.missionId, title: milestone.title },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_slice_add ─────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_slice_add",
|
||
label: "fn: Add Slice",
|
||
description: "Add a slice to a milestone. Slices are work units that can be activated for implementation.",
|
||
promptSnippet: "Add a work slice to a milestone",
|
||
promptGuidelines: [
|
||
"Slices represent work units within a milestone",
|
||
"Slices are activated for implementation, linking features to tasks",
|
||
"Order slices by priority — they execute in sequence",
|
||
],
|
||
parameters: Type.Object({
|
||
milestoneId: Type.String({ description: "Parent milestone ID (e.g., MS-001)" }),
|
||
title: Type.String({ description: "Slice title" }),
|
||
description: Type.Optional(Type.String({ description: "Slice description" })),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const milestone = missionStore.getMilestone(params.milestoneId);
|
||
if (!milestone) {
|
||
return {
|
||
content: [{ type: "text", text: `Milestone ${params.milestoneId} not found` }],
|
||
isError: true,
|
||
details: { error: "Milestone not found" },
|
||
};
|
||
}
|
||
|
||
const slice = missionStore.addSlice(params.milestoneId, {
|
||
title: params.title.trim(),
|
||
description: params.description?.trim(),
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{ type: "text", text: `Added ${slice.id}: "${slice.title}" to ${params.milestoneId}` },
|
||
],
|
||
details: { sliceId: slice.id, milestoneId: params.milestoneId, title: slice.title },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_feature_add ────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_feature_add",
|
||
label: "fn: Add Feature",
|
||
description: "Add a feature to a slice. Features are deliverables that can be linked to tasks.",
|
||
promptSnippet: "Add a feature to a slice",
|
||
promptGuidelines: [
|
||
"Features represent deliverables within a slice",
|
||
"Features start as 'defined' and progress through 'triaged' → 'in-progress' → 'done'",
|
||
"Link features to tasks using fn_feature_link_task",
|
||
],
|
||
parameters: Type.Object({
|
||
sliceId: Type.String({ description: "Parent slice ID (e.g., SL-001)" }),
|
||
title: Type.String({ description: "Feature title" }),
|
||
description: Type.Optional(Type.String({ description: "Feature description" })),
|
||
acceptanceCriteria: Type.Optional(
|
||
Type.String({ description: "Acceptance criteria for completing the feature" })
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const slice = missionStore.getSlice(params.sliceId);
|
||
if (!slice) {
|
||
return {
|
||
content: [{ type: "text", text: `Slice ${params.sliceId} not found` }],
|
||
isError: true,
|
||
details: { error: "Slice not found" },
|
||
};
|
||
}
|
||
|
||
const feature = missionStore.addFeature(params.sliceId, {
|
||
title: params.title.trim(),
|
||
description: params.description?.trim(),
|
||
acceptanceCriteria: params.acceptanceCriteria?.trim(),
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{ type: "text", text: `Added ${feature.id}: "${feature.title}" to ${params.sliceId}` },
|
||
],
|
||
details: { featureId: feature.id, sliceId: params.sliceId, title: feature.title },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_slice_activate ────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_slice_activate",
|
||
label: "fn: Activate Slice",
|
||
description:
|
||
"Activate a pending slice for implementation. " +
|
||
"Sets status to 'active' and enables task linking for its features.",
|
||
promptSnippet: "Activate a slice for implementation",
|
||
promptGuidelines: [
|
||
"Activating a slice allows its features to be linked to tasks",
|
||
"Only pending slices can be activated",
|
||
"Slice activation triggers auto-advance when linked tasks complete",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Slice ID to activate (e.g., SL-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const slice = missionStore.getSlice(params.id);
|
||
if (!slice) {
|
||
return {
|
||
content: [{ type: "text", text: `Slice ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Slice not found" },
|
||
};
|
||
}
|
||
|
||
if (slice.status !== "pending") {
|
||
return {
|
||
content: [{ type: "text", text: `Slice ${params.id} is not pending (status: ${slice.status})` }],
|
||
isError: true,
|
||
details: { error: "Slice not pending", currentStatus: slice.status },
|
||
};
|
||
}
|
||
|
||
const activated = await missionStore.activateSlice(params.id);
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Activated ${activated.id}: "${activated.title}"\nStatus: ${activated.status}`,
|
||
},
|
||
],
|
||
details: { sliceId: activated.id, title: activated.title, status: activated.status },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_feature_link_task ──────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_feature_link_task",
|
||
label: "fn: Link Feature to Task",
|
||
description:
|
||
"Link a feature to a fn task for implementation. " +
|
||
"Updates the feature status to 'triaged' and associates it with the task.",
|
||
promptSnippet: "Link a feature to a task",
|
||
promptGuidelines: [
|
||
"Use when a feature is ready for implementation and has a corresponding task",
|
||
"The feature's slice must be active to link tasks",
|
||
"Linking updates the feature status to 'triaged'",
|
||
"When the linked task moves to 'done', the feature status becomes 'done'",
|
||
],
|
||
parameters: Type.Object({
|
||
featureId: Type.String({ description: "Feature ID to link (e.g., F-001)" }),
|
||
taskId: Type.String({ description: "Task ID to link to (e.g., FN-001)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const feature = missionStore.getFeature(params.featureId);
|
||
if (!feature) {
|
||
return {
|
||
content: [{ type: "text", text: `Feature ${params.featureId} not found` }],
|
||
isError: true,
|
||
details: { error: "Feature not found" },
|
||
};
|
||
}
|
||
|
||
// Check if task exists
|
||
try {
|
||
await store.getTask(params.taskId);
|
||
} catch {
|
||
return {
|
||
content: [{ type: "text", text: `Task ${params.taskId} not found` }],
|
||
isError: true,
|
||
details: { error: "Task not found" },
|
||
};
|
||
}
|
||
|
||
const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId);
|
||
await store.updateTask(params.taskId, { sliceId: feature.sliceId });
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Linked ${updated.id}: "${updated.title}" → ${params.taskId}\nStatus: ${updated.status}`,
|
||
},
|
||
],
|
||
details: { featureId: updated.id, taskId: params.taskId, title: updated.title, status: updated.status },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_feature_update ─────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_feature_update",
|
||
label: "fn: Update Feature",
|
||
description:
|
||
"Update an existing feature's title, description, or acceptance criteria. " +
|
||
"Partial patches leave untouched fields intact.",
|
||
promptSnippet: "Update an existing mission feature",
|
||
promptGuidelines: [
|
||
"Use to revise acceptance criteria after reconciliation without re-creating the feature",
|
||
"Slice ordering and linked tasks are preserved",
|
||
"Provide only the fields you want to change",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Feature ID to update (e.g., F-001)" }),
|
||
title: Type.Optional(Type.String({ description: "Updated feature title" })),
|
||
description: Type.Optional(Type.String({ description: "Updated feature description" })),
|
||
acceptanceCriteria: Type.Optional(
|
||
Type.String({ description: "Updated acceptance criteria for completing the feature" })
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const store = await getStore(ctx.cwd);
|
||
const missionStore = store.getMissionStore();
|
||
|
||
const existingFeature = missionStore.getFeature(params.id);
|
||
if (!existingFeature) {
|
||
return {
|
||
content: [{ type: "text", text: `Feature ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Feature not found" },
|
||
};
|
||
}
|
||
|
||
const updates: { title?: string; description?: string; acceptanceCriteria?: string } = {};
|
||
|
||
if ("title" in params) {
|
||
updates.title = params.title?.trim();
|
||
}
|
||
if ("description" in params) {
|
||
updates.description = params.description?.trim();
|
||
}
|
||
if ("acceptanceCriteria" in params) {
|
||
updates.acceptanceCriteria = params.acceptanceCriteria?.trim();
|
||
}
|
||
|
||
if (Object.keys(updates).length === 0) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: "No fields to update (provide at least one of: title, description, acceptanceCriteria)",
|
||
},
|
||
],
|
||
isError: true,
|
||
details: { error: "No fields to update" },
|
||
};
|
||
}
|
||
|
||
const feature = missionStore.updateFeature(params.id, updates);
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Updated ${feature.id}: "${feature.title}"` }],
|
||
details: {
|
||
featureId: feature.id,
|
||
sliceId: feature.sliceId,
|
||
title: feature.title,
|
||
description: feature.description,
|
||
acceptanceCriteria: feature.acceptanceCriteria,
|
||
status: feature.status,
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_agent_stop ─────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_agent_stop",
|
||
label: "fn: Stop Agent",
|
||
description:
|
||
"Stop a running agent — pauses its execution. " +
|
||
"Transitions the agent from running/active to paused state.",
|
||
promptSnippet: "Stop (pause) a running Fusion agent",
|
||
promptGuidelines: [
|
||
"Use to pause an agent that is currently running or active",
|
||
"Stopped agents can be resumed with fn_agent_start",
|
||
"Agents in 'idle', 'error', or already-paused state cannot be stopped",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Agent ID to stop (e.g., agent-abc123)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
|
||
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
|
||
const agent = await agentStore.getAgent(params.id);
|
||
if (!agent) {
|
||
return {
|
||
content: [{ type: "text", text: `Agent ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Agent not found" },
|
||
};
|
||
}
|
||
|
||
if (agent.state === "paused") {
|
||
return {
|
||
content: [{ type: "text", text: `Agent ${params.id} is already paused` }],
|
||
details: { agentId: params.id, state: agent.state },
|
||
};
|
||
}
|
||
|
||
const validTargets = AGENT_VALID_TRANSITIONS[agent.state];
|
||
if (!validTargets.includes("paused")) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Cannot stop agent ${params.id} — current state '${agent.state}' cannot transition to 'paused'. Valid transitions: ${validTargets.join(", ")}`,
|
||
},
|
||
],
|
||
isError: true,
|
||
details: { agentId: params.id, currentState: agent.state, validTargets },
|
||
};
|
||
}
|
||
|
||
await agentStore.updateAgentState(params.id, "paused");
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Stopped ${params.id}` }],
|
||
details: { agentId: params.id, previousState: agent.state, newState: "paused" },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_agent_start ────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_agent_start",
|
||
label: "fn: Start Agent",
|
||
description:
|
||
"Start a stopped agent — resumes its execution. " +
|
||
"Transitions the agent from paused to active state.",
|
||
promptSnippet: "Start (resume) a stopped Fusion agent",
|
||
promptGuidelines: [
|
||
"Use to resume an agent that has been paused",
|
||
"Only agents in 'paused' state can be started",
|
||
"Agents in 'idle' or 'error' state cannot be started — use reset instead",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Agent ID to start (e.g., agent-abc123)" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
|
||
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
|
||
const agent = await agentStore.getAgent(params.id);
|
||
if (!agent) {
|
||
return {
|
||
content: [{ type: "text", text: `Agent ${params.id} not found` }],
|
||
isError: true,
|
||
details: { error: "Agent not found" },
|
||
};
|
||
}
|
||
|
||
if (agent.state === "active" || agent.state === "running") {
|
||
return {
|
||
content: [{ type: "text", text: `Agent ${params.id} is already running (${agent.state})` }],
|
||
details: { agentId: params.id, state: agent.state },
|
||
};
|
||
}
|
||
|
||
const validTargets = AGENT_VALID_TRANSITIONS[agent.state];
|
||
if (!validTargets.includes("active")) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Cannot start agent ${params.id} — current state '${agent.state}' cannot transition to 'active'. Valid transitions: ${validTargets.join(", ")}`,
|
||
},
|
||
],
|
||
isError: true,
|
||
details: { agentId: params.id, currentState: agent.state, validTargets },
|
||
};
|
||
}
|
||
|
||
await agentStore.updateAgentState(params.id, "active");
|
||
|
||
return {
|
||
content: [{ type: "text", text: `Started ${params.id}` }],
|
||
details: { agentId: params.id, previousState: agent.state, newState: "active" },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_agent_create ─────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_agent_create",
|
||
label: "fn: Create Agent",
|
||
description: "Create a new non-ephemeral agent.",
|
||
parameters: Type.Object({
|
||
name: Type.String({ description: "Agent name" }),
|
||
role: Type.Union([
|
||
Type.Literal("triage"),
|
||
Type.Literal("executor"),
|
||
Type.Literal("reviewer"),
|
||
Type.Literal("merger"),
|
||
Type.Literal("engineer"),
|
||
Type.Literal("custom"),
|
||
], { description: "Agent role/capability" }),
|
||
soul: Type.Optional(Type.String({ description: "Agent personality/identity text" })),
|
||
instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions" })),
|
||
instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown" })),
|
||
reportsTo: Type.Optional(Type.String({ description: "Manager agent ID" })),
|
||
heartbeat_interval_ms: Type.Optional(Type.Number({ minimum: 1000 })),
|
||
heartbeat_timeout_ms: Type.Optional(Type.Number({ minimum: 5000 })),
|
||
max_concurrent_runs: Type.Optional(Type.Number({ minimum: 1 })),
|
||
message_response_mode: Type.Optional(Type.Union([Type.Literal("immediate"), Type.Literal("on-heartbeat")])),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const { AgentStore, ApprovalRequestStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
const store = await getStore(ctx.cwd);
|
||
const caller = { id: "user", role: "user", isPrivileged: true } as const;
|
||
const policy = resolveAgentProvisioningPolicy({
|
||
tool: "fn_agent_create",
|
||
caller,
|
||
settings: await store.getSettings(),
|
||
});
|
||
|
||
if (!caller.isPrivileged && params.reportsTo !== undefined && params.reportsTo !== caller.id) {
|
||
return {
|
||
content: [{ type: "text" as const, text: "ERROR: You can only create agents that report to you" }],
|
||
details: { outcome: "denied", matchedRule: "privileged-caller", effectiveMode: policy.effectiveMode },
|
||
};
|
||
}
|
||
|
||
if (policy.decision === "require-approval") {
|
||
const approvalStore = new ApprovalRequestStore(store.getDatabase());
|
||
const request = approvalStore.create({
|
||
requester: { actorId: "user", actorType: "user", actorName: "CLI User" },
|
||
targetAction: { category: "agent_provisioning", action: "create", summary: `Create agent ${params.name} (${params.role})`, resourceType: "agent", resourceId: "", context: { tool: "fn_agent_create", params } },
|
||
});
|
||
return { content: [{ type: "text" as const, text: `Approval required. Request ${request.id} created.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode } };
|
||
}
|
||
|
||
const runtimeConfig: Record<string, unknown> = {
|
||
...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
|
||
...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}),
|
||
...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}),
|
||
...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}),
|
||
};
|
||
const created = await agentStore.createAgent({
|
||
name: params.name,
|
||
role: params.role as never,
|
||
...(params.soul !== undefined ? { soul: params.soul } : {}),
|
||
...(params.instructions_text !== undefined ? { instructionsText: params.instructions_text } : {}),
|
||
...(params.instructions_path !== undefined ? { instructionsPath: params.instructions_path } : {}),
|
||
...(params.reportsTo !== undefined ? { reportsTo: params.reportsTo } : {}),
|
||
...(Object.keys(runtimeConfig).length > 0 ? { runtimeConfig } : {}),
|
||
});
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: `Created agent ${created.name} (${created.id})` }],
|
||
details: { outcome: "created", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agent: created, agentId: created.id },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_agent_delete ─────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_agent_delete",
|
||
label: "fn: Delete Agent",
|
||
description: "Delete a non-ephemeral agent.",
|
||
parameters: Type.Object({
|
||
agent_id: Type.String({ description: "Agent ID to delete" }),
|
||
force: Type.Optional(Type.Boolean({ description: "Force delete when holding checkout" })),
|
||
reassign_to: Type.Optional(Type.String({ description: "Optional replacement agent for assigned tasks" })),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const { AgentStore, ApprovalRequestStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
const store = await getStore(ctx.cwd);
|
||
const caller = { id: "user", role: "user", isPrivileged: true } as const;
|
||
const policy = resolveAgentProvisioningPolicy({
|
||
tool: "fn_agent_delete",
|
||
caller,
|
||
settings: await store.getSettings(),
|
||
});
|
||
|
||
if (policy.decision === "require-approval") {
|
||
const approvalStore = new ApprovalRequestStore(store.getDatabase());
|
||
const request = approvalStore.create({
|
||
requester: { actorId: "user", actorType: "user", actorName: "CLI User" },
|
||
targetAction: { category: "agent_provisioning", action: "delete", summary: `Delete agent ${params.agent_id}`, resourceType: "agent", resourceId: params.agent_id, context: { tool: "fn_agent_delete", params } },
|
||
});
|
||
return { content: [{ type: "text" as const, text: `Approval required. Request ${request.id} created.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id } };
|
||
}
|
||
|
||
if (policy.decision === "deny") {
|
||
return {
|
||
content: [{ type: "text" as const, text: `DENIED: agent delete blocked by policy (${policy.matchedRule})` }],
|
||
details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id },
|
||
};
|
||
}
|
||
|
||
await agentStore.deleteAgent(params.agent_id, { force: params.force === true, reassignTo: params.reassign_to });
|
||
return {
|
||
content: [{ type: "text" as const, text: `Deleted ${params.agent_id}` }],
|
||
details: { outcome: "deleted", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_list_agents ───────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_list_agents",
|
||
label: "fn: List Agents",
|
||
description:
|
||
"List all available agents in the system. Shows each agent's name, role, state, " +
|
||
"personality (soul), and current assignment. Use this to discover which agents exist " +
|
||
"and what they specialize in before delegating work.",
|
||
promptSnippet: "List all available Fusion agents",
|
||
promptGuidelines: [
|
||
"Use fn_list_agents to discover which agents exist before delegating work",
|
||
"Filter by role or state to narrow results",
|
||
"Ephemeral/runtime agents are excluded by default",
|
||
],
|
||
parameters: Type.Object({
|
||
role: Type.Optional(
|
||
Type.String({ description: "Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa')" }),
|
||
),
|
||
state: Type.Optional(
|
||
Type.String({ description: "Filter by agent state (e.g., 'idle', 'active', 'running')" }),
|
||
),
|
||
includeEphemeral: Type.Optional(
|
||
Type.Boolean({ description: "Include ephemeral/runtime agents (default: false)" }),
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const { AgentStore } = await import("@fusion/core");
|
||
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
|
||
const filter: Record<string, unknown> = {};
|
||
if (params.role) filter.role = params.role;
|
||
if (params.state) filter.state = params.state;
|
||
if (params.includeEphemeral !== undefined) filter.includeEphemeral = params.includeEphemeral;
|
||
|
||
const agents = await agentStore.listAgents(filter as Parameters<typeof agentStore.listAgents>[0]);
|
||
|
||
if (agents.length === 0) {
|
||
return {
|
||
content: [{ type: "text" as const, text: "No agents found matching the specified filters." }],
|
||
details: { agents: [], count: 0 },
|
||
};
|
||
}
|
||
|
||
const lines = agents.map((agent) => {
|
||
const parts: string[] = [
|
||
`ID: ${agent.id}`,
|
||
`Name: ${agent.name}`,
|
||
`Role: ${agent.role}`,
|
||
`State: ${agent.state}`,
|
||
];
|
||
|
||
if (agent.title) parts.push(`Title: ${agent.title}`);
|
||
if (agent.soul) parts.push(`Soul: ${agent.soul.slice(0, 200)}`);
|
||
if (agent.instructionsText) {
|
||
const snippet = agent.instructionsText.slice(0, 100);
|
||
parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`);
|
||
}
|
||
if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`);
|
||
|
||
return parts.join("\n");
|
||
});
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: `Available agents (${agents.length}):\n\n${lines.join("\n\n")}` }],
|
||
details: { agents, count: agents.length },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_delegate_task ──────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_delegate_task",
|
||
label: "fn: Delegate Task",
|
||
description:
|
||
"Create a new task and assign it to a specific agent for execution. The task goes to " +
|
||
"'todo' and will be picked up by the target agent on their next heartbeat cycle. " +
|
||
"Use fn_list_agents first to find available agents and their capabilities.",
|
||
promptSnippet: "Delegate a task to a specific Fusion agent",
|
||
promptGuidelines: [
|
||
"Use fn_list_agents first to find available agents and their capabilities",
|
||
"The task is created in 'todo' and assigned to the target agent",
|
||
"Cannot delegate to ephemeral/runtime agents",
|
||
"Implementation tasks use executor by default; durable engineer supports explicit routing without override, other non-executor roles require override=true",
|
||
"Optionally specify dependencies on other tasks",
|
||
],
|
||
parameters: Type.Object({
|
||
agent_id: Type.String({ description: "The agent ID to delegate work to" }),
|
||
description: Type.String({ description: "What needs to be done" }),
|
||
dependencies: Type.Optional(
|
||
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"]" }),
|
||
),
|
||
override: Type.Optional(
|
||
Type.Boolean({ description: "Set true to bypass executor-role assignment policy" }),
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
// Validate target agent exists and is not ephemeral
|
||
const delegateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "todo" };
|
||
const agentError = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), params.agent_id, delegateTask, params.override === true);
|
||
if (agentError) {
|
||
return {
|
||
content: [{ type: "text", text: `ERROR: ${agentError}` }],
|
||
isError: true,
|
||
details: { error: agentError },
|
||
};
|
||
}
|
||
|
||
const { AgentStore } = await import("@fusion/core");
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
const agent = await agentStore.getAgent(params.agent_id);
|
||
|
||
try {
|
||
// Create task assigned to the target agent
|
||
const store = await getStore(ctx.cwd);
|
||
const task = await store.createTask({
|
||
description: params.description,
|
||
dependencies: params.dependencies,
|
||
column: "todo",
|
||
assignedAgentId: params.agent_id,
|
||
source: {
|
||
sourceType: "api",
|
||
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
|
||
},
|
||
});
|
||
|
||
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` +
|
||
`The task will be picked up by ${agent!.name} on their next heartbeat cycle.`,
|
||
}],
|
||
details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name },
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
|
||
return {
|
||
content: [{ type: "text", text: `ERROR: ${error.message}` }],
|
||
isError: true,
|
||
details: { error: error.message },
|
||
};
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
});
|
||
|
||
// ── fn_agent_show ─────────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_agent_show",
|
||
label: "fn: Show Agent",
|
||
description:
|
||
"Show detailed information about a single agent, including their role, state, " +
|
||
"position in the org hierarchy (reports-to, direct reports), skills, and current assignment.",
|
||
promptSnippet: "Show details of a specific Fusion agent",
|
||
promptGuidelines: [
|
||
"Use to get full details about a specific agent",
|
||
"Provide agent ID or a resolvable name",
|
||
"Shows the agent's position in the org hierarchy",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Agent ID or resolvable name" }),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const { AgentStore } = await import("@fusion/core");
|
||
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
|
||
const agent = await agentStore.resolveAgent(params.id);
|
||
if (!agent) {
|
||
return {
|
||
content: [{ type: "text", text: `Agent '${params.id}' not found` }],
|
||
isError: true,
|
||
details: { error: "Agent not found" },
|
||
};
|
||
}
|
||
|
||
// Get direct reports
|
||
const directReports = await agentStore.getAgentsByReportsTo(agent.id);
|
||
|
||
const parts: string[] = [
|
||
`ID: ${agent.id}`,
|
||
`Name: ${agent.name}`,
|
||
`Role: ${agent.role}`,
|
||
`State: ${agent.state}`,
|
||
];
|
||
|
||
if (agent.title) parts.push(`Title: ${agent.title}`);
|
||
if (agent.icon) parts.push(`Icon: ${agent.icon}`);
|
||
|
||
if (agent.reportsTo) {
|
||
const manager = await agentStore.getAgent(agent.reportsTo);
|
||
if (manager) {
|
||
parts.push(`Reports To: ${manager.name} (${manager.id})`);
|
||
} else {
|
||
parts.push(`Reports To: ${agent.reportsTo}`);
|
||
}
|
||
}
|
||
|
||
if (directReports.length > 0) {
|
||
parts.push(`Direct Reports: ${directReports.map((r) => `${r.name} (${r.id})`).join(", ")}`);
|
||
}
|
||
|
||
if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`);
|
||
|
||
if (agent.instructionsText) {
|
||
const snippet = agent.instructionsText.slice(0, 100);
|
||
parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`);
|
||
}
|
||
|
||
if (agent.soul) {
|
||
const snippet = agent.soul.slice(0, 200);
|
||
parts.push(`Soul: ${snippet}${agent.soul.length > 200 ? "…" : ""}`);
|
||
}
|
||
|
||
if (agent.metadata?.skills) {
|
||
parts.push(`Skills: ${JSON.stringify(agent.metadata.skills)}`);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: parts.join("\n") }],
|
||
details: {
|
||
agent,
|
||
directReports: directReports.map((r) => ({ id: r.id, name: r.name, role: r.role })),
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_agent_org_chart ────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_agent_org_chart",
|
||
label: "fn: Agent Org Chart",
|
||
description:
|
||
"Show the organizational tree of agents, displaying the role hierarchy. " +
|
||
"Optionally filter to a subtree rooted at a specific agent.",
|
||
promptSnippet: "Show the Fusion agent org chart",
|
||
promptGuidelines: [
|
||
"Use to understand the team structure and reporting hierarchy",
|
||
"Optionally specify a root agent to see only their subtree",
|
||
"Ephemeral/runtime agents are excluded by default",
|
||
],
|
||
parameters: Type.Object({
|
||
root_agent_id: Type.Optional(
|
||
Type.String({ description: "If provided, show only the subtree rooted at this agent" }),
|
||
),
|
||
include_ephemeral: Type.Optional(
|
||
Type.Boolean({ description: "Include ephemeral/runtime agents (default: false)" }),
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
const { AgentStore } = await import("@fusion/core");
|
||
type OrgTreeNode = { agent: { id: string; icon?: string; name: string; role: string; state: string; taskId?: string }; children: OrgTreeNode[] };
|
||
|
||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||
await agentStore.init();
|
||
|
||
const includeEphemeral = params.include_ephemeral ?? false;
|
||
|
||
// If root_agent_id specified, show subtree via chain-of-command + reports
|
||
if (params.root_agent_id) {
|
||
const rootAgent = await agentStore.resolveAgent(params.root_agent_id);
|
||
if (!rootAgent) {
|
||
return {
|
||
content: [{ type: "text", text: `Agent '${params.root_agent_id}' not found` }],
|
||
isError: true,
|
||
details: { error: "Root agent not found" },
|
||
};
|
||
}
|
||
|
||
// Get the full tree, then find the subtree
|
||
const fullTree = await agentStore.getOrgTree({ includeEphemeral });
|
||
|
||
// Find the subtree rooted at the specified agent
|
||
const findSubtree = (nodes: OrgTreeNode[]): OrgTreeNode | null => {
|
||
for (const node of nodes) {
|
||
if (node.agent.id === rootAgent.id) return node;
|
||
const found = findSubtree(node.children);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const subtree = findSubtree(fullTree);
|
||
if (!subtree) {
|
||
// Agent exists but has no tree position — show just that agent
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: `${rootAgent.icon ?? "🤖"} ${rootAgent.name} (${rootAgent.role}) — ${rootAgent.state}${rootAgent.taskId ? ` [${rootAgent.taskId}]` : ""}`,
|
||
}],
|
||
details: { tree: [{ agent: rootAgent, children: [] }] },
|
||
};
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
const renderNode = (node: OrgTreeNode, indent: string) => {
|
||
const a = node.agent;
|
||
lines.push(
|
||
`${indent}${a.icon ?? "🤖"} ${a.name} (${a.role}) — ${a.state}${a.taskId ? ` [${a.taskId}]` : ""}`,
|
||
);
|
||
for (const child of node.children) {
|
||
renderNode(child, indent + " ");
|
||
}
|
||
};
|
||
renderNode(subtree, "");
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: `Agent Org Tree (subtree: ${rootAgent.name}):\n${lines.join("\n")}` }],
|
||
details: { tree: [subtree] },
|
||
};
|
||
}
|
||
|
||
// Full tree
|
||
const tree = await agentStore.getOrgTree({ includeEphemeral });
|
||
|
||
if (tree.length === 0) {
|
||
return {
|
||
content: [{ type: "text" as const, text: "No agents found." }],
|
||
details: { tree: [], count: 0 },
|
||
};
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
let count = 0;
|
||
const renderNode = (node: OrgTreeNode, indent: string) => {
|
||
const a = node.agent;
|
||
lines.push(
|
||
`${indent}${a.icon ?? "🤖"} ${a.name} (${a.role}) — ${a.state}${a.taskId ? ` [${a.taskId}]` : ""}`,
|
||
);
|
||
count++;
|
||
for (const child of node.children) {
|
||
renderNode(child, indent + " ");
|
||
}
|
||
};
|
||
for (const root of tree) {
|
||
renderNode(root, "");
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: `Agent Org Tree (${count} agents):\n${lines.join("\n")}` }],
|
||
details: { tree, count },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_skills_search ─────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_skills_search",
|
||
label: "FN: Search Skills",
|
||
description:
|
||
"Search the skills.sh directory for agent skills. Returns matching skills with names, " +
|
||
"sources (owner/repo), install counts, and install commands. " +
|
||
"Use fn_skills_install to install a selected skill.",
|
||
promptSnippet: "Search skills.sh for agent skills",
|
||
promptGuidelines: [
|
||
"Use fn_skills_search to discover skills before installing",
|
||
"Returns skills sorted by popularity (install count)",
|
||
],
|
||
parameters: Type.Object({
|
||
query: Type.String({
|
||
description:
|
||
"Search query — framework name, technology, or capability (e.g., 'react', 'firebase', 'testing', 'docker')",
|
||
}),
|
||
limit: Type.Optional(
|
||
Type.Number({
|
||
description: "Max results to return (default: 10, max: 50)",
|
||
minimum: 1,
|
||
maximum: 50,
|
||
}),
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||
// Dynamic import to match existing extension patterns
|
||
const { searchSkills, formatInstalls } = await import("./commands/skills.js");
|
||
|
||
const skills = await searchSkills(params.query, params.limit ?? 10);
|
||
|
||
if (skills.length === 0) {
|
||
return {
|
||
content: [{ type: "text", text: `No skills found for '${params.query}'` }],
|
||
details: { count: 0, skills: [] },
|
||
};
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
lines.push(`Found ${skills.length} skills matching '${params.query}':\n`);
|
||
|
||
for (let i = 0; i < skills.length; i++) {
|
||
const skill = skills[i]!;
|
||
const installs = formatInstalls(skill.installs);
|
||
lines.push(`${i + 1}. ${skill.name} (${skill.source})${installs ? ` — ${installs}` : ""}`);
|
||
}
|
||
|
||
lines.push("\nInstall a skill with: fn_skills_install({ source: \"<owner/repo>\", skill: \"<name>\" })");
|
||
|
||
return {
|
||
content: [{ type: "text", text: lines.join("\n") }],
|
||
details: {
|
||
count: skills.length,
|
||
skills: skills.map((s) => ({
|
||
name: s.name,
|
||
source: s.source,
|
||
installs: s.installs,
|
||
installCommand: `fn skills install ${s.source} --skill ${s.name}`,
|
||
})),
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── fn_skills_install ─────────────────────────────────────────────
|
||
|
||
pi.registerTool({
|
||
name: "fn_skills_install",
|
||
label: "FN: Install Skill",
|
||
description:
|
||
"Install an agent skill from skills.sh into the current project. " +
|
||
"Downloads skill files into the project's skill directories (.fusion/skills/, legacy .pi/skills/, .agents/skills/). " +
|
||
"The skill becomes available to AI agents in subsequent sessions.",
|
||
promptSnippet: "Install a skill from skills.sh into the current project",
|
||
promptGuidelines: [
|
||
"Use fn_skills_install after fn_skills_search to install a discovered skill",
|
||
"The source is in owner/repo format (e.g., 'firebase/agent-skills')",
|
||
"Specify the skill name to install a specific skill, or omit to install all from the source",
|
||
],
|
||
parameters: Type.Object({
|
||
source: Type.String({
|
||
description: "GitHub source in owner/repo format (e.g., 'firebase/agent-skills')",
|
||
}),
|
||
skill: Type.Optional(
|
||
Type.String({
|
||
description: "Specific skill name to install (e.g., 'firebase-basics'). Omit to install all skills from the source.",
|
||
}),
|
||
),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||
// Validate source format
|
||
if (!/^[^/]+\/[^/]+$/.test(params.source)) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Invalid source format: '${params.source}'. Use owner/repo format (e.g., 'firebase/agent-skills').`,
|
||
},
|
||
],
|
||
isError: true,
|
||
details: { error: "Invalid source format" },
|
||
};
|
||
}
|
||
|
||
// Build npx skills add arguments
|
||
const npxArgs = ["skills", "add", params.source];
|
||
|
||
if (params.skill) {
|
||
npxArgs.push("--skill", params.skill);
|
||
}
|
||
|
||
// Non-interactive mode (-y) targeting pi agent (-a pi)
|
||
npxArgs.push("-y", "-a", "pi");
|
||
|
||
// Execute via spawn
|
||
const child = spawn("npx", npxArgs, {
|
||
cwd: resolveProjectRoot(ctx.cwd),
|
||
stdio: "pipe",
|
||
shell: true,
|
||
});
|
||
|
||
let stderr = "";
|
||
|
||
child.stdout?.on("data", () => {});
|
||
|
||
child.stderr?.on("data", (data) => {
|
||
stderr += data.toString();
|
||
});
|
||
|
||
const exitCode = await new Promise<number>((resolve) => {
|
||
child.on("exit", (code) => {
|
||
resolve(code ?? 1);
|
||
});
|
||
child.on("error", () => {
|
||
resolve(1);
|
||
});
|
||
});
|
||
|
||
try {
|
||
// Always dispose the child process
|
||
child.kill();
|
||
} catch {
|
||
// Ignore errors during cleanup
|
||
}
|
||
|
||
if (exitCode !== 0) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Failed to install skill: ${stderr || "npx skills add exited with code " + exitCode}`,
|
||
},
|
||
],
|
||
isError: true,
|
||
details: { exitCode, stderr },
|
||
};
|
||
}
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Installed skill from ${params.source}. Skills are discovered from .fusion/skills/, legacy .pi/skills/, and .agents/skills/. The skill will be available in future agent sessions.`,
|
||
},
|
||
],
|
||
details: { source: params.source, skill: params.skill ?? "all" },
|
||
};
|
||
},
|
||
});
|
||
|
||
// ── /fn command — start the dashboard + engine ───────────────────
|
||
|
||
let dashboardProcess: ChildProcess | null = null;
|
||
let dashboardPort: number | null = null;
|
||
|
||
pi.registerCommand("fn", {
|
||
description: "Start (or stop) the Fusion dashboard and AI engine",
|
||
handler: async (args, ctx) => {
|
||
const trimmed = (args ?? "").trim();
|
||
|
||
// /fn stop — kill the dashboard
|
||
if (trimmed === "stop") {
|
||
if (dashboardProcess) {
|
||
dashboardProcess.kill("SIGINT");
|
||
dashboardProcess = null;
|
||
dashboardPort = null;
|
||
ctx.ui.setStatus("fn", "");
|
||
ctx.ui.notify("Fusion dashboard stopped", "info");
|
||
} else {
|
||
ctx.ui.notify("Fusion dashboard is not running", "warning");
|
||
}
|
||
return;
|
||
}
|
||
|
||
// /fn status
|
||
if (trimmed === "status") {
|
||
if (dashboardProcess && !dashboardProcess.killed) {
|
||
ctx.ui.notify(`Fusion dashboard running on http://localhost:${dashboardPort}`, "info");
|
||
} else {
|
||
dashboardProcess = null;
|
||
dashboardPort = null;
|
||
ctx.ui.notify("Fusion dashboard is not running", "info");
|
||
}
|
||
return;
|
||
}
|
||
|
||
// /fn [port] — start the dashboard
|
||
if (dashboardProcess && !dashboardProcess.killed) {
|
||
ctx.ui.notify(
|
||
`Fusion dashboard already running on http://localhost:${dashboardPort}. Use /fn stop first.`,
|
||
"warning",
|
||
);
|
||
return;
|
||
}
|
||
|
||
const port = trimmed ? parseInt(trimmed, 10) || 4040 : 4040;
|
||
|
||
// Find the fn binary: prefer local node_modules, then global
|
||
const child = spawn("fn", ["dashboard", "--port", String(port)], {
|
||
cwd: resolveProjectRoot(ctx.cwd),
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
detached: false,
|
||
env: { ...process.env },
|
||
shell: true,
|
||
});
|
||
|
||
dashboardProcess = child;
|
||
dashboardPort = port;
|
||
|
||
// Watch for early exit (e.g. fn not found)
|
||
child.on("error", (err) => {
|
||
dashboardProcess = null;
|
||
dashboardPort = null;
|
||
ctx.ui.setStatus("fn", "");
|
||
ctx.ui.notify(`Failed to start Fusion dashboard: ${err.message}`, "error");
|
||
});
|
||
|
||
child.on("exit", (code) => {
|
||
if (dashboardProcess === child) {
|
||
dashboardProcess = null;
|
||
dashboardPort = null;
|
||
ctx.ui.setStatus("fn", "");
|
||
if (code !== 0 && code !== null) {
|
||
ctx.ui.notify(`Fusion dashboard exited with code ${code}`, "warning");
|
||
}
|
||
}
|
||
});
|
||
|
||
// Wait briefly to see if it crashes immediately
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
|
||
if (dashboardProcess && !dashboardProcess.killed) {
|
||
const url = `http://localhost:${port}`;
|
||
ctx.ui.notify(`Fusion dashboard started on ${url} (AI engine active)`, "info");
|
||
const link = `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\`;
|
||
ctx.ui.setStatus("fn", `Fusion ● ${link}`);
|
||
}
|
||
},
|
||
});
|
||
|
||
// ── Cleanup on session end ───────────────────────────────────────
|
||
|
||
pi.on("session_shutdown", async () => {
|
||
if (dashboardProcess) {
|
||
dashboardProcess.kill("SIGINT");
|
||
dashboardProcess = null;
|
||
dashboardPort = null;
|
||
}
|
||
storeCache.clear();
|
||
});
|
||
}
|