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>
5503 lines
222 KiB
TypeScript
5503 lines
222 KiB
TypeScript
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();
|
||
});
|
||
}
|