Files
fusion/packages/cli/src/commands/task.ts
gsxdsm 8b22dd2ffe FN-7090: link imported GitHub issues as tracked tasks
Link GitHub issue imports to existing source issues when tracking defaults are enabled.

- Resolve project/global GitHub tracking defaults before CLI, extension, and dashboard issue imports.
- Mark imported issue tasks as tracking-enabled so the post-create hook adopts the source issue instead of creating duplicates.
- Cover tracked and untracked import behavior across CLI tools, task commands, and dashboard GitHub routes.
- Document the import tracking behavior and add a minor changeset for the published CLI.

Files changed:
 .changeset/fn-7090-import-github-tracked.md        |   7 ++
 docs/cli-reference.md                              |   2 +
 docs/settings-reference.md                         |   2 +-
 .../__tests__/extension-github-tracking.test.ts    |  63 +++++++++++
 packages/cli/src/__tests__/extension.test.ts       | 125 ++++++++++++++++++++-
 .../task-command-github-import-tracking.test.ts    | 119 ++++++++++++++++++++
 packages/cli/src/commands/__tests__/task.test.ts   |  64 ++++++++++-
 packages/cli/src/commands/task.ts                  |  30 ++++-
 packages/cli/src/extension.ts                      |  27 +++++
 .../dashboard/src/__tests__/routes-github.test.ts  |  74 ++++++++++++
 .../dashboard/src/routes/register-git-github.ts    |  21 +++-
 11 files changed, 524 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7090
Fusion-Task-Lineage: 1e5510c3-7a98-4ff2-a109-e465ff58b9c2
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 09:28:29 -07:00

2010 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { runAiMerge, landWorkspaceTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import * as dashboard from "@fusion/dashboard";
import {
getGhErrorMessage,
isGhAuthenticated,
isGhAvailable,
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { resolveProject, type ProjectContext } from "../project-context.js";
import { findNodeByNameOrId } from "./node.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
/** #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;
}
// Register GitHub tracking hook so CLI task creation paths (add, duplicate,
// refine, import, delegate) trigger tracking issue creation.
try {
dashboard.registerGithubTrackingHook?.();
} catch {
// Some tests partially mock @fusion/dashboard and omit the hook export.
}
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined;
const issueUrl = (sourceMetadata as { issueUrl?: unknown }).issueUrl;
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
}
function getResearchSourceContext(sourceMetadata: unknown): string | undefined {
if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined;
const findingLabel = (sourceMetadata as { findingLabel?: unknown }).findingLabel;
if (typeof findingLabel === "string" && findingLabel.length > 0) {
return findingLabel;
}
const runId = (sourceMetadata as { runId?: unknown }).runId;
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
}
async function formatTaskDuplicateLineage(task: Awaited<ReturnType<TaskStore["getTask"]>>, 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 labels.join(", ");
}
function formatTaskSource(task: {
sourceType?: string;
sourceAgentId?: string;
sourceParentTaskId?: string;
sourceMetadata?: unknown;
}): string | null {
switch (task.sourceType) {
case "dashboard_ui":
return "Dashboard";
case "quick_chat":
return "Quick Chat";
case "chat_session":
return "Chat Session";
case "agent_heartbeat":
return task.sourceAgentId ? `Agent (${task.sourceAgentId})` : "Agent";
case "automation":
return "Automation";
case "cron":
return "Scheduled Task";
case "workflow_step":
return "Workflow Step";
case "github_import": {
const issueUrl = getGitHubIssueUrl(task.sourceMetadata);
return issueUrl ? `GitHub Import (${issueUrl})` : "GitHub Import";
}
case "research": {
const context = getResearchSourceContext(task.sourceMetadata);
return context ? `Research (${context})` : "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";
case "unknown":
default:
return null;
}
}
interface CommandContext {
store: TaskStore;
projectPath: string;
projectName?: string;
explicit: boolean;
}
function asLocalProjectContext(store: TaskStore): ProjectContext {
const cwd = process.cwd();
return {
projectId: cwd,
projectPath: cwd,
projectName: basename(cwd) || "current-project",
isRegistered: false,
store,
};
}
async function getCommandContext(projectName?: string): Promise<CommandContext> {
if (projectName) {
const context = await resolveProject(projectName);
if (!context) {
throw new Error(`Project ${projectName} not found`);
}
return {
store: context.store,
projectPath: context.projectPath,
projectName: context.projectName,
explicit: true,
};
}
try {
const context = await resolveProject(undefined);
if (!context) {
throw new Error("No project context");
}
return {
store: context.store,
projectPath: context.projectPath,
projectName: context.projectName,
explicit: false,
};
} catch {
const store = new TaskStore(process.cwd());
await store.init();
return {
store,
projectPath: process.cwd(),
projectName: undefined,
explicit: false,
};
}
}
async function getStore(projectName?: string): Promise<TaskStore> {
return (await getCommandContext(projectName)).store;
}
async function getProjectContext(projectName?: string): Promise<ProjectContext | undefined> {
if (projectName) {
return resolveProject(projectName);
}
try {
return await resolveProject(undefined);
} catch {
const store = await getStore();
return asLocalProjectContext(store);
}
}
async function getProjectPath(projectName?: string): Promise<string> {
return (await getCommandContext(projectName)).projectPath;
}
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
const central = new CentralCore();
await central.init();
try {
const looksLikeNodeId = nodeNameOrId.includes("-") && nodeNameOrId.length > 20;
let node = looksLikeNodeId
? await central.getNode(nodeNameOrId)
: await central.getNodeByName(nodeNameOrId);
if (!node) {
node = await findNodeByNameOrId(central, nodeNameOrId);
}
if (!node) {
throw new Error(`Node not found: ${nodeNameOrId}`);
}
return { id: node.id, name: node.name };
} finally {
await central.close();
}
}
function truncateNearDuplicateLabel(value: string): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) return "(untitled)";
return normalized.length > 60 ? `${normalized.slice(0, 60)}…` : normalized;
}
function formatNearDuplicateMatch(match: NearDuplicateMatch, candidates: Map<string, NearDuplicateCandidate>): string[] {
const candidate = candidates.get(match.id);
const labelSource = candidate?.title?.trim() || candidate?.description?.trim() || "(untitled)";
const shared = match.sharedTokens.slice(0, 6).join(", ");
return [
` ${match.id} (${candidate?.column ?? "unknown"}) ${truncateNearDuplicateLabel(labelSource)}`,
` score: ${match.score.toFixed(2)} shared: ${shared}`,
];
}
interface CliNearDuplicateOutcome {
signature?: IntentSignature;
}
function hasIntentSignal(signature?: IntentSignature): signature is IntentSignature {
return !!signature && (signature.routePaths.length + signature.filePaths.length + signature.identifiers.length) > 0;
}
async function runCliNearDuplicateCheck(args: {
store: TaskStore;
description: string;
bypass: boolean;
}): Promise<CliNearDuplicateOutcome> {
let signature: IntentSignature | undefined;
let matches: NearDuplicateMatch[] = [];
const candidates = new Map<string, NearDuplicateCandidate>();
try {
signature = extractIntentSignature({ description: args.description });
const signalCount = signature.routePaths.length + signature.filePaths.length + signature.identifiers.length;
if (signalCount === 0) {
return { signature };
}
if (!args.bypass) {
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
const taskCandidates = (await args.store.listTasks({ slim: false, includeArchived: false }))
.filter((task) => task.column !== "done")
.filter((task) => {
const createdAtMs = Date.parse(task.createdAt);
return Number.isFinite(createdAtMs) && createdAtMs >= cutoff;
})
.slice(0, 200)
.map((task) => ({
id: task.id,
title: task.title ?? "",
description: task.description,
column: task.column,
fileScope: Array.isArray(task.sourceMetadata?.fileScope)
? task.sourceMetadata.fileScope.filter((entry: unknown): entry is string => typeof entry === "string")
: undefined,
createdAt: Date.parse(task.createdAt),
} satisfies NearDuplicateCandidate));
for (const candidate of taskCandidates) {
candidates.set(candidate.id, candidate);
}
matches = findNearDuplicates(
{ description: args.description },
taskCandidates,
{ windowMs: 7 * 24 * 60 * 60 * 1000 },
);
}
} catch (error) {
console.error(`Warning: near-duplicate check failed (${error instanceof Error ? error.message : String(error)}); proceeding.`);
return { signature: undefined };
}
if (matches.length === 0) {
return { signature };
}
console.error("Possible near-duplicate of existing task(s):");
for (const match of matches) {
for (const line of formatNearDuplicateMatch(match, candidates)) {
console.error(line);
}
}
console.error("Pass --no-dedup to create anyway.");
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
console.error("Refusing to create near-duplicate task in non-interactive mode. Re-run with --no-dedup to override.");
process.exit(1);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = (await rl.question("Create anyway? [y/N]: ")).trim().toLowerCase();
if (answer === "y" || answer === "yes") {
return { signature };
}
} finally {
rl.close();
}
console.error("Task creation cancelled. Use --no-dedup to bypass.");
process.exit(0);
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string, noDedup = false) {
let description = descriptionArg;
const projectContext = await getProjectContext(projectName);
if (!description) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
description = await rl.question("Task description: ");
rl.close();
}
if (!description?.trim()) {
console.error("Description is required");
process.exit(1);
}
const store = projectContext?.store ?? await getStore(projectName);
const guard = await runDeterministicDuplicateGuard(
store,
{ description: description.trim() },
{
lockScope: projectContext?.projectId ?? store.getRootDir?.() ?? process.cwd(),
bypass: noDedup,
},
);
let task = guard.existing;
let linkedExisting = false;
try {
if (guard.action === "duplicate" && guard.existing) {
task = guard.existing;
linkedExisting = true;
} else {
// FN-5171: create ordering remains deterministic duplicate (FN-4918) -> near-duplicate intent.
// Mirrors the dashboard FN-5152 guard while keeping CLI create fail-open.
const nearDuplicate = await runCliNearDuplicateCheck({
store,
description: description.trim(),
bypass: noDedup,
});
const sourceMetadata = {
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
...(hasIntentSignal(nearDuplicate.signature) ? { intentSignature: nearDuplicate.signature } : {}),
};
const created = await store.createTask({
description: description.trim(),
dependencies: depends,
source: {
sourceType: "cli",
sourceMetadata: Object.keys(sourceMetadata).length > 0 ? sourceMetadata : undefined,
},
});
const reconcileResult = await reconcileDeterministicDuplicate(store, {
createdTask: created,
fingerprint: guard.fingerprint,
});
task = reconcileResult.canonical;
linkedExisting = reconcileResult.outcome === "archived";
}
} finally {
guard.releaseLock();
}
if (!task) {
console.error("Failed to create or link task");
process.exit(1);
}
let resolvedNode: { id: string; name?: string } | undefined;
if (nodeName) {
try {
resolvedNode = await resolveNodeByNameOrId(nodeName);
await store.updateTask(task.id, { nodeId: resolvedNode.id });
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
const label = task.description.length > 60
? task.description.slice(0, 60) + "…"
: task.description;
console.log();
if (projectContext) {
console.log(` Project: ${projectContext.projectName}`);
}
if (linkedExisting) {
console.log(` ✓ Linked existing ${task.id}: ${label}`);
} else {
console.log(` ✓ Created ${task.id}: ${label}`);
}
console.log(` Column: ${task.column}`);
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
if (resolvedNode) {
console.log(` Node: ${resolvedNode.name || resolvedNode.id}`);
}
console.log(` Path: .fusion/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
const { readFile } = await import("node:fs/promises");
const { basename, extname, resolve } = await import("node:path");
for (const filePath of attachFiles) {
const resolvedPath = resolve(filePath);
const filename = basename(resolvedPath);
const ext = extname(filename).toLowerCase();
const mimeType = MIME_TYPES[ext];
if (!mimeType) {
console.error(` ✗ Unsupported file type: ${ext} (${filename})`);
continue;
}
let content: Buffer;
try {
content = await readFile(resolvedPath);
} catch {
console.error(` ✗ Cannot read file: ${filePath}`);
continue;
}
const attachment = await store.addAttachment(task.id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
console.log(` 📎 Attached: ${attachment.originalName} (${sizeKB} KB)`);
}
}
console.log();
}
export async function runTaskList(projectName?: string) {
const projectContext = await getProjectContext(projectName);
const store = projectContext?.store ?? await getStore(projectName);
const tasks = await store.listTasks({ slim: true });
if (tasks.length === 0) {
console.log("\n No tasks yet. Create one with: fn task create\n");
process.exit(0);
}
console.log();
if (projectContext && projectName) {
console.log(` Tasks for project '${projectContext.projectName}':`);
console.log();
}
for (const col of COLUMNS) {
const colTasks = tasks.filter((t) => t.column === col);
if (colTasks.length === 0) continue;
const label = COLUMN_LABELS[col];
const dot =
col === "triage" ? "●" :
col === "todo" ? "●" :
col === "in-progress" ? "●" :
col === "in-review" ? "●" : "○";
console.log(` ${dot} ${label} (${colTasks.length})`);
for (const t of colTasks) {
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
const label = t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
console.log(` ${t.id} ${label}${deps}`);
}
console.log();
}
process.exit(0);
}
export async function runTaskUpdate(id: string, stepStr: string, status: string, projectName?: string) {
const stepIndex = parseInt(stepStr, 10);
if (isNaN(stepIndex)) {
console.error(`Invalid step number: ${stepStr}`);
process.exit(1);
}
if (!STEP_STATUSES.includes(status as StepStatus)) {
console.error(`Invalid status: ${status}`);
console.error(`Valid statuses: ${STEP_STATUSES.join(", ")}`);
process.exit(1);
}
const store = await getStore(projectName);
const task = await store.updateStep(id, stepIndex, status as StepStatus);
const step = task.steps[stepIndex];
console.log();
console.log(` ✓ ${task.id} Step ${stepIndex} (${step.name}) → ${status}`);
console.log(` Progress: ${task.steps.filter((s) => s.status === "done").length}/${task.steps.length} steps done`);
console.log();
}
export async function runTaskDeps(
operation: "add" | "remove" | "replace" | "set",
id: string,
dependencyArgs: string[],
projectName?: string,
) {
const store = await getStore(projectName);
let mutation: TaskDependencyMutation;
switch (operation) {
case "add":
if (dependencyArgs.length !== 1) throw new Error("Usage: fn task deps add <task> <dependency>");
mutation = { operation, dependency: dependencyArgs[0] };
break;
case "remove":
if (dependencyArgs.length !== 1) throw new Error("Usage: fn task deps remove <task> <dependency>");
mutation = { operation, dependency: dependencyArgs[0] };
break;
case "replace":
if (dependencyArgs.length !== 2) throw new Error("Usage: fn task deps replace <task> <old> <new>");
mutation = { operation, from: dependencyArgs[0], to: dependencyArgs[1] };
break;
case "set":
mutation = { operation, dependencies: dependencyArgs };
break;
}
const task = await store.updateTaskDependencies(id, mutation);
console.log();
console.log(` ✓ ${task.id}: dependencies → ${task.dependencies.length ? task.dependencies.join(", ") : "none"}`);
if (task.blockedBy) {
console.log(` Blocked by: ${task.blockedBy}`);
} else {
console.log(" Blocked by: none");
}
console.log();
}
export async function runTaskLog(id: string, message: string, outcome?: string, projectName?: string) {
const store = await getStore(projectName);
await store.logEntry(id, message, outcome);
console.log();
console.log(` ✓ ${id}: logged "${message}"`);
console.log();
}
export interface LogsOptions {
follow?: boolean;
limit?: number;
type?: AgentLogType;
}
// ANSI color codes for terminal output
const ANSI = {
reset: "\x1b[0m",
dim: "\x1b[2m",
red: "\x1b[31m",
gray: "\x1b[90m",
};
/**
* Format a timestamp for display (locale time string)
*/
function formatTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString();
}
/**
* Format a single agent log entry for display
*/
function formatLogEntry(entry: AgentLogEntry): string {
const ts = formatTimestamp(entry.timestamp);
const agent = entry.agent ? `[${entry.agent.toUpperCase()}] ` : "";
switch (entry.type) {
case "text":
return ` ${ts} ${agent}${entry.text}`;
case "thinking":
return `${ANSI.dim}${ANSI.gray} ${ts} ${agent}[THINK] ${entry.text}${ANSI.reset}`;
case "tool":
return ` ${ts} ${agent}[TOOL] ${entry.text}${entry.detail ? ` (${entry.detail})` : ""}`;
case "tool_result":
return ` ${ts} ${agent}[RESULT] ${entry.text}${entry.detail ? ` (${entry.detail})` : ""}`;
case "tool_error":
return `${ANSI.red} ${ts} ${agent}[ERROR] ${entry.text}${entry.detail ? ` (${entry.detail})` : ""}${ANSI.reset}`;
default:
return ` ${ts} ${agent}${entry.text}`;
}
}
/**
* Print log entries to console
*/
function printEntries(entries: AgentLogEntry[]): void {
for (const entry of entries) {
console.log(formatLogEntry(entry));
}
}
/**
* Filter and limit entries based on options
*/
function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLogEntry[] {
let result = entries;
// Filter by type if specified
if (options.type) {
result = result.filter((e) => e.type === options.type);
}
// Apply limit (default 100, max 1000)
const limit = Math.min(options.limit ?? 100, 1000);
if (result.length > limit) {
result = result.slice(-limit);
}
return result;
}
export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) {
const projectContext = await getProjectContext(projectName);
const store = projectContext?.store ?? await getStore(projectName);
// Verify task exists
try {
await store.getTask(id);
} catch {
console.error(`Task ${id} not found`);
process.exit(1);
}
// Get agent logs
const entries = await store.getAgentLogs(id);
if (entries.length === 0 && !options.follow) {
console.log(`No agent logs found for ${id}`);
return;
}
if (projectContext && projectName) {
console.log(` Logs for project '${projectContext.projectName}':`);
}
// Print existing entries (filtered)
const filteredEntries = filterEntries(entries, options);
printEntries(filteredEntries);
// Follow mode: watch for new entries
if (options.follow) {
const projectPath = projectContext?.projectPath ?? process.cwd();
const logPath = join(projectPath, ".fusion", "tasks", id, "agent.log");
if (!existsSync(logPath)) {
console.log(`\n Waiting for log file to be created...`);
}
let lastPosition = 0;
let lastSize = 0;
// Try to get initial file size
try {
const stats = statSync(logPath);
lastSize = stats.size;
lastPosition = lastSize;
} catch {
// File doesn't exist yet, will watch for creation
}
// Track if we're shutting down
let isShuttingDown = false;
// Set up SIGINT handler for clean exit
const sigintHandler = () => {
if (isShuttingDown) return;
isShuttingDown = true;
unwatchFile(logPath);
console.log("\n (stopped following logs)");
process.exit(0);
};
process.on("SIGINT", sigintHandler);
// Start watching the file
watchFile(logPath, { interval: 1000 }, () => {
if (isShuttingDown) return;
try {
const stats = statSync(logPath);
// File was truncated or recreated
if (stats.size < lastPosition) {
lastPosition = 0;
}
// New content available
if (stats.size > lastPosition) {
const content = readFileSync(logPath, "utf-8");
const lines = content.slice(lastPosition).split("\n");
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as AgentLogEntry;
// Apply type filter if specified
if (!options.type || entry.type === options.type) {
console.log(formatLogEntry(entry));
}
} catch {
// Skip malformed lines
}
}
lastPosition = stats.size;
}
lastSize = stats.size;
} catch {
// File may have been deleted, ignore
}
});
// Keep process alive
await new Promise(() => {
// Infinite wait - SIGINT handler will exit
});
}
}
export async function runTaskSetNode(id: string, nodeNameOrId: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
if (task.column === "in-progress") {
console.error(`Cannot change node override: task ${id} is in progress`);
process.exit(1);
}
let resolvedNode: { id: string; name?: string };
try {
resolvedNode = await resolveNodeByNameOrId(nodeNameOrId);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
await store.updateTask(id, { nodeId: resolvedNode.id });
console.log(`✓ Set node override for ${id}: ${resolvedNode.name || resolvedNode.id}`);
}
export async function runTaskClearNode(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
if (task.column === "in-progress") {
console.error(`Cannot change node override: task ${id} is in progress`);
process.exit(1);
}
await store.updateTask(id, { nodeId: null });
console.log(`✓ Cleared node override for ${id}`);
}
export async function runTaskShow(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
const settings: Partial<Settings> = "getSettings" in store ? await store.getSettings() : {};
let nodeSummary = "(default local)";
if (task.nodeId) {
let nodeName: string | undefined;
const central = new CentralCore();
await central.init();
try {
nodeName = (await central.getNode(task.nodeId))?.name;
} finally {
await central.close();
}
nodeSummary = nodeName ? `${nodeName} (${task.nodeId})` : task.nodeId;
} else if (settings.defaultNodeId) {
nodeSummary = `project default: ${settings.defaultNodeId}`;
}
console.log();
console.log(` ${task.id}: ${task.title || task.description}`);
console.log(` Column: ${columnLabel(task.column)}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`);
if (task.dependencies.length) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Node: ${nodeSummary}`);
if (settings.unavailableNodePolicy) {
console.log(` Unavailable Node Policy: ${settings.unavailableNodePolicy}`);
}
const sourceSummary = formatTaskSource(task);
if (sourceSummary) {
console.log(` Source: ${sourceSummary}`);
}
const duplicateLineage = await formatTaskDuplicateLineage(task, store);
if (duplicateLineage) {
console.log(` Duplicate of: ${duplicateLineage}`);
}
console.log();
// Steps
if (task.steps.length > 0) {
console.log(` Steps (${task.steps.filter((s) => s.status === "done").length}/${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" ? " ◀" : "";
console.log(` [${icon}] ${i}: ${s.name}${marker}`);
}
console.log();
}
// Recent log
if (task.log.length > 0) {
const recent = task.log.slice(-5);
console.log(` Log (last ${recent.length}):`);
for (const l of recent) {
const ts = new Date(l.timestamp).toLocaleTimeString();
console.log(` ${ts} ${l.action}${l.outcome ? ` → ${l.outcome}` : ""}`);
}
console.log();
}
}
export async function runTaskMerge(id: string, projectName?: string) {
const store = await getStore(projectName);
const projectPath = await getProjectPath(projectName);
console.log(`\n Merging ${id} with AI...\n`);
try {
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// User-triggered `fn task merge`. A workspace-mode task routes through the
// ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own
// LOCAL integration ref, no push) instead of throwing — manual merge works in
// Phase C (user decision). U0's R7 throw is replaced here by routing; the
// engine chokepoint + store.mergeTask/aiMergeTask keep throwing.
const mergeTaskRecord = await store.getTask(id).catch(() => null);
// FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask`
// (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check.
const isWorkspaceMerge = !!mergeTaskRecord && isWorkspaceTask(mergeTaskRecord);
if (isWorkspaceMerge) {
const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, {
onAgentText: (delta) => process.stdout.write(delta),
});
console.log();
for (const repo of workspaceResult.repos) {
const label =
repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}`
: repo.status === "empty" ? "no net changes"
: `failed: ${repo.error ?? "unknown"}`;
console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`);
}
// FNXC:Workspace 2026-06-22-05:10 (Phase C review B3):
// landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2),
// so report it as merged rather than "remains in review until U2". A partial land leaves
// the task in review (landed repos stay landed locally) and exits non-zero.
console.log(
`\n ${workspaceResult.allLanded ? "✓ All sub-repos landed — task finalized to done" : "✗ Partial land — see failures above (task remains in review; landed repos stay landed locally)"}\n`,
);
if (!workspaceResult.allLanded) process.exit(1);
return;
}
const result = await runAiMerge(store, projectPath, id, {
onAgentText: (delta) => process.stdout.write(delta),
});
console.log();
if (result.merged) {
console.log(` ✓ Merged ${result.task.id}`);
console.log(` Branch: ${result.branch}`);
console.log(` Worktree: ${result.worktreeRemoved ? "removed" : "not found"}`);
console.log(` Branch: ${result.branchDeleted ? "deleted" : "kept"}`);
} else {
console.log(` ✓ Closed ${result.task.id} (${result.error})`);
}
console.log(` Status: done`);
console.log();
} catch (err) {
console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
}
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",
};
export async function runTaskAttach(id: string, filePath: string, projectName?: string) {
const { readFile } = await import("node:fs/promises");
const { basename, extname } = await import("node:path");
const { resolve } = await import("node:path");
const resolvedPath = resolve(filePath);
const filename = basename(resolvedPath);
const ext = extname(filename).toLowerCase();
const mimeType = MIME_TYPES[ext];
if (!mimeType) {
console.error(`Unsupported file type: ${ext}`);
console.error(`Supported: ${Object.keys(MIME_TYPES).join(", ")}`);
process.exit(1);
}
let content: Buffer;
try {
content = await readFile(resolvedPath);
} catch {
console.error(`Cannot read file: ${filePath}`);
process.exit(1);
}
const store = await getStore(projectName);
const attachment = await store.addAttachment(id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
console.log();
console.log(` ✓ Attached to ${id}: ${attachment.originalName}`);
console.log(` File: ${attachment.filename} (${sizeKB} KB)`);
console.log(` Path: .fusion/tasks/${id}/attachments/${attachment.filename}`);
console.log();
}
export async function runTaskPause(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.pauseTask(id, true);
console.log();
console.log(` ✓ Paused ${task.id}`);
console.log();
}
export async function runTaskUnpause(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.pauseTask(id, false);
console.log();
console.log(` ✓ Unpaused ${task.id}`);
console.log();
}
export async function runTaskMove(id: string, column: string, projectName?: string) {
if (!COLUMNS.includes(column as Column)) {
console.error(`Invalid column: ${column}`);
console.error(`Valid columns: ${COLUMNS.join(", ")}`);
process.exit(1);
}
const store = await getStore(projectName);
const task = await store.moveTask(id, column as Column);
console.log();
console.log(` ✓ Moved ${task.id} → ${columnLabel(task.column)}`);
console.log();
}
export async function runTaskDuplicate(id: string, projectName?: string) {
const store = await getStore(projectName);
const newTask = await store.duplicateTask(id);
console.log();
console.log(` ✓ Duplicated ${id} → ${newTask.id}`);
console.log(` Path: .fusion/tasks/${newTask.id}/`);
console.log();
}
export async function runTaskRefine(id: string, feedbackArg?: string, projectName?: string) {
const store = await getStore(projectName);
// Get feedback interactively only if not provided (undefined)
let feedback = feedbackArg;
if (feedback === undefined) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
feedback = await rl.question("What needs to be refined? ");
rl.close();
}
if (!feedback?.trim()) {
console.error("Feedback is required");
process.exit(1);
}
// Validate length (matches API validation)
if (feedback.length > 2000) {
console.error("Feedback must be 2000 characters or less");
process.exit(1);
}
const newTask = await store.refineTask(id, feedback.trim());
console.log();
console.log(` ✓ Created refinement ${newTask.id} for ${id}`);
console.log(` Column: triage`);
console.log(` Dependency: ${id}`);
console.log(` Path: .fusion/tasks/${newTask.id}/`);
console.log();
}
export async function runTaskArchive(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.archiveTask(id);
console.log();
console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`);
console.log();
}
export async function runTaskUnarchive(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.unarchiveTask(id);
console.log();
console.log(` ✓ Unarchived ${task.id} → ${columnLabel(task.column)}`);
console.log();
}
export async function runTaskRetry(id: string, projectName?: string) {
const store = await getStore(projectName);
// Fetch task and validate it exists
let task;
try {
task = await store.getTask(id);
} catch {
throw new Error(`Task ${id} not found`);
}
const isInReviewStatusNone =
task.column === "in-review" && (task.status === null || task.status === undefined);
const hasIncompleteSteps = task.steps.some(
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
);
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
const isExecutionFailureInReview =
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
const isInReviewExecutionStall = isInReviewStatusNone && isExecutionFailureInReview;
const isInReviewMergeRetryStall = isInReviewStatusNone && (task.mergeRetries ?? 0) > 0;
const isInReviewRetry =
task.column === "in-review" &&
(task.status === "failed" ||
task.status === "stuck-killed" ||
isInReviewExecutionStall ||
isInReviewMergeRetryStall);
// Validate task is in a retryable state
if (task.status !== 'failed' && task.status !== 'stuck-killed' && !isInReviewRetry) {
throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`);
}
const autoPauseClearPatch = buildAutoPauseClearPatch(task);
const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0;
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
// In-review retry: distinguish between execution failures (incomplete steps)
// and merge failures (all steps done).
if (isInReviewRetry) {
if (isExecutionFailureInReview) {
await store.moveTask(id, "todo", { preserveProgress: true });
await store.updateTask(id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch(),
});
await store.logEntry(
id,
isInReviewExecutionStall
? `Retry requested from CLI (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})`
: `Retry requested from CLI (execution failure in-review → todo, preserving progress${retryLogSuffix})`,
);
console.log();
console.log(` ✓ Retried ${id} → todo (execution failure, preserving step progress)`);
console.log();
return;
}
await store.moveTask(id, "todo");
await store.updateTask(id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
await store.logEntry(id, `Retry requested from CLI (merge retry → todo, mergeRetries reset${retryLogSuffix})`);
console.log();
console.log(` ✓ Retried ${id} → todo (merge retry state cleared)`);
console.log();
return;
}
// Move to todo column before applying retry resets. `moveTask` reads from the
// store's durable index and may overwrite task.json-only updates, so apply the
// manual retry reset patch after the move to make the cleared counters stick.
await store.moveTask(id, 'todo');
// Clear failure state and stale branch refs so retry can choose a fresh base.
await store.updateTask(id, {
status: null,
error: null,
worktree: null,
branch: null,
baseBranch: null,
baseCommitSha: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
// Log the retry action
await store.logEntry(
id,
clearedDeadlockAutoPause ? "Retry requested from CLI (cleared deadlock auto-pause)" : "Retry requested from CLI",
"Task reset to todo for retry",
);
console.log();
console.log(` ✓ Retried ${id} → todo (failure state cleared)`);
console.log();
}
export async function runTaskDelete(id: string, force?: boolean, allowResurrection?: boolean, projectName?: string) {
const store = await getStore(projectName);
// Check if task exists first
try {
await store.getTask(id);
} catch {
console.error(`✗ Task ${id} not found`);
process.exit(1);
return;
}
// Prompt for confirmation unless force is used
if (!force) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`Are you sure you want to delete ${id}? [y/N] `);
rl.close();
const trimmed = answer.trim().toLowerCase();
if (trimmed !== "y" && trimmed !== "yes") {
console.log("Cancelled.");
process.exit(0);
return;
}
}
try {
await store.deleteTask(id, {
allowResurrection: allowResurrection === true,
auditContext: {
agentId: "cli",
runId: `synthetic-cli-delete-${id}-${Date.now()}`,
},
});
console.log();
console.log(` ✓ Deleted ${id}`);
console.log();
} catch (err) {
console.error(`✗ Failed to delete ${id}: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}
}
export async function runTaskImportGitHubInteractive(
ownerRepo: string,
options: TaskImportOptions = {},
projectName?: string
): Promise<void> {
// Parse owner/repo
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
if (!match) {
console.error(`Invalid owner/repo format: ${ownerRepo}`);
console.error(`Expected format: owner/repo (e.g., dustinbyrne/fusion)`);
process.exit(1);
}
const [, owner, repo] = match;
const { limit = 30, labels } = options;
console.log(`\n Fetching issues from ${owner}/${repo}...\n`);
const store = await getStore(projectName);
const existingTasks = await store.listTasks({ slim: true });
// Build a set of already-imported issue URLs
const importedUrls = new Map<string, string>();
for (const task of existingTasks) {
// Match Source URL anywhere in description (more robust than end-of-string anchor)
const sourceMatch = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
if (sourceMatch) {
importedUrls.set(sourceMatch[1], task.id);
}
}
let issues: GitHubIssue[];
try {
issues = await fetchGitHubIssues(owner, repo, { limit, labels });
} catch (err) {
console.error(` ✗ ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
if (issues.length === 0) {
console.log(` No open issues found in ${owner}/${repo}.\n`);
return;
}
// Display issues with numbers
console.log(` Found ${issues.length} issues:\n`);
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
const alreadyImported = importedUrls.has(issue.html_url);
const status = alreadyImported ? ` [Imported as ${importedUrls.get(issue.html_url)}]` : "";
console.log(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${status}`);
}
console.log();
// Create readline interface for interactive selection
const rl = createInterface({ input: process.stdin, output: process.stdout });
let selectedIndices: number[] = [];
let validInput = false;
while (!validInput) {
const answer = await rl.question(' Enter numbers to import (comma-separated) or "all": ');
const trimmed = answer.trim().toLowerCase();
if (trimmed === "all") {
selectedIndices = issues.map((_, i) => i);
validInput = true;
} else {
const nums = trimmed
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
if (nums.length === 0) {
console.log(" Please enter at least one number or 'all'");
continue;
}
const outOfRange = nums.filter((n) => n < 1 || n > issues.length);
if (outOfRange.length > 0) {
console.log(` Invalid selection: ${outOfRange.join(", ")} (range: 1-${issues.length})`);
continue;
}
selectedIndices = nums.map((n) => n - 1); // Convert to 0-based
validInput = true;
}
}
rl.close();
console.log();
const projectSettings = await store.getSettings();
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const resolvedTracking = resolveTaskGithubTracking(
{ githubTracking: undefined },
projectSettings,
globalSettings,
);
/*
FNXC:GithubImportTracking 2026-06-26-00:00:
CLI issue imports mark created tasks as tracking-enabled only when defaults resolve on. The task-created hook then links the GitHub source issue through source_issue_linked and avoids a duplicate tracking issue.
*/
const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined;
let created = 0;
let skipped = 0;
for (const idx of selectedIndices) {
const issue = issues[idx];
// Check if already imported
if (importedUrls.has(issue.html_url)) {
const existingId = importedUrls.get(issue.html_url)!;
console.log(` → Skipping #${issue.number}: already imported as ${existingId}`);
skipped++;
continue;
}
// Prepare title (truncate to 200 chars)
const title = issue.title.slice(0, 200);
// Prepare description
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
// FN-5060: intentional same-content sibling; deterministic guard skipped here.
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 } : {}),
});
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
console.log(` ✓ Created ${task.id}: ${label}`);
created++;
}
console.log();
console.log(` ✓ Imported ${created} tasks from ${owner}/${repo}${skipped > 0 ? ` (${skipped} skipped)` : ""}`);
console.log();
}
// ── GitHub Issue Import ───────────────────────────────────────────
export interface GitHubIssue {
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
created_at: string;
updated_at: string;
}
export interface FetchGitHubIssuesOptions {
limit?: number;
labels?: string[];
since?: string;
}
export async function fetchGitHubIssues(
owner: string,
repo: string,
options: FetchGitHubIssuesOptions = {}
): Promise<GitHubIssue[]> {
const { limit = 30, labels, since } = options;
if (!isGhAvailable() || !isGhAuthenticated()) {
throw new Error("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
}
// Build query parameters - only open issues, no PRs
const params = new URLSearchParams();
params.append("state", "open");
params.append("per_page", String(Math.min(limit, 100)));
if (labels && labels.length > 0) {
params.append("labels", labels.join(","));
}
if (since) {
params.append("since", since);
}
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params.toString()}`;
try {
const issues = await runGhJsonAsync<Array<GitHubIssue & { pull_request?: unknown }>>(["api", path]);
return issues.filter((issue) => !issue.pull_request).slice(0, limit);
} catch (error) {
throw new Error(getGhErrorMessage(error));
}
}
export interface TaskImportOptions {
limit?: number;
labels?: string[];
}
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 },
};
}
export async function runTaskImportFromGitHub(
ownerRepo: string,
options: TaskImportOptions = {},
projectName?: string
): Promise<void> {
// Parse owner/repo
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
if (!match) {
console.error(`Invalid owner/repo format: ${ownerRepo}`);
console.error(`Expected format: owner/repo (e.g., dustinbyrne/fusion)`);
process.exit(1);
}
const [, owner, repo] = match;
const { limit = 30, labels } = options;
console.log(`\n Importing issues from ${owner}/${repo}...\n`);
const store = await getStore(projectName);
const existingTasks = await store.listTasks({ slim: true });
// Build a set of already-imported issue URLs
const importedUrls = new Map<string, string>();
for (const task of existingTasks) {
// Match Source URL anywhere in description (more robust than end-of-string anchor)
const sourceMatch = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
if (sourceMatch) {
importedUrls.set(sourceMatch[1], task.id);
}
}
let issues: GitHubIssue[];
try {
issues = await fetchGitHubIssues(owner, repo, { limit, labels });
} catch (err) {
console.error(` ✗ ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
if (issues.length === 0) {
console.log(` No open issues found in ${owner}/${repo}.\n`);
return;
}
const projectSettings = await store.getSettings();
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const resolvedTracking = resolveTaskGithubTracking(
{ githubTracking: undefined },
projectSettings,
globalSettings,
);
/*
FNXC:GithubImportTracking 2026-06-26-00:00:
Non-interactive fn task import uses the same tracking default resolution as the extension tools so imported source issues are adopted instead of duplicated when tracking is on.
*/
const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined;
let created = 0;
let skipped = 0;
for (const issue of issues) {
// Check if already imported
if (importedUrls.has(issue.html_url)) {
const existingId = importedUrls.get(issue.html_url)!;
console.log(` → Skipping #${issue.number}: already imported as ${existingId}`);
skipped++;
continue;
}
// Prepare title (truncate to 200 chars)
const title = issue.title.slice(0, 200);
// Prepare description
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
// FN-5060: intentional same-content sibling; deterministic guard skipped here.
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 } : {}),
});
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
console.log(` ✓ Created ${task.id}: ${label}`);
created++;
}
console.log();
console.log(` ✓ Imported ${created} tasks from ${owner}/${repo}${skipped > 0 ? ` (${skipped} skipped)` : ""}`);
console.log();
}
export async function runTaskComment(id: string, message?: string, author = "user", projectName?: string) {
const store = await getStore(projectName);
let text = message;
if (text === undefined) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
text = await rl.question("Comment: ");
rl.close();
}
if (!text || text.trim().length === 0) {
console.error("Error: Comment is required");
process.exit(1);
}
const trimmed = text.trim();
if (trimmed.length > 2000) {
console.error("Error: Comment must be between 1 and 2000 characters");
process.exit(1);
}
const task = await store.addTaskComment(id, trimmed, author || "user");
const latestComment = task.comments?.[task.comments.length - 1];
console.log();
console.log(` ✓ Comment added to ${task.id}`);
if (latestComment) {
console.log(` ID: ${latestComment.id}`);
}
console.log();
}
export async function runTaskComments(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
const comments = task.comments || [];
console.log();
if (comments.length === 0) {
console.log(` No comments on ${id}`);
console.log();
return;
}
console.log(` Comments for ${id}:`);
for (const comment of comments) {
console.log(` ${comment.id} · ${comment.author} · ${new Date(comment.updatedAt || comment.createdAt).toLocaleString()}`);
console.log(` ${comment.text}`);
}
console.log();
}
export async function runTaskSteer(id: string, message?: string, projectName?: string) {
const store = await getStore(projectName);
// Get message interactively if not provided as argument
let text = message;
if (text === undefined) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
text = await rl.question("Message: ");
rl.close();
}
// Validate message
if (!text || text.trim().length === 0) {
console.error("Error: Message is required");
process.exit(1);
}
const trimmed = text.trim();
if (trimmed.length > 2000) {
console.error("Error: Message must be between 1 and 2000 characters");
process.exit(1);
}
// Add steering comment
let task;
try {
task = await store.addSteeringComment(id, trimmed, "user");
} catch (err) {
if (typeof err === "object" && err !== null && (err as Record<string, unknown>).code === "ENOENT") {
console.error(`Error: Task not found: ${id}`);
process.exit(1);
}
throw err;
}
// Show success with preview
const preview = trimmed.length > 60 ? trimmed.slice(0, 60) + "…" : trimmed;
console.log();
console.log(` ✓ Steering comment added to ${task.id}`);
console.log(` "${preview}"`);
console.log();
}
// ── PR Creation ─────────────────────────────────────────────────────────────
//
// The PR-creation implementation moved to commands/pr.ts as `runPrCreate` when
// the per-task `fn task pr-create` command was retired in favor of the unified
// `fn pr` namespace (U8, R13). These re-exports are kept ONLY so existing
// importers/tests that referenced the old symbols keep resolving; `fn task
// pr-create` no longer dispatches from bin.ts. Prefer `runPrCreate` / `fn pr
// create` for new code.
export type { PrCreateOptions } from "./pr.js";
export { runPrCreate as runTaskPrCreate } from "./pr.js";
// ── Planning Mode ───────────────────────────────────────────────────────────
/** Helper to display thinking indicator */
function showThinking(): void {
process.stdout.write(" AI is thinking...");
}
/** Helper to clear thinking indicator */
function clearThinking(): void {
process.stdout.write("\r" + " ".repeat(20) + "\r");
}
/** Prompt for text (multi-line) question */
async function promptText(question: PlanningQuestion): Promise<string> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
console.log(" (Enter your response. Type DONE on its own line when finished):\n");
const rl = createInterface({ input: process.stdin, output: process.stdout });
const lines: string[] = [];
return new Promise((resolve) => {
const askLine = () => {
rl.question(" ").then((line) => {
if (line.trim() === "DONE") {
rl.close();
resolve(lines.join("\n"));
} else {
lines.push(line);
askLine();
}
});
};
askLine();
});
}
/** Prompt for single_select question */
async function promptSingleSelect(question: PlanningQuestion): Promise<string> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
console.log();
if (!question.options || question.options.length === 0) {
throw new Error("Single select question has no options");
}
for (let i = 0; i < question.options.length; i++) {
const opt = question.options[i];
console.log(` ${i + 1}. ${opt.label}`);
if (opt.description) {
console.log(` ${opt.description}`);
}
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
while (true) {
const answer = await rl.question("\n Select (1-" + question.options.length + "): ");
const num = parseInt(answer.trim(), 10);
if (!isNaN(num) && num >= 1 && num <= question.options.length) {
rl.close();
return question.options[num - 1].id;
}
console.log(` Invalid selection. Please enter a number between 1 and ${question.options.length}`);
}
}
/** Prompt for multi_select question */
async function promptMultiSelect(question: PlanningQuestion): Promise<string[]> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
console.log(" (Enter comma-separated numbers, e.g., 1,3,4):\n");
if (!question.options || question.options.length === 0) {
throw new Error("Multi select question has no options");
}
for (let i = 0; i < question.options.length; i++) {
const opt = question.options[i];
console.log(` ${i + 1}. ${opt.label}`);
if (opt.description) {
console.log(` ${opt.description}`);
}
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
while (true) {
const answer = await rl.question("\n Select (comma-separated): ");
const nums = answer
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
if (nums.length === 0) {
console.log(" Please select at least one option");
continue;
}
const invalid = nums.filter((n) => n < 1 || n > question.options!.length);
if (invalid.length > 0) {
console.log(` Invalid selection: ${invalid.join(", ")}. Range: 1-${question.options.length}`);
continue;
}
rl.close();
return nums.map((n) => question.options![n - 1].id);
}
}
/** Prompt for confirm question */
async function promptConfirm(question: PlanningQuestion): Promise<boolean> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question("\n [Y/n]: ");
rl.close();
const trimmed = answer.trim().toLowerCase();
return trimmed === "" || trimmed === "y" || trimmed === "yes";
}
/** Display planning summary */
function displaySummary(summary: PlanningSummary): void {
console.log();
console.log(" ╔══════════════════════════════════════════════════════════════╗");
console.log(" ║ Planning Summary ║");
console.log(" ╠══════════════════════════════════════════════════════════════╣");
console.log(` ║ Title: ${summary.title.slice(0, 55).padEnd(55)} ║`);
console.log(" ╠══════════════════════════════════════════════════════════════╣");
// Description (wrapped to box width)
const descLines = wrapText(summary.description, 58);
for (const line of descLines.slice(0, 10)) {
console.log(` ║ ${line.padEnd(58)} ║`);
}
if (descLines.length > 10) {
console.log(` ║ ... (${descLines.length - 10} more lines) ...`.padEnd(62) + " ║");
}
console.log(" ╠══════════════════════════════════════════════════════════════╣");
console.log(` ║ Size: ${summary.suggestedSize.padEnd(52)} ║`);
if (summary.suggestedDependencies.length > 0) {
console.log(` ║ Dependencies: ${summary.suggestedDependencies.join(", ").slice(0, 45).padEnd(45)} ║`);
} else {
console.log(` ║ Dependencies: none`.padEnd(62) + " ║");
}
console.log(" ╠══════════════════════════════════════════════════════════════╣");
console.log(" ║ Key Deliverables: ║");
for (const deliverable of summary.keyDeliverables) {
console.log(` ║ • ${deliverable.slice(0, 54).padEnd(54)} ║`);
}
console.log(" ╚══════════════════════════════════════════════════════════════╝");
console.log();
}
/** Wrap text to specified width */
function wrapText(text: string, width: number): string[] {
const lines: string[] = [];
const paragraphs = text.split("\n");
for (const paragraph of paragraphs) {
if (paragraph.trim() === "") {
lines.push("");
continue;
}
let remaining = paragraph.trim();
while (remaining.length > 0) {
if (remaining.length <= width) {
lines.push(remaining);
break;
}
let breakPoint = width;
while (breakPoint > 0 && remaining[breakPoint] !== " ") {
breakPoint--;
}
if (breakPoint === 0) {
// No space found, force break
breakPoint = width;
}
lines.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trim();
}
}
return lines;
}
/** Run the planning mode */
export async function runTaskPlan(
initialPlanArg?: string,
yesFlag = false,
projectName?: string,
baseBranch?: string,
): Promise<string | undefined> {
let initialPlan = initialPlanArg;
// If no initial plan, prompt interactively
if (!initialPlan) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log("\n Let's plan your task. What would you like to accomplish?\n");
initialPlan = await rl.question(" Describe your idea: ");
rl.close();
if (!initialPlan?.trim()) {
console.error("\n Description is required");
process.exit(1);
}
}
const store = await getStore(projectName);
// Create planning session
let sessionId: string;
let firstQuestion: PlanningQuestion;
try {
showThinking();
const projectPath = await getProjectPath(projectName);
const result = await createSession("127.0.0.1", initialPlan.trim(), store, projectPath);
clearThinking();
sessionId = result.sessionId;
firstQuestion = result.firstQuestion;
} catch (err) {
clearThinking();
if (err instanceof RateLimitError) {
console.error("\n Rate limit exceeded. Maximum 1000 planning sessions per hour.\n");
process.exit(1);
}
console.error(`\n Failed to start planning session: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
// Interactive Q&A loop
let currentQuestion = firstQuestion;
let cancelled = false;
// Handle Ctrl+C gracefully
const handleSigint = () => {
cancelled = true;
console.log("\n\n Planning session cancelled.\n");
process.exit(0);
};
process.on("SIGINT", handleSigint);
try {
while (!cancelled) {
// Get user response based on question type
let response: Record<string, unknown>;
try {
switch (currentQuestion.type) {
case "text": {
const textResponse = await promptText(currentQuestion);
response = { [currentQuestion.id]: textResponse };
break;
}
case "single_select": {
const selectResponse = await promptSingleSelect(currentQuestion);
response = { [currentQuestion.id]: selectResponse };
break;
}
case "multi_select": {
const multiResponse = await promptMultiSelect(currentQuestion);
response = { [currentQuestion.id]: multiResponse };
break;
}
case "confirm": {
const confirmResponse = await promptConfirm(currentQuestion);
response = { [currentQuestion.id]: confirmResponse };
break;
}
default: {
console.error(`\n Unknown question type: ${String((currentQuestion as unknown as Record<string, unknown>).type)}`);
process.exit(1);
}
}
} catch (promptErr) {
// Prompt was cancelled (Ctrl+C handled above)
if (cancelled) {
return undefined;
}
throw promptErr;
}
// Submit response and get next question or summary
let result: { type: "question"; data: PlanningQuestion } | { type: "complete"; data: PlanningSummary };
try {
showThinking();
result = await submitResponse(sessionId, response) as typeof result;
clearThinking();
} catch (err) {
clearThinking();
if (err instanceof SessionNotFoundError) {
console.error("\n Session expired. Please start again.\n");
process.exit(1);
}
if (err instanceof InvalidSessionStateError) {
console.error(`\n Invalid session state: ${err.message}\n`);
process.exit(1);
}
console.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
if (result.type === "complete") {
// Display summary
displaySummary(result.data);
// Ask for confirmation (unless --yes flag)
let confirmed = yesFlag;
if (!yesFlag) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(" Create this task? [Y/n]: ");
rl.close();
const trimmed = answer.trim().toLowerCase();
confirmed = trimmed === "" || trimmed === "y" || trimmed === "yes";
}
if (confirmed) {
// Create the task
// FN-5060: intentional same-content sibling; deterministic guard skipped here.
const task = await store.createTask({
title: result.data.title,
description: result.data.description,
column: "triage",
dependencies: result.data.suggestedDependencies,
baseBranch: baseBranch?.trim() || undefined,
source: { sourceType: "cli" },
});
console.log();
console.log(` ✓ Created ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}`);
console.log(` Column: triage`);
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Path: .fusion/tasks/${task.id}/`);
console.log();
return task.id;
}
console.log("\n Task creation cancelled.\n");
return undefined;
}
// Next question
currentQuestion = result.data;
}
} finally {
process.off("SIGINT", handleSigint);
}
}