2041 lines
64 KiB
TypeScript
2041 lines
64 KiB
TypeScript
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, 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 { aiMergeTask } 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 {
|
||
classifyGhError,
|
||
getGhErrorMessage,
|
||
getCurrentRepo,
|
||
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 formatGhErrorForCli(err: unknown): string {
|
||
const structured = classifyGhError(err);
|
||
const lines = [`GitHub error: ${structured.message}`];
|
||
if (structured.hint) lines.push(` Hint: ${structured.hint}`);
|
||
if (structured.action?.kind === "shell") lines.push(` Action: run \`${structured.action.command}\``);
|
||
if (structured.action?.kind === "open") lines.push(` Action: open ${structured.action.url}`);
|
||
if (structured.action?.kind === "retry") lines.push(" Action: retry the command");
|
||
if (structured.retryable) lines.push(" (retryable — re-run `fn pr create <task-id>` to try again)");
|
||
return `${lines.join("\n")}\n`;
|
||
}
|
||
|
||
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 {
|
||
const result = await aiMergeTask(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`);
|
||
}
|
||
|
||
// Validate task is in a retryable state
|
||
if (task.status !== 'failed' && task.status !== 'stuck-killed') {
|
||
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;
|
||
|
||
// 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 }),
|
||
});
|
||
|
||
// Move to todo column
|
||
await store.moveTask(id, 'todo');
|
||
|
||
// 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();
|
||
|
||
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,
|
||
},
|
||
});
|
||
|
||
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;
|
||
}
|
||
|
||
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,
|
||
},
|
||
});
|
||
|
||
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 ─────────────────────────────────────────────────────────────
|
||
|
||
export interface PrCreateOptions {
|
||
title?: string;
|
||
base?: string;
|
||
body?: string;
|
||
draft?: boolean;
|
||
/** When true (default), call generatePrMetadata for title/body unless user provided both. */
|
||
ai?: boolean;
|
||
/** Repeatable --reviewer flag values. */
|
||
reviewers?: string[];
|
||
}
|
||
|
||
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) {
|
||
const store = await getStore(projectName);
|
||
|
||
// Fetch task and validate it exists
|
||
let task;
|
||
try {
|
||
task = await store.getTask(id);
|
||
} catch (err) {
|
||
if (typeof err === "object" && err !== null && (err as Record<string, unknown>).code === "ENOENT") {
|
||
console.error(`Error: Task ${id} not found`);
|
||
process.exit(1);
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
// Validate task is in 'in-review' column
|
||
if (task.column !== "in-review") {
|
||
console.error(`Error: Task must be in 'in-review' column to create a PR (current: ${task.column})`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Check if task already has PR info
|
||
if (task.prInfo) {
|
||
console.error(`Error: Task already has PR #${task.prInfo.number}: ${task.prInfo.url}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Determine owner/repo from GITHUB_REPOSITORY env or git remote
|
||
let owner: string;
|
||
let repo: string;
|
||
|
||
const envRepo = process.env.GITHUB_REPOSITORY;
|
||
if (envRepo) {
|
||
const [o, r] = envRepo.split("/");
|
||
if (!o || !r) {
|
||
console.error("Error: GITHUB_REPOSITORY format is invalid (expected: owner/repo)");
|
||
process.exit(1);
|
||
}
|
||
owner = o;
|
||
repo = r;
|
||
} else {
|
||
const projectPath = await getProjectPath(projectName);
|
||
const gitRepo = getCurrentRepo(projectPath);
|
||
if (!gitRepo) {
|
||
console.error("Error: Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
|
||
process.exit(1);
|
||
}
|
||
owner = gitRepo.owner;
|
||
repo = gitRepo.repo;
|
||
}
|
||
|
||
// Validate GitHub auth
|
||
if (!isGhAvailable() || !isGhAuthenticated()) {
|
||
console.error("Error: GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
|
||
process.exit(1);
|
||
}
|
||
|
||
// Build branch name using the established project convention
|
||
const branchName = `fusion/${id.toLowerCase()}`;
|
||
|
||
// Build deterministic fallback PR title
|
||
const fallbackTitle = options.title
|
||
? options.title
|
||
: task.title
|
||
? task.title
|
||
: (() => {
|
||
const desc = task.description.trim();
|
||
let derived = desc.charAt(0).toUpperCase() + desc.slice(1, 50);
|
||
if (desc.length > 50) {
|
||
derived += "…";
|
||
}
|
||
return derived;
|
||
})();
|
||
|
||
let resolvedTitle = fallbackTitle;
|
||
let resolvedBody = options.body;
|
||
|
||
const shouldUseAi = options.ai !== false && !(options.title && options.body);
|
||
if (shouldUseAi) {
|
||
try {
|
||
const repoRoot = await getProjectPath(projectName);
|
||
const settings = ("getSettings" in store
|
||
? await store.getSettings()
|
||
: {}) as Parameters<typeof dashboard.generatePrMetadata>[0]["settings"];
|
||
const generated = await dashboard.generatePrMetadata({ task, repoRoot, settings });
|
||
if (!options.title) {
|
||
resolvedTitle = generated.title;
|
||
}
|
||
if (!options.body) {
|
||
resolvedBody = generated.body;
|
||
}
|
||
console.log(" → Using AI-generated title/body (use --no-ai to skip)");
|
||
} catch (err) {
|
||
process.stderr.write(`AI metadata generation failed; using fallback PR metadata. ${getGhErrorMessage(err)}\n`);
|
||
}
|
||
}
|
||
|
||
// Create PR via GitHubClient
|
||
const client = new dashboard.GitHubClient();
|
||
|
||
try {
|
||
const prInfo = await client.createPr({
|
||
owner,
|
||
repo,
|
||
title: resolvedTitle,
|
||
body: resolvedBody,
|
||
head: branchName,
|
||
base: options.base,
|
||
draft: options.draft,
|
||
reviewers: options.reviewers,
|
||
});
|
||
|
||
// Store PR info
|
||
await store.updatePrInfo(task.id, prInfo);
|
||
await store.logEntry(task.id, "Created PR", `PR #${prInfo.number}: ${prInfo.url}`);
|
||
|
||
console.log();
|
||
console.log(` ✓ Created PR for ${task.id}`);
|
||
console.log(` PR #${prInfo.number}: ${prInfo.url}`);
|
||
console.log(` Branch: ${branchName} → ${prInfo.baseBranch}`);
|
||
console.log();
|
||
} catch (err) {
|
||
// Handle specific error cases
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
if (msg.includes("already exists")) {
|
||
console.error(`Error: A pull request already exists for ${owner}/${repo}:${branchName}`);
|
||
process.exit(1);
|
||
} else if (msg.includes("No commits between")) {
|
||
console.error(`Error: No commits between ${options.base || "default base"} and ${branchName}. Push changes before creating PR.`);
|
||
process.exit(1);
|
||
} else {
|
||
process.stderr.write(formatGhErrorForCli(err));
|
||
process.exit(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
}
|