feat(FN-3739): add fusion-plugin-even-cards workspace plugin

- Add a new fusion-plugin-even-cards package with typed card formatting utilities and board/task card generation
- Implement authenticated plugin routes for board card endpoints and plugin registration wiring
- Add unit tests for auth handling, board routes, and card formatting behavior
- Document plugin in PLUGIN_AUTHORING guide and register it in pnpm workspace

Fusion-Task-Id: FN-3739
This commit is contained in:
Fusion
2026-05-08 12:45:58 -07:00
committed by gsxdsm
parent 71e5360630
commit 7582a32cda
15 changed files with 635 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
# Even Cards Plugin
Standalone Fusion plugin for the Even Realities on-device card flow focused on glanceable board/task status.
## Reading the board
All endpoints are mounted under `/api/plugins/fusion-plugin-even-cards`.
Authentication header (required):
```http
Authorization: Bearer <apiKey>
```
| Method | Path | Query params | Response |
|---|---|---|---|
| GET | `/board/cards` | `columns` (comma-separated columns), `max` (1-20 total cards) | `{ deck, generatedAt }` |
| GET | `/board` | `columns` (optional) | `{ summary, updatedAt }` |
| GET | `/tasks/:id/cards` | none | `{ deck, generatedAt }` or `404` |
`max` is **total deck size** (summary + tasks). Example: `max=20` returns 1 summary card and up to 19 task cards.
## Card data model
```ts
type GlassesCard = {
id: string;
kind: "summary" | "task";
title: string;
lines: string[];
badge: { label: string; tone: "triage" | "todo" | "in-progress" | "in-review" | "done" | "neutral" };
taskId?: string;
updatedAt: string;
};
type CardDeck = {
cards: GlassesCard[];
summary: {
counts: Record<string, number>;
updatedAt: string | null;
};
};
```
## Display defaults
Current defaults in `src/cards/format.ts`:
- `DEFAULT_MAX_CHARS_PER_LINE = 24`
- `DEFAULT_MAX_LINES_PER_CARD = 4`
- `DEFAULT_MAX_CARDS_PER_DECK = 8`
These are provisional fallback values because FN-3737 research artifacts were unavailable in this worktree; revisit task is tracked in FN-3754.

View File

@@ -0,0 +1,25 @@
{
"name": "@fusion-plugin-examples/even-cards",
"version": "0.1.0",
"type": "module",
"description": "Even Realities on-device cards flow for Fusion board/task status",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.5.2",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { requireApiKey } from "../routes/auth.js";
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
describe("requireApiKey", () => {
it("returns 503 when api key setting is missing", () => {
const result = requireApiKey({ settings: {}, logger } as any, { headers: {} });
expect(result).toEqual({ ok: false, response: { status: 503, body: { error: "plugin not configured" } } });
});
it("returns 401 when header missing", () => {
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: {} });
expect(result).toEqual({ ok: false, response: { status: 401, body: { error: "unauthorized" } } });
});
it("returns 401 when key does not match", () => {
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: { authorization: "Bearer nope" } });
expect(result).toEqual({ ok: false, response: { status: 401, body: { error: "unauthorized" } } });
});
it("returns ok on matching key", () => {
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: { authorization: "Bearer secret" } });
expect(result).toEqual({ ok: true });
});
});

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from "vitest";
import plugin from "../index.js";
import type { FusionTask } from "../cards/types.js";
function makeTask(id: string, column: FusionTask["column"], updatedAt: string): FusionTask {
return {
id,
title: id,
description: id,
column,
status: "pending",
priority: "normal",
createdAt: "2026-05-08T10:00:00.000Z",
updatedAt,
currentStep: 0,
steps: [],
dependencies: [],
} as FusionTask;
}
function createContext(tasks: FusionTask[], apiKey = "secret") {
return {
settings: { apiKey },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
taskStore: {
listTasks: vi.fn(async () => tasks),
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id)),
},
} as any;
}
function route(path: string) {
return plugin.routes!.find((entry) => entry.path === path)!;
}
describe("even cards routes", () => {
it("returns 401 when auth header missing", async () => {
const response = (await route("/board/cards").handler({ headers: {} }, createContext([]))) as any;
expect(response.status).toBe(401);
});
it("returns 503 when plugin is not configured", async () => {
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext([], ""))) as any;
expect(response.status).toBe(503);
});
it("returns deck on happy path", async () => {
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "in-progress", "2026-05-08T12:00:00.000Z")];
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
expect(response.status).toBe(200);
expect(response.body.deck.cards[0].id).toBe("summary");
expect(response.body.deck.cards).toHaveLength(3);
});
it("filters columns in memory", async () => {
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "triage", "2026-05-08T12:00:00.000Z")];
const response = (await route("/board/cards").handler(
{ headers: { authorization: "Bearer secret" }, query: { columns: "triage" } },
createContext(tasks),
)) as any;
expect(response.body.deck.summary.counts.triage).toBe(1);
expect(response.body.deck.summary.counts.todo).toBe(0);
});
it("clamps max bounds", async () => {
const tasks = Array.from({ length: 30 }, (_, idx) => makeTask(`FN-${idx + 1}`, "todo", `2026-05-08T12:${String(idx).padStart(2, "0")}:00.000Z`));
const low = (await route("/board/cards").handler(
{ headers: { authorization: "Bearer secret" }, query: { max: "0" } },
createContext(tasks),
)) as any;
const high = (await route("/board/cards").handler(
{ headers: { authorization: "Bearer secret" }, query: { max: "99" } },
createContext(tasks),
)) as any;
expect(low.body.deck.cards).toHaveLength(1);
expect(high.body.deck.cards).toHaveLength(20);
});
it("returns board summary", async () => {
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
const response = (await route("/board").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
expect(response.status).toBe(200);
expect(response.body.summary.counts.todo).toBe(1);
});
it("returns task deck for known id", async () => {
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
const response = (await route("/tasks/:id/cards").handler(
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-1" } },
createContext(tasks),
)) as any;
expect(response.status).toBe(200);
expect(response.body.deck.cards[0].id).toBe("FN-1");
});
it("returns 404 for unknown task id", async () => {
const response = (await route("/tasks/:id/cards").handler(
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-404" } },
createContext([]),
)) as any;
expect(response.status).toBe(404);
});
it("excludes archived tasks from deck cards", async () => {
const tasks = [makeTask("FN-1", "archived", "2026-05-08T12:00:00.000Z"), makeTask("FN-2", "todo", "2026-05-08T11:00:00.000Z")];
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
const ids = response.body.deck.cards.map((card: any) => card.id);
expect(ids).toEqual(["summary", "FN-2"]);
});
});

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { boardToDeck } from "../cards/board-cards.js";
import { formatRelativeAge, formatTaskId, statusBadge, truncateLine, wrapLines } from "../cards/format.js";
import { taskToCard } from "../cards/task-cards.js";
import type { FusionTask } from "../cards/types.js";
function makeTask(id: string, column: FusionTask["column"], updatedAt: string): FusionTask {
return {
id,
description: `Task ${id}`,
title: `Title ${id}`,
column,
status: "pending",
priority: "normal",
currentStep: 0,
steps: [],
dependencies: [],
createdAt: "2026-05-08T10:00:00.000Z",
updatedAt,
} as FusionTask;
}
describe("cards format", () => {
it("truncates at boundary", () => {
expect(truncateLine("12345", 5)).toBe("12345");
expect(truncateLine("123456", 5)).toBe("1234…");
});
it("wraps across lines", () => {
expect(wrapLines("one two three four", 7, 3)).toEqual(["one two", "three", "four"]);
});
it("formats task id and relative age", () => {
expect(formatTaskId("fn-42")).toBe("FN-42");
expect(formatRelativeAge("2026-05-08T11:58:00.000Z", "2026-05-08T12:00:00.000Z")).toBe("2m");
});
it("maps all task columns to badges", () => {
expect(statusBadge("triage").tone).toBe("triage");
expect(statusBadge("todo").tone).toBe("todo");
expect(statusBadge("in-progress").tone).toBe("in-progress");
expect(statusBadge("in-review").tone).toBe("in-review");
expect(statusBadge("done").tone).toBe("done");
expect(statusBadge("archived").tone).toBe("neutral");
});
});
describe("board deck", () => {
it("summarizes counts and handles empty board", () => {
const deck = boardToDeck([], { now: "2026-05-08T12:00:00.000Z" });
expect(deck.cards).toHaveLength(1);
expect(deck.summary.counts.todo).toBe(0);
});
it("creates a task card", () => {
const card = taskToCard(makeTask("FN-7", "in-review", "2026-05-08T12:00:00.000Z"), { now: "2026-05-08T12:10:00.000Z" });
expect(card.id).toBe("FN-7");
expect(card.badge.tone).toBe("in-review");
expect(card.lines.join(" ")).toContain("Assignee unassigned");
});
it("caps deck and sorts deterministically", () => {
const tasks: FusionTask[] = [
makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"),
makeTask("FN-9", "in-progress", "2026-05-08T11:00:00.000Z"),
makeTask("FN-2", "done", "2026-05-08T12:00:00.000Z"),
makeTask("FN-3", "archived", "2026-05-08T12:00:00.000Z"),
];
const deck = boardToDeck(tasks, { maxCards: 3, now: "2026-05-08T12:00:00.000Z" });
expect(deck.cards).toHaveLength(3);
expect(deck.cards[1].id).toBe("FN-9");
expect(deck.cards[2].id).toBe("FN-1");
});
});

View File

@@ -0,0 +1,43 @@
import { DEFAULT_MAX_CARDS_PER_DECK, DEFAULT_MAX_CHARS_PER_LINE, DEFAULT_MAX_LINES_PER_CARD, statusBadge, truncateLine, wrapLines } from "./format.js";
import { taskToCard } from "./task-cards.js";
import type { BoardSummary, CardDeck, FusionColumn, FusionTask, GlassesCard } from "./types.js";
const COLUMN_ORDER: FusionColumn[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
function boardSummary(tasks: FusionTask[]): BoardSummary {
const counts = Object.fromEntries(COLUMN_ORDER.map((column) => [column, 0]));
let updatedAt: string | null = null;
for (const task of tasks) {
counts[task.column] = (counts[task.column] ?? 0) + 1;
if (!updatedAt || task.updatedAt > updatedAt) updatedAt = task.updatedAt;
}
return { counts, updatedAt };
}
function summaryCard(summary: BoardSummary, now: string, maxCharsPerLine: number, maxLines: number): GlassesCard {
const summaryText = `Triage ${summary.counts.triage} Todo ${summary.counts.todo} Doing ${summary.counts["in-progress"]} Review ${summary.counts["in-review"]} Done ${summary.counts.done}`;
return {
id: "summary",
kind: "summary",
title: truncateLine("Fusion board", maxCharsPerLine),
lines: wrapLines(summaryText, maxCharsPerLine, maxLines),
badge: statusBadge("todo"),
updatedAt: summary.updatedAt ?? now,
};
}
export function boardToDeck(tasks: FusionTask[], opts?: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string }): CardDeck {
const maxCharsPerLine = opts?.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
const maxCards = opts?.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK;
const now = opts?.now ?? new Date().toISOString();
const summary = boardSummary(tasks);
const active = tasks
.filter((task) => task.column !== "archived" && task.column !== "done")
.sort((a, b) => (b.updatedAt === a.updatedAt ? b.id.localeCompare(a.id) : b.updatedAt.localeCompare(a.updatedAt)))
.slice(0, Math.max(0, maxCards - 1));
const cards: GlassesCard[] = [summaryCard(summary, now, maxCharsPerLine, maxLines), ...active.map((task) => taskToCard(task, { maxCharsPerLine, maxLines, now }))];
return { cards, summary };
}

View File

@@ -0,0 +1,70 @@
import type { CardStatusBadge, FusionColumn } from "./types.js";
export const DEFAULT_MAX_CHARS_PER_LINE = 24;
export const DEFAULT_MAX_LINES_PER_CARD = 4;
export const DEFAULT_MAX_CARDS_PER_DECK = 8;
export function truncateLine(input: string, max: number): string {
const value = input.trim();
if (value.length <= max) return value;
if (max <= 1) return "…";
return `${value.slice(0, max - 1)}`;
}
export function wrapLines(text: string, max: number, maxLines: number): string[] {
const words = text.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [];
const lines: string[] = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length <= max) {
current = candidate;
continue;
}
if (current) {
lines.push(current);
if (lines.length === maxLines) return lines.map((line, idx) => (idx === maxLines - 1 ? truncateLine(line, max) : line));
}
current = word.length <= max ? word : truncateLine(word, max);
}
if (current && lines.length < maxLines) {
lines.push(current);
}
return lines.slice(0, maxLines).map((line, idx, arr) => (idx === arr.length - 1 ? truncateLine(line, max) : line));
}
export function statusBadge(column: FusionColumn): CardStatusBadge {
switch (column) {
case "triage":
case "todo":
case "in-progress":
case "in-review":
case "done":
return { label: column, tone: column };
case "archived":
default:
return { label: column, tone: "neutral" };
}
}
export function formatTaskId(id: string): string {
return id.trim().toUpperCase();
}
export function formatRelativeAge(createdAtIso: string, nowIso: string): string {
const created = Date.parse(createdAtIso);
const now = Date.parse(nowIso);
if (!Number.isFinite(created) || !Number.isFinite(now) || now <= created) return "0m";
const diffMs = now - created;
const minutes = Math.floor(diffMs / 60000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}

View File

@@ -0,0 +1,20 @@
import { DEFAULT_MAX_CHARS_PER_LINE, DEFAULT_MAX_LINES_PER_CARD, formatRelativeAge, formatTaskId, statusBadge, truncateLine, wrapLines } from "./format.js";
import type { FusionTask, GlassesCard } from "./types.js";
export function taskToCard(task: FusionTask, opts?: { maxCharsPerLine?: number; maxLines?: number; now?: string }): GlassesCard {
const maxCharsPerLine = opts?.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
const now = opts?.now ?? new Date().toISOString();
const assignee = task.assignedAgentId ?? task.assigneeUserId ?? "unassigned";
const body = [`Priority ${task.priority ?? "normal"}`, `Assignee ${assignee}`, `Age ${formatRelativeAge(task.createdAt, now)}`].join(" ");
return {
id: task.id,
taskId: task.id,
kind: "task",
title: truncateLine(formatTaskId(task.id) + " " + (task.title?.trim() || task.description), maxCharsPerLine),
lines: wrapLines(body, maxCharsPerLine, maxLines),
badge: statusBadge(task.column),
updatedAt: task.updatedAt,
};
}

View File

@@ -0,0 +1,40 @@
export type FusionColumn = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
export interface FusionTask {
id: string;
title?: string;
description: string;
column: FusionColumn;
priority?: string;
assignedAgentId?: string;
assigneeUserId?: string;
createdAt: string;
updatedAt: string;
}
export type CardTone = "triage" | "todo" | "in-progress" | "in-review" | "done" | "neutral";
export interface CardStatusBadge {
label: string;
tone: CardTone;
}
export interface GlassesCard {
id: string;
kind: "summary" | "task";
title: string;
lines: string[];
badge: CardStatusBadge;
taskId?: string;
updatedAt: string;
}
export interface BoardSummary {
counts: Record<string, number>;
updatedAt: string | null;
}
export interface CardDeck {
cards: GlassesCard[];
summary: BoardSummary;
}

View File

@@ -0,0 +1,39 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginSettingSchema } from "@fusion/plugin-sdk";
import { boardRoutes } from "./routes/board-routes.js";
const settingsSchema: Record<string, PluginSettingSchema> = {
apiKey: {
type: "password",
label: "Companion API Key",
description: "Bearer token used by the glasses companion app.",
required: true,
group: "Authentication",
},
boardPollingMs: {
type: "number",
label: "Board Polling Interval (ms)",
defaultValue: 10000,
group: "On-device Cards",
},
};
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-even-cards",
name: "Even Cards",
version: "0.1.0",
description: "On-device card payloads for board/task status in Even Realities flows",
author: "Fusion Team",
settingsSchema,
},
state: "installed",
routes: [...boardRoutes],
hooks: {
onLoad: async (ctx) => {
ctx.logger.info("Even Cards plugin loaded");
},
},
});
export default plugin;

View File

@@ -0,0 +1,39 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { PluginContext, PluginRouteResponse } from "@fusion/plugin-sdk";
function toDigest(value: string): Buffer {
return createHash("sha256").update(value).digest();
}
function readBearer(headers: Record<string, string | string[] | undefined>): string | undefined {
const auth = headers.authorization ?? headers.Authorization;
const header = Array.isArray(auth) ? auth[0] : auth;
if (!header) return undefined;
const match = header.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || undefined;
}
export function requireApiKey(
ctx: PluginContext,
req: { headers: Record<string, string | string[] | undefined> },
): { ok: true } | { ok: false; response: PluginRouteResponse } {
const expected = typeof ctx.settings.apiKey === "string" ? ctx.settings.apiKey.trim() : "";
if (!expected) {
return { ok: false, response: { status: 503, body: { error: "plugin not configured" } } };
}
const provided = readBearer(req.headers ?? {});
if (!provided) {
return { ok: false, response: { status: 401, body: { error: "unauthorized" } } };
}
const expectedDigest = toDigest(expected);
const providedDigest = toDigest(provided);
const valid = timingSafeEqual(expectedDigest, providedDigest);
if (!valid) {
return { ok: false, response: { status: 401, body: { error: "unauthorized" } } };
}
return { ok: true };
}

View File

@@ -0,0 +1,79 @@
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import { boardToDeck } from "../cards/board-cards.js";
import { DEFAULT_MAX_CARDS_PER_DECK, DEFAULT_MAX_CHARS_PER_LINE, DEFAULT_MAX_LINES_PER_CARD } from "../cards/format.js";
import { taskToCard } from "../cards/task-cards.js";
import type { CardDeck, FusionColumn, FusionTask } from "../cards/types.js";
import { requireApiKey } from "./auth.js";
const ALLOWED_COLUMNS: FusionColumn[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
function parseColumns(raw: unknown): Set<FusionColumn> | null {
if (typeof raw !== "string" || !raw.trim()) return null;
const parsed = raw
.split(",")
.map((value) => value.trim())
.filter((value): value is FusionColumn => ALLOWED_COLUMNS.includes(value as FusionColumn));
return parsed.length ? new Set(parsed) : null;
}
function parseMax(raw: unknown): number {
const value = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
if (!Number.isFinite(value)) return DEFAULT_MAX_CARDS_PER_DECK;
return Math.max(1, Math.min(20, Math.floor(value)));
}
function requestData(req: unknown): { headers: Record<string, string | string[] | undefined>; query: Record<string, unknown>; params: Record<string, unknown> } {
const candidate = (req ?? {}) as { headers?: Record<string, string | string[] | undefined>; query?: Record<string, unknown>; params?: Record<string, unknown> };
return { headers: candidate.headers ?? {}, query: candidate.query ?? {}, params: candidate.params ?? {} };
}
async function getBoardCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
const request = requestData(req);
const auth = requireApiKey(ctx, { headers: request.headers });
if (!auth.ok) return auth.response;
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as FusionTask[]) ?? [];
const columns = parseColumns(request.query.columns);
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
const maxCards = parseMax(request.query.max);
const deck = boardToDeck(filtered, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD, maxCards });
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
}
async function getBoardSummary(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
const request = requestData(req);
const auth = requireApiKey(ctx, { headers: request.headers });
if (!auth.ok) return auth.response;
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as FusionTask[]) ?? [];
const columns = parseColumns(request.query.columns);
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
const deck = boardToDeck(filtered, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD, maxCards: 1 });
return { status: 200, body: { summary: deck.summary, updatedAt: deck.summary.updatedAt } };
}
async function getTaskCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
const request = requestData(req);
const auth = requireApiKey(ctx, { headers: request.headers });
if (!auth.ok) return auth.response;
const taskId = typeof request.params.id === "string" ? request.params.id.trim() : "";
if (!taskId) return { status: 400, body: { error: "task id is required" } };
const task = (await ctx.taskStore.getTask(taskId)) as FusionTask | undefined;
if (!task) return { status: 404, body: { error: "task not found" } };
const deck: CardDeck = {
cards: [taskToCard(task, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD })],
summary: boardToDeck([task], { maxCards: 1 }).summary,
};
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
}
export const boardRoutes: PluginRouteDefinition[] = [
{ method: "GET", path: "/board/cards", handler: getBoardCards, description: "Read-only board card deck" },
{ method: "GET", path: "/board", handler: getBoardSummary, description: "Read-only board summary" },
{ method: "GET", path: "/tasks/:id/cards", handler: getTaskCards, description: "Read-only single task card deck" },
];

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}