Files
fusion/packages/cli/src/extension.ts
gsxdsm 1ea185daa5 FN-7911: add workflow validate dry-run command, tool, and API route
Adds a non-mutating `fn workflow validate` dry-run path across CLI, agent tools, and dashboard API so custom workflow IR can be checked before create/update.

- Add `packages/cli/src/commands/workflow.ts` implementing `fn workflow validate <id> | --file <path>` with JSON/text output, wired into `bin.ts`.
- Add `fn_workflow_validate` agent tool (`agent-tools.ts`, `index.ts`) reusing the existing parseWorkflowIr/trait/code-node/column-agent validation used by create/update, performing no persistence.
- Add `POST /api/workflows/validate` route in `register-workflow-routes.ts` plus dashboard route test coverage.
- Extend heartbeat tool-gating/exposure tests and gating classifications to include `fn_workflow_validate` alongside the other workflow tools.
- Update CLI/agent extension docs (`docs/cli-reference.md`, `docs/agents.md`, `docs/workflow-steps.md`, fusion skill references) to document the new command/tool.
- Add changeset `.changeset/fn-7911-workflow-validate.md` (minor) describing the new capability.

Files changed:
 .changeset/fn-7911-workflow-validate.md            |   7 ++
 docs/agents.md                                     |   5 +-
 docs/cli-reference.md                              |  13 ++
 docs/workflow-steps.md                             |   3 +-
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 .../cli/skill/fusion/references/extension-tools.md |  10 ++
 .../skill/fusion/references/fusion-capabilities.md |   1 +
 .../src/__tests__/extension-workflow-tools.test.ts |   1 +
 packages/cli/src/__tests__/extension.test.ts       |   1 +
 .../src/__tests__/workflow-docs-current.test.ts    |   1 +
 packages/cli/src/bin.ts                            |  22 ++++
 packages/cli/src/commands/workflow.ts              |  80 ++++++++++++
 packages/cli/src/extension.ts                      |  10 ++
 .../dashboard/src/__tests__/chat-manager.test.ts   |   1 +
 .../dashboard/src/__tests__/chat.rooms.test.ts     |   1 +
 .../planning-document-tools-exposure.test.ts       |   1 +
 .../__tests__/workflow-validate-route.test.ts      | 101 +++++++++++++++
 .../src/routes/register-workflow-routes.ts         |  27 +++-
 .../engine/src/__tests__/agent-action-gate.test.ts |   2 +-
 .../agent-workflow-tools-exposure.test.ts          |  70 ++++++++++-
 .../src/__tests__/gating-classifications.test.ts   |   3 +-
 .../src/__tests__/heartbeat-executor.test.ts       |  37 +++---
 .../src/__tests__/heartbeat-session-prompt.test.ts |   5 +-
 .../src/__tests__/permanent-agent-gating.test.ts   |   2 +-
 packages/engine/src/agent-heartbeat.ts             |   5 +-
 packages/engine/src/agent-tools.ts                 | 140 ++++++++++++++++++++-
 packages/engine/src/executor.ts                    |   6 +
 packages/engine/src/gating-classifications.ts      |   2 +
 packages/engine/src/index.ts                       |   4 +
 29 files changed, 532 insertions(+), 31 deletions(-)

Fusion-Task-Id: FN-7911

Fusion-Task-Lineage: 903d15fe-a7ec-458f-aa34-8f2e895a9603

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-12 21:39:23 -07:00

5503 lines
222 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import * as fusionCore from "@fusion/core";
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,
type AgentCapability,
type AgentUpdateInput,
RESEARCH_RUN_STATUSES,
isResearchExperimentalEnabled,
isEphemeralAgent,
resolveResearchSettings,
getTaskDuplicateLineage,
resolveAgentProvisioningPolicy,
TASK_PRIORITIES,
MAX_TASK_LIST_TEXT_CHARS,
resolveSecretAccessPolicy,
getProjectRootFromWorktree,
resolveTaskGithubTracking,
formatCurrentTaskLine,
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,
createWorkflowAuthoringTools,
workflowListParams,
workflowGetParams,
workflowValidateParams,
workflowSelectParams,
workflowCreateParams,
workflowUpdateParams,
workflowDeleteParams,
workflowSettingsParams,
traitListParams,
isInReviewMissingWorktreeSessionStartFailure,
} from "@fusion/engine";
import * as dashboard from "@fusion/dashboard";
import { resolve, relative, isAbsolute, sep, 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 ────────────────────────────────────────────────────────
type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string;
type TaskListFormatter = (
lines: string[],
opts?: { maxChars?: number; clamp?: TaskListClamp },
) => string;
type AgentDiagnosticLineInput = {
state?: string;
lastError?: string;
pauseReason?: string;
metadata?: Record<string, unknown> | null;
};
function truncateAgentDiagnosticText(value: string, maxChars: number): string {
return value.length > maxChars ? `${value.slice(0, maxChars)}…` : value;
}
function formatAgentErrorRecoveryLine(metadata: Record<string, unknown> | null | undefined): string | null {
const heartbeatRaw = metadata?.heartbeatErrorRecovery;
const heartbeat = heartbeatRaw && typeof heartbeatRaw === "object" ? heartbeatRaw as Record<string, unknown> : null;
const heartbeatAttempts = typeof heartbeat?.consecutiveAttempts === "number" && Number.isFinite(heartbeat.consecutiveAttempts)
? Math.max(0, Math.floor(heartbeat.consecutiveAttempts))
: undefined;
const durableRaw = metadata?.durableErrorRecovery;
const durable = durableRaw && typeof durableRaw === "object" ? durableRaw as Record<string, unknown> : null;
const durableAttempts = typeof durable?.attempts === "number" && Number.isFinite(durable.attempts)
? Math.max(0, Math.floor(durable.attempts))
: undefined;
const attempts = Math.max(heartbeatAttempts ?? 0, durableAttempts ?? 0);
const hasRecoveryMetadata = heartbeatAttempts !== undefined || durableAttempts !== undefined;
if (!hasRecoveryMetadata) {
return null;
}
const details: string[] = [`attempts ${attempts}`];
if (durable?.exhausted === true) details.push("exhausted");
if (typeof durable?.nextRetryAt === "string") details.push(`next ${durable.nextRetryAt}`);
return `Error Recovery: ${details.join(", ")}`;
}
function appendAgentDiagnosticLines(parts: string[], agent: AgentDiagnosticLineInput, options: { compact: boolean }): void {
const shouldShowStateDetails = !options.compact || agent.state === "error" || agent.state === "paused";
if (!shouldShowStateDetails) {
return;
}
if (agent.lastError) {
const maxChars = options.compact ? 180 : 500;
parts.push(`Last Error: ${truncateAgentDiagnosticText(agent.lastError, maxChars)}`);
}
if (agent.pauseReason) {
parts.push(`Pause Reason: ${truncateAgentDiagnosticText(agent.pauseReason, 180)}`);
}
const recoveryLine = formatAgentErrorRecoveryLine(agent.metadata);
if (recoveryLine) {
parts.push(recoveryLine);
}
}
export function inlineTaskListFallback(
lines: string[],
opts: { maxChars?: number } = {},
): string {
/*
FNXC:TaskListOutput 2026-06-18-03:20:
FN-6629 requires stale-runtime fallback formatting to mirror the shared host-safe task-list budget; otherwise missing @fusion/core formatter exports can re-emit imageified column-filtered listings.
*/
const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS));
try {
const text = lines.join("\n");
if (text.length <= maxChars) {
return text;
}
return text.slice(0, Math.max(0, maxChars - 1)) + "…";
} catch {
return "";
}
}
export function resolveTaskListFormatter(core: { formatTaskListText?: unknown }): TaskListFormatter {
return typeof core.formatTaskListText === "function"
? (core.formatTaskListText as TaskListFormatter)
: inlineTaskListFallback;
}
/** #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<string, string>)[column] ?? column;
}
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".txt": "text/plain",
".log": "text/plain",
".json": "application/json",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".csv": "text/csv",
".xml": "application/xml",
};
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<string, TaskStore>();
async function getStore(cwd: string): Promise<TaskStore> {
const projectRoot = resolveProjectRoot(cwd);
const existing = storeCache.get(projectRoot);
if (existing) return existing;
const store = new TaskStore(projectRoot);
await store.init();
storeCache.set(projectRoot, store);
return store;
}
/** @internal Exposed so tests and the extension shutdown hook can close cached stores deterministically; not a public CLI API contract. */
export async function closeCachedStores(): Promise<void> {
/*
FNXC:CliTests 2026-06-17-23:58: FN-6626 found the CLI extension cache cleared real TaskStore instances without closing them, leaving SQLite/WAL handles to survive module resets and making canonical-project-root task-tool tests timeout under suite load.
FNXC:CliTests 2026-06-21-09:58: FN-6839 requires awaiting TaskStore.close() so deferred task-created filesystem work and SQLite/WAL handles drain before temp-root removal; do not appease this loaded-lane seam with timeouts, retries, or worker changes.
*/
const stores = [...storeCache.values()];
storeCache.clear();
for (const store of stores) {
try {
await store.close();
} catch (error) {
console.warn("[fusion-extension] cached TaskStore close skipped", error);
}
}
}
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<string, unknown>,
): 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<Task, "id" | "column"> | null,
override = false,
): Promise<string | null> {
const { AgentStore, isEphemeralAgent, evaluateImplementationTaskBind } = 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) {
// FNXC:AgentRouting 2026-07-12-12:30: issue #2015 — shared bind evaluator; override bypasses role only, never assignmentPolicy "none".
const verdict = evaluateImplementationTaskBind(agent, task, {
explicitRouting: true,
executorRoleOverride: override,
});
if (!verdict.allowed) {
return verdict.reason;
}
}
return null;
}
/*
FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
fn_task_create runs inside whatever agent loaded the pi extension. When the caller is an ephemeral/runtime task-worker (executor-FN-XXXX and friends), the project setting `ephemeralAgentsCanCreateTasks` decides whether it may open new tasks.
Human/dashboard/CLI callers have no `ctx.agentId`, so they are never gated here — the setting only constrains runtime-managed agents.
Resolution is fail-open on lookup errors: a missing/unresolvable caller is treated as non-ephemeral so a store hiccup never blocks legitimate task creation.
*/
async function isEphemeralCallerAgent(cwd: string, callerAgentId: string | undefined): Promise<boolean> {
if (!callerAgentId) return false;
try {
const { AgentStore, isEphemeralAgent } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) });
await agentStore.init();
const agent = await agentStore.resolveAgent(callerAgentId);
if (!agent) return false;
return isEphemeralAgent(agent);
} catch {
return false;
}
}
function normalizeNullableStringInput(value: string | null | undefined): string | null | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.toLowerCase() === "null") {
return null;
}
return trimmed;
}
const INSIGHT_CATEGORIES: InsightCategory[] = [
"quality",
"performance",
"architecture",
"security",
"reliability",
"ux",
"testability",
"documentation",
"dependency",
"workflow",
"other",
"features",
"competitive_analysis",
"research",
"trends",
];
const INSIGHT_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dismissed", "archived"];
const INSIGHT_RUN_STATUSES: InsightRunStatus[] = ["pending", "running", "completed", "failed", "cancelled"];
const INSIGHT_RUN_TRIGGERS: InsightRunTrigger[] = ["schedule", "manual", "task_completion", "merge_event", "api"];
function getTaskSourceAgentLabel(task: Pick<Task, "sourceMetadata" | "sourceAgentId">): string | undefined {
const metadataAgentName = task.sourceMetadata?.agentName;
if (typeof metadataAgentName === "string" && metadataAgentName.trim().length > 0) {
return metadataAgentName.trim();
}
if (typeof task.sourceAgentId === "string" && task.sourceAgentId.trim().length > 0) {
return task.sourceAgentId.trim();
}
return undefined;
}
function getTaskSourceLabel(task: Pick<Task, "sourceType" | "sourceMetadata" | "sourceAgentId" | "sourceParentTaskId">): string | undefined {
switch (task.sourceType) {
case "dashboard_ui":
return "Dashboard";
case "quick_chat":
return "Quick Chat";
case "chat_session":
return "Chat Session";
case "agent_heartbeat": {
const sourceAgent = getTaskSourceAgentLabel(task);
return sourceAgent ? `Agent (${sourceAgent})` : "Agent";
}
case "automation": {
const sourceAgent = getTaskSourceAgentLabel(task);
return sourceAgent ? `Automation (${sourceAgent})` : "Automation";
}
case "cron":
return "Scheduled Task";
case "workflow_step":
return "Workflow Step";
case "github_import": {
const issueUrl = task.sourceMetadata?.issueUrl;
return typeof issueUrl === "string" && issueUrl.length > 0
? `GitHub Import (${issueUrl})`
: "GitHub Import";
}
case "research": {
const findingLabel = task.sourceMetadata?.findingLabel;
if (typeof findingLabel === "string" && findingLabel.length > 0) {
return `Research (${findingLabel})`;
}
const runId = task.sourceMetadata?.runId;
return typeof runId === "string" && runId.length > 0
? `Research (${runId})`
: "Research";
}
case "task" + "_refine":
return task.sourceParentTaskId ? `Refinement of ${task.sourceParentTaskId}` : "Refinement";
case "task" + "_duplicate":
return task.sourceParentTaskId ? `Duplicate of ${task.sourceParentTaskId}` : "Duplicate";
case "cli":
return "CLI";
case "api":
return "API";
case "recovery":
return "Recovery";
default:
return undefined;
}
}
async function formatDuplicateLineageLine(task: Task, store: TaskStore): Promise<string | null> {
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<ResearchRunStatus>([
"completed",
"failed",
"cancelled",
"timed_out",
"retry_exhausted",
]);
function toResearchRunDetails(run: ResearchRun) {
return {
runId: run.id,
status: run.status,
query: run.query,
summary: run.results?.summary ?? null,
findings: run.results?.findings ?? [],
citations: run.results?.citations ?? [],
sourceCount: Array.isArray(run.sources) ? run.sources.length : 0,
error: run.error ?? null,
setup: null,
};
}
function isResearchRunTerminal(status: ResearchRunStatus): boolean {
return RESEARCH_RUN_TERMINAL_STATUSES.has(status);
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) {
return;
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new Error("Operation aborted"));
};
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
interface GitHubIssueApiResult {
number: number;
title: string;
body: string | null;
html_url: string;
labels?: Array<{ name: string }>;
pull_request?: unknown;
}
function ensureGhCliAuth(): void {
if (!isGhAvailable() || !isGhAuthenticated()) {
throw new Error("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
}
}
async function fetchGitHubIssuesViaGh(
owner: string,
repo: string,
options: { limit?: number; labels?: string[]; signal?: AbortSignal } = {},
): Promise<GitHubIssueApiResult[]> {
ensureGhCliAuth();
const queryParams = new URLSearchParams();
queryParams.append("state", "open");
queryParams.append("per_page", String(Math.min(options.limit ?? 30, 100)));
if (options.labels && options.labels.length > 0) {
queryParams.append("labels", options.labels.join(","));
}
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams.toString()}`;
try {
const issues = await runGhJsonAsync<GitHubIssueApiResult[]>(["api", path], { signal: options.signal });
return issues.filter((issue) => !issue.pull_request);
} catch (error) {
throw new Error(getGhErrorMessage(error));
}
}
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
return {
sourceIssue: {
provider: "github" as const,
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
};
}
async function resolveImportedIssueGithubTracking(store: TaskStore): Promise<{ enabled: true } | undefined> {
const projectSettings = await store.getSettings();
if (projectSettings.githubLinkImportedIssuesToTracking === true) {
/*
FNXC:GithubImportTracking 2026-07-01-00:00:
Pi extension GitHub issue imports honor the import-only linking setting without enabling tracking for ordinary task tools. The created task keeps sourceIssue so the post-create hook links the imported issue instead of opening a duplicate tracking issue.
*/
return { enabled: true };
}
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const resolvedTracking = resolveTaskGithubTracking(
{ githubTracking: undefined },
projectSettings,
globalSettings,
);
return resolvedTracking.enabled ? { enabled: true } : undefined;
}
function isIssueAlreadyImported(
task: Pick<Task, "description" | "sourceIssue">,
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<GitHubIssueApiResult> {
ensureGhCliAuth();
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
try {
return await runGhJsonAsync<GitHubIssueApiResult>(["api", path], { signal: options.signal });
} catch (error) {
throw new Error(getGhErrorMessage(error));
}
}
type EngineWorkflowToolName =
| "fn_workflow_list"
| "fn_workflow_get"
| "fn_workflow_validate"
| "fn_workflow_create"
| "fn_workflow_update"
| "fn_workflow_delete"
| "fn_workflow_settings"
| "fn_trait_list"
| "fn_workflow_select";
const workflowExtensionToolSpecs: Array<{
name: EngineWorkflowToolName;
label: string;
description: string;
promptSnippet: string;
promptGuidelines: string[];
parameters: TSchema;
}> = [
{
name: "fn_workflow_list",
label: "fn: List Workflows",
description: "List built-in and custom Fusion workflow definitions available in this project.",
promptSnippet: "List Fusion workflow definitions available in this project",
promptGuidelines: ["Use before selecting or editing workflows to discover valid workflow IDs."],
parameters: workflowListParams,
},
{
name: "fn_workflow_get",
label: "fn: Get Workflow",
description: "Fetch a Fusion workflow definition by ID, including its resolved workflow IR.",
promptSnippet: "Fetch a Fusion workflow definition by ID",
promptGuidelines: ["Use after fn_workflow_list to inspect the current IR before updating a workflow."],
parameters: workflowGetParams,
},
{
name: "fn_workflow_validate",
label: "fn: Validate Workflow",
description: "Dry-run validate a Fusion workflow IR without creating or mutating any workflow.",
promptSnippet: "Validate a Fusion workflow IR without persisting it",
promptGuidelines: ["Use before create/update while iterating on custom workflow IR; validation failures are reported as dry-run results with no persistence."],
parameters: workflowValidateParams,
},
{
name: "fn_workflow_create",
label: "fn: Create Workflow",
description: "Create a custom Fusion workflow definition from a validated workflow IR.",
promptSnippet: "Create a custom Fusion workflow definition",
promptGuidelines: ["Use fn_trait_list first when choosing column traits for a new workflow IR."],
parameters: workflowCreateParams,
},
{
name: "fn_workflow_update",
label: "fn: Update Workflow",
description: "Update a custom Fusion workflow definition's metadata, IR, or layout.",
promptSnippet: "Update a custom Fusion workflow definition",
promptGuidelines: ["Fetch the existing workflow first and preserve intentional IR fields when editing."],
parameters: workflowUpdateParams,
},
{
name: "fn_workflow_delete",
label: "fn: Delete Workflow",
description: "Delete a custom Fusion workflow definition; built-in workflows are protected.",
promptSnippet: "Delete a custom Fusion workflow definition",
promptGuidelines: ["Only delete custom workflows; built-in workflows are protected."],
parameters: workflowDeleteParams,
},
{
name: "fn_workflow_settings",
label: "fn: Workflow Settings",
description: "Read or write per-project values for a workflow's declared settings.",
promptSnippet: "Read or write per-project values for a workflow's declared settings",
promptGuidelines: ["Use fn_workflow_get to inspect setting declarations before writing values."],
parameters: workflowSettingsParams,
},
{
name: "fn_trait_list",
label: "fn: List Workflow Traits",
description: "List column traits available when authoring Fusion workflow IR columns.",
promptSnippet: "List column traits available for Fusion workflow authoring",
promptGuidelines: ["Use when authoring or updating workflow IR column traits."],
parameters: traitListParams,
},
{
name: "fn_workflow_select",
label: "fn: Select Task Workflow",
description: "Assign a workflow definition to a task by workflow ID.",
promptSnippet: "Assign a Fusion workflow to a task",
promptGuidelines: ["Provide task_id unless the current agent context is already bound to the intended task."],
parameters: workflowSelectParams,
},
];
// ── 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.
}
for (const spec of workflowExtensionToolSpecs) {
pi.registerTool({
name: spec.name,
label: spec.label,
description: spec.description,
promptSnippet: spec.promptSnippet,
promptGuidelines: spec.promptGuidelines,
parameters: spec.parameters,
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
/*
FNXC:WorkflowAuthoringTools 2026-06-29-22:20:
The pi extension must expose the same workflow authoring surface agents get in engine lanes: list, get, create, update, delete, settings, trait vocabulary, and task selection when an ambient or explicit task target exists.
Reuse engine factories per call so validation and store-side behavior stay centralized; the extension only adapts cwd/task context to ExtensionAPI.registerTool.
FNXC:WorkflowAuthoringTools 2026-06-29-22:42:
The published/pi extension is prompt-injectable rather than executor-owner controlled, so workflow create/update must strip approval-bypass flags before persistence. Only task executor owner paths may keep cliSkipApproval/autoApprove intact.
FNXC:WorkflowAuthoringTools 2026-06-29-23:06:
fn_workflow_select may default only in task-bound extension contexts; no-task published API calls must pass task_id explicitly so an empty ambient task cannot accidentally route the wrong card.
*/
const store = await getStore(ctx.cwd);
const extensionContext = ctx as typeof ctx & { taskId?: string };
const currentTaskId = typeof extensionContext.taskId === "string" ? extensionContext.taskId : "";
const workflowTools = createWorkflowAuthoringTools(store, currentTaskId, { stripApprovalFlags: true });
const tool = workflowTools.find((candidate) => candidate.name === spec.name);
if (!tool) {
return {
content: [{ type: "text" as const, text: `ERROR: Workflow tool '${spec.name}' is not available.` }],
details: {},
isError: true,
};
}
if (spec.name === "fn_workflow_select" && !currentTaskId) {
const explicitTaskId = typeof (params as { task_id?: unknown }).task_id === "string"
? (params as { task_id: string }).task_id.trim()
: "";
if (!explicitTaskId) {
return {
content: [{ type: "text" as const, text: "ERROR: task_id is required when fn_workflow_select is called outside a task-bound extension context." }],
details: { error: "task_id_required" },
isError: true,
};
}
}
return tool.execute(toolCallId, params as never, _signal, _onUpdate, ctx);
},
});
}
// ── 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);
/*
FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
Gate ephemeral task-worker callers behind the project `ephemeralAgentsCanCreateTasks` toggle (default true). Only runtime-managed agents are affected; humans/CLI/dashboard calls carry no ctx.agentId and pass through.
*/
const fnCtx = ctx as typeof ctx & { agentId?: string };
const projectSettingsForGate = await store.getSettings();
if (
projectSettingsForGate.ephemeralAgentsCanCreateTasks === false &&
(await isEphemeralCallerAgent(ctx.cwd ?? process.cwd(), fnCtx.agentId))
) {
const error =
"Ephemeral task-worker agents are not allowed to create tasks (ephemeralAgentsCanCreateTasks is disabled for this project).";
return {
content: [{ type: "text", text: `ERROR: ${error}` }],
isError: true,
details: { error, rule: "ephemeral-agents-cannot-create-tasks", callerAgentId: fnCtx.agentId },
};
}
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
if (normalizedAgentId !== undefined && normalizedAgentId !== null) {
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "todo" };
const error = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), normalizedAgentId, candidateTask);
if (error) {
return {
content: [{ type: "text", text: error }],
isError: true,
details: { error },
};
}
}
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const resolvedTracking = resolveTaskGithubTracking(
{ githubTracking: undefined },
projectSettingsForGate,
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;
/*
FNXC:Workflows 2026-07-05-00:00:
The response text must reflect the ACTUAL resolved landing column (task.column),
not a hardcoded "triage" string. store.createTask already resolves intake correctly
(this call never overrides `column`), so a custom workflow's non-triage intake
column (e.g. "Inbox") must be echoed back to the caller instead of a fixed value
that would misreport where the card actually landed.
*/
return {
content: [
{
type: "text",
text:
`Created ${task.id}: ${label}${workflowId ? ` (workflow: ${workflowId})` : ""}\n` +
`Column: ${task.column}\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<string, unknown> = {};
const updatedFields: string[] = [];
if (params.title !== undefined) {
updates.title = params.title.trim();
updatedFields.push("title");
}
if (params.description !== undefined) {
updates.description = params.description.trim();
updatedFields.push("description");
}
if (params.depends !== undefined) {
updates.dependencies = params.depends;
updatedFields.push("dependencies");
}
if (params.agentId !== undefined) {
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
if (typeof normalizedAgentId === "string") {
const error = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), normalizedAgentId, task);
if (error) {
return {
content: [{ type: "text", text: error }],
isError: true,
details: { error },
};
}
}
updates.assignedAgentId = normalizedAgentId;
updatedFields.push("agentId");
}
if (params.nodeId !== undefined) {
/*
FNXC:StateMachine 2026-07-07-12:00:
Signature 2 (FN-7641 / NEXT-322 / NEXT-375 / NEXT-340): nodeId='end' after an
out-of-band merge must never silently no-op here either. Pre-validate exactly like
the dashboard route so the CLI tool returns an explicit isError instead of a "success"
response that changed nothing when there is no durable merge proof. When proof exists,
`store.updateTask` below performs the real finalize-to-done move (shared logic in
TaskStore.updateTask / node-override-guard.ts), so this tool, the dashboard route, and
store.updateTask all exhibit identical behavior.
*/
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 requestedColumn = params.column as ColumnId | undefined;
const lines: string[] = [];
for (const col of COLUMNS) {
if (requestedColumn && requestedColumn !== 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("");
}
const emptyStateText = requestedColumn
? `No tasks in ${columnLabel(requestedColumn)} (${requestedColumn}).`
: "No matching tasks.";
/*
FNXC:TaskListOutput 2026-06-16-17:47:
FN-6492 routes CLI fn_task_list through the shared clamp so large column-filtered board reads remain text-only instead of being converted to host attachments.
FNXC:TaskListOutput 2026-06-17-05:46:
FN-6570 resolves the clamp from the runtime @fusion/core namespace and lets formatTaskListText fall back when stale dist/interoperability paths omit clampTaskListText, preventing heartbeat board reads from crashing.
FNXC:TaskListOutput 2026-06-17-07:25:
FN-6573 requires CLI fn_task_list to resolve formatTaskListText from the runtime @fusion/core namespace with a typeof guard and a self-contained bounded fallback. A stale @fusion/core dist missing the FN-6570 formatter export crashed ambient heartbeat agents as `(0 , _core.formatTaskListText) is not a function`; the tool must now return bounded text instead.
FNXC:TaskListOutput 2026-06-18-04:46:
FN-6630 refines FN-6492 by requiring filtered fn_task_list calls against empty target columns to return explicit empty-state text. Host runtimes can imageify empty content blocks as `(see attached image)`, so this call site must never emit empty or whitespace-only text.
*/
const formatter = resolveTaskListFormatter(fusionCore);
const text = lines.length === 0
? emptyStateText
: formatter(lines, { clamp: fusionCore.clampTaskListText }).trimEnd();
return {
content: [{ type: "text", text: text.trim().length > 0 ? text : emptyStateText }],
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(", ")}`,
);
}
/*
* FNXC:CliTaskAttach 2026-07-05-00:00:
* fn_task_attach must confine reads to the task worktree boundary (ctx.cwd) to
* prevent a path-traversal / absolute-path read-boundary bypass — an agent could
* previously pass "../../../etc/hosts" or an absolute path (with an allowed
* extension) and exfiltrate arbitrary files into a task's attachments. The guard
* must run BEFORE readFile so an out-of-boundary path is never opened, even to
* fail. The boundary is intentionally ctx.cwd (the worktree) — not a broader
* project root — to avoid re-exposing sibling worktrees. (FN-7619, flagged
* out-of-scope during FN-7608.)
*/
const boundaryRoot = resolve(ctx.cwd);
const rel = relative(boundaryRoot, filePath);
const escapesBoundary =
rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
if (escapesBoundary) {
throw new Error(
`Refusing to attach file outside the task worktree boundary: ${params.path}`,
);
}
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 ────────────────────────────────────────────────
/*
* FNXC:AgentPauseGuidance 2026-06-28-00:05:
* Pause is a user-requested manual-control lever, not an agent failure-handling response.
* Tool guidance must steer agents toward retry, follow-up work, or normal failure surfacing instead of stranding tasks in paused state.
*/
pi.registerTool({
name: "fn_task_pause",
label: "fn: Pause Task",
description:
"Pause a task for explicit user-requested manual control — stops all automated agent and scheduler interaction. Agents should not pause tasks to handle failures or blockers; use retry, create/delegate follow-up work, or let the task surface as failed instead.",
promptSnippet:
"Pause a Fusion task only for explicit user-requested manual control; do not use for failure/blocker handling",
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);
/*
FNXC:MissingWorktreeRetry 2026-07-10-18:30:
Upstream #1992 requires fn_task_retry to recover an in-review unusable-worktree session-start failure even when status remains merge-active. Keep this status bypass constrained to the centrally classified missing/incomplete/unregistered worktree signature.
*/
const isMissingWorktreeSessionRetry = isInReviewMissingWorktreeSessionStartFailure(task);
// Validate task is in a retryable state
if (task.status !== 'failed' && task.status !== 'stuck-killed' && !isInReviewRetry && !isMissingWorktreeSessionRetry) {
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" : "";
if (isMissingWorktreeSessionRetry) {
await store.updateTask(params.id, {
status: null,
error: null,
worktree: null,
branch: null,
sessionFile: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
await store.logEntry(params.id, `Retry requested via Fusion extension (unusable worktree session-start recovery → todo, preserving progress${retryLogSuffix})`);
await store.moveTask(params.id, "todo", { preserveProgress: true });
return {
content: [{ type: "text", text: `Retried ${params.id} → todo (unusable worktree session metadata cleared)` }],
details: { taskId: params.id, newColumn: 'todo' },
};
}
// 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_bypass_review ────────────────────────────────────────
/*
* FNXC:ReviewLaneBypass 2026-07-09-00:00:
* Policy-gated escape hatch (FN-7720) for a card stranded in `in-review`
* solely by a failed pre-merge review step — the leading real-world cause
* being the Runfusion/Fusion#1946 `(no feedback captured)` no-verdict
* dispatch defect. Registered ONLY on this pi-extension/CLI operator tool
* surface — deliberately NOT wired into packages/engine/src/executor.ts or
* packages/engine/src/agent-heartbeat.ts autonomous per-role tool lists, and
* NOT part of packages/dashboard/src/planning-board-tools.ts read-only
* planning tools — so headless executor/reviewer/triage agent runs never
* gain the bypass. Requires a mandatory reason; audit-logged via
* store.bypassFailedPreMergeReviewStep's run-audit event.
*/
pi.registerTool({
name: "fn_task_bypass_review",
label: "fn: Bypass Failed Review Step",
description:
"Policy-gated escape hatch for an in-review task stranded solely by a failed pre-merge " +
"review lane (leading real-world cause: the Runfusion/Fusion#1946 '(no feedback captured)' " +
"no-verdict dispatch defect), not a real REVISE. Rewrites the latest failed pre-merge " +
"WorkflowStepResult to a terminal non-blocking status with explicit bypass audit metadata " +
"(who/when/why/prior status) — it never fabricates a reviewer verdict. Requires a mandatory " +
"reason and is audit-logged. Clears ONLY the failed-pre-merge-step merge blocker; paused, " +
"incomplete-step, blocking-status, and still-pending conditions still block, and an " +
"autoMerge:false task is not force-merged.",
promptSnippet:
"Bypass a failed pre-merge review step on an in-review Fusion task (policy-gated, mandatory reason, audit-logged)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
reason: Type.String({ description: "Mandatory justification for the bypass (audit-logged)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const fnCtx = ctx as typeof ctx & { agentId?: string };
const actor = fnCtx.agentId ?? "cli-operator";
try {
const task = await store.bypassFailedPreMergeReviewStep(params.id, {
reason: params.reason,
actor,
});
return {
content: [{ type: "text", text: `Bypassed failed pre-merge review step for ${task.id}` }],
details: { taskId: task.id },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return {
content: [{ type: "text", text: `ERROR: Failed to bypass review lane for ${params.id}: ${err?.message ?? err}` }],
isError: true,
details: { taskId: params.id, error: String(err?.message ?? err) },
};
}
},
});
// ── 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 task from any live column (move to archived). " +
"Archived tasks are preserved for historical reference but moved out of the main board view. " +
"If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed.",
promptSnippet: "Archive a Fusion task from any live column (moves to archived column)",
promptGuidelines: [
"Use to clean up tasks from any live board column when you want them hidden from active views",
"Already archived tasks cannot be archived again",
"Archived tasks can be unarchived later if needed",
"If archiving fails because the task is still referenced as a lineage parent by another task, retry with removeLineageReferences:true to clear that reference and unblock the archive",
],
/*
FNXC:TaskLifecycleTools 2026-07-07-00:00:
fn_task_archive and fn_task_delete both gate on store.TaskHasLineageChildrenError, whose message tells the
caller to pass { removeLineageReferences: true } — but neither tool schema exposed that parameter, leaving
lineage-parent tasks permanently stuck (FN-7661). Expose it on both tools' Type.Object schema and forward it
to the store call so the recovery path the error message advertises is actually reachable by agents. Keep
this in sync with store.archiveTask / store.deleteTask option shapes if they change.
*/
parameters: Type.Object({
id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }),
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references (child sourceParentTaskId) before archiving, so a task still referenced as a lineage parent can be archived." })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.archiveTask(params.id, {
removeLineageReferences: params.removeLineageReferences === true,
});
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 → its restore column). " +
"Restores to the pre-archive column when available, with active execution columns downgraded to todo.",
promptSnippet: "Unarchive a Fusion task (restores to its pre-archive column)",
promptGuidelines: [
"Use to restore an archived task back to its pre-archive column when available",
"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. " +
"If the task is still referenced as a lineage parent by another task, deletion is rejected unless removeLineageReferences:true is passed.",
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",
"If deletion fails because the task is still referenced as a lineage parent by another task, retry with removeLineageReferences:true to clear that reference and unblock the delete",
],
/*
FNXC:TaskLifecycleTools 2026-07-07-00:00:
See matching comment on fn_task_archive above (FN-7661): the store's TaskHasLineageChildrenError message
advertises { removeLineageReferences: true } as the recovery path, so this tool must expose and forward it too.
*/
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." })),
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references (child sourceParentTaskId) before deleting, so a task still referenced as a lineage parent can be removed." })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const callerTaskId = (ctx as { taskId?: string }).taskId;
const task = await store.deleteTask(params.id, {
allowResurrection: params.allowResurrection === true,
removeLineageReferences: params.removeLineageReferences === true,
auditContext: {
agentId: "pi-extension",
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
taskId: callerTaskId,
},
});
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 importedIssueGithubTracking = await resolveImportedIssueGithubTracking(store);
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,
},
...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}),
});
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 importedIssueGithubTracking = await resolveImportedIssueGithubTracking(store);
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,
},
...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}),
});
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
return {
content: [
{
type: "text",
text: `Imported ${task.id} from GitHub\n${sourceUrl}`,
},
],
details: { taskId: task.id, sourceUrl },
};
},
});
// ── fn_task_browse_github_issues ──────────────────────────────────
// Browse available GitHub issues before importing
pi.registerTool({
name: "fn_task_browse_github_issues",
label: "fn: Browse GitHub Issues",
description:
"List open GitHub issues from a repository to browse before importing. " +
"Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue " +
"to import specific issues by number.",
promptSnippet: "Browse open GitHub issues in a repository",
promptGuidelines: [
"Use to preview available issues before importing",
"Returns a list you can reference when importing specific issues",
"Use --limit to control how many issues to show (default: 30)",
"Use --labels to filter by specific labels",
"Uses gh CLI authentication (run 'gh auth login')",
],
parameters: Type.Object({
owner: Type.String({
description: "Repository owner (e.g., 'dustinbyrne')",
}),
repo: Type.String({
description: "Repository name (e.g., 'fusion')",
}),
limit: Type.Optional(
Type.Number({
description: "Max issues to show (default: 30, max: 100)",
minimum: 1,
maximum: 100,
})
),
labels: Type.Optional(
Type.Array(Type.String(), {
description: "Label names to filter by",
})
),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const { owner, repo, limit = 30, labels } = params;
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
if (issues.length === 0) {
return {
content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
details: { count: 0, issues: [] },
};
}
// Check which issues are already imported
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks({ slim: true });
const importedUrls = new Set<string>();
for (const task of existingTasks) {
const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
if (match) {
importedUrls.add(match[1]);
}
}
const lines: string[] = [];
lines.push(`Found ${issues.length} open issues in ${owner}/${repo}:\n`);
for (const issue of issues) {
const isImported = importedUrls.has(issue.html_url);
const issueLabels = issue.labels ?? [];
const labelStr = issueLabels.length > 0 ? ` [${issueLabels.map((label) => label.name).join(", ")}]` : "";
const importedStr = isImported ? " ✓ Imported" : "";
lines.push(` #${issue.number}: ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${labelStr}${importedStr}`);
lines.push(` ${issue.html_url}`);
}
lines.push("\nUse fn_task_import_github_issue to import a specific issue by number.");
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
count: issues.length,
issues: issues.map((issue) => ({
number: issue.number,
title: issue.title,
url: issue.html_url,
labels: (issue.labels ?? []).map((label) => label.name),
imported: importedUrls.has(issue.html_url),
})),
},
};
},
});
async function createGitLabClient(ctx: { cwd: string }) {
const store = await getStore(ctx.cwd);
const projectSettings = await store.getSettings();
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const auth = dashboard.resolveGitlabAuth({ projectSettings, globalSettings });
if (!auth.ok) throw new Error(auth.message);
return { store, client: new dashboard.GitLabClient(auth.auth) };
}
async function importGitLabItems(ctx: { cwd: string }, resourceType: dashboard.GitLabResourceType, target: string, items: Array<dashboard.GitLabIssue | dashboard.GitLabMergeRequest>) {
const { store, client } = await createGitLabClient(ctx);
const existingTasks = await store.listTasks({ slim: false, includeArchived: false });
const createdTasks: Array<{ id: string; title: string }> = [];
for (const item of items) {
const provenance = dashboard.buildGitLabTaskProvenance({ auth: client.auth, resourceType, item, projectInput: resourceType !== "group_issue" ? target : undefined, groupInput: resourceType === "group_issue" ? target : undefined });
if (existingTasks.some((task) => dashboard.isGitLabAlreadyImported(task, provenance))) continue;
const title = resourceType === "merge_request" ? `Review MR !${item.iid}: ${item.title.slice(0, 180)}` : item.title.slice(0, 200);
const task = await store.createTask({ title: title || undefined, description: dashboard.buildGitLabTaskDescription(item), column: "triage", dependencies: [], sourceIssue: provenance.sourceIssue, gitlabTracking: provenance.gitlabTracking, source: { sourceType: "gitlab_import", sourceMetadata: provenance.sourceMetadata } });
await store.logEntry(task.id, resourceType === "merge_request" ? "Imported merge request from GitLab" : "Imported from GitLab", item.webUrl);
existingTasks.push(task);
createdTasks.push({ id: task.id, title: task.title || item.title });
}
return createdTasks;
}
pi.registerTool({
name: "fn_task_browse_gitlab_project_issues",
label: "fn: Browse GitLab Project Issues",
description: "List GitLab project issues from the configured GitLab instance.",
promptSnippet: "Browse GitLab project issues",
parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const { client } = await createGitLabClient(ctx);
const issues = await client.listProjectIssues(params.project, { limit: params.limit, labels: params.labels });
return { content: [{ type: "text", text: issues.map((issue) => `#${issue.iid}: ${issue.title}\n${issue.webUrl}`).join("\n") || `No GitLab project issues found in ${params.project}.` }], details: { count: issues.length, issues } };
},
});
pi.registerTool({
name: "fn_task_import_gitlab_project_issues",
label: "fn: Import GitLab Project Issues",
description: "Import GitLab project issues as Fusion tasks using configured GitLab HTTP API auth.",
promptSnippet: "Import GitLab project issues",
parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const { client } = await createGitLabClient(ctx);
const issues = await client.listProjectIssues(params.project, { limit: params.limit, labels: params.labels });
const createdTasks = await importGitLabItems(ctx, "project_issue", params.project, issues);
return { content: [{ type: "text", text: `Imported ${createdTasks.length} GitLab project issue tasks.` }], details: { createdTasks } };
},
});
pi.registerTool({
name: "fn_task_browse_gitlab_group_issues",
label: "fn: Browse GitLab Group Issues",
description: "List GitLab group issues while preserving each issue's originating project identity.",
promptSnippet: "Browse GitLab group issues",
parameters: Type.Object({ group: Type.String({ description: "GitLab group path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const { client } = await createGitLabClient(ctx);
const issues = await client.listGroupIssues(params.group, { limit: params.limit, labels: params.labels });
return { content: [{ type: "text", text: issues.map((issue) => `#${issue.iid}: ${issue.projectPath ?? issue.projectId} — ${issue.title}\n${issue.webUrl}`).join("\n") || `No GitLab group issues found in ${params.group}.` }], details: { count: issues.length, issues } };
},
});
pi.registerTool({
name: "fn_task_import_gitlab_group_issues",
label: "fn: Import GitLab Group Issues",
description: "Import GitLab group issues as Fusion tasks using each issue's originating project identity.",
promptSnippet: "Import GitLab group issues",
parameters: Type.Object({ group: Type.String({ description: "GitLab group path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const { client } = await createGitLabClient(ctx);
const issues = await client.listGroupIssues(params.group, { limit: params.limit, labels: params.labels });
const createdTasks = await importGitLabItems(ctx, "group_issue", params.group, issues);
return { content: [{ type: "text", text: `Imported ${createdTasks.length} GitLab group issue tasks.` }], details: { createdTasks } };
},
});
pi.registerTool({
name: "fn_task_browse_gitlab_merge_requests",
label: "fn: Browse GitLab Merge Requests",
description: "List GitLab project merge requests from the configured GitLab instance.",
promptSnippet: "Browse GitLab merge requests",
parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const { client } = await createGitLabClient(ctx);
const mergeRequests = await client.listMergeRequests(params.project, { limit: params.limit, labels: params.labels });
return { content: [{ type: "text", text: mergeRequests.map((mr) => `!${mr.iid}: ${mr.title}\n${mr.webUrl}`).join("\n") || `No GitLab merge requests found in ${params.project}.` }], details: { count: mergeRequests.length, mergeRequests } };
},
});
pi.registerTool({
name: "fn_task_import_gitlab_merge_requests",
label: "fn: Import GitLab Merge Requests",
description: "Import GitLab project merge requests as Fusion review tasks using configured GitLab HTTP API auth.",
promptSnippet: "Import GitLab merge requests",
parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const { client } = await createGitLabClient(ctx);
const mergeRequests = await client.listMergeRequests(params.project, { limit: params.limit, labels: params.labels });
const createdTasks = await importGitLabItems(ctx, "merge_request", params.project, mergeRequests);
return { content: [{ type: "text", text: `Imported ${createdTasks.length} GitLab merge request tasks.` }], details: { createdTasks } };
},
});
// ── 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<typeof goal> => 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<typeof linkedGoal> => 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<typeof linkedGoal> => 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<string, unknown> = {
...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}),
...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}),
...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}),
};
const created = await agentStore.createAgent({
name: params.name,
role: params.role as never,
...(params.soul !== undefined ? { soul: params.soul } : {}),
...(params.instructions_text !== undefined ? { instructionsText: params.instructions_text } : {}),
...(params.instructions_path !== undefined ? { instructionsPath: params.instructions_path } : {}),
...(params.reportsTo !== undefined ? { reportsTo: params.reportsTo } : {}),
...(Object.keys(runtimeConfig).length > 0 ? { runtimeConfig } : {}),
});
return {
content: [{ type: "text" as const, text: `Created agent ${created.name} (${created.id})` }],
details: { outcome: "created", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agent: created, agentId: created.id },
};
},
});
// ── fn_agent_update ─────────────────────────────────────────────
/**
* FNXC:AgentManagement 2026-07-02-12:00:
* Chat operators need broad in-place edits for existing non-ephemeral agent configuration so they can change role, instructions, manager links, and heartbeat settings without delete/recreate churn.
* Keep the update org-scoped for agent callers and funnel successful edits through one AgentStore.updateAgent call so hierarchy checks and config revisions stay auditable.
*/
pi.registerTool({
name: "fn_agent_update",
label: "fn: Update Agent",
description:
"Update editable configuration for an existing non-ephemeral agent. " +
"Agent callers can only update direct or indirect reports inside their management subtree; user/operator calls are privileged.",
promptSnippet: "Update an existing Fusion agent without deleting and recreating it",
promptGuidelines: [
"Use to update editable agent configuration such as role, instructions, manager, and heartbeat settings",
"Agent callers can only target direct or indirect reports, never themselves, peers, ancestors, or unrelated agents",
"Use reportsTo as an agent ID/name for a new manager; privileged user/operator calls may pass reportsTo: \"\" to clear the manager",
],
parameters: Type.Object({
agent_id: Type.String({ description: "Target agent ID or name to update" }),
name: Type.Optional(Type.String({ description: "New display name" })),
role: Type.Optional(Type.Union([
Type.Literal("triage"),
Type.Literal("executor"),
Type.Literal("reviewer"),
Type.Literal("merger"),
Type.Literal("engineer"),
Type.Literal("custom"),
], { description: "Agent role/capability" })),
title: Type.Optional(Type.String({ description: "Optional title shown for the agent" })),
icon: Type.Optional(Type.String({ description: "Optional compact icon/emoji" })),
soul: Type.Optional(Type.String({ description: "Agent personality/identity text", maxLength: 10000 })),
instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions", maxLength: 50000 })),
instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown", maxLength: 500 })),
heartbeat_procedure_path: Type.Optional(Type.String({ description: "Path to heartbeat procedure markdown", maxLength: 500 })),
reportsTo: Type.Optional(Type.String({ description: "Manager agent ID/name. Pass empty string to clear for privileged user/operator calls." })),
heartbeat_interval_ms: Type.Optional(Type.Number({ minimum: 1000, description: "Heartbeat polling interval in ms" })),
heartbeat_timeout_ms: Type.Optional(Type.Number({ minimum: 5000, description: "Heartbeat timeout in ms" })),
max_concurrent_runs: Type.Optional(Type.Number({ minimum: 1, description: "Max concurrent heartbeat runs" })),
message_response_mode: Type.Optional(Type.Union([
Type.Literal("immediate"),
Type.Literal("on-heartbeat"),
], { description: "How agent responds to messages" })),
}),
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 updateParamKeys = [
"name",
"role",
"title",
"icon",
"soul",
"instructions_text",
"instructions_path",
"heartbeat_procedure_path",
"reportsTo",
"heartbeat_interval_ms",
"heartbeat_timeout_ms",
"max_concurrent_runs",
"message_response_mode",
] as const;
const providedKeys = updateParamKeys.filter((key) => params[key] !== undefined);
const invalid = (field: string, message: string, extra: Record<string, unknown> = {}) => ({
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
isError: true,
details: { outcome: "invalid", field, error: message, ...extra },
});
const denied = (message: string, extra: Record<string, unknown> = {}) => ({
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
isError: true,
details: { outcome: "denied", error: message, ...extra },
});
if (providedKeys.length === 0) {
return invalid("fields", "Provide at least one field to update");
}
if (params.soul !== undefined && params.soul.length > 10000) {
return invalid("soul", "soul exceeds 10000 character limit");
}
if (params.instructions_text !== undefined && params.instructions_text.length > 50000) {
return invalid("instructions_text", "instructions_text exceeds 50000 character limit");
}
if (params.instructions_path !== undefined && params.instructions_path.length > 500) {
return invalid("instructions_path", "instructions_path exceeds 500 character limit");
}
if (params.heartbeat_procedure_path !== undefined && params.heartbeat_procedure_path.length > 500) {
return invalid("heartbeat_procedure_path", "heartbeat_procedure_path exceeds 500 character limit");
}
if (params.heartbeat_interval_ms !== undefined && params.heartbeat_interval_ms < 1000) {
return invalid("heartbeat_interval_ms", "heartbeat_interval_ms must be at least 1000");
}
if (params.heartbeat_timeout_ms !== undefined && params.heartbeat_timeout_ms < 5000) {
return invalid("heartbeat_timeout_ms", "heartbeat_timeout_ms must be at least 5000");
}
if (params.max_concurrent_runs !== undefined && params.max_concurrent_runs < 1) {
return invalid("max_concurrent_runs", "max_concurrent_runs must be at least 1");
}
const target = (await agentStore.getAgent(params.agent_id)) ?? (await agentStore.resolveAgent(params.agent_id));
if (!target) {
return {
content: [{ type: "text" as const, text: `Agent '${params.agent_id}' not found` }],
isError: true,
details: { outcome: "not_found", error: "Agent not found", agentId: params.agent_id },
};
}
if (isEphemeralAgent(target)) {
return invalid("agent_id", `Cannot update ephemeral/runtime agent ${target.id}`, { agentId: target.id });
}
const fnCtx = ctx as typeof ctx & { agentId?: string };
const callerAgentId = fnCtx.agentId;
const targetChain = await agentStore.getChainOfCommand(target.id);
if (callerAgentId) {
if (callerAgentId === target.id) {
return denied("You can only update your own direct or indirect reports, not yourself.", {
agentId: target.id,
callerAgentId,
rule: "direct-or-indirect-reports-only",
});
}
const callerIndex = targetChain.findIndex((agent) => agent.id === callerAgentId);
if (callerIndex < 1) {
return denied("You can only update your own direct or indirect reports.", {
agentId: target.id,
callerAgentId,
rule: "direct-or-indirect-reports-only",
});
}
}
let resolvedReportsTo: string | undefined;
let managerForCycleCheck: string | undefined;
if (params.reportsTo !== undefined) {
if (params.reportsTo === "") {
if (callerAgentId) {
return denied("Only privileged user/operator calls can clear an agent's manager.", {
agentId: target.id,
callerAgentId,
rule: "privileged-clear-manager-only",
});
}
resolvedReportsTo = undefined;
} else {
const manager = await agentStore.resolveAgent(params.reportsTo);
if (!manager) {
return invalid("reportsTo", `Manager '${params.reportsTo}' not found`, { agentId: target.id });
}
if (manager.id === target.id) {
return invalid("reportsTo", "An agent cannot report to itself", { agentId: target.id });
}
const managerChain = await agentStore.getChainOfCommand(manager.id);
if (managerChain.some((agent) => agent.id === target.id)) {
return invalid("reportsTo", "reportsTo would create a management cycle", {
agentId: target.id,
managerId: manager.id,
});
}
if (callerAgentId) {
const managerCallerIndex = managerChain.findIndex((agent) => agent.id === callerAgentId);
if (manager.id !== callerAgentId && managerCallerIndex < 1) {
return denied("You can only reparent reports to yourself or another agent in your management subtree.", {
agentId: target.id,
callerAgentId,
managerId: manager.id,
rule: "reparent-within-subtree-only",
});
}
}
resolvedReportsTo = manager.id;
managerForCycleCheck = manager.id;
}
}
const hasRuntimeConfigUpdates = [
params.heartbeat_interval_ms,
params.heartbeat_timeout_ms,
params.max_concurrent_runs,
params.message_response_mode,
].some((value) => value !== undefined);
const updateInput: AgentUpdateInput = {};
const updatedFields: string[] = [];
const setField = <K extends keyof AgentUpdateInput>(field: K, value: AgentUpdateInput[K]) => {
updateInput[field] = value;
updatedFields.push(String(field));
};
if (params.name !== undefined) setField("name", params.name);
if (params.role !== undefined) setField("role", params.role as AgentCapability);
if (params.title !== undefined) setField("title", params.title);
if (params.icon !== undefined) setField("icon", params.icon);
if (params.soul !== undefined) setField("soul", params.soul);
if (params.instructions_text !== undefined) setField("instructionsText", params.instructions_text);
if (params.instructions_path !== undefined) setField("instructionsPath", params.instructions_path);
if (params.heartbeat_procedure_path !== undefined) setField("heartbeatProcedurePath", params.heartbeat_procedure_path);
if (params.reportsTo !== undefined) {
setField("reportsTo", resolvedReportsTo);
}
if (hasRuntimeConfigUpdates) {
setField("runtimeConfig", {
...((target.runtimeConfig ?? {}) as Record<string, unknown>),
...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}),
...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}),
...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}),
});
}
if (managerForCycleCheck && managerForCycleCheck === target.id) {
return invalid("reportsTo", "An agent cannot report to itself", { agentId: target.id });
}
const updated = await agentStore.updateAgent(target.id, updateInput);
return {
content: [{
type: "text" as const,
text: `Updated ${updated.name} (${updated.id}): ${updatedFields.join(", ")}`,
}],
details: { outcome: "updated", agentId: updated.id, updatedFields, agent: updated },
};
},
});
// ── fn_agent_set_instructions ───────────────────────────────────
/**
* FNXC:AgentManagement 2026-06-19-06:58:
* Managing agents need a scoped runtime tool for updating a direct or indirect report's operating instructions without granting peer, ancestor, or self-mutation rights.
* The no-agent caller path remains privileged for CLI/user control, while agent callers must appear as an ancestor in the target's chain of command so AgentStore config revisions preserve an auditable record of each instruction edit.
*/
pi.registerTool({
name: "fn_agent_set_instructions",
label: "fn: Set Agent Instructions",
description:
"Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. " +
"At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. " +
"The change is persisted and recorded as a config revision.",
promptSnippet: "Update operating instructions for an agent in your management subtree",
promptGuidelines: [
"Use to update operating instructions for an agent in your management subtree",
"You can only target your own direct or indirect reports, not yourself, peers, or ancestors",
"Provide instructions_text, instructions_path, or both; use an explicit empty string to clear a field",
],
parameters: Type.Object({
agent_id: Type.String({ description: "Target agent whose instructions to set" }),
instructions_text: Type.Optional(
Type.String({ description: "Inline instructions. Pass an empty string to clear." }),
),
instructions_path: Type.Optional(
Type.String({ description: "Path to a markdown instructions file. Pass an empty string to clear." }),
),
}),
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 hasInstructionsText = params.instructions_text !== undefined;
const hasInstructionsPath = params.instructions_path !== undefined;
if (!hasInstructionsText && !hasInstructionsPath) {
return {
content: [{ type: "text" as const, text: "ERROR: Provide instructions_text and/or instructions_path to update agent instructions." }],
isError: true,
details: { outcome: "invalid", error: "instructions_text or instructions_path is required" },
};
}
const target = await agentStore.resolveAgent(params.agent_id);
if (!target) {
return {
content: [{ type: "text" as const, text: `Agent '${params.agent_id}' not found` }],
isError: true,
details: { outcome: "not_found", error: "Agent not found", agentId: params.agent_id },
};
}
const fnCtx = ctx as typeof ctx & { agentId?: string };
const callerAgentId = fnCtx.agentId;
if (callerAgentId) {
if (callerAgentId === target.id) {
return {
content: [{ type: "text" as const, text: "ERROR: You can only set instructions for your own direct or indirect reports, not yourself." }],
isError: true,
details: { outcome: "denied", agentId: target.id, callerAgentId, rule: "direct-or-indirect-reports-only" },
};
}
const chain = await agentStore.getChainOfCommand(target.id);
const callerIndex = chain.findIndex((agent) => agent.id === callerAgentId);
if (callerIndex < 1) {
return {
content: [{ type: "text" as const, text: "ERROR: You can only set instructions for your own direct or indirect reports." }],
isError: true,
details: { outcome: "denied", agentId: target.id, callerAgentId, rule: "direct-or-indirect-reports-only" },
};
}
}
const updatedFields: string[] = [];
if (hasInstructionsText) updatedFields.push("instructionsText");
if (hasInstructionsPath) updatedFields.push("instructionsPath");
const updated = await agentStore.updateAgent(target.id, {
...(hasInstructionsText ? { instructionsText: params.instructions_text } : {}),
...(hasInstructionsPath ? { instructionsPath: params.instructions_path } : {}),
});
return {
content: [{
type: "text" as const,
text: `Updated ${updated.name} (${updated.id}) instructions: ${updatedFields.join(", ")}`,
}],
details: { outcome: "updated", agentId: updated.id, updatedFields },
};
},
});
// ── fn_agent_delete ─────────────────────────────────────────────
pi.registerTool({
name: "fn_agent_delete",
label: "fn: Delete Agent",
description: "Delete a non-ephemeral agent.",
parameters: Type.Object({
agent_id: Type.String({ description: "Agent ID to delete" }),
force: Type.Optional(Type.Boolean({ description: "Force delete when holding checkout" })),
reassign_to: Type.Optional(Type.String({ description: "Optional replacement agent for assigned tasks" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, ApprovalRequestStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const store = await getStore(ctx.cwd);
const caller = { id: "user", role: "user", isPrivileged: true } as const;
const policy = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller,
settings: await store.getSettings(),
});
if (policy.decision === "require-approval") {
const approvalStore = new ApprovalRequestStore(store.getDatabase());
const request = approvalStore.create({
requester: { actorId: "user", actorType: "user", actorName: "CLI User" },
targetAction: { category: "agent_provisioning", action: "delete", summary: `Delete agent ${params.agent_id}`, resourceType: "agent", resourceId: params.agent_id, context: { tool: "fn_agent_delete", params } },
});
return { content: [{ type: "text" as const, text: `Approval required. Request ${request.id} created.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id } };
}
if (policy.decision === "deny") {
return {
content: [{ type: "text" as const, text: `DENIED: agent delete blocked by policy (${policy.matchedRule})` }],
details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id },
};
}
await agentStore.deleteAgent(params.agent_id, { force: params.force === true, reassignTo: params.reassign_to });
return {
content: [{ type: "text" as const, text: `Deleted ${params.agent_id}` }],
details: { outcome: "deleted", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id },
};
},
});
// ── fn_list_agents ───────────────────────────────────────────────
pi.registerTool({
name: "fn_list_agents",
label: "fn: List Agents",
description:
"List all available agents in the system. Shows each agent's name, role, state, " +
"personality (soul), and current assignment. Use this to discover which agents exist " +
"and what they specialize in before delegating work.",
promptSnippet: "List all available Fusion agents",
promptGuidelines: [
"Use fn_list_agents to discover which agents exist before delegating work",
"Filter by role or state to narrow results",
"Ephemeral/runtime agents are excluded by default",
],
parameters: Type.Object({
role: Type.Optional(
Type.String({ description: "Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa')" }),
),
state: Type.Optional(
Type.String({ description: "Filter by agent state (e.g., 'idle', 'active', 'running')" }),
),
includeEphemeral: Type.Optional(
Type.Boolean({ description: "Include ephemeral/runtime agents (default: false)" }),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const filter: Record<string, unknown> = {};
if (params.role) filter.role = params.role;
if (params.state) filter.state = params.state;
if (params.includeEphemeral !== undefined) filter.includeEphemeral = params.includeEphemeral;
const agents = await agentStore.listAgents(filter as Parameters<typeof agentStore.listAgents>[0]);
const store = await getStore(ctx.cwd);
if (agents.length === 0) {
return {
content: [{ type: "text" as const, text: "No agents found matching the specified filters." }],
details: { agents: [], count: 0 },
};
}
const lines = await Promise.all(agents.map(async (agent) => {
const parts: string[] = [
`ID: ${agent.id}`,
`Name: ${agent.name}`,
`Role: ${agent.role}`,
`State: ${agent.state}`,
];
if (agent.title) parts.push(`Title: ${agent.title}`);
/*
FNXC:AgentHeartbeat 2026-07-12-18:20:
FN-7859 requires list output to explain error/paused durable agents without DB inspection, while keeping healthy-agent rows compact.
*/
appendAgentDiagnosticLines(parts, agent, { compact: true });
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) {
/*
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
Show the linked task column in fn_list_agents so parked triage/todo ownership is not mistaken for an in-progress execution mismatch.
*/
let linkedTask: Pick<Task, "id" | "column"> | null = null;
try {
linkedTask = await store.getTask(agent.taskId);
} catch {
linkedTask = null;
}
parts.push(formatCurrentTaskLine(agent.taskId, linkedTask));
}
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<Task, "id" | "column"> = { id: "<new>", column: "todo" };
const agentError = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), params.agent_id, delegateTask, params.override === true);
if (agentError) {
return {
content: [{ type: "text", text: `ERROR: ${agentError}` }],
isError: true,
details: { error: agentError },
};
}
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.agent_id);
try {
// Create task assigned to the target agent
const store = await getStore(ctx.cwd);
const 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 store = await getStore(ctx.cwd);
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}`);
/*
FNXC:AgentHeartbeat 2026-07-12-18:20:
FN-7859 requires fn_agent_show to surface why an agent is in error/paused so operators can classify recovery state without engine logs.
*/
appendAgentDiagnosticLines(parts, agent, { compact: false });
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) {
/*
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
Show the linked task column in fn_agent_show so parked triage/todo ownership is not mistaken for an in-progress execution mismatch.
*/
let linkedTask: Pick<Task, "id" | "column"> | null = null;
try {
linkedTask = await store.getTask(agent.taskId);
} catch {
linkedTask = null;
}
parts.push(formatCurrentTaskLine(agent.taskId, linkedTask));
}
if (agent.instructionsText) {
const snippet = agent.instructionsText.slice(0, 100);
parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`);
}
if (agent.soul) {
const snippet = agent.soul.slice(0, 200);
parts.push(`Soul: ${snippet}${agent.soul.length > 200 ? "…" : ""}`);
}
if (agent.metadata?.skills) {
parts.push(`Skills: ${JSON.stringify(agent.metadata.skills)}`);
}
return {
content: [{ type: "text" as const, text: parts.join("\n") }],
details: {
agent,
directReports: directReports.map((r) => ({ id: r.id, name: r.name, role: r.role })),
},
};
},
});
// ── fn_agent_org_chart ────────────────────────────────────────────
pi.registerTool({
name: "fn_agent_org_chart",
label: "fn: Agent Org Chart",
description:
"Show the organizational tree of agents, displaying the role hierarchy. " +
"Optionally filter to a subtree rooted at a specific agent.",
promptSnippet: "Show the Fusion agent org chart",
promptGuidelines: [
"Use to understand the team structure and reporting hierarchy",
"Optionally specify a root agent to see only their subtree",
"Ephemeral/runtime agents are excluded by default",
],
parameters: Type.Object({
root_agent_id: Type.Optional(
Type.String({ description: "If provided, show only the subtree rooted at this agent" }),
),
include_ephemeral: Type.Optional(
Type.Boolean({ description: "Include ephemeral/runtime agents (default: false)" }),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
type OrgTreeNode = { agent: { id: string; icon?: string; name: string; role: string; state: string; taskId?: string }; children: OrgTreeNode[] };
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const includeEphemeral = params.include_ephemeral ?? false;
// If root_agent_id specified, show subtree via chain-of-command + reports
if (params.root_agent_id) {
const rootAgent = await agentStore.resolveAgent(params.root_agent_id);
if (!rootAgent) {
return {
content: [{ type: "text", text: `Agent '${params.root_agent_id}' not found` }],
isError: true,
details: { error: "Root agent not found" },
};
}
// Get the full tree, then find the subtree
const fullTree = await agentStore.getOrgTree({ includeEphemeral });
// Find the subtree rooted at the specified agent
const findSubtree = (nodes: OrgTreeNode[]): OrgTreeNode | null => {
for (const node of nodes) {
if (node.agent.id === rootAgent.id) return node;
const found = findSubtree(node.children);
if (found) return found;
}
return null;
};
const subtree = findSubtree(fullTree);
if (!subtree) {
// Agent exists but has no tree position — show just that agent
return {
content: [{
type: "text" as const,
text: `${rootAgent.icon ?? "🤖"} ${rootAgent.name} (${rootAgent.role}) — ${rootAgent.state}${rootAgent.taskId ? ` [${rootAgent.taskId}]` : ""}`,
}],
details: { tree: [{ agent: rootAgent, children: [] }] },
};
}
const lines: string[] = [];
const renderNode = (node: OrgTreeNode, indent: string) => {
const a = node.agent;
lines.push(
`${indent}${a.icon ?? "🤖"} ${a.name} (${a.role}) — ${a.state}${a.taskId ? ` [${a.taskId}]` : ""}`,
);
for (const child of node.children) {
renderNode(child, indent + " ");
}
};
renderNode(subtree, "");
return {
content: [{ type: "text" as const, text: `Agent Org Tree (subtree: ${rootAgent.name}):\n${lines.join("\n")}` }],
details: { tree: [subtree] },
};
}
// Full tree
const tree = await agentStore.getOrgTree({ includeEphemeral });
if (tree.length === 0) {
return {
content: [{ type: "text" as const, text: "No agents found." }],
details: { tree: [], count: 0 },
};
}
const lines: string[] = [];
let count = 0;
const renderNode = (node: OrgTreeNode, indent: string) => {
const a = node.agent;
lines.push(
`${indent}${a.icon ?? "🤖"} ${a.name} (${a.role}) — ${a.state}${a.taskId ? ` [${a.taskId}]` : ""}`,
);
count++;
for (const child of node.children) {
renderNode(child, indent + " ");
}
};
for (const root of tree) {
renderNode(root, "");
}
return {
content: [{ type: "text" as const, text: `Agent Org Tree (${count} agents):\n${lines.join("\n")}` }],
details: { tree, count },
};
},
});
// ── fn_skills_search ─────────────────────────────────────────────
pi.registerTool({
name: "fn_skills_search",
label: "FN: Search Skills",
description:
"Search the skills.sh directory for agent skills. Returns matching skills with names, " +
"sources (owner/repo), install counts, and install commands. " +
"Use fn_skills_install to install a selected skill.",
promptSnippet: "Search skills.sh for agent skills",
promptGuidelines: [
"Use fn_skills_search to discover skills before installing",
"Returns skills sorted by popularity (install count)",
],
parameters: Type.Object({
query: Type.String({
description:
"Search query — framework name, technology, or capability (e.g., 'react', 'firebase', 'testing', 'docker')",
}),
limit: Type.Optional(
Type.Number({
description: "Max results to return (default: 10, max: 50)",
minimum: 1,
maximum: 50,
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
// Dynamic import to match existing extension patterns
const { searchSkills, formatInstalls } = await import("./commands/skills.js");
const skills = await searchSkills(params.query, params.limit ?? 10);
if (skills.length === 0) {
return {
content: [{ type: "text", text: `No skills found for '${params.query}'` }],
details: { count: 0, skills: [] },
};
}
const lines: string[] = [];
lines.push(`Found ${skills.length} skills matching '${params.query}':\n`);
for (let i = 0; i < skills.length; i++) {
const skill = skills[i]!;
const installs = formatInstalls(skill.installs);
lines.push(`${i + 1}. ${skill.name} (${skill.source})${installs ? ` — ${installs}` : ""}`);
}
lines.push("\nInstall a skill with: fn_skills_install({ source: \"<owner/repo>\", skill: \"<name>\" })");
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
count: skills.length,
skills: skills.map((s) => ({
name: s.name,
source: s.source,
installs: s.installs,
installCommand: `fn skills install ${s.source} --skill ${s.name}`,
})),
},
};
},
});
// ── fn_skills_install ─────────────────────────────────────────────
pi.registerTool({
name: "fn_skills_install",
label: "FN: Install Skill",
description:
"Install an agent skill from skills.sh into the current project. " +
"Downloads skill files into the project's skill directories (.fusion/skills/, legacy .pi/skills/, .agents/skills/). " +
"The skill becomes available to AI agents in subsequent sessions.",
promptSnippet: "Install a skill from skills.sh into the current project",
promptGuidelines: [
"Use fn_skills_install after fn_skills_search to install a discovered skill",
"The source is in owner/repo format (e.g., 'firebase/agent-skills')",
"Specify the skill name to install a specific skill, or omit to install all from the source",
],
parameters: Type.Object({
source: Type.String({
description: "GitHub source in owner/repo format (e.g., 'firebase/agent-skills')",
}),
skill: Type.Optional(
Type.String({
description: "Specific skill name to install (e.g., 'firebase-basics'). Omit to install all skills from the source.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Validate source format
if (!/^[^/]+\/[^/]+$/.test(params.source)) {
return {
content: [
{
type: "text",
text: `Invalid source format: '${params.source}'. Use owner/repo format (e.g., 'firebase/agent-skills').`,
},
],
isError: true,
details: { error: "Invalid source format" },
};
}
// Build npx skills add arguments
const npxArgs = ["skills", "add", params.source];
if (params.skill) {
npxArgs.push("--skill", params.skill);
}
// Non-interactive mode (-y) targeting pi agent (-a pi)
npxArgs.push("-y", "-a", "pi");
// Execute via spawn
const child = spawn("npx", npxArgs, {
cwd: resolveProjectRoot(ctx.cwd),
stdio: "pipe",
shell: true,
});
let stderr = "";
child.stdout?.on("data", () => {});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
const exitCode = await new Promise<number>((resolve) => {
child.on("exit", (code) => {
resolve(code ?? 1);
});
child.on("error", () => {
resolve(1);
});
});
try {
// Always dispose the child process
child.kill();
} catch {
// Ignore errors during cleanup
}
if (exitCode !== 0) {
return {
content: [
{
type: "text",
text: `Failed to install skill: ${stderr || "npx skills add exited with code " + exitCode}`,
},
],
isError: true,
details: { exitCode, stderr },
};
}
return {
content: [
{
type: "text",
text: `Installed skill from ${params.source}. Skills are discovered from .fusion/skills/, legacy .pi/skills/, and .agents/skills/. The skill will be available in future agent sessions.`,
},
],
details: { source: params.source, skill: params.skill ?? "all" },
};
},
});
// ── /fn command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;
let dashboardPort: number | null = null;
pi.registerCommand("fn", {
description: "Start (or stop) the Fusion dashboard and AI engine",
handler: async (args, ctx) => {
const trimmed = (args ?? "").trim();
// /fn stop — kill the dashboard
if (trimmed === "stop") {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("fn", "");
ctx.ui.notify("Fusion dashboard stopped", "info");
} else {
ctx.ui.notify("Fusion dashboard is not running", "warning");
}
return;
}
// /fn status
if (trimmed === "status") {
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(`Fusion dashboard running on http://localhost:${dashboardPort}`, "info");
} else {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.notify("Fusion dashboard is not running", "info");
}
return;
}
// /fn [port] — start the dashboard
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(
`Fusion dashboard already running on http://localhost:${dashboardPort}. Use /fn stop first.`,
"warning",
);
return;
}
const port = trimmed ? parseInt(trimmed, 10) || 4040 : 4040;
// Find the fn binary: prefer local node_modules, then global
const child = spawn("fn", ["dashboard", "--port", String(port)], {
cwd: resolveProjectRoot(ctx.cwd),
stdio: ["ignore", "pipe", "pipe"],
detached: false,
env: { ...process.env },
shell: true,
});
dashboardProcess = child;
dashboardPort = port;
// Watch for early exit (e.g. fn not found)
child.on("error", (err) => {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("fn", "");
ctx.ui.notify(`Failed to start Fusion dashboard: ${err.message}`, "error");
});
child.on("exit", (code) => {
if (dashboardProcess === child) {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("fn", "");
if (code !== 0 && code !== null) {
ctx.ui.notify(`Fusion dashboard exited with code ${code}`, "warning");
}
}
});
// Wait briefly to see if it crashes immediately
await new Promise((r) => setTimeout(r, 500));
if (dashboardProcess && !dashboardProcess.killed) {
const url = `http://localhost:${port}`;
ctx.ui.notify(`Fusion dashboard started on ${url} (AI engine active)`, "info");
const link = `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\`;
ctx.ui.setStatus("fn", `Fusion ● ${link}`);
}
},
});
// ── Cleanup on session end ───────────────────────────────────────
pi.on("session_shutdown", async () => {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
}
void closeCachedStores();
});
}