diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index afd9a1c615..be868d0d4d 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -640,6 +640,89 @@ describe("Board view", () => { }); }); +describe("Board view — U11 custom-column graceful degradation (R18)", () => { + function renderBoardWithTasks(tasks: TaskItem[], cols = 200, rows = 50) { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + controller.setInteractiveData(makeInteractiveData({ + projects: [{ id: "p1", name: "my-project", path: "/tmp/p" }], + tasks, + })); + controller.setMode("interactive"); + controller.setInteractiveView("board"); + const rendered = render(renderDashboardAppNode(controller)); + setTerminalSize(rendered, cols, rows); + rendered.rerender(renderDashboardAppNode(controller)); + return rendered; + } + + it("renders an 'Other (custom)' bucket between in-review and done", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "t1", title: "Legacy", description: "", column: "todo" }, + ]); + await waitForFrameContains(lastFrame, "OTHER (CUSTOM)"); + const frame = lastFrame() ?? ""; + const headerLine = frame.split("\n").find((l) => l.includes("OTHER (CUSTOM)")) ?? ""; + // Column headers share a row; verify in-review precedes other precedes done. + expect(headerLine.indexOf("IN REVIEW")).toBeLessThan(headerLine.indexOf("OTHER (CUSTOM)")); + expect(headerLine.indexOf("OTHER (CUSTOM)")).toBeLessThan(headerLine.indexOf("DONE")); + unmount(); + }); + + it("never drops a card: a task in an unknown column with no flags surfaces in 'Other (custom)' with its column name", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "drop1", title: "Should Not Vanish", description: "", column: "staging", columnName: "Staging" }, + ]); + await waitForFrameContains(lastFrame, "Should Not Vanish"); + const frame = lastFrame() ?? ""; + // The card is visible AND shows its real column name as a secondary label. + expect(frame).toContain("Should Not Vanish"); + expect(frame).toContain("Staging"); + // Other bucket header count reflects the card. + expect(frame).toContain("OTHER (CUSTOM) (1)"); + unmount(); + }); + + it("maps trait-flagged custom columns into legacy buckets (intake→todo, mergeBlocker→in-review, complete→done, wip→in-progress)", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "i", title: "IntakeCard", description: "", column: "triage", columnName: "Triage", columnFlags: { intake: true } }, + { id: "r", title: "ReviewCard", description: "", column: "gate", columnName: "Gate", columnFlags: { mergeBlocker: true } }, + { id: "d", title: "DoneCard", description: "", column: "shipped", columnName: "Shipped", columnFlags: { complete: true } }, + { id: "w", title: "WipCard", description: "", column: "building", columnName: "Building", columnFlags: { countsTowardWip: true } }, + ]); + await waitForFrameContains(lastFrame, "IntakeCard"); + const frame = lastFrame() ?? ""; + // All four mapped to legacy buckets; none in the "other" bucket. + expect(frame).toContain("TODO (1)"); + expect(frame).toContain("IN PROGRESS (1)"); + expect(frame).toContain("IN REVIEW (1)"); + expect(frame).toContain("DONE (1)"); + expect(frame).toContain("OTHER (CUSTOM) (0)"); + expect(frame).toContain("IntakeCard"); + expect(frame).toContain("ReviewCard"); + expect(frame).toContain("DoneCard"); + expect(frame).toContain("WipCard"); + unmount(); + }); + + it("shows a read-only move-disabled hint when the focused column is the 'other' bucket", async () => { + const { lastFrame, stdin, unmount } = renderBoardWithTasks([ + { id: "o1", title: "CustomCard", description: "", column: "staging", columnName: "Staging" }, + ]); + await waitForFrameContains(lastFrame, "CustomCard"); + // Buckets render left→right: todo, in-progress, in-review, other, done. + // Move focus right 3 times to land on the "other" bucket. + stdin.write(""); + await waitForFrameUpdateAfterInput(); + stdin.write(""); + await waitForFrameUpdateAfterInput(); + stdin.write(""); + await waitForFrameUpdateAfterInput(); + await waitForFrameContains(lastFrame, "move disabled here"); + unmount(); + }); +}); + describe("LogsPanel indicator", () => { it("renders the selection arrow on the highlighted log row", async () => { const controller = newController(); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts new file mode 100644 index 0000000000..b6b70d067e --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import type { TraitFlags } from "@fusion/core"; +import { + bucketForTask, + groupTasksByBucket, + otherBucketSecondaryLabel, + TUI_BUCKETS, + OTHER_BUCKET, + isOtherBucket, +} from "../bucket-mapping.js"; +import type { TaskItem } from "../state.js"; + +function task(partial: Partial & { id: string; column: string }): TaskItem { + return { description: "", ...partial }; +} + +describe("bucketForTask (U11 / R18 graceful degradation)", () => { + it("keeps legacy column ids in their own bucket verbatim", () => { + expect(bucketForTask(task({ id: "a", column: "todo" }))).toBe("todo"); + expect(bucketForTask(task({ id: "b", column: "in-progress" }))).toBe("in-progress"); + expect(bucketForTask(task({ id: "c", column: "in-review" }))).toBe("in-review"); + expect(bucketForTask(task({ id: "d", column: "done" }))).toBe("done"); + }); + + it("flag-OFF (no columnFlags) sends a non-legacy column to the read-only 'other' bucket — never dropped", () => { + expect(bucketForTask(task({ id: "x", column: "staging" }))).toBe(OTHER_BUCKET); + expect(bucketForTask(task({ id: "y", column: "qa-review" }))).toBe(OTHER_BUCKET); + }); + + it("maps each built-in trait flag to the correct legacy bucket", () => { + const cases: Array<[TraitFlags, string]> = [ + [{ complete: true }, "done"], + [{ humanReview: true }, "in-review"], + [{ mergeBlocker: true }, "in-review"], + [{ countsTowardWip: true }, "in-progress"], + [{ hold: true }, "todo"], + [{ intake: true }, "todo"], + ]; + for (const [flags, expected] of cases) { + expect( + bucketForTask(task({ id: "t", column: "custom-col", columnFlags: flags })), + ).toBe(expected); + } + }); + + it("applies flag precedence: complete > review > wip > todo-like", () => { + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { complete: true, countsTowardWip: false, humanReview: true } })), + ).toBe("done"); + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { humanReview: true, countsTowardWip: true } })), + ).toBe("in-review"); + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { countsTowardWip: true, hold: true } })), + ).toBe("in-progress"); + }); + + it("flagged column with no mapping-relevant flags lands in 'other'", () => { + expect( + bucketForTask(task({ id: "t", column: "weird", columnFlags: { notify: true, timing: true } })), + ).toBe(OTHER_BUCKET); + }); +}); + +describe("groupTasksByBucket", () => { + it("never drops a card: every task lands in exactly one of the five buckets", () => { + const tasks: TaskItem[] = [ + task({ id: "1", column: "todo" }), + task({ id: "2", column: "in-progress" }), + task({ id: "3", column: "in-review" }), + task({ id: "4", column: "done" }), + task({ id: "5", column: "triage", columnFlags: { intake: true } }), + task({ id: "6", column: "staging" }), // unmapped → other + task({ id: "7", column: "deploying", columnFlags: { notify: true } }), // unmapped → other + task({ id: "8", column: "blocked", columnFlags: { mergeBlocker: true } }), + ]; + const grouped = groupTasksByBucket(tasks); + const total = TUI_BUCKETS.reduce((n, b) => n + grouped[b].length, 0); + expect(total).toBe(tasks.length); + + const allIds = TUI_BUCKETS.flatMap((b) => grouped[b].map((t) => t.id)).sort(); + expect(allIds).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]); + + expect(grouped.todo.map((t) => t.id)).toEqual(["1", "5"]); // intake → todo + expect(grouped["in-review"].map((t) => t.id)).toEqual(["3", "8"]); // mergeBlocker → in-review + expect(grouped[OTHER_BUCKET].map((t) => t.id)).toEqual(["6", "7"]); + }); +}); + +describe("bucket ordering + labels", () => { + it("orders 'other' between in-review and done", () => { + expect([...TUI_BUCKETS]).toEqual(["todo", "in-progress", "in-review", OTHER_BUCKET, "done"]); + const reviewIdx = TUI_BUCKETS.indexOf("in-review"); + const otherIdx = TUI_BUCKETS.indexOf(OTHER_BUCKET); + const doneIdx = TUI_BUCKETS.indexOf("done"); + expect(otherIdx).toBeGreaterThan(reviewIdx); + expect(otherIdx).toBeLessThan(doneIdx); + }); + + it("isOtherBucket flags only the catch-all", () => { + expect(isOtherBucket(OTHER_BUCKET)).toBe(true); + expect(isOtherBucket("done")).toBe(false); + }); + + it("secondary label uses the real column name, falling back to the id", () => { + expect(otherBucketSecondaryLabel(task({ id: "a", column: "staging", columnName: "Staging area" }))).toBe("Staging area"); + expect(otherBucketSecondaryLabel(task({ id: "b", column: "qa-gate" }))).toBe("qa-gate"); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index ebb51aceb0..913e77b9a9 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -70,6 +70,14 @@ import type { LogEntry } from "./log-ring-buffer.js"; import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js"; import { useProjects, useTasks } from "./hooks/use-projects.js"; import { copyToClipboard } from "./utils.js"; +import { + TUI_BUCKETS, + OTHER_BUCKET, + isOtherBucket, + groupTasksByBucket, + otherBucketSecondaryLabel, + type TuiBucket, +} from "./bucket-mapping.js"; // ── Format helpers ──────────────────────────────────────────────────────────── @@ -1133,17 +1141,23 @@ function MainHeader({ state }: { state: DashboardState }) { // ── Kanban board ────────────────────────────────────────────────────────────── -const KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const; -type KanbanColumn = typeof KANBAN_COLUMNS[number]; +// U11 (R18): the TUI renders five buckets — its four legacy kanban columns plus +// a read-only "Other (custom)" catch-all wedged between in-review and done for +// workflow columns it can't express. `KANBAN_COLUMNS` is the bucket render +// order; see ./bucket-mapping.ts for how tasks land in each. +const KANBAN_COLUMNS = TUI_BUCKETS; +type KanbanColumn = TuiBucket; -const COLUMN_COLORS: Record = { +const COLUMN_COLORS: Record = { todo: "yellow", "in-progress": "cyanBright", "in-review": "cyan", + [OTHER_BUCKET]: "magenta", done: "green", }; function columnLabel(col: string): string { + if (col === OTHER_BUCKET) return "Other (custom)"; return col.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } @@ -1151,10 +1165,14 @@ function TaskCard({ task, selected, width, + secondaryLabel, }: { task: TaskItem; selected: boolean; width: number; + // U11: real column name shown under cards in the read-only "other" bucket so + // the user keeps the true position despite the TUI not modeling that column. + secondaryLabel?: string; }) { const { t } = useTranslation("cli"); const accent = COLUMN_COLORS[task.column] ?? "white"; @@ -1180,6 +1198,9 @@ function TaskCard({ {title} + {secondaryLabel && ( + ↳ {secondaryLabel} + )} ); } @@ -1201,6 +1222,7 @@ function KanbanColumnView({ }) { const accent = COLUMN_COLORS[column]; const headerColor = isFocused ? "whiteBright" : accent; + const isOther = isOtherBucket(column); const cardWidth = Math.max(16, width - 2); const innerHeaderWidth = Math.max(8, width - 2); const label = `${columnLabel(column).toUpperCase()} (${tasks.length})`; @@ -1250,6 +1272,7 @@ function KanbanColumnView({ task={task} selected={isFocused && (windowStart + i) === selectedIndex} width={cardWidth} + secondaryLabel={isOther ? otherBucketSecondaryLabel(task) : undefined} /> ))} {hiddenBelow > 0 && ( @@ -1695,21 +1718,10 @@ function clamp(n: number, min: number, max: number): number { return Math.max(min, Math.min(max, n)); } -function groupTasksByColumn(tasks: TaskItem[]): Record { - const out: Record = { - todo: [], - "in-progress": [], - "in-review": [], - done: [], - }; - for (const task of tasks) { - const col = (KANBAN_COLUMNS as readonly string[]).includes(task.column) - ? (task.column as KanbanColumn) - : "todo"; - out[col].push(task); - } - return out; -} +// U11 (R18): bucket tasks into the five TUI buckets via trait-flag mapping so +// cards in workflow columns the TUI can't express land in a legacy bucket or +// the read-only "other" bucket — never dropped. Delegates to the shared helper. +const groupTasksByColumn = groupTasksByBucket; function BoardView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) { const { t } = useTranslation("cli"); @@ -1734,6 +1746,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D todo: 0, "in-progress": 0, "in-review": 0, + [OTHER_BUCKET]: 0, done: 0, }); const [pickerOriginal, setPickerOriginal] = useState(0); @@ -1859,13 +1872,22 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D const narrowColumnIndicator = isNarrow ? ` · ${colIndex + 1}/${KANBAN_COLUMNS.length} ${columnLabel(focusedColumn).toUpperCase()} (${focusedTasks.length})` : ""; + // U11 (R18): the "other" bucket is a read-only view of custom workflow + // columns the TUI can't model — moving cards into/out of it from here would + // mean expressing a column the TUI has no name for, so it's disabled with a + // hint. (The TUI has no in-place move action yet; this keeps the affordance + // honest for when one lands.) + const focusedIsReadOnly = isOtherBucket(focusedColumn); + const boardHint = focusedIsReadOnly + ? `←→ column · ↑↓ task · Enter open · ${t("tui.boardOtherReadOnlyHint", "custom column — move disabled here")}` + : `←→ column · ↑↓ task · Enter open · n new · p project`; const hintText = subView === "picker" ? "↑↓ pick · Enter confirm · Esc cancel" : subView === "detail" ? "Esc back · q quit" : subView === "create" ? "type a task title · Enter create · Esc cancel" - : `←→ column · ↑↓ task · Enter open · n new · p project${narrowColumnIndicator}`; + : `${boardHint}${narrowColumnIndicator}`; const submitNewTask = async () => { const title = newTaskTitle.trim(); diff --git a/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts b/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts new file mode 100644 index 0000000000..7f4f92afa6 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts @@ -0,0 +1,84 @@ +import type { TraitFlags } from "@fusion/core"; +import type { TaskItem } from "./state.js"; + +// ── TUI bucket model (U11, R18) ────────────────────────────────────────────── +// +// The TUI renders a fixed set of buckets. The first four are the legacy kanban +// columns it has always shown. The fifth, "other", is a read-only catch-all for +// cards whose resolved workflow column the TUI cannot express as one of its +// legacy buckets — it sits between "in-review" and "done" so a custom column +// roughly "after review, before done" reads in a sensible place, and each card +// in it keeps its real column name as a secondary label so the user never loses +// the true position. The cardinal rule (R18): a card is NEVER dropped. + +export const LEGACY_KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const; +export type LegacyKanbanColumn = (typeof LEGACY_KANBAN_COLUMNS)[number]; + +/** The "other (custom)" catch-all bucket id. Read-only. */ +export const OTHER_BUCKET = "other" as const; + +/** All TUI buckets in render order: legacy columns with "other" wedged between + * in-review and done (U11 ordering requirement). */ +export const TUI_BUCKETS = ["todo", "in-progress", "in-review", OTHER_BUCKET, "done"] as const; +export type TuiBucket = (typeof TUI_BUCKETS)[number]; + +/** True when the bucket is the read-only custom catch-all. */ +export function isOtherBucket(bucket: TuiBucket): bucket is typeof OTHER_BUCKET { + return bucket === OTHER_BUCKET; +} + +/** + * Map a task to its TUI bucket (U11, R18). + * + * 1. If the task sits in one of the legacy kanban column ids, keep it there + * verbatim — flag-OFF and all-legacy boards behave exactly as before. + * 2. Otherwise, if the task carries resolved column flags (flag-ON payload), + * map by trait flags into the nearest legacy bucket: + * - complete → done + * - humanReview || mergeBlocker → in-review + * - countsTowardWip → in-progress + * - hold || intake → todo + * 3. Anything still unmapped lands in the read-only "other" bucket. The card is + * never dropped. + * + * Precedence note: `complete` wins over the others (a terminal column is shown + * as done even if it carried other advisory flags); review beats wip; wip beats + * the todo-like flags. This mirrors the lane priority the dashboard board uses. + */ +export function bucketForTask(task: TaskItem): TuiBucket { + if ((LEGACY_KANBAN_COLUMNS as readonly string[]).includes(task.column)) { + return task.column as LegacyKanbanColumn; + } + + const flags: TraitFlags | undefined = task.columnFlags; + if (flags) { + if (flags.complete) return "done"; + if (flags.humanReview || flags.mergeBlocker) return "in-review"; + if (flags.countsTowardWip) return "in-progress"; + if (flags.hold || flags.intake) return "todo"; + } + + return OTHER_BUCKET; +} + +/** Group tasks into the five TUI buckets, preserving input order within each. + * Every task lands in exactly one bucket; none are dropped. */ +export function groupTasksByBucket(tasks: TaskItem[]): Record { + const out: Record = { + todo: [], + "in-progress": [], + "in-review": [], + [OTHER_BUCKET]: [], + done: [], + }; + for (const task of tasks) { + out[bucketForTask(task)].push(task); + } + return out; +} + +/** Secondary label shown under a card in the "other" bucket: its real column + * name (falling back to the column id) so the user keeps the true position. */ +export function otherBucketSecondaryLabel(task: TaskItem): string { + return task.columnName ?? task.column; +} diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index 2749048e37..b5bd86f976 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -1,3 +1,4 @@ +import type { TraitFlags } from "@fusion/core"; import type { LogEntry } from "./log-ring-buffer.js"; // ── Public types shared across the whole dashboard-tui module ───────────────── @@ -131,6 +132,15 @@ export interface TaskItem { description: string; column: string; agentState?: string; + /** Display name of the task's resolved workflow column (U11, flag-ON only). + * Used as the secondary label when a card lands in the "Other (custom)" + * bucket so the user keeps the real position. Absent on flag-OFF / legacy + * boards. */ + columnName?: string; + /** Merged trait flags of the task's resolved workflow column (U11, flag-ON + * only). Drives graceful-degradation bucket mapping for non-legacy columns. + * Absent on flag-OFF / legacy boards, where bucketing is by column id. */ + columnFlags?: TraitFlags; } // Slim agent shape for Agents view list diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index cb7fa0248f..8df4be7de2 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -16,6 +16,11 @@ import { GlobalSettingsStore, resolveGlobalDir, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS, + isWorkflowColumnsEnabled, + resolveColumnFlags, + BUILTIN_CODING_WORKFLOW_IR, + type WorkflowIrColumn, + type TraitFlags, } from "@fusion/core"; import { createServer, @@ -936,6 +941,50 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: return projectStore; } + // ── U11: resolve per-task workflow column flags for the TUI (flag-ON only) ── + // + // The CLI TUI degrades gracefully (R18): cards in workflow columns it can't + // express must map by trait flags into its buckets or a read-only "other" + // bucket, never silently disappear. The TUI is flag-blind, so when + // `workflowColumns` is ON we enrich each slim task with its resolved column's + // display name + merged trait flags. Self-contained: derives everything from + // already-exposed store methods (workflow selection + definition) + the core + // `resolveColumnFlags` export — no dependency on concurrent U9 server work. + // Flag-OFF: returns undefineds and the TUI renders exactly as before. + type ResolvedColumnInfo = { columnName?: string; columnFlags?: TraitFlags }; + async function resolveTaskColumnInfo( + projectStore: TaskStore, + flagOn: boolean, + workflowIrCache: Map, + task: { id: string; column: string }, + ): Promise { + if (!flagOn) return {}; + try { + const selection = projectStore.getTaskWorkflowSelection(task.id); + const workflowId = selection?.workflowId; + let columns = workflowIrCache.get(workflowId); + if (columns === undefined) { + // Resolve the governing workflow IR. No selection → built-in default + // (KTD-1), matching the store's own resolution order. + const def = workflowId + ? await projectStore.getWorkflowDefinition(workflowId) + : undefined; + const ir = def?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + columns = ir.version === "v2" ? ir.columns : []; + workflowIrCache.set(workflowId, columns); + } + if (!columns) return {}; + // `task.column` is the IR column id (the store stores the column id). + const column = columns.find((c) => c.id === task.column); + if (!column) return {}; + return { columnName: column.name, columnFlags: resolveColumnFlags(column) }; + } catch { + // Degrade silently: an unresolvable workflow must never drop a card, just + // fall back to legacy column-id bucketing in the TUI. + return {}; + } + } + /** * Debounced refresh of TUI stats - batches rapid task updates. * If the BoardView has a scoped project path set on the controller, @@ -2378,13 +2427,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: listTasks: async (projectPath: string) => { const projectStore = await getProjectStore(projectPath); const tasks = await projectStore.listTasks({ slim: true, includeArchived: false }); - return tasks.map((t) => ({ - id: t.id, - title: t.title, - description: t.description ?? "", - column: t.column, - agentState: (t as { agentState?: string }).agentState, - })); + // U11 (R18): when the workflow-columns flag is ON, enrich each task + // with its resolved column display name + trait flags so the + // flag-blind TUI can map non-legacy columns into its buckets (or the + // read-only "other" bucket) instead of silently dropping them. The + // IR cache keeps this O(workflows) rather than O(tasks) DB reads. + const settings = await projectStore.getSettings(); + const flagOn = isWorkflowColumnsEnabled(settings); + const workflowIrCache = new Map(); + return Promise.all( + tasks.map(async (t) => { + const info = await resolveTaskColumnInfo(projectStore, flagOn, workflowIrCache, t); + return { + id: t.id, + title: t.title, + description: t.description ?? "", + column: t.column, + agentState: (t as { agentState?: string }).agentState, + ...(info.columnName !== undefined ? { columnName: info.columnName } : {}), + ...(info.columnFlags !== undefined ? { columnFlags: info.columnFlags } : {}), + }; + }), + ); }, createTask: async (projectPath: string, input: { title: string; description?: string }) => { const projectStore = await getProjectStore(projectPath);