Merge main into fast-tests: keep both CONCEPTS additions (workflow columns/plugins + merge-gate testing vocabulary)

This commit is contained in:
gsxdsm
2026-06-05 09:37:22 -07:00
261 changed files with 49409 additions and 5866 deletions

View File

@@ -16,7 +16,13 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |
| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) |
| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) |
| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) |
| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none |
| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) |
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) |
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
@@ -53,7 +59,7 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi
| Tool | Purpose | Parameters |
|---|---|---|
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) |
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 1-indexed), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) |
| `fn_task_add_dep` | Add a dependency to current task (confirmation-gated) | `task_id` (string), `confirm?` (boolean) |
| `fn_task_done` | Mark task complete and optionally store summary | `summary?` (string) |
| `fn_review_step` | Spawn step plan/code reviewer | `step` (number), `type` (`plan` \| `code`), `step_name` (string), `baseline?` (string) |

View File

@@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({
}));
// Mock @fusion/core before importing
vi.mock("@fusion/core", () => ({
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
TaskStore: vi.fn(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {

View File

@@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({
}));
// Mock @fusion/core before importing
vi.mock("@fusion/core", () => ({
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
TaskStore: vi.fn(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {

View File

@@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
import { normalizeOpencodeGoModel, parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
@@ -75,8 +75,8 @@ describe("startup-model-sync", () => {
expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) }));
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: expect.arrayContaining([
expect.objectContaining({ id: "opencode-go/gpt-5" }),
expect.objectContaining({ id: "opencode-go/custom" }),
expect.objectContaining({ id: "gpt-5" }),
expect.objectContaining({ id: "custom" }),
]),
}));
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
@@ -257,7 +257,7 @@ describe("startup-model-sync", () => {
expect(result).toEqual({ registeredCount: 1 });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [expect.objectContaining({ id: "opencode-go/gpt-5" })],
models: [expect.objectContaining({ id: "gpt-5" })],
}));
});
@@ -319,4 +319,52 @@ describe("startup-model-sync", () => {
"opencode-go/custom",
]);
});
it("deduplicates models when CLI emits both prefix forms", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("opencode/foo\nopencode-go/foo\nopencode/bar\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [
expect.objectContaining({ id: "foo" }),
expect.objectContaining({ id: "bar" }),
],
}));
});
it("throws on empty model ID after prefix stripping", () => {
expect(() => normalizeOpencodeGoModel("opencode/")).toThrow("no model name");
expect(() => normalizeOpencodeGoModel("opencode-go/")).toThrow("no model name");
});
it("accepts apiKey and passes it as env var to spawn", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("opencode/foo\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn(), apiKey: "test-key" });
expect(mockSpawn).toHaveBeenCalledWith(
"opencode",
["models", "opencode", "--refresh"],
expect.objectContaining({
env: expect.objectContaining({ OPENCODE_API_KEY: "test-key" }),
}),
);
});
});

View File

@@ -71,7 +71,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
@@ -720,14 +720,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
(scope, message) => console.log(`[${scope}] ${message}`),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();

View File

@@ -643,6 +643,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();

View File

@@ -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<TaskItem> & { 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");
});
});

View File

@@ -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<string, "yellow" | "cyanBright" | "cyan" | "green"> = {
const COLUMN_COLORS: Record<string, "yellow" | "cyanBright" | "cyan" | "green" | "magenta"> = {
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({
<Text bold={selected} color={titleColor} wrap="wrap">
{title}
</Text>
{secondaryLabel && (
<Text dimColor wrap="truncate-end">↳ {secondaryLabel}</Text>
)}
</Box>
);
}
@@ -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 && (
@@ -1590,6 +1613,15 @@ function TaskDetailScreen({
</Box>
)}
{/* Card-placed custom fields (U13/KTD-14): read-only bracketed labels. */}
{detail.customFields && detail.customFields.length > 0 && (
<Box flexDirection="row" gap={1} flexWrap="wrap" flexShrink={0}>
{detail.customFields.map((f) => (
<Text key={f.label} color="magenta">[{f.label}: {f.value}]</Text>
))}
</Box>
)}
<Box height={1} flexShrink={0} />
{/* Steps section */}
@@ -1695,21 +1727,10 @@ function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
function groupTasksByColumn(tasks: TaskItem[]): Record<KanbanColumn, TaskItem[]> {
const out: Record<KanbanColumn, TaskItem[]> = {
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 +1755,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 +1881,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();

View File

@@ -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<TuiBucket, TaskItem[]> {
const out: Record<TuiBucket, TaskItem[]> = {
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;
}

View File

@@ -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
@@ -220,6 +230,10 @@ export interface TaskDetailData {
currentStepIndex?: number;
steps: TaskStep[];
recentLogs: TaskLogEntry[]; // last ~200 entries on initial load
/** Card-placed custom field values, pre-rendered as read-only bracketed
* labels for the task detail view (U13/KTD-14). Absent/empty when the
* workflow declares no card fields or none have values. */
customFields?: Array<{ label: string; value: string }>;
}
export type TaskEvent =

View File

@@ -16,6 +16,12 @@ import {
GlobalSettingsStore,
resolveGlobalDir,
DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS,
isWorkflowColumnsEnabled,
resolveColumnFlags,
BUILTIN_CODING_WORKFLOW_IR,
parseWorkflowIr,
type WorkflowIrColumn,
type TraitFlags,
} from "@fusion/core";
import {
createServer,
@@ -78,7 +84,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"
import { resolveSelfExtension } from "./self-extension.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
@@ -936,6 +942,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<string | undefined, WorkflowIrColumn[] | null>,
task: { id: string; column: string },
): Promise<ResolvedColumnInfo> {
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,
@@ -1711,14 +1761,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
(scope, message) => logSink.log(message, scope),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
@@ -2032,14 +2080,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
(scope, message) => logSink.log(message, scope),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
@@ -2378,13 +2424,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<string | undefined, WorkflowIrColumn[] | null>();
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);
@@ -2678,6 +2739,48 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
text: entry.outcome ? `${entry.action} → ${entry.outcome}` : entry.action,
source: entry.runContext?.agentId ? "agent" : "executor",
}));
// Card-placed custom fields → read-only bracketed labels
// (U13/KTD-14). Resolve the task's workflow IR, filter
// card-placed field defs, and render any present values.
// Best-effort: any resolution failure simply omits the chips.
let customFields: Array<{ label: string; value: string }> | undefined;
try {
const values = (t as { customFields?: Record<string, unknown> }).customFields;
if (values && Object.keys(values).length > 0) {
const selection = projectStore.getTaskWorkflowSelection(t.id);
const def = selection?.workflowId
? await projectStore.getWorkflowDefinition(selection.workflowId)
: undefined;
const ir = def
? (typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir)
: BUILTIN_CODING_WORKFLOW_IR;
const fields = ir.version === "v2" ? (ir.fields ?? []) : [];
const chips: Array<{ label: string; value: string }> = [];
for (const field of fields) {
if (field.render?.placement !== "card") continue;
const raw = values[field.id];
if (raw === undefined || raw === null || raw === "") continue;
const optLabel = (v: string): string =>
field.options?.find((o) => o.value === v)?.label ?? v;
let display: string;
if (field.type === "boolean") {
if (raw !== true) continue;
display = field.name;
} else if (field.type === "multi-enum" && Array.isArray(raw)) {
if (raw.length === 0) continue;
display = raw.map((v) => optLabel(String(v))).join(", ");
} else if (field.type === "enum") {
display = optLabel(String(raw));
} else {
display = String(raw);
}
chips.push({ label: field.name, value: display });
}
if (chips.length > 0) customFields = chips;
}
} catch {
customFields = undefined;
}
return {
id: t.id,
title: t.title,
@@ -2689,6 +2792,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
currentStepIndex: t.currentStep,
steps,
recentLogs,
...(customFields ? { customFields } : {}),
};
} catch {
// Task not found (deleted/archived between selection and fetch).

View File

@@ -71,7 +71,7 @@ import {
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -827,14 +827,12 @@ export async function runServe(
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
(scope, message) => console.log(`[${scope}] ${message}`),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();

View File

@@ -205,15 +205,22 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti
export function normalizeOpencodeGoModel(modelId: string): ModelConfig {
const trimmed = modelId.trim();
const normalizedId = trimmed.startsWith("opencode/")
? `opencode-go/${trimmed.slice("opencode/".length)}`
: trimmed.startsWith("opencode-go/")
? trimmed
: `opencode-go/${trimmed}`;
// Strip the provider prefix (opencode/ or opencode-go/) — the Pi SDK
// already routes requests by provider, and the OpenCode API expects the
// bare model name (e.g. "deepseek-v4-flash", not "opencode-go/deepseek-v4-flash").
const bareModel = trimmed.startsWith("opencode-go/")
? trimmed.slice("opencode-go/".length)
: trimmed.startsWith("opencode/")
? trimmed.slice("opencode/".length)
: trimmed;
if (!bareModel) {
throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`);
}
return {
id: normalizedId,
name: normalizedId,
id: bareModel,
name: bareModel,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -233,10 +240,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] {
return [...ids];
}
export async function discoverOpencodeGoModels(): Promise<string[]> {
export async function discoverOpencodeGoModels(apiKey?: string): Promise<string[]> {
return await new Promise<string[]>((resolve, reject) => {
const env: Record<string, string> = { ...process.env as Record<string, string> };
if (apiKey) {
env.OPENCODE_API_KEY = apiKey;
}
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
stdio: ["ignore", "pipe", "pipe"],
env,
});
let stdout = "";
@@ -272,16 +284,25 @@ export async function discoverOpencodeGoModels(): Promise<string[]> {
export async function refreshOpencodeGoModels(options: {
modelRegistry: ModelRegistryLike;
log: (scope: string, message: string) => void;
apiKey?: string;
}): Promise<OpencodeGoRefreshResult> {
try {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
const { modelRegistry, log, apiKey } = options;
const modelIds = await discoverOpencodeGoModels(apiKey);
if (modelIds.length === 0) {
log("opencode-go", "No models discovered from opencode CLI refresh");
return { registeredCount: 0, reason: "no-models-from-cli" };
}
const models = modelIds.map(normalizeOpencodeGoModel);
const normalized = modelIds.map(normalizeOpencodeGoModel);
// Deduplicate: CLI can emit both "opencode/foo" and "opencode-go/foo"
// which normalize to the same bare ID.
const seen = new Set<string>();
const models = normalized.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
@@ -310,6 +331,27 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise<vo
}
if (settings.opencodeGoModelSync !== false) {
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
const opencodeGoApiKey = await options.authStorage.getApiKey("opencode-go") ?? await options.authStorage.getApiKey("opencode");
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log, apiKey: opencodeGoApiKey });
}
}
/**
* Shared handler for the onApiKeySaved callback used by serve, daemon, and
* dashboard. Resolves the opencode-go API key from auth storage (falling back
* to the "opencode" provider ID) and triggers a model refresh, respecting the
* opencodeGoModelSync setting.
*/
export async function handleOpencodeGoApiKeySaved(
dashboardAuthStorage: AuthStorageLike,
store: { getSettings: () => Promise<SettingsLike> },
modelRegistry: ModelRegistryLike,
log: (scope: string, message: string) => void,
): Promise<OpencodeGoRefreshResult | undefined> {
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode");
return await refreshOpencodeGoModels({ modelRegistry, log, apiKey: opencodeGoKey });
}

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
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";
@@ -19,6 +19,12 @@ 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 {
@@ -806,7 +812,7 @@ export async function runTaskShow(id: string, projectName?: string) {
console.log();
console.log(` ${task.id}: ${task.title || task.description}`);
console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`);
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(", ")}`);
}
@@ -959,7 +965,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri
const task = await store.moveTask(id, column as Column);
console.log();
console.log(` ✓ Moved ${task.id} → ${COLUMN_LABELS[task.column as Column]}`);
console.log(` ✓ Moved ${task.id} → ${columnLabel(task.column)}`);
console.log();
}
@@ -1010,7 +1016,7 @@ export async function runTaskArchive(id: string, projectName?: string) {
const task = await store.archiveTask(id);
console.log();
console.log(` ✓ Archived ${task.id} → ${COLUMN_LABELS[task.column]}`);
console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`);
console.log();
}
@@ -1019,7 +1025,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) {
const task = await store.unarchiveTask(id);
console.log();
console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`);
console.log(` ✓ Unarchived ${task.id} → ${columnLabel(task.column)}`);
console.log();
}

View File

@@ -9,6 +9,7 @@ import {
buildManualRetryResetPatch,
validateNodeOverrideChange,
type Task,
type ColumnId,
type InsightCategory,
type TaskPriority,
type InsightStatus,
@@ -57,6 +58,12 @@ import { spawn, type ChildProcess } from "node:child_process";
// ── Helpers ────────────────────────────────────────────────────────
/** #1403: display a column's label, falling back to the raw id for
* workflow-defined custom columns that have no legacy label. */
function columnLabel(column: ColumnId): string {
return (COLUMN_LABELS as Record<string, string>)[column] ?? column;
}
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -782,7 +789,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const lines: string[] = [];
lines.push(`${task.id}: ${task.title || task.description}`);
lines.push(
`Column: ${COLUMN_LABELS[task.column]}` +
`Column: ${columnLabel(task.column)}` +
(task.size ? ` · Size: ${task.size}` : "") +
(task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""),
);
@@ -1145,7 +1152,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const task = await store.archiveTask(params.id);
return {
content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
details: { taskId: task.id, column: task.column },
};
},
@@ -1173,7 +1180,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const task = await store.unarchiveTask(params.id);
return {
content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
content: [{ type: "text", text: `Unarchived ${task.id} → ${columnLabel(task.column)}` }],
details: { taskId: task.id, column: task.column },
};
},

View File

@@ -0,0 +1,57 @@
/**
* Drift guard for the intentionally duplicated resolvePluginEntryPath.
*
* The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks
* work in tests) while @fusion/core owns the copy used by the dashboard
* install/enable routes. This test runs both against real on-disk layouts and
* asserts identical results, so a candidate-list change applied to one copy
* but not the other fails CI instead of silently diverging.
*
* No fs mocks here on purpose — vitest module mocks don't reach the
* externalized @fusion/core import, so real temp directories are the only
* seam that exercises both implementations equally.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
import { resolvePluginEntryPath as coreResolve } from "@fusion/core";
describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "entry-path-sync-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function touch(relative: string) {
const full = join(dir, relative);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, "// entry\n");
}
const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
{ name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" },
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
{ name: "no entry files", files: ["README.md"], expected: null },
];
for (const layout of layouts) {
it(`resolves identically for: ${layout.name}`, () => {
for (const f of layout.files) touch(f);
const expected = layout.expected === null ? null : join(dir, layout.expected);
expect(cliResolve(dir)).toBe(expected);
expect(coreResolve(dir)).toBe(expected);
});
}
});

View File

@@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null {
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing bundle rather than
* persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
* which the dashboard install/enable routes use for the same contract.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [

View File

@@ -41,6 +41,8 @@ const reportsPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-r
const reportsPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-reports");
const cliPrintingPressPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-cli-printing-press");
const cliPrintingPressPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-cli-printing-press");
const compoundEngineeringPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-compound-engineering");
const compoundEngineeringPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-compound-engineering");
const dashboardClientStub = `<!doctype html>
<html lang="en">
<head>
@@ -241,6 +243,12 @@ const cliBuildConfig = {
destDir: roadmapPluginDest,
});
await bundlePluginEntry({
pluginId: "fusion-plugin-compound-engineering",
srcDir: compoundEngineeringPluginSrc,
destDir: compoundEngineeringPluginDest,
});
if (existsSync(reportsPluginDest)) {
rmSync(reportsPluginDest, { recursive: true, force: true });
}