import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type, type TSchema } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; import { TaskStore, COLUMNS, COLUMN_LABELS, buildAutoPauseClearPatch, buildManualRetryResetPatch, validateNodeOverrideChange, type Task, type ColumnId, type InsightCategory, type TaskPriority, type InsightStatus, type InsightRunStatus, type InsightRunTrigger, type ResearchRun, type ResearchRunStatus, RESEARCH_RUN_STATUSES, isResearchExperimentalEnabled, resolveResearchSettings, canAgentTakeImplementationTaskForExplicitRouting, formatRoleMismatchReason, getTaskDuplicateLineage, resolveAgentProvisioningPolicy, TASK_PRIORITIES, resolveSecretAccessPolicy, getProjectRootFromWorktree, resolveTaskGithubTracking, type SecretScope, } 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, assertNoSecretPlaintext, emitGoalRetrievalAudit, } from "@fusion/engine"; import * as dashboard from "@fusion/dashboard"; 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 ──────────────────────────────────────────────────────── /** #1403: display a column's label, falling back to the raw id for * workflow-defined custom columns that have no legacy label. */ function columnLabel(column: ColumnId): string { return (COLUMN_LABELS as Record)[column] ?? column; } const MIME_TYPES: Record = { ".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", }; let warnedMissingProjectRootResolver = false; function resolveProjectRoot(cwd: string): string { const worktreeProjectRoot = typeof getProjectRootFromWorktree === "function" ? getProjectRootFromWorktree(cwd) : null; if (typeof getProjectRootFromWorktree !== "function" && !warnedMissingProjectRootResolver) { warnedMissingProjectRootResolver = true; console.warn("[fusion-extension] @fusion/core.getProjectRootFromWorktree is unavailable; using filesystem fallback resolution."); } if (worktreeProjectRoot && existsSync(join(worktreeProjectRoot, ".fusion"))) { return worktreeProjectRoot; } 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(); async function getStore(cwd: string): Promise { 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"); } function emitSecretAudit( store: TaskStore, ctx: { runId?: string; agentId?: string; taskId?: string }, mutationType: string, target: string, metadata?: Record, ): void { if (!ctx.runId || !ctx.agentId) return; try { assertNoSecretPlaintext(metadata); store.recordRunAuditEvent({ runId: ctx.runId, agentId: ctx.agentId, taskId: ctx.taskId, domain: "filesystem", mutationType, target, metadata, }); } catch (error) { console.warn("[fusion-extension] secret audit emission skipped", error); } } /** * 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 | null, override = false, ): Promise { 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): 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): 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; } } async function formatDuplicateLineageLine(task: Task, store: TaskStore): Promise { const lineage = getTaskDuplicateLineage(task); if (lineage.length === 0) return null; const labels = await Promise.all(lineage.map(async (id) => { try { const linked = await store.getTask(id); return linked.column === "archived" ? `${id} (archived)` : id; } catch { return id; } })); return `Duplicate of: ${labels.join(", ")}`; } export 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 isTerminalColumn = t.column === "done" || t.column === "archived"; const paused = t.paused && !isTerminalColumn ? " (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([ "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 { if (ms <= 0) { return; } await new Promise((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 { 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(["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 }, }; } function isIssueAlreadyImported( task: Pick, owner: string, repo: string, issueNumber: number, sourceUrl: string, ): boolean { const sourceIssue = task.sourceIssue; return task.description.includes(sourceUrl) || (sourceIssue?.provider === "github" && sourceIssue.repository === `${owner}/${repo}` && sourceIssue.issueNumber === issueNumber); } async function fetchGitHubIssueViaGh( owner: string, repo: string, issueNumber: number, options: { signal?: AbortSignal } = {}, ): Promise { ensureGhCliAuth(); const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`; try { return await runGhJsonAsync(["api", path], { signal: options.signal }); } catch (error) { throw new Error(getGhErrorMessage(error)); } } // ── Extension entry point ────────────────────────────────────────── export default function kbExtension(pi: ExtensionAPI) { // Register GitHub tracking hook once per extension lifecycle so that // fn_task_create, fn_task_import_github*, fn_delegate_task, etc. // trigger tracking issue creation when settings enable it. try { dashboard.registerGithubTrackingHook?.(); } catch { // Tests may provide partial @fusion/dashboard mocks without this export. } // ── 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. Optionally pass workflow_id to select " + "a workflow at creation time; use fn_workflow_list to discover valid IDs.", 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, ), workflow_id: Type.Optional( Type.String({ description: "Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " + "Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.", }), ), }), 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 = { id: "", 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 projectSettings = await store.getSettings(); const globalSettings = await store.getGlobalSettingsStore().getSettings(); const resolvedTracking = resolveTaskGithubTracking( { githubTracking: undefined }, projectSettings, globalSettings, ); const workflowId = params.workflow_id?.trim() || undefined; const task = await store.createTask({ description: params.description.trim(), dependencies: params.depends, assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId, priority: params.priority as TaskPriority | undefined, ...(workflowId ? { workflowId } : {}), source: { sourceType: "api" }, githubTracking: resolvedTracking.enabled ? { enabled: true, ...(resolvedTracking.repo ? { repoOverride: `${resolvedTracking.repo.owner}/${resolvedTracking.repo.repo}` } : {}), } : undefined, }); const label = task.description.length > 80 ? task.description.slice(0, 80) + "…" : task.description; return { content: [ { type: "text", text: `Created ${task.id}: ${label}${workflowId ? ` (workflow: ${workflowId})` : ""}\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, assigned agent, priority, and workflow_id after task creation. " + "Set workflow_id to a workflow ID to select it, or null to clear the workflow selection.", promptSnippet: "Update fields on an existing Fusion task", promptGuidelines: [ "Use fn_task_update to modify task title, description, dependencies, assigned agent, priority, or workflow_id after creation.", "Set workflow_id to null to clear a task's workflow selection and enabled workflow steps.", "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", }), ), priority: Type.Optional( StringEnum([...TASK_PRIORITIES], { description: "Task priority (low, normal, high, urgent)" }) as unknown as TSchema, ), workflow_id: Type.Optional( Type.Union([Type.String(), Type.Null()], { description: "Workflow ID to select for this task (e.g. 'WF-003' or 'builtin:coding'), " + "or null to clear the workflow selection and revert to the project default. " + "Use fn_workflow_list to discover valid IDs.", }), ), }), 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 = {}; 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 (params.priority !== undefined) { updates.priority = params.priority; updatedFields.push("priority"); } if (params.workflow_id !== undefined) { if (params.workflow_id === null) { await store.clearTaskWorkflowSelection(task.id); updatedFields.push("workflowId"); } else { const workflowId = params.workflow_id.trim(); if (workflowId.length > 0) { try { await store.selectTaskWorkflowAndReconcile(task.id, workflowId); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `ERROR: ${message}` }], isError: true, details: { error: message }, }; } updatedFields.push("workflowId"); } } } if (updatedFields.length === 0) { return { content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId, priority, workflow_id." }], isError: true, details: { error: "No fields provided" }, }; } if (Object.keys(updates).length > 0) { 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: ${columnLabel(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}`); } const duplicateLineage = await formatDuplicateLineageLine(task, store); if (duplicateLineage) { lines.push(duplicateLineage); } 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, plus stranded in-review tasks with incomplete execution or prior merge attempts, 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" }, }; } const isInReviewStatusNone = task.column === "in-review" && (task.status === null || task.status === undefined); 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); const isInReviewExecutionStall = isInReviewStatusNone && isExecutionFailureInReview; const isInReviewMergeRetryStall = isInReviewStatusNone && (task.mergeRetries ?? 0) > 0; const isInReviewRetry = task.column === "in-review" && (task.status === "failed" || task.status === "stuck-killed" || isInReviewExecutionStall || isInReviewMergeRetryStall); // Validate task is in a retryable state if (task.status !== 'failed' && task.status !== 'stuck-killed' && !isInReviewRetry) { 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 }, }; } const autoPauseClearPatch = buildAutoPauseClearPatch(task); const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0; const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : ""; // In-review retry: distinguish between execution failures and merge failures. if (isInReviewRetry) { if (isExecutionFailureInReview) { await store.updateTask(params.id, { status: null, error: null, ...autoPauseClearPatch, ...buildManualRetryResetPatch(), }); await store.logEntry( params.id, isInReviewExecutionStall ? `Retry requested via Fusion extension (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})` : `Retry requested via Fusion extension (execution failure in-review → todo, preserving progress${retryLogSuffix})`, ); 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, ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); await store.logEntry(params.id, `Retry requested via Fusion extension (in-review merge retry, mergeRetries reset${retryLogSuffix})`); 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, ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); // 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} → ${columnLabel(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} → ${columnLabel(task.column)}` }], details: { taskId: task.id, column: task.column }, }; }, }); // ── fn_task_delete ───────────────────────────────────────────────── pi.registerTool({ name: "fn_task_delete", label: "fn: Delete Task", description: "Soft-delete a task from active Fusion board views. " + "The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation.", promptSnippet: "Soft-delete a Fusion task", promptGuidelines: [ "Use for cleaning up test tasks or tasks created in error when you want the task hidden from active board views", "This tool performs a soft delete: task data is preserved and the ID stays reserved", "Use allowResurrection:true when operators want the deleted task ID to be intentionally reusable on future createTask calls", "Use fn_task_archive for completed work you want to keep referenceable in the board", "True hard removal is handled by archive cleanup paths (archiveTaskAndCleanup / cleanupArchivedTasks), not fn_task_delete", ], parameters: Type.Object({ id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }), allowResurrection: Type.Optional(Type.Boolean({ description: "When true, mark this tombstone as explicitly reusable for future recreation." })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const task = await store.deleteTask(params.id, { allowResurrection: params.allowResurrection === true, auditContext: { agentId: "pi-extension", runId: `synthetic-pi-delete-${params.id}-${Date.now()}`, }, }); 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: false }); const createdTasks: Array<{ id: string; title: string }> = []; for (const issue of issues) { const sourceUrl = issue.html_url; const alreadyImported = existingTasks.some((task) => isIssueAlreadyImported(task, owner, repo, issue.number, 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); } 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: false }); const sourceUrl = issue.html_url; for (const task of existingTasks) { if (isIssueAlreadyImported(task, owner, repo, issueNumber, 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(); 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", }) ), baseBranch: Type.Optional(Type.String({ description: "Optional base branch for the task created from this planning session" })), }), 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, undefined, params.baseBranch); // 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.", }, }; }, }); pi.registerTool({ name: "fn_secret_get", label: "fn: Secret Get", description: "Read a secret by key using per-secret access policy.", parameters: Type.Object({ key: Type.String({ description: "Secret key" }), scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("global")], { description: "Optional scope" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const fnCtx = ctx as typeof ctx & { agentId?: string; agentName?: string; runId?: string; taskId?: string; }; const store = await getStore(ctx.cwd); const secretsStore = await store.getSecretsStore(); const scopes: SecretScope[] = params.scope ? [params.scope] : ["project", "global"]; let record: import("@fusion/core").SecretRecord | null = null; let resolvedScope: import("@fusion/core").SecretScope | null = null; for (const scope of scopes) { const match = secretsStore.listSecrets(scope).find((candidate) => candidate.key === params.key); if (match) { record = match; resolvedScope = scope; break; } } if (!record || !resolvedScope) { return { content: [{ type: "text", text: `Secret '${params.key}' not found.` }], details: { error: "not-found", key: params.key, scope: params.scope ?? null } }; } const globalSettings = await store.getGlobalSettingsStore().getSettings(); const decision = resolveSecretAccessPolicy({ secretPolicy: record.accessPolicy, settings: { secretsAccessPolicy: globalSettings.secretsAccessPolicy }, }); if (decision.policy === "deny") { emitSecretAudit(store, fnCtx, "secret:approval-denied", `${resolvedScope}:${params.key}`); return { content: [{ type: "text", text: "Secret access denied by policy." }], details: { error: "denied", key: params.key, scope: resolvedScope, policySource: decision.source } }; } if (decision.policy === "prompt") { const { ApprovalRequestStore } = await import("@fusion/core"); const approvalStore = new ApprovalRequestStore(store.getDatabase()); const dedupeKey = `secret-read:${resolvedScope}:${params.key}:${fnCtx.agentId ?? "unknown"}`; const existing = approvalStore.findLatestByDedupeKey({ requesterActorId: fnCtx.agentId ?? "user", taskId: fnCtx.taskId, dedupeKey }); const request = existing && existing.status === "pending" ? existing : approvalStore.create({ requester: { actorId: fnCtx.agentId ?? "user", actorType: "agent", actorName: fnCtx.agentName ?? fnCtx.agentId ?? "Agent" }, targetAction: { category: "task_mutation", action: "read", summary: `Read secret ${params.key}`, resourceType: "secret", resourceId: record.id, context: { approvalDedupeKey: dedupeKey, key: params.key, scope: resolvedScope }, }, ...(fnCtx.runId ? { runId: fnCtx.runId } : {}), ...(fnCtx.taskId ? { taskId: fnCtx.taskId } : {}), }); emitSecretAudit(store, fnCtx, "secret:approval-requested", `${resolvedScope}:${params.key}`); return { content: [{ type: "text", text: `Secret access requires approval. Request ${request.id} is pending. Approve via POST /api/approvals/:id/decision.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, key: params.key, scope: resolvedScope }, }; } const revealed = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: fnCtx.agentId ?? null }); emitSecretAudit(store, fnCtx, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope }); return { content: [{ type: "text", text: `Loaded secret '${params.key}' from ${resolvedScope} scope.` }], details: { key: params.key, value: revealed.plaintextValue, scope: resolvedScope }, }; }, }); // ── 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" }) ), baseBranch: Type.Optional(Type.String({ description: "Optional integration base branch for tasks triaged from this mission" })), }), 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(), baseBranch: params.baseBranch?.trim() || undefined, }); 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', 'complete') AND COALESCE(archived, 0) = 0 ORDER BY updatedAt DESC`, ) .all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error" | "complete"; 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) { const draftStatus = draft.status === "complete" ? "plan ready" : draft.status; lines.push(` ◌ ${draft.id}: ${draft.title} (draft · interview ${draftStatus})`); } 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 })), }, }; }, }); // ── Goal Tools ─────────────────────────────────────────────── // Author-facing goal management const GOAL_LIST_HARD_LIMIT = 5; const GOAL_LIST_SOFT_WARNING_THRESHOLD = 3; const GOAL_SNIPPET_MAX_CHARS = 80; const buildGoalSnippet = (description?: string): string | undefined => { const firstLine = description?.split(/\r?\n/, 1)[0]?.replace(/\s+/g, " ").trim(); if (!firstLine) return undefined; if (firstLine.length <= GOAL_SNIPPET_MAX_CHARS) return firstLine; return `${firstLine.slice(0, GOAL_SNIPPET_MAX_CHARS - 1).trimEnd()}…`; }; const buildGoalListEntry = (goal: { id: string; title: string; status: string; description?: string }) => { const snippet = buildGoalSnippet(goal.description); return snippet ? { id: goal.id, title: goal.title, status: goal.status, snippet } : { id: goal.id, title: goal.title, status: goal.status }; }; const formatGoalListLine = (goal: { id: string; title: string; status: string; snippet?: string }) => ( `- ${goal.id} [${goal.status}] ${goal.title}${goal.snippet ? ` — ${goal.snippet}` : ""}` ); pi.registerTool({ name: "fn_goal_list", label: "fn: List Goals", description: "List goals by status with active-goal warning details.", promptSnippet: "List project goals", promptGuidelines: [ "Use to inspect current goals before creating or archiving", "Default status is active; pass archived/all when needed", "Soft warning begins at 3 active goals and hard cap is 5", ], parameters: Type.Object({ status: Type.Optional( Type.Union([ Type.Literal("active"), Type.Literal("archived"), Type.Literal("all"), ], { description: "Filter by goal status (default: active)" }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const fnCtx = ctx as typeof ctx & { agentId?: string; runId?: string; taskId?: string; }; const store = await getStore(ctx.cwd); const goalStore = store.getGoalStore(); const status = params.status ?? "active"; const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status }); const activeCount = goalStore.listGoals({ status: "active" }).length; const softWarning = activeCount >= GOAL_LIST_SOFT_WARNING_THRESHOLD; const goalEntries = goals.map(buildGoalListEntry); emitGoalRetrievalAudit(store, fnCtx, { toolName: "fn_goal_list", resultCount: goals.length, goalIds: goals.map((goal) => goal.id), }); const lines: string[] = []; lines.push(`Goals (${goals.length}) [filter: ${status}]`); lines.push(`Active: ${activeCount}/${GOAL_LIST_HARD_LIMIT}`); if (softWarning) { lines.push(`⚠ ${GOAL_LIST_SOFT_WARNING_THRESHOLD}/${GOAL_LIST_HARD_LIMIT} active goals — soft warning at ${GOAL_LIST_SOFT_WARNING_THRESHOLD}, hard cap at ${GOAL_LIST_HARD_LIMIT}`); } lines.push(""); if (goalEntries.length === 0) { lines.push("No goals found."); } else { lines.push(...goalEntries.map(formatGoalListLine)); } return { content: [{ type: "text", text: lines.join("\n") }], details: { goals: goalEntries, activeCount, softWarning, hardLimit: GOAL_LIST_HARD_LIMIT }, }; }, }); pi.registerTool({ name: "fn_goal_create", label: "fn: Create Goal", description: "Create a new project goal.", promptSnippet: "Create a new goal", promptGuidelines: [ "Use clear titles and optional context-rich descriptions", "Goal creation counts toward the 5 active-goal hard cap", "Archive older goals when the active cap is reached", ], parameters: Type.Object({ title: Type.String({ description: "Goal title — brief but descriptive" }), description: Type.Optional( Type.String({ description: "Long-form goal description (free-text markdown)" }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const goalStore = store.getGoalStore(); try { const goal = goalStore.createGoal({ title: params.title.trim(), description: params.description?.trim() || undefined, }); const activeCount = goalStore.listGoals({ status: "active" }).length; const softWarning = activeCount >= 3; return { content: [{ type: "text", text: `Created ${goal.id}: ${goal.title}\nStatus: ${goal.status}${softWarning ? `\n⚠ ${activeCount}/5 active goals — approaching hard cap` : ""}` }], details: { goalId: goal.id, title: goal.title, status: goal.status, softWarning }, }; } catch (err) { if (typeof err === "object" && err !== null && "code" in err && (err as { code?: string }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED") { const limit = (err as { limit?: number }).limit ?? 5; const currentActive = (err as { currentActive?: number }).currentActive ?? 5; return { isError: true, content: [{ type: "text", text: `Cannot create goal — already at the hard cap of 5 active goals (currently ${currentActive}). Archive one first.` }], details: { code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit, currentActive }, }; } throw err; } }, }); pi.registerTool({ name: "fn_goal_archive", label: "fn: Archive Goal", description: "Archive a goal by ID.", promptSnippet: "Archive a goal by ID", promptGuidelines: [ "Use when a goal is complete or no longer active", "Archiving frees active-goal capacity", "Returns success for already archived goals", ], parameters: Type.Object({ id: Type.String({ description: "Goal ID (G-…) to archive" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const goalStore = store.getGoalStore(); const goal = goalStore.getGoal(params.id); if (!goal) { return { isError: true, content: [{ type: "text", text: `Goal ${params.id} not found` }], details: { code: "GOAL_NOT_FOUND", goalId: params.id }, }; } if (goal.status === "archived") { return { content: [{ type: "text", text: `Goal ${params.id} is already archived` }], details: { goalId: params.id, status: "archived" }, }; } const archived = goalStore.archiveGoal(params.id); return { content: [{ type: "text", text: `Archived ${archived.id}: ${archived.title}` }], details: { goalId: archived.id, status: "archived" }, }; }, }); pi.registerTool({ name: "fn_goal_show", label: "fn: Show Goal", description: "Show full details for a single goal by ID.", promptSnippet: "Show goal details by ID", promptGuidelines: [ "Use to inspect a specific goal after listing or referencing its ID", "Cite the goal ID and status when summarizing or planning from this output", "Read-only retrieval; does not mutate goals", ], parameters: Type.Object({ id: Type.String({ description: "Goal ID (G-…)" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const fnCtx = ctx as typeof ctx & { agentId?: string; runId?: string; taskId?: string; }; const store = await getStore(ctx.cwd); const goalStore = store.getGoalStore(); const goal = goalStore.getGoal(params.id); if (!goal) { emitGoalRetrievalAudit(store, fnCtx, { toolName: "fn_goal_show", resultCount: 0, goalId: params.id, goalIds: [], notFound: true, }); return { isError: true, content: [{ type: "text", text: `Goal ${params.id} not found` }], details: { code: "GOAL_NOT_FOUND", goalId: params.id }, }; } const lines: string[] = []; lines.push(`${goal.id}: ${goal.title}`); lines.push(`Status: ${goal.status}`); lines.push(`Created: ${goal.createdAt}`); lines.push(`Updated: ${goal.updatedAt}`); if (goal.description) { lines.push(`Description: ${goal.description}`); } emitGoalRetrievalAudit(store, fnCtx, { toolName: "fn_goal_show", resultCount: 1, goalId: params.id, goalIds: [params.id], }); return { content: [{ type: "text", text: lines.join("\n") }], details: { goal }, }; }, }); // ── 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[] = []; const renderGateLine = (indent: string, label: string, value: string | undefined) => { const trimmed = value?.trim(); if (!trimmed) return; if (trimmed.length > 240) { lines.push(`${indent}${label} ${trimmed.slice(0, 240)}… (truncated, ${trimmed.length} chars)`); return; } lines.push(`${indent}${label} ${trimmed}`); }; lines.push(`${mission.id}: ${mission.title}`); lines.push(`Status: ${mission.status}`); if (mission.description) { lines.push(`Description: ${mission.description}`); } lines.push(""); lines.push("Linked Goals:"); if ((mission.linkedGoals?.length ?? 0) === 0) { lines.push("No linked goals."); } else { for (const goal of mission.linkedGoals ?? []) { lines.push(`- ${goal.id}: ${goal.title}`); } } 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})`); renderGateLine(" ", "AC:", milestone.acceptanceCriteria); for (const slice of milestone.slices) { const sIcon = slice.status === "complete" ? "✓" : slice.status === "active" ? "●" : "○"; lines.push(` ${sIcon} ${slice.id}: ${slice.title} (${slice.status})`); renderGateLine(" ", "Verification:", slice.verification); 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}`); renderGateLine(" ", "AC:", feature.acceptanceCriteria); } } } } return { content: [{ type: "text", text: lines.join("\n") }], details: { mission }, }; }, }); // ── fn_mission_list_goals ───────────────────────────────────── pi.registerTool({ name: "fn_mission_list_goals", label: "fn: List Mission Goals", description: "List goals linked to a mission.", promptSnippet: "List goals linked to a mission", promptGuidelines: [ "Use after fn_mission_list or fn_mission_show when you need goal linkage details", "Returns linked goals in mission-link order", "Prefer this before linking or unlinking to avoid duplicate work", ], parameters: Type.Object({ missionId: 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 goalStore = store.getGoalStore(); const mission = missionStore.getMission(params.missionId); if (!mission) { return { content: [{ type: "text", text: `Mission ${params.missionId} not found` }], isError: true, details: { code: "MISSION_NOT_FOUND", missionId: params.missionId }, }; } const goals = missionStore .listGoalIdsForMission(params.missionId) .map((goalId) => goalStore.getGoal(goalId)) .filter((goal): goal is NonNullable => Boolean(goal)); const lines = [`Linked goals for ${mission.id}: ${mission.title}`]; if (goals.length === 0) { lines.push("No linked goals."); } else { for (const goal of goals) { const description = goal.description ? ` — ${goal.description}` : ""; lines.push(`- ${goal.id} [${goal.status}] ${goal.title}${description}`); } } return { content: [{ type: "text", text: lines.join("\n") }], details: { missionId: mission.id, missionTitle: mission.title, goals, }, }; }, }); // ── fn_mission_link_goal ─────────────────────────────────────── pi.registerTool({ name: "fn_mission_link_goal", label: "fn: Link Mission Goal", description: "Link a goal to a mission.", promptSnippet: "Link a goal to a mission", promptGuidelines: [ "Use after confirming both the mission and goal IDs", "Idempotent: linking an already-linked goal is safe", "Use fn_mission_list_goals afterward to verify the resulting set", ], parameters: Type.Object({ missionId: Type.String({ description: "Mission ID (e.g., M-001)" }), goalId: Type.String({ description: "Goal ID (e.g., G-001)" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); const goalStore = store.getGoalStore(); const mission = missionStore.getMission(params.missionId); if (!mission) { return { content: [{ type: "text", text: `Mission ${params.missionId} not found` }], isError: true, details: { code: "MISSION_NOT_FOUND", missionId: params.missionId }, }; } const goal = goalStore.getGoal(params.goalId); if (!goal) { return { content: [{ type: "text", text: `Goal ${params.goalId} not found` }], isError: true, details: { code: "GOAL_NOT_FOUND", goalId: params.goalId }, }; } if (goal.status === "archived") { return { content: [{ type: "text", text: `Goal ${params.goalId} is archived and cannot be linked` }], isError: true, details: { code: "GOAL_ARCHIVED", goalId: params.goalId }, }; } missionStore.linkGoal(params.missionId, params.goalId); const goals = missionStore .listGoalIdsForMission(params.missionId) .map((goalId) => goalStore.getGoal(goalId)) .filter((linkedGoal): linkedGoal is NonNullable => Boolean(linkedGoal)); return { content: [{ type: "text", text: `Linked ${goal.id}: ${goal.title} → ${mission.id}` }], details: { missionId: mission.id, missionTitle: mission.title, goal, goals, }, }; }, }); // ── fn_mission_unlink_goal ───────────────────────────────────── pi.registerTool({ name: "fn_mission_unlink_goal", label: "fn: Unlink Mission Goal", description: "Unlink a goal from a mission.", promptSnippet: "Unlink a goal from a mission", promptGuidelines: [ "Use when a goal no longer belongs on a mission", "Idempotent: unlinking an absent link is safe", "Returns the remaining linked goals for quick verification", ], parameters: Type.Object({ missionId: Type.String({ description: "Mission ID (e.g., M-001)" }), goalId: Type.String({ description: "Goal ID (e.g., G-001)" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); const goalStore = store.getGoalStore(); const mission = missionStore.getMission(params.missionId); if (!mission) { return { content: [{ type: "text", text: `Mission ${params.missionId} not found` }], isError: true, details: { code: "MISSION_NOT_FOUND", missionId: params.missionId }, }; } const goal = goalStore.getGoal(params.goalId); if (!goal) { return { content: [{ type: "text", text: `Goal ${params.goalId} not found` }], isError: true, details: { code: "GOAL_NOT_FOUND", goalId: params.goalId }, }; } missionStore.unlinkGoal(params.missionId, params.goalId); const goals = missionStore .listGoalIdsForMission(params.missionId) .map((goalId) => goalStore.getGoal(goalId)) .filter((linkedGoal): linkedGoal is NonNullable => Boolean(linkedGoal)); return { content: [{ type: "text", text: `Unlinked ${goal.id}: ${goal.title} from ${mission.id}` }], details: { missionId: mission.id, missionTitle: mission.title, goal, goals, }, }; }, }); // ── fn_mission_backfill_assertions ───────────────────────────── pi.registerTool({ name: "fn_mission_backfill_assertions", label: "fn: Backfill Mission Assertions", description: "Backfill mission assertions by deriving and linking one store-managed assertion for each feature without linked assertions. Supports dry-run mode.", promptSnippet: "Backfill mission assertions for unlinked features", promptGuidelines: [ "Use dryRun=true first to inspect proposed repairs before applying", "Scopes to a single mission when missionId is provided; otherwise scans all missions", "Creates one store-managed assertion per unlinked feature and links it", ], parameters: Type.Object({ missionId: Type.Optional(Type.String({ description: "Mission ID to scope backfill to (e.g., M-001)" })), dryRun: Type.Optional(Type.Boolean({ description: "When true, preview repairs without writing changes (default: true)" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); const report = missionStore.backfillFeatureAssertions({ missionId: params.missionId, dryRun: params.dryRun ?? true, }); const scopeLabel = params.missionId ? `mission ${params.missionId}` : "all missions"; const modeLabel = (params.dryRun ?? true) ? "dry-run" : "apply"; const lines = [ `Backfill ${modeLabel} complete for ${scopeLabel}.`, `Scanned: ${report.scanned}`, `Already linked: ${report.alreadyLinked}`, `Repaired: ${report.repaired.length}`, `Skipped errors: ${report.skippedErrors.length}`, ]; return { content: [{ type: "text", text: lines.join("\n") }], details: report, }; }, }); // ── 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_mission_update ─────────────────────────────────────────── pi.registerTool({ name: "fn_mission_update", label: "fn: Update Mission", description: "Update an existing mission's title or description. " + "Partial patches leave untouched fields intact.", promptSnippet: "Update an existing mission", promptGuidelines: [ "Use to revise mission framing without re-creating the mission", "Mission hierarchy and ordering are preserved", "Provide only the fields you want to change", ], parameters: Type.Object({ id: Type.String({ description: "Mission ID to update (e.g., M-001)" }), title: Type.Optional(Type.String({ description: "Updated mission title" })), description: Type.Optional(Type.String({ description: "Updated mission description" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); const existingMission = missionStore.getMission(params.id); if (!existingMission) { return { content: [{ type: "text", text: `Mission ${params.id} not found` }], isError: true, details: { error: "Mission not found" }, }; } const updates: { title?: string; description?: string } = {}; if ("title" in params) { updates.title = params.title?.trim(); } if ("description" in params) { updates.description = params.description?.trim(); } if (Object.keys(updates).length === 0) { return { content: [ { type: "text", text: "No fields to update (provide at least one of: title, description)", }, ], isError: true, details: { error: "No fields to update" }, }; } const mission = missionStore.updateMission(params.id, updates); return { content: [{ type: "text", text: `Updated ${mission.id}: "${mission.title}"` }], details: { missionId: mission.id, title: mission.title, description: mission.description, status: mission.status, }, }; }, }); // ── 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_feature_delete ──────────────────────────────────────────── pi.registerTool({ name: "fn_feature_delete", label: "fn: Delete Feature", description: "Delete a feature. Rejects deletion when linked to a live task unless force=true.", promptSnippet: "Delete a mission feature", promptGuidelines: [ "Use force=true only when intentionally overriding linked live-task guards", "Deleting a feature is permanent and cannot be undone", ], parameters: Type.Object({ featureId: Type.String({ description: "Feature ID to delete (e.g., F-001)" }), force: Type.Optional(Type.Boolean({ description: "Override linked-task guard" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); try { missionStore.deleteFeature(params.featureId, params.force === true); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: message }], isError: true, details: { error: message }, }; } return { content: [{ type: "text", text: `Deleted ${params.featureId}` }], details: { featureId: params.featureId, force: params.force === true }, }; }, }); // ── fn_slice_delete ──────────────────────────────────────────── pi.registerTool({ name: "fn_slice_delete", label: "fn: Delete Slice", description: "Delete a slice and its features. Rejects deletion when child features link to live tasks unless force=true.", promptSnippet: "Delete a mission slice", parameters: Type.Object({ sliceId: Type.String({ description: "Slice ID to delete (e.g., SL-001)" }), force: Type.Optional(Type.Boolean({ description: "Override linked-task guard" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); try { missionStore.deleteSlice(params.sliceId, params.force === true); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: message }], isError: true, details: { error: message }, }; } return { content: [{ type: "text", text: `Deleted ${params.sliceId}` }], details: { sliceId: params.sliceId, force: params.force === true }, }; }, }); // ── fn_milestone_delete ──────────────────────────────────────────── pi.registerTool({ name: "fn_milestone_delete", label: "fn: Delete Milestone", description: "Delete a milestone and all descendant slices/features. Rejects deletion when child features link to live tasks unless force=true.", promptSnippet: "Delete a mission milestone", parameters: Type.Object({ milestoneId: Type.String({ description: "Milestone ID to delete (e.g., MS-001)" }), force: Type.Optional(Type.Boolean({ description: "Override linked-task guard" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); try { missionStore.deleteMilestone(params.milestoneId, params.force === true); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: message }], isError: true, details: { error: message }, }; } return { content: [{ type: "text", text: `Deleted ${params.milestoneId}` }], details: { milestoneId: params.milestoneId, force: params.force === true }, }; }, }); // ── 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. " + "If the target task is not on the active board (for example archived, deleted, or never created), " + "the tool returns a clear validation error indicating that only active tasks can be linked.", 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" }, }; } try { 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 }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: message }], isError: true, details: { error: message }, }; } }, }); // ── 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_milestone_update ─────────────────────────────────────────── pi.registerTool({ name: "fn_milestone_update", label: "fn: Update Milestone", description: "Update an existing milestone's title, description, or acceptance criteria (the structured pass/fail bar, distinct from verification's free-form how-to-confirm notes). " + "Partial patches leave untouched fields intact.", promptSnippet: "Update an existing mission milestone", promptGuidelines: [ "Use to revise milestone details without re-creating it", "Mission linkage and ordering are preserved", "Provide only the fields you want to change", ], parameters: Type.Object({ id: Type.String({ description: "Milestone ID to update (e.g., MS-001)" }), title: Type.Optional(Type.String({ description: "Updated milestone title" })), description: Type.Optional(Type.String({ description: "Updated milestone description" })), acceptanceCriteria: Type.Optional( Type.String({ description: "Updated acceptance criteria for completing the milestone" }) ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); const missionStore = store.getMissionStore(); const existingMilestone = missionStore.getMilestone(params.id); if (!existingMilestone) { return { content: [{ type: "text", text: `Milestone ${params.id} not found` }], isError: true, details: { error: "Milestone 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 milestone = missionStore.updateMilestone(params.id, updates); return { content: [{ type: "text", text: `Updated ${milestone.id}: "${milestone.title}"` }], details: { milestoneId: milestone.id, missionId: milestone.missionId, title: milestone.title, description: milestone.description, acceptanceCriteria: milestone.acceptanceCriteria, status: milestone.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, active, or in error", "Stopped agents can be resumed with fn_agent_start", "Agents in 'idle' 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 = { ...(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 = {}; 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[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. " + "Optionally pass workflow_id to select a workflow at creation time; use " + "fn_workflow_list to discover valid IDs.", 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\"]" }), ), workflow_id: Type.Optional( Type.String({ description: "Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " + "Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.", }), ), 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 = { id: "", 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 workflowId = params.workflow_id?.trim() || undefined; const task = await store.createTask({ description: params.description, dependencies: params.dependencies, column: "todo", assignedAgentId: params.agent_id, ...(workflowId ? { workflowId } : {}), source: { sourceType: "api", ...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}), }, }); const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const workflow = workflowId ? ` (workflow: ${workflowId})` : ""; return { content: [{ type: "text" as const, text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}${workflow}. ` + `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: \"\", skill: \"\" })"); 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((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(); }); }