feat(FN-3742): unify agent action gating classifications across permanent a

Merges a significant refactor of agent action gating into a unified `gating-classifications.ts` module shared across permanent agents and executor gating logic, plus new glasses quick-capture support with dedicated routes and parser, and a fix restoring step progress visibility during task execution

Fusion-Task-Id: FN-3742
This commit is contained in:
Fusion
2026-05-08 13:59:14 -07:00
committed by gsxdsm
parent 934b706910
commit 07cd3d065c
12 changed files with 422 additions and 54 deletions

View File

@@ -1118,14 +1118,14 @@
/* Larger touch targets for file entries on mobile */
.changed-files-sidebar.mobile .changed-files-entry {
padding: 10px var(--space-md);
padding: calc(var(--space-sm) + var(--space-xs) / 2) var(--space-md);
min-height: 36px;
}
/* Clearer active state on mobile */
.changed-files-sidebar.mobile .changed-files-entry.active {
background: var(--card-hover);
border-left: 3px solid var(--in-progress);
border-left: calc(var(--btn-border-width) * 3) solid var(--in-progress);
}
.changed-files-content.mobile {

View File

@@ -210,7 +210,7 @@ async function runConfiguredCommand(
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
const taskUpdateParams = Type.Object({
step: Type.Number({ description: "Step number (0-indexed)" }),
step: Type.Number({ description: "Step number (1-indexed)" }),
status: Type.Union(
STEP_STATUSES.map((s) => Type.Literal(s)),
{ description: "New status: pending, in-progress, done, or skipped" },
@@ -3916,18 +3916,28 @@ export class TaskExecutor {
};
}
if (!Number.isInteger(step) || step < 0) {
if (!Number.isInteger(step) || step < 1) {
return {
content: [{
type: "text" as const,
text: `Invalid step number: ${step}. Steps are 0-indexed.`,
text: `Invalid step number: ${step}. Steps are 1-indexed.`,
}],
details: {},
};
}
const task = await store.updateStep(taskId, step, status as StepStatus);
const stepInfo = task.steps[step];
const stepIndex = step - 1;
const task = await store.updateStep(taskId, stepIndex, status as StepStatus);
const stepInfo = task.steps[stepIndex];
if (!stepInfo) {
return {
content: [{
type: "text" as const,
text: `Invalid step number: ${step}. This task has ${task.steps.length} step(s) (1-indexed).`,
}],
details: {},
};
}
const persistedStatus = stepInfo.status;
const progress = task.steps.filter((s) => s.status === "done").length;

View File

@@ -31,6 +31,47 @@ pnpm --filter @fusion-plugin-examples/even-realities-glasses test
- `quickCaptureDefaultColumn` (default `triage`)
- `enableAgentActions` (default `true`)
## Quick capture
Use `POST /quick-capture` for one-gesture glasses capture (`POST /tasks` is still the general-purpose route).
Pipeline:
1. Strip leading wake phrase (`hey fusion`, `fusion`, `ok fusion`, `note`, `task`, `capture`)
2. Strip filler tokens (`um`, `uh`, `er`, `like`, `you know`) and transcript punctuation noise
3. Split first sentence into title + description (title capped at 80 chars; overflow moved into description)
4. Resolve column from request `column` or plugin setting `quickCaptureDefaultColumn` (fallback `triage`)
Example:
```bash
curl -X POST http://localhost:4040/api/plugins/fusion-plugin-even-realities-glasses/quick-capture \
-H "Authorization: Bearer <apiKey>" \
-H "Content-Type: application/json" \
-d '{"text":"hey fusion, file a bug about the merge gate"}'
```
Response:
```json
{
"task": {
"id": "FN-1234",
"description": "file a bug about the merge gate\nfile a bug about the merge gate",
"column": "triage"
},
"card": {
"id": "task-FN-1234",
"kind": "task",
"title": "FN-1234: file a bug about the merge gate",
"bodyLines": [
"file a bug about the merge gate\nfile a bug about the merge gate",
"Column: triage"
],
"accentColor": "yellow"
}
}
```
## Security notes
- Uses `Authorization: Bearer <token>` for all API requests.

View File

@@ -25,6 +25,7 @@ describe("cards", () => {
"Column: in-review",
],
"id": "task-FN-1",
"kind": "task",
"title": "FN-1: Ship",
}
`);
@@ -42,6 +43,7 @@ describe("cards", () => {
"done: 1",
],
"id": "board-summary",
"kind": "summary",
"title": "Fusion Board Summary",
}
`);
@@ -64,6 +66,7 @@ describe("cards", () => {
"entered notify column",
],
"id": "notification-FN-2-entered notify column",
"kind": "notification",
"title": "Task update: FN-2",
}
`);

View File

@@ -5,6 +5,7 @@ describe("even realities plugin", () => {
it("has expected manifest and settings keys", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-even-realities-glasses");
expect(Object.keys(plugin.manifest.settingsSchema ?? {}).sort()).toEqual([
"apiKey",
"enableAgentActions",
"fusionApiBaseUrl",
"fusionApiToken",

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import { quickCaptureRoutes } from "../routes/quick-capture-routes.js";
function getRoute() {
const route = quickCaptureRoutes.find((entry) => entry.method === "POST" && entry.path === "/quick-capture");
if (!route) throw new Error("missing /quick-capture route");
return route;
}
function createCtx(overrides: Record<string, unknown> = {}) {
return {
pluginId: "fusion-plugin-even-realities-glasses",
settings: { apiKey: "secret", quickCaptureDefaultColumn: "triage" },
taskStore: {
createTask: vi.fn(async (input) => ({ id: "FN-100", ...input, title: "write the spec", column: input.column })),
},
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
...overrides,
} as never;
}
describe("quickCaptureRoutes", () => {
it("returns 401 for missing or wrong api key", async () => {
const route = getRoute();
const ctx = createCtx();
const missing = await route.handler({ body: { text: "hello" }, headers: {} } as never, ctx);
const wrong = await route.handler(
{ body: { text: "hello" }, headers: { authorization: "Bearer nope" } } as never,
ctx,
);
expect(missing).toMatchObject({ status: 401 });
expect(wrong).toMatchObject({ status: 401 });
});
it.each([undefined, "", " "])("returns 400 for empty text: %p", async (text) => {
const route = getRoute();
const ctx = createCtx();
const res = await route.handler(
{ body: { text }, headers: { authorization: "Bearer secret" } } as never,
ctx,
);
expect(res).toMatchObject({ status: 400, body: { error: "empty utterance" } });
});
it("returns 400 for invalid column", async () => {
const route = getRoute();
const ctx = createCtx();
const res = await route.handler(
{ body: { text: "hello", column: "bad" }, headers: { authorization: "Bearer secret" } } as never,
ctx,
);
expect(res).toMatchObject({ status: 400 });
});
it("returns 201 with task and card, using default column", async () => {
const route = getRoute();
const createTask = vi.fn(async (input) => ({ id: "FN-9", ...input, title: "write the spec", column: input.column }));
const ctx = createCtx({ taskStore: { createTask } });
const res = await route.handler(
{ body: { text: "hey fusion, um, write the spec" }, headers: { authorization: "Bearer secret" } } as never,
ctx,
);
expect(res).toMatchObject({ status: 201, body: { task: { id: "FN-9" }, card: { id: expect.any(String), kind: "task" } } });
expect(createTask).toHaveBeenCalledTimes(1);
expect(createTask).toHaveBeenCalledWith(
expect.objectContaining({
column: "triage",
description: expect.stringContaining("write the spec"),
source: expect.objectContaining({ sourceMetadata: expect.objectContaining({ channel: "glasses-quick-capture" }) }),
}),
);
});
it("returns 201 for valid column override", async () => {
const route = getRoute();
const createTask = vi.fn(async (input) => ({ id: "FN-10", ...input, title: "x", column: input.column }));
const ctx = createCtx({ taskStore: { createTask } });
const res = await route.handler(
{ body: { text: "write docs", column: "done" }, headers: { authorization: "Bearer secret" } } as never,
ctx,
);
expect(res).toMatchObject({ status: 201 });
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ column: "done" }));
});
it("returns 500 with generic message when createTask throws", async () => {
const route = getRoute();
const ctx = createCtx({ taskStore: { createTask: vi.fn(async () => {
throw new Error("db down");
}) } });
const res = await route.handler(
{ body: { text: "write docs" }, headers: { authorization: "Bearer secret" } } as never,
ctx,
);
expect(res).toMatchObject({ status: 500, body: { error: "quick capture failed" } });
});
});

View File

@@ -1,21 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { runQuickCapture } from "../quick-capture.js";
import {
FILLER_TOKENS,
GlassesInputError,
parseUtterance,
runQuickCapture,
splitTitleAndDescription,
stripFillerTokens,
stripWakePhrases,
} from "../quick-capture.js";
describe("runQuickCapture", () => {
it("uses first line as title and rest as description", async () => {
const createTask = vi.fn(async (input) => ({ id: "FN-1", ...input }));
const result = await runQuickCapture("Title\nDetail line", {
apiClient: { createTask } as never,
defaultColumn: "triage",
});
expect(createTask).toHaveBeenCalledWith({ title: "Title", description: "Detail line", column: "triage" });
expect(result.taskId).toBe("FN-1");
describe("quick-capture parsing", () => {
it("strips wake phrases only at start", () => {
expect(stripWakePhrases("hey fusion add a feature")).toBe("add a feature");
expect(stripWakePhrases("call hey fusion later")).toBe("call hey fusion later");
});
it("uses description fallback", async () => {
const createTask = vi.fn(async (input) => ({ id: "FN-2", ...input }));
await runQuickCapture("Only title", { apiClient: { createTask } as never, defaultColumn: "todo" });
expect(createTask).toHaveBeenCalledWith({ title: "Only title", description: "(captured from glasses)", column: "todo" });
it("removes filler tokens as whole words", () => {
expect(FILLER_TOKENS).toContain("um");
expect(stripFillerTokens("um, ship it")).toBe("ship it");
expect(stripFillerTokens("summary")).toBe("summary");
});
it("splits title and description on first sentence boundary", () => {
expect(splitTitleAndDescription("Ship parser. Add tests")).toEqual({
title: "Ship parser.",
description: "Add tests",
});
expect(splitTitleAndDescription("No boundary text")).toEqual({
title: "No boundary text",
description: "No boundary text",
});
});
it("truncates long title and pushes overflow to description", () => {
const { title, description } = splitTitleAndDescription(
"this title is intentionally very long and should be truncated before eighty characters with overflow kept",
{ maxTitleChars: 80 },
);
expect(title.length).toBeLessThanOrEqual(80);
expect(description).toContain("overflow kept");
});
it("throws on empty utterance", () => {
expect(() => parseUtterance("")).toThrowError(GlassesInputError);
expect(() => parseUtterance(" ")).toThrowError(/empty utterance/);
});
it("creates task with default column and channel metadata", async () => {
const createTask = vi.fn(async (input) => ({ id: "FN-1", ...input, title: "t", column: input.column }));
await runQuickCapture(
{ text: "hey fusion, write docs" },
{ taskStore: { createTask } as never, pluginId: "fusion-plugin-even-realities-glasses", defaultColumn: "triage" },
);
expect(createTask).toHaveBeenCalledWith(
expect.objectContaining({
column: "triage",
source: expect.objectContaining({
sourceMetadata: expect.objectContaining({ channel: "glasses-quick-capture" }),
}),
}),
);
});
it("honors valid column override and rejects invalid column", async () => {
const createTask = vi.fn(async (input) => ({ id: "FN-2", ...input, title: "t", column: input.column }));
await runQuickCapture(
{ text: "ship it", column: "done" },
{ taskStore: { createTask } as never, pluginId: "fusion-plugin-even-realities-glasses", defaultColumn: "triage" },
);
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ column: "done" }));
await expect(
runQuickCapture(
{ text: "ship it", column: "bad-column" },
{ taskStore: { createTask } as never, pluginId: "fusion-plugin-even-realities-glasses", defaultColumn: "triage" },
),
).rejects.toThrowError(GlassesInputError);
});
});

View File

@@ -8,6 +8,7 @@ export type GlassesCardAction = {
export type GlassesCard = {
id: string;
kind?: "task" | "summary" | "notification";
title: string;
bodyLines: string[];
accentColor: string;
@@ -25,6 +26,7 @@ const COLUMN_COLORS: Record<string, string> = {
export function taskToCard(task: FusionTask): GlassesCard {
return {
id: `task-${task.id}`,
kind: "task",
title: `${task.id}: ${task.title}`,
bodyLines: [task.description, `Column: ${task.column}`],
accentColor: COLUMN_COLORS[task.column] ?? "blue",
@@ -39,6 +41,7 @@ export function boardSummaryCard(tasksByColumn: Record<string, number>): Glasses
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
return {
id: "board-summary",
kind: "summary",
title: "Fusion Board Summary",
bodyLines: ordered.map((column) => `${column}: ${tasksByColumn[column] ?? 0}`),
accentColor: "blue",
@@ -48,6 +51,7 @@ export function boardSummaryCard(tasksByColumn: Record<string, number>): Glasses
export function notificationCard(task: FusionTask, reason: string): GlassesCard {
return {
id: `notification-${task.id}-${reason}`,
kind: "notification",
title: `Task update: ${task.id}`,
bodyLines: [task.title, `Now in ${task.column}`, reason],
accentColor: COLUMN_COLORS[task.column] ?? "blue",

View File

@@ -3,14 +3,13 @@ import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteRes
import { requestReview, startWork } from "./agent-actions.js";
import { FusionApiClient } from "./fusion-api-client.js";
import { createNotifier } from "./notifier.js";
import { runQuickCapture } from "./quick-capture.js";
import { quickCaptureRoutes } from "./routes/quick-capture-routes.js";
import {
agentActionsEnabled,
getFusionBaseUrl,
getFusionToken,
getNotifyColumns,
getPollingIntervalMs,
getQuickCaptureColumn,
settingsSchema,
} from "./settings.js";
import { StubGlassesTransport } from "./transport.js";
@@ -61,18 +60,6 @@ const routes: PluginRouteDefinition[] = [
};
},
},
{
method: "POST",
path: "/quick-capture",
handler: async (req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
const text = typeof (req as { body?: { text?: unknown } }).body?.text === "string" ? (req as { body?: { text?: string } }).body?.text ?? "" : "";
if (!text.trim()) return { status: 400, body: { error: "text is required" } };
const result = await runQuickCapture(text, { apiClient: instance.client, defaultColumn: getQuickCaptureColumn(ctx.settings) });
return { status: 200, body: result };
},
},
{
method: "POST",
path: "/actions/start-work",
@@ -121,7 +108,7 @@ const plugin: FusionPlugin = definePlugin({
settingsSchema,
},
state: "installed",
routes,
routes: [...routes, ...quickCaptureRoutes],
hooks: {
onSchemaInit: (db) => {
(db as PluginDb).exec(`

View File

@@ -1,26 +1,121 @@
import type { PluginContext } from "@fusion/plugin-sdk";
import { taskToCard, type GlassesCard } from "./cards.js";
import type { FusionApiClient } from "./fusion-api-client.js";
import type { TaskColumn } from "./settings.js";
export async function runQuickCapture(
text: string,
deps: { apiClient: FusionApiClient; defaultColumn: TaskColumn },
): Promise<{ taskId: string; confirmationCard: GlassesCard }> {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
const title = lines[0] ?? "Quick capture";
const description = lines.slice(1).join("\n") || "(captured from glasses)";
export const FILLER_TOKENS = ["um", "uh", "er", "like", "you know"] as const;
const task = await deps.apiClient.createTask({
title,
description,
column: deps.defaultColumn,
const DEFAULT_MAX_TITLE_CHARS = 80;
export class GlassesInputError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "GlassesInputError";
}
}
export function normalizeDescription(value: unknown): string {
if (typeof value === "string") return value.trim();
if (value == null) return "";
return String(value).trim();
}
export function stripWakePhrases(text: string): string {
const trimmed = text.trim();
return trimmed
.replace(/^\s*(?:hey\s+fusion|ok\s+fusion|fusion|note|task|capture)\s*,?\s*/i, "")
.trim();
}
export function stripFillerTokens(text: string): string {
let cleaned = text.replace(/\s+/g, " ").trim();
cleaned = cleaned.replace(/[.\s]+$/g, "").trim();
for (const token of FILLER_TOKENS) {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
cleaned = cleaned.replace(new RegExp(`(^|[\\s,;:!?()-])${escaped}(?=$|[\\s,;:!?()-])`, "gi"), "$1");
}
return cleaned.replace(/\s+/g, " ").replace(/\s+([,;:!?])/g, "$1").trim().replace(/^[,;:!?]+\s*/, "");
}
export function splitTitleAndDescription(
text: string,
opts: { maxTitleChars?: number } = {},
): { title: string; description: string } {
const maxTitleChars = opts.maxTitleChars ?? DEFAULT_MAX_TITLE_CHARS;
const normalized = text.trim();
if (!normalized) return { title: "", description: "" };
const boundaryMatch = normalized.match(/[.!?]\s|\n/);
const boundaryIndex = boundaryMatch?.index ?? -1;
let title = boundaryIndex >= 0 ? normalized.slice(0, boundaryIndex + (boundaryMatch?.[0] === "\n" ? 0 : 1)).trim() : normalized;
let remainder = boundaryIndex >= 0 ? normalized.slice(boundaryIndex + (boundaryMatch?.[0].length ?? 0)).trim() : "";
if (title.length > maxTitleChars) {
const candidate = title.slice(0, maxTitleChars);
const lastSpace = candidate.lastIndexOf(" ");
const cut = lastSpace > 0 ? lastSpace : maxTitleChars;
const overflow = title.slice(cut).trim();
title = title.slice(0, cut).trim();
remainder = [overflow, remainder].filter(Boolean).join(" ").trim();
}
const descriptionBase = remainder || title;
const description = normalizeDescription(descriptionBase).slice(0, 280);
return { title, description };
}
export function parseUtterance(raw: unknown, opts: { maxTitleChars?: number } = {}): { title: string; description: string } {
const text = normalizeDescription(raw);
const stripped = stripFillerTokens(stripWakePhrases(text));
if (!stripped) {
throw new GlassesInputError(400, "empty utterance");
}
return splitTitleAndDescription(stripped, opts);
}
function normalizeCaptureColumn(value: unknown, fallback: TaskColumn): TaskColumn {
const raw = normalizeDescription(value);
if (raw === "triage" || raw === "todo" || raw === "in-progress" || raw === "in-review" || raw === "done") {
return raw;
}
return fallback;
}
export async function runQuickCapture(
input: { text: unknown; column?: unknown },
deps: {
taskStore: PluginContext["taskStore"];
pluginId: string;
defaultColumn: TaskColumn;
},
): Promise<{ task: Awaited<ReturnType<PluginContext["taskStore"]["createTask"]>>; card: GlassesCard }> {
const { title, description } = parseUtterance(input.text);
const requested = input.column;
const normalizedColumn = normalizeCaptureColumn(requested, deps.defaultColumn);
if (requested !== undefined && normalizeDescription(requested) !== normalizedColumn) {
throw new GlassesInputError(400, "invalid column");
}
const persistedDescription = `${title}\n${description}`.trim();
const task = await deps.taskStore.createTask({
description: persistedDescription,
column: normalizedColumn,
source: {
sourceType: "api",
sourceMetadata: {
pluginId: deps.pluginId,
channel: "glasses-quick-capture",
},
},
});
return {
taskId: task.id,
confirmationCard: taskToCard(task),
task,
card: taskToCard(task as never),
};
}

View File

@@ -0,0 +1,65 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import { runQuickCapture, GlassesInputError } from "../quick-capture.js";
import { getQuickCaptureColumn } from "../settings.js";
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 valid = timingSafeEqual(toDigest(expected), toDigest(provided));
if (!valid) {
return { ok: false, response: { status: 401, body: { error: "unauthorized" } } };
}
return { ok: true };
}
export const quickCaptureRoutes: PluginRouteDefinition[] = [
{
method: "POST",
path: "/quick-capture",
handler: async (req, ctx) => {
const auth = requireApiKey(ctx, req as { headers?: Record<string, string | string[] | undefined> });
if (!auth.ok) return auth.response;
const body = (req as { body?: unknown }).body;
const payload = typeof body === "object" && body ? (body as Record<string, unknown>) : {};
const defaultColumn = getQuickCaptureColumn(ctx.settings);
try {
const result = await runQuickCapture(
{ text: payload.text, column: payload.column },
{ taskStore: ctx.taskStore, pluginId: ctx.pluginId, defaultColumn },
);
return { status: 201, body: result };
} catch (error) {
if (error instanceof GlassesInputError) {
return { status: error.status, body: { error: error.message } };
}
ctx.logger?.error?.("quick capture failed", error);
return { status: 500, body: { error: "quick capture failed" } };
}
},
},
];

View File

@@ -20,6 +20,10 @@ export const settingsSchema: Record<string, PluginSettingSchema> = {
type: "password",
label: "Fusion API Token",
},
apiKey: {
type: "password",
label: "Glasses Route API Key",
},
glassesDeviceId: {
type: "string",
label: "Glasses Device ID",