Closes #2790 by finishing a decision that was already made and written down. ## The issue's premise was wrong, including as I filed it I raised this as "a package accidentally missing from `pnpm-workspace.yaml`." It wasn't. `CHANGELOG-archive.md:9596`: > Consolidate Even Realities plugin support into `fusion-plugin-even-realities-glasses` and **remove `fusion-plugin-even-cards` from the active workspace package list to avoid duplicate user-facing integrations.** The removal was deliberate, for a stated reason. The directory is what got left behind. That also rules out the option I had been weighting first — adding it back would undo a shipped consolidation and re-create the duplicate integration it was removed to prevent. ## Unreachable by every path | check | result | |---|---| | `pnpm-workspace.yaml` globs | no — never installed or built | | CLI bundle list (`packages/cli/tsup.config.ts`) | no — 0 mentions, while seven other plugins are named | | runtime `plugins/*` directory-scan discovery | none exists — plugins are enumerated explicitly | | `package.json` | `private: true` — never published | | imports outside its own directory | none | | kept as a docs/authoring example | no — zero references in `docs/` or any root `*.md` | | successor in the workspace | yes — `fusion-plugin-even-realities-glasses` | ## It was also polluting two ratchets Dead code in a scanned tree is worse than dead code: both censuses are **source-text scanners**, so they counted debt in files no build or typecheck covers. Nobody could retire those entries through a normally-verified refactor, and they inflated how much of the remaining debt looked actionable. Both baselines regenerated, and I checked each diff rather than trusting the totals: | baseline | change | |---|---| | `lane-wiring` | 26 → 23 sites, 21 → 20 files — **one entry removed**, `board-routes.ts: 3` | | `lifecycle-column-census` | exactly its two `board-cards.ts` entries | Neither regeneration tightened anything unrelated — worth confirming explicitly, because `lifecycle-column-census.mjs --strict` **writes** its baseline as a side effect and could have folded an unrelated drop into this commit. **Verified:** lane-wiring, SQL-literal and FNXC gates all exit 0 after the deletion; lint clean. 15 files removed. ## Why I went ahead I said twice I would not delete this unilaterally. What changed is that the trade-off dissolved — once the consolidation decision turned out to be documented and the "is it a teaching example?" question answered by a docs grep, there was nothing left to decide, only to execute. The deletion is git-reversible and the standing guidance is that reversible calls are mine to make. Fourth time today a thing I filed as "needs someone else's judgement" turned out to have its answer already in the repository. Cheap habit worth keeping: before deferring, grep for whether the judgement has already been made. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +0,0 @@
|
||||
# @fusion-plugin-examples/even-cards
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @fusion/plugin-sdk@0.26.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @fusion/plugin-sdk@0.25.0
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @fusion/plugin-sdk@0.24.0
|
||||
@@ -1,17 +0,0 @@
|
||||
# Even Cards Plugin (Deprecated)
|
||||
|
||||
`fusion-plugin-even-cards` is deprecated and superseded by the canonical plugin:
|
||||
|
||||
- `fusion-plugin-even-realities-glasses`
|
||||
|
||||
Board/task card APIs previously provided here (`/board/cards`, `/board`, `/tasks/:id/cards`) now live under the canonical plugin id and route namespace.
|
||||
|
||||
## Migration
|
||||
|
||||
Use:
|
||||
|
||||
- `/api/plugins/fusion-plugin-even-realities-glasses/board/cards`
|
||||
- `/api/plugins/fusion-plugin-even-realities-glasses/board`
|
||||
- `/api/plugins/fusion-plugin-even-realities-glasses/tasks/:id/cards`
|
||||
|
||||
This package is removed from the active workspace package list and retained only as a temporary compatibility artifact pending full cleanup.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@fusion-plugin-examples/even-cards",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"description": "Even Realities on-device cards flow for Fusion board/task status",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"source": "./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": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-21:10 DELIBERATE-LITERAL: no resolution source reaches this package.
|
||||
|
||||
This plugin depends on `@fusion/plugin-sdk` ONLY (see its package.json) — not `@fusion/core` — and the
|
||||
SDK does not re-export the lifecycle role helpers. There is no IR, no store, and no trait flags on
|
||||
`FusionTask` here, so there is nothing to resolve FROM: converting is possible only at the caller or by
|
||||
giving the plugin a new dependency, both structural changes and out of scope for the conversion.
|
||||
|
||||
`COLUMN_ORDER` is a DISPLAY ordering for the glasses deck and degrades honestly on a renamed board — an
|
||||
unknown column is simply absent from the summary line. The `!== "archived" && !== "done"` filter in
|
||||
`boardToDeck` is the one that misreads such a board: a finished card is neither, so it shows as active.
|
||||
That is a cosmetic over-report on a secondary surface, not a lifecycle decision. The real fix is the SDK
|
||||
exposing role flags on the task shape it hands plugins — recorded here as the correct home rather than
|
||||
guessed at.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/* FNXC:WorkflowResolvedColumns 2026-07-30-21:10 DELIBERATE-LITERAL: no resolution source in this package — see the note above `COLUMN_ORDER`. */
|
||||
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 };
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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`;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
@@ -1,39 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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" },
|
||||
];
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
|
||||
|
||||
const maxWorkers = computeMaxWorkers();
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||
pool: "threads",
|
||||
maxWorkers,
|
||||
minWorkers: 1,
|
||||
},
|
||||
});
|
||||
@@ -19,7 +19,6 @@
|
||||
"packages/cli/src/commands/dashboard-tui/bucket-mapping.ts": 1,
|
||||
"packages/cli/src/extension.ts": 1,
|
||||
"plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx": 1,
|
||||
"plugins/fusion-plugin-even-cards/src/routes/board-routes.ts": 3,
|
||||
"plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,6 @@
|
||||
"packages/engine/src/scheduler.ts\u0000todo": 1,
|
||||
"packages/engine/src/self-healing.ts\u0000archived": 1,
|
||||
"packages/engine/src/triage.ts\u0000triage": 1,
|
||||
"plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000archived": 1,
|
||||
"plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000done": 1,
|
||||
"plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts\u0000done": 1,
|
||||
"plugins/fusion-plugin-reports/src/store/report-types.ts\u0000archived": 1
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user