@@ -326,6 +355,8 @@ function ConnectionInspectorViewContent() {
resources={resources}
tools={tools}
isLoadingTools={isLoadingTools}
+ onRefreshTools={handleRefreshTools}
+ isRefreshingTools={actions.update.isPending}
/>
);
}
diff --git a/apps/mesh/src/web/index.tsx b/apps/mesh/src/web/index.tsx
index ab0d6ede08..1ab4ce57d5 100644
--- a/apps/mesh/src/web/index.tsx
+++ b/apps/mesh/src/web/index.tsx
@@ -232,18 +232,6 @@ const orgWorkflowRoute = createRoute({
component: lazyRouteComponent(() => import("./routes/orgs/workflow.tsx")),
});
-/**
- * Dynamic plugin route
- * Routes to plugins based on $pluginId parameter
- */
-const pluginLayoutRoute = createRoute({
- getParentRoute: () => shellLayout,
- path: "/$org/$pluginId",
- component: lazyRouteComponent(
- () => import("./layouts/dynamic-plugin-layout.tsx"),
- ),
-});
-
/**
* In-memory state for plugins to register stuff via callbacks.
*/
@@ -253,14 +241,32 @@ export const pluginRootSidebarItems: {
label: string;
}[] = [];
-const pluginRoutes: AnyRoute[] = [];
+/**
+ * Create plugin routes with unique parent routes per plugin.
+ * Each plugin gets its own parent route with a static path based on the plugin ID.
+ */
+const pluginLayoutRoutes: AnyRoute[] = [];
sourcePlugins.forEach((plugin: AnyClientPlugin) => {
+ // Create a unique parent route for this plugin
+ const pluginParentRoute = createRoute({
+ getParentRoute: () => shellLayout,
+ path: `/$org/${plugin.id}`,
+ component: lazyRouteComponent(
+ () => import("./layouts/dynamic-plugin-layout.tsx"),
+ ),
+ });
+
// Only invoke setup if the plugin provides it
- if (!plugin.setup) return;
+ if (!plugin.setup) {
+ pluginLayoutRoutes.push(pluginParentRoute);
+ return;
+ }
+
+ const pluginChildRoutes: AnyRoute[] = [];
const context: PluginSetupContext = {
- parentRoute: pluginLayoutRoute as AnyRoute,
+ parentRoute: pluginParentRoute as AnyRoute,
routing: {
createRoute: createRoute,
lazyRouteComponent: lazyRouteComponent,
@@ -268,15 +274,19 @@ sourcePlugins.forEach((plugin: AnyClientPlugin) => {
registerRootSidebarItem: (item) =>
pluginRootSidebarItems.push({ pluginId: plugin.id, ...item }),
registerPluginRoutes: (routes) => {
- pluginRoutes.push(...routes);
+ pluginChildRoutes.push(...routes);
},
};
plugin.setup(context);
-});
-// Add all plugin routes as children of the plugin layout
-const pluginLayoutWithChildren = pluginLayoutRoute.addChildren(pluginRoutes);
+ // Add child routes to this plugin's parent route
+ if (pluginChildRoutes.length > 0) {
+ pluginLayoutRoutes.push(pluginParentRoute.addChildren(pluginChildRoutes));
+ } else {
+ pluginLayoutRoutes.push(pluginParentRoute);
+ }
+});
const oauthCallbackRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -302,7 +312,7 @@ const shellRouteTree = shellLayout.addChildren([
orgWorkflowRoute,
connectionLayoutRoute,
collectionDetailsRoute,
- pluginLayoutWithChildren,
+ ...pluginLayoutRoutes,
]);
const routeTree = rootRoute.addChildren([
diff --git a/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx b/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx
index a1951f3f54..f1de34a073 100644
--- a/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx
+++ b/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx
@@ -1,18 +1,22 @@
/**
* Dynamic Plugin Layout
*
- * Routes to the appropriate plugin layout based on the $pluginId param.
+ * Routes to the appropriate plugin layout based on the URL path.
* Uses the plugin's renderHeader/renderEmptyState if defined, otherwise falls back to Outlet.
*/
-import { Outlet, useParams } from "@tanstack/react-router";
+import { Outlet, useLocation } from "@tanstack/react-router";
import { Suspense } from "react";
import { Loading01 } from "@untitledui/icons";
import { sourcePlugins } from "../plugins";
import { PluginLayout } from "./plugin-layout";
export default function DynamicPluginLayout() {
- const { pluginId } = useParams({ strict: false }) as { pluginId: string };
+ const location = useLocation();
+
+ // Extract plugin ID from path: /$org/$pluginId/... -> pluginId
+ const pathParts = location.pathname.split("/").filter(Boolean);
+ const pluginId = pathParts.length >= 2 ? pathParts[1] : undefined;
// Find the plugin by ID
const plugin = sourcePlugins.find((p) => p.id === pluginId);
diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx
index 553d263807..6a400408fa 100644
--- a/apps/mesh/src/web/layouts/shell-layout.tsx
+++ b/apps/mesh/src/web/layouts/shell-layout.tsx
@@ -31,7 +31,13 @@ import {
import { useSuspenseQuery } from "@tanstack/react-query";
import { Outlet, useParams, useRouterState } from "@tanstack/react-router";
import { MessageChatSquare } from "@untitledui/icons";
-import { PropsWithChildren, Suspense, useRef, useTransition } from "react";
+import {
+ PropsWithChildren,
+ Suspense,
+ useEffect,
+ useRef,
+ useTransition,
+} from "react";
import { KEYS } from "../lib/query-keys";
function Topbar({
@@ -138,12 +144,37 @@ function PersistentSidebarProvider({ children }: PropsWithChildren) {
);
}
+/**
+ * Event name for opening the chat panel from plugins or other components
+ */
+export const CHAT_OPEN_EVENT = "deco:open-chat";
+
+/**
+ * Dispatch an event to open the chat panel
+ */
+export function dispatchOpenChat(): void {
+ window.dispatchEvent(new CustomEvent(CHAT_OPEN_EVENT));
+}
+
/**
* This component renders the chat panel and the main content.
* It's important to keep it like this to avoid unnecessary re-renders.
*/
function ChatPanels({ disableChat = false }: { disableChat?: boolean }) {
- const [chatOpen] = useDecoChatOpen();
+ const [chatOpen, setChatOpen] = useDecoChatOpen();
+
+ // Listen for open chat events from plugins
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect
+ useEffect(() => {
+ const handleOpenChat = () => {
+ setChatOpen(true);
+ };
+
+ window.addEventListener(CHAT_OPEN_EVENT, handleOpenChat);
+ return () => {
+ window.removeEventListener(CHAT_OPEN_EVENT, handleOpenChat);
+ };
+ }, [setChatOpen]);
return (
diff --git a/apps/mesh/src/web/plugins.ts b/apps/mesh/src/web/plugins.ts
index d2ad20bcdd..81db5781bd 100644
--- a/apps/mesh/src/web/plugins.ts
+++ b/apps/mesh/src/web/plugins.ts
@@ -1,9 +1,13 @@
import type { AnyClientPlugin } from "@decocms/bindings/plugins";
import { objectStoragePlugin } from "mesh-plugin-object-storage";
import { clientPlugin as userSandboxPlugin } from "mesh-plugin-user-sandbox/client";
+import { taskRunnerPlugin } from "mesh-plugin-task-runner";
+import { siteBuilderPlugin } from "mesh-plugin-site-builder";
// Registered plugins
export const sourcePlugins: AnyClientPlugin[] = [
objectStoragePlugin,
userSandboxPlugin,
+ taskRunnerPlugin,
+ siteBuilderPlugin,
];
diff --git a/biome.json b/biome.json
index 1f43b266d9..67e352e27e 100644
--- a/biome.json
+++ b/biome.json
@@ -7,7 +7,7 @@
},
"files": {
"includes": ["**", "!**/dist"],
- "ignoreUnknown": false
+ "ignoreUnknown": true
},
"formatter": {
"enabled": true,
diff --git a/packages/bindings/src/core/plugins.ts b/packages/bindings/src/core/plugins.ts
index 9f6bad4808..757c9a9ff5 100644
--- a/packages/bindings/src/core/plugins.ts
+++ b/packages/bindings/src/core/plugins.ts
@@ -94,6 +94,12 @@ export {
type AnyRoute,
type RouteIds,
type RouteById,
+ // TanStack router hooks for plugin use
+ useNavigate,
+ useParams,
+ useSearch,
+ useLocation,
+ Link,
} from "./plugin-router";
// Re-export plugin context provider and hook (React components)
diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts
index e3048c0fa5..600c5b5f73 100644
--- a/packages/bindings/src/index.ts
+++ b/packages/bindings/src/index.ts
@@ -101,3 +101,10 @@ export {
type DeleteObjectsInput,
type DeleteObjectsOutput,
} from "./well-known/object-storage";
+
+// Re-export task runner binding types
+export {
+ TASK_RUNNER_BINDING,
+ type TaskRunnerBinding,
+ type Task,
+} from "./well-known/task-runner";
diff --git a/packages/bindings/src/well-known/task-runner.ts b/packages/bindings/src/well-known/task-runner.ts
new file mode 100644
index 0000000000..2b615cf08e
--- /dev/null
+++ b/packages/bindings/src/well-known/task-runner.ts
@@ -0,0 +1,357 @@
+/**
+ * Task Runner Well-Known Binding
+ *
+ * Defines the interface for task orchestration with Beads integration
+ * and Ralph-style execution loops.
+ *
+ * This binding includes:
+ * - WORKSPACE_SET/GET: Manage working directory
+ * - BEADS_*: Task management via Beads CLI
+ * - LOOP_*: Ralph-style execution loop control
+ * - SKILL_*: Skill management and application
+ */
+
+import { z } from "zod";
+import type { Binder, ToolBinder } from "../core/binder";
+
+// ============================================================================
+// Task Schema
+// ============================================================================
+
+const TaskSchema = z.object({
+ id: z.string().describe("Task ID (e.g., bd-abc or bd-abc.1)"),
+ title: z.string().describe("Task title"),
+ description: z.string().optional(),
+ status: z.enum(["open", "in_progress", "blocked", "closed"]).optional(),
+ priority: z.number().optional(),
+ issue_type: z.string().optional(),
+ owner: z.string().optional(),
+ created_at: z.string().optional(),
+ created_by: z.string().optional(),
+ updated_at: z.string().optional(),
+});
+
+export type Task = z.infer;
+
+// ============================================================================
+// Workspace Tools
+// ============================================================================
+
+const WorkspaceSetInputSchema = z.object({
+ directory: z.string().describe("Absolute path to the workspace directory"),
+});
+
+const WorkspaceSetOutputSchema = z.object({
+ success: z.boolean(),
+ workspace: z.string(),
+ hasBeads: z.boolean().describe("Whether .beads/ directory exists"),
+});
+
+const WorkspaceGetInputSchema = z.object({});
+
+const WorkspaceGetOutputSchema = z.object({
+ workspace: z.string().nullable(),
+ hasBeads: z.boolean().nullable().describe("Whether .beads/ directory exists"),
+});
+
+// ============================================================================
+// Beads Tools
+// ============================================================================
+
+const BeadsInitInputSchema = z.object({
+ prefix: z.string().optional().describe("Custom prefix for task IDs"),
+ quiet: z.boolean().optional(),
+});
+
+const BeadsInitOutputSchema = z.object({
+ success: z.boolean(),
+ message: z.string(),
+ workspace: z.string(),
+});
+
+const BeadsReadyInputSchema = z.object({
+ limit: z.number().optional(),
+});
+
+const BeadsReadyOutputSchema = z.object({
+ tasks: z.array(TaskSchema),
+ count: z.number(),
+});
+
+const BeadsCreateInputSchema = z.object({
+ title: z.string(),
+ type: z.enum(["epic", "story", "task", "bug"]).optional(),
+ priority: z.number().optional(),
+ description: z.string().optional(),
+ epic: z.string().optional(),
+ blockedBy: z.array(z.string()).optional(),
+ labels: z.array(z.string()).optional(),
+});
+
+const BeadsCreateOutputSchema = z.object({
+ success: z.boolean(),
+ taskId: z.string(),
+ message: z.string(),
+});
+
+const BeadsUpdateInputSchema = z.object({
+ taskId: z.string(),
+ status: z.enum(["open", "in_progress", "blocked", "closed"]).optional(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ priority: z.number().optional(),
+ notes: z.string().optional(),
+});
+
+const BeadsUpdateOutputSchema = z.object({
+ success: z.boolean(),
+ taskId: z.string(),
+ message: z.string(),
+});
+
+const BeadsCloseInputSchema = z.object({
+ taskIds: z.array(z.string()).min(1),
+ reason: z.string().optional(),
+});
+
+const BeadsCloseOutputSchema = z.object({
+ success: z.boolean(),
+ closedTasks: z.array(z.string()),
+ message: z.string(),
+});
+
+const BeadsListInputSchema = z.object({
+ tree: z.boolean().optional(),
+ status: z.enum(["open", "in_progress", "blocked", "closed"]).optional(),
+ epic: z.string().optional(),
+});
+
+const BeadsListOutputSchema = z.object({
+ tasks: z.array(TaskSchema),
+ count: z.number(),
+});
+
+// ============================================================================
+// Loop Tools
+// ============================================================================
+
+const LoopStartInputSchema = z.object({
+ maxIterations: z.number().optional(),
+ maxTokens: z.number().optional(),
+ qualityGates: z.array(z.string()).optional(),
+ singleIteration: z.boolean().optional(),
+});
+
+const LoopStartOutputSchema = z.object({
+ status: z.string(),
+ iterations: z.number(),
+ tasksCompleted: z.array(z.string()),
+ tasksFailed: z.array(z.string()),
+ totalTokens: z.number(),
+ message: z.string(),
+});
+
+const LoopStatusInputSchema = z.object({});
+
+const LoopStatusOutputSchema = z.object({
+ status: z.string(),
+ currentTask: z.string().nullable(),
+ iteration: z.number(),
+ maxIterations: z.number(),
+ totalTokens: z.number(),
+ maxTokens: z.number(),
+ tasksCompleted: z.array(z.string()),
+ tasksFailed: z.array(z.string()),
+ startedAt: z.string().nullable(),
+ lastActivity: z.string().nullable(),
+ error: z.string().nullable(),
+});
+
+const LoopPauseInputSchema = z.object({});
+const LoopPauseOutputSchema = z.object({
+ success: z.boolean(),
+ message: z.string(),
+});
+
+const LoopStopInputSchema = z.object({});
+const LoopStopOutputSchema = z.object({
+ success: z.boolean(),
+ finalState: z.object({
+ iterations: z.number(),
+ tasksCompleted: z.array(z.string()),
+ tasksFailed: z.array(z.string()),
+ }),
+});
+
+// ============================================================================
+// Skill Tools
+// ============================================================================
+
+const SkillSummarySchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string(),
+ stack: z.array(z.string()),
+ storyCount: z.number(),
+});
+
+const SkillListInputSchema = z.object({});
+const SkillListOutputSchema = z.object({
+ skills: z.array(SkillSummarySchema),
+});
+
+const SkillApplyInputSchema = z.object({
+ skillId: z.string(),
+ customization: z
+ .object({
+ prefix: z.string().optional(),
+ extraContext: z.string().optional(),
+ })
+ .optional(),
+});
+
+const SkillApplyOutputSchema = z.object({
+ success: z.boolean(),
+ tasksCreated: z.array(z.string()),
+ message: z.string(),
+});
+
+// ============================================================================
+// Binding Definition
+// ============================================================================
+
+/**
+ * Task Runner Binding
+ *
+ * Defines the interface for task orchestration with Beads and Ralph loop.
+ */
+export const TASK_RUNNER_BINDING = [
+ {
+ name: "WORKSPACE_SET" as const,
+ inputSchema: WorkspaceSetInputSchema,
+ outputSchema: WorkspaceSetOutputSchema,
+ } satisfies ToolBinder<
+ "WORKSPACE_SET",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "WORKSPACE_GET" as const,
+ inputSchema: WorkspaceGetInputSchema,
+ outputSchema: WorkspaceGetOutputSchema,
+ } satisfies ToolBinder<
+ "WORKSPACE_GET",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "BEADS_INIT" as const,
+ inputSchema: BeadsInitInputSchema,
+ outputSchema: BeadsInitOutputSchema,
+ } satisfies ToolBinder<
+ "BEADS_INIT",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "BEADS_READY" as const,
+ inputSchema: BeadsReadyInputSchema,
+ outputSchema: BeadsReadyOutputSchema,
+ } satisfies ToolBinder<
+ "BEADS_READY",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "BEADS_CREATE" as const,
+ inputSchema: BeadsCreateInputSchema,
+ outputSchema: BeadsCreateOutputSchema,
+ } satisfies ToolBinder<
+ "BEADS_CREATE",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "BEADS_UPDATE" as const,
+ inputSchema: BeadsUpdateInputSchema,
+ outputSchema: BeadsUpdateOutputSchema,
+ } satisfies ToolBinder<
+ "BEADS_UPDATE",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "BEADS_CLOSE" as const,
+ inputSchema: BeadsCloseInputSchema,
+ outputSchema: BeadsCloseOutputSchema,
+ } satisfies ToolBinder<
+ "BEADS_CLOSE",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "BEADS_LIST" as const,
+ inputSchema: BeadsListInputSchema,
+ outputSchema: BeadsListOutputSchema,
+ } satisfies ToolBinder<
+ "BEADS_LIST",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "LOOP_START" as const,
+ inputSchema: LoopStartInputSchema,
+ outputSchema: LoopStartOutputSchema,
+ } satisfies ToolBinder<
+ "LOOP_START",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "LOOP_STATUS" as const,
+ inputSchema: LoopStatusInputSchema,
+ outputSchema: LoopStatusOutputSchema,
+ } satisfies ToolBinder<
+ "LOOP_STATUS",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "LOOP_PAUSE" as const,
+ inputSchema: LoopPauseInputSchema,
+ outputSchema: LoopPauseOutputSchema,
+ } satisfies ToolBinder<
+ "LOOP_PAUSE",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "LOOP_STOP" as const,
+ inputSchema: LoopStopInputSchema,
+ outputSchema: LoopStopOutputSchema,
+ } satisfies ToolBinder<
+ "LOOP_STOP",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "SKILL_LIST" as const,
+ inputSchema: SkillListInputSchema,
+ outputSchema: SkillListOutputSchema,
+ } satisfies ToolBinder<
+ "SKILL_LIST",
+ z.infer,
+ z.infer
+ >,
+ {
+ name: "SKILL_APPLY" as const,
+ inputSchema: SkillApplyInputSchema,
+ outputSchema: SkillApplyOutputSchema,
+ } satisfies ToolBinder<
+ "SKILL_APPLY",
+ z.infer,
+ z.infer
+ >,
+] as const satisfies Binder;
+
+export type TaskRunnerBinding = typeof TASK_RUNNER_BINDING;
diff --git a/packages/mesh-plugin-site-builder/components/plugin-empty-state.tsx b/packages/mesh-plugin-site-builder/components/plugin-empty-state.tsx
new file mode 100644
index 0000000000..729a1c5da4
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/components/plugin-empty-state.tsx
@@ -0,0 +1,47 @@
+/**
+ * Plugin Empty State Component
+ *
+ * Shown when no Site Builder connection is available.
+ * Provides instructions on how to connect a Deco site.
+ */
+
+import { Globe01, FolderPlus } from "@untitledui/icons";
+
+export default function PluginEmptyState() {
+ return (
+
+
+
+
+
No Site Connected
+
+ Connect a local Deco site to start building with AI-assisted editing and
+ live preview.
+
+
+
+
+
+
+
+
+
How to connect a site
+
+ Add a local filesystem MCP connection
+ Point it to your Deco site folder
+ The site will be detected automatically
+
+
+
+
+
+
+ Tip: Sites are detected by checking for{" "}
+ deno.json with{" "}
+ deco/ imports.
+
+
+
+
+ );
+}
diff --git a/packages/mesh-plugin-site-builder/components/plugin-header.tsx b/packages/mesh-plugin-site-builder/components/plugin-header.tsx
new file mode 100644
index 0000000000..581d93ede7
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/components/plugin-header.tsx
@@ -0,0 +1,160 @@
+/**
+ * Plugin Header Component
+ *
+ * Connection selector with Deco site detection badges.
+ */
+
+import type { PluginRenderHeaderProps } from "@decocms/bindings/plugins";
+import { Globe01, ChevronDown, Check } from "@untitledui/icons";
+import { useState, useRef } from "react";
+import { useSiteDetection } from "../hooks/use-site-detection";
+import { usePluginContext } from "@decocms/bindings/plugins";
+import { SITE_BUILDER_BINDING } from "../lib/binding";
+
+/**
+ * Badge to show Deco site detection status
+ */
+function SiteBadge({ connectionId }: { connectionId: string }) {
+ const { connectionId: currentConnectionId } =
+ usePluginContext();
+
+ // Only fetch detection if this is the current connection
+ const { data: detection } = useSiteDetection();
+ const isCurrentConnection = connectionId === currentConnectionId;
+
+ if (!isCurrentConnection) {
+ return null;
+ }
+
+ if (!detection) {
+ return null;
+ }
+
+ if (detection.isDeco) {
+ return (
+
+ Deco
+
+ );
+ }
+
+ if (detection.hasDenoJson) {
+ return (
+
+ Deno
+
+ );
+ }
+
+ return null;
+}
+
+/**
+ * Simple dropdown menu for connection selection with site detection.
+ */
+function ConnectionSelector({
+ connections,
+ selectedConnectionId,
+ onConnectionChange,
+}: PluginRenderHeaderProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const selectedConnection = connections.find(
+ (c) => c.id === selectedConnectionId,
+ );
+
+ const handleBlur = (e: React.FocusEvent) => {
+ if (!dropdownRef.current?.contains(e.relatedTarget as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ if (connections.length === 0) {
+ return (
+
+
+ No sites connected
+
+ );
+ }
+
+ if (connections.length === 1) {
+ return (
+
+ {selectedConnection?.icon ? (
+
+ ) : (
+
+ )}
+
{selectedConnection?.title || "Site Builder"}
+ {selectedConnection && (
+
+ )}
+
+ );
+ }
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md border border-border bg-background hover:bg-accent transition-colors"
+ >
+ {selectedConnection?.icon ? (
+
+ ) : (
+
+ )}
+ {selectedConnection?.title || "Select site"}
+ {selectedConnection && (
+
+ )}
+
+
+
+ {isOpen && (
+
+ {connections.map((connection) => (
+
{
+ onConnectionChange(connection.id);
+ setIsOpen(false);
+ }}
+ className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-sm hover:bg-accent transition-colors"
+ >
+ {connection.icon ? (
+
+ ) : (
+
+ )}
+ {connection.title}
+ {connection.id === selectedConnectionId && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function PluginHeader(props: PluginRenderHeaderProps) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/mesh-plugin-site-builder/components/preview-frame.tsx b/packages/mesh-plugin-site-builder/components/preview-frame.tsx
new file mode 100644
index 0000000000..2d637fbfba
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/components/preview-frame.tsx
@@ -0,0 +1,148 @@
+/**
+ * Preview Frame Component
+ *
+ * Shows a live preview of the Deco site in an iframe with URL bar and controls.
+ */
+
+import { useState } from "react";
+import {
+ RefreshCw01,
+ Expand01,
+ Monitor01,
+ Phone01,
+ Tablet01,
+ XClose,
+} from "@untitledui/icons";
+import { cn } from "@deco/ui/lib/utils.ts";
+
+export interface PreviewFrameProps {
+ url: string;
+ onClose?: () => void;
+}
+
+type DeviceMode = "desktop" | "tablet" | "mobile";
+
+const deviceDimensions: Record = {
+ desktop: { width: "100%", label: "Desktop" },
+ tablet: { width: "768px", label: "Tablet" },
+ mobile: { width: "375px", label: "Mobile" },
+};
+
+export function PreviewFrame({ url, onClose }: PreviewFrameProps) {
+ const [deviceMode, setDeviceMode] = useState("desktop");
+ const [key, setKey] = useState(0);
+
+ const handleRefresh = () => {
+ setKey((k) => k + 1);
+ };
+
+ const handleOpenExternal = () => {
+ window.open(url, "_blank");
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+ {/* URL Bar */}
+
+ {url}
+
+
+ {/* Device Mode Buttons */}
+
+
setDeviceMode("desktop")}
+ className={cn(
+ "p-1.5 transition-colors",
+ deviceMode === "desktop"
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-muted",
+ )}
+ title="Desktop view"
+ >
+
+
+
setDeviceMode("tablet")}
+ className={cn(
+ "p-1.5 transition-colors border-l border-border",
+ deviceMode === "tablet"
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-muted",
+ )}
+ title="Tablet view"
+ >
+
+
+
setDeviceMode("mobile")}
+ className={cn(
+ "p-1.5 transition-colors border-l border-border",
+ deviceMode === "mobile"
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-muted",
+ )}
+ title="Mobile view"
+ >
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ {onClose && (
+
+
+
+ )}
+
+
+ {/* Iframe Container */}
+
+
+ );
+}
diff --git a/packages/mesh-plugin-site-builder/components/site-list.tsx b/packages/mesh-plugin-site-builder/components/site-list.tsx
new file mode 100644
index 0000000000..247160a39f
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/components/site-list.tsx
@@ -0,0 +1,386 @@
+/**
+ * Site List Component
+ *
+ * Shows site detection, dev server status, available pages, and preview.
+ */
+
+import { useState } from "react";
+import { useSiteDetection } from "../hooks/use-site-detection";
+import { useDevServer } from "../hooks/use-dev-server";
+import { usePages } from "../hooks/use-pages";
+import { PreviewFrame } from "./preview-frame";
+import {
+ CheckCircle,
+ XClose,
+ AlertCircle,
+ File06,
+ Play,
+ RefreshCw01,
+ Copy01,
+ Loading02,
+ Eye,
+ Plus,
+ Edit02,
+} from "@untitledui/icons";
+import { useParams } from "@tanstack/react-router";
+import { toast } from "sonner";
+import { cn } from "@deco/ui/lib/utils.ts";
+
+export default function SiteList() {
+ const { data: detection, isLoading } = useSiteDetection();
+ const {
+ isRunning,
+ isChecking,
+ isStarting,
+ startCommand,
+ serverUrl,
+ refetch: refetchServer,
+ startServer,
+ canStart,
+ } = useDevServer();
+ const { pages, isLoading: pagesLoading } = usePages();
+ const [previewUrl, setPreviewUrl] = useState(null);
+ const { org, connectionId } = useParams({ strict: false }) as {
+ org?: string;
+ connectionId?: string;
+ };
+
+ // Navigation to Tasks with context
+ // Use window.location for cross-plugin navigation
+ const navigateToTasks = (params: {
+ skill?: string;
+ template?: string;
+ edit?: string;
+ }) => {
+ const searchParams = new URLSearchParams();
+ if (params.skill) searchParams.set("skill", params.skill);
+ if (params.template) searchParams.set("template", params.template);
+ if (params.edit) searchParams.set("edit", params.edit);
+ if (connectionId) searchParams.set("site", connectionId);
+
+ const search = searchParams.toString();
+ // URL pattern: /{org}/task-runner/{connectionId}
+ const basePath = org
+ ? connectionId
+ ? `/${org}/task-runner/${connectionId}`
+ : `/${org}/task-runner`
+ : "/task-runner";
+ window.location.href = search ? `${basePath}?${search}` : basePath;
+ };
+
+ const handleCreatePage = () => {
+ navigateToTasks({ skill: "decocms-landing-pages" });
+ };
+
+ const handleUseAsTemplate = (pagePath: string) => {
+ navigateToTasks({ skill: "decocms-landing-pages", template: pagePath });
+ };
+
+ const handleEditPage = (pagePath: string) => {
+ navigateToTasks({ edit: pagePath });
+ };
+
+ if (isLoading) {
+ return (
+
+
+ Detecting site configuration...
+
+
+ );
+ }
+
+ if (!detection) {
+ return (
+
+
+ Unable to detect site configuration
+
+
+ );
+ }
+
+ const handlePreview = (pagePath?: string) => {
+ const url = pagePath ? `${serverUrl}${pagePath}` : serverUrl;
+ setPreviewUrl(url);
+ };
+
+ const handleClosePreview = () => {
+ setPreviewUrl(null);
+ };
+
+ const handleCopyCommand = async () => {
+ try {
+ await navigator.clipboard.writeText(startCommand);
+ toast.success("Command copied to clipboard");
+ } catch {
+ toast.error("Failed to copy command");
+ }
+ };
+
+ const handleStartServer = async () => {
+ if (!startServer) return;
+ try {
+ const result = await startServer();
+ if (result?.success) {
+ toast.success("Dev server starting...");
+ } else {
+ toast.error(result?.error || "Failed to start server");
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to start server",
+ );
+ }
+ };
+
+ // Not a Deco site - show help
+ if (!detection.isDeco) {
+ return (
+
+
+
+ {detection.hasDenoJson ? (
+
+ ) : (
+
+ )}
+
+
+ {detection.hasDenoJson
+ ? "Deno Project Detected"
+ : "Not a Deco Site"}
+
+
+ {detection.hasDenoJson
+ ? "This folder has deno.json but no deco/ imports."
+ : detection.error ||
+ "No deno.json file found in this directory."}
+
+
+
+
+
+
+
+ This plugin is designed for Deco sites. To use it:
+
+
+
+ Create a new Deco site or clone an existing one with{" "}
+ deno.json
+
+
+ Ensure your imports{" "}
+ field includes{" "}
+ deco/ packages
+
+ Connect this plugin to your site folder
+
+
+
+ );
+ }
+
+ // Valid Deco site - with preview and task panel
+ return (
+
+ {previewUrl ? (
+
+ ) : (
+
+ {/* Site Status */}
+
+
+
+
+
+
Deco Site Detected
+
+ {detection.decoImports.length} deco imports found
+
+
+
+ {isRunning && (
+
handlePreview()}
+ className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
+ >
+
+ Preview Site
+
+ )}
+
+
+
+ {/* Dev Server Status */}
+
+
+
Dev Server
+ refetchServer()}
+ disabled={isChecking}
+ className="p-1.5 rounded-md hover:bg-muted transition-colors disabled:opacity-50"
+ title="Refresh status"
+ >
+
+
+
+
+ {isRunning ? (
+
+
+
+
+ Running on localhost:8000
+
+
+
+ ) : (
+
+
+
+
+
+ Server not running
+
+
+ {startServer && (
+
+ {isStarting ? (
+ <>
+
+ Starting...
+ >
+ ) : (
+ <>
+
+ Start Server
+ >
+ )}
+
+ )}
+
+ {!startServer && (
+ <>
+
+
+ {startCommand}
+
+
+
+
+
+
+ Run this command in your terminal to start the dev server
+
+ >
+ )}
+
+ )}
+
+
+ {/* Pages List */}
+
+
+
Pages
+
+
+ Create Page
+
+
+ {!isRunning ? (
+
+
+
+
+ Start the dev server to see pages
+
+
+
+ ) : pagesLoading ? (
+
+ ) : pages.length === 0 ? (
+
+
+ No pages found in this site.
+
+
+ ) : (
+
+ {pages.map((page) => (
+
+
+
handlePreview(page.path)}
+ >
+
{page.name}
+
+ {page.path}
+
+
+
+ handlePreview(page.path)}
+ className="p-1.5 rounded-md hover:bg-muted transition-colors"
+ title="Preview"
+ >
+
+
+ handleUseAsTemplate(page.path)}
+ className="p-1.5 rounded-md hover:bg-muted transition-colors"
+ title="Use as Template"
+ >
+
+
+ handleEditPage(page.path)}
+ className="p-1.5 rounded-md hover:bg-muted transition-colors"
+ title="Edit Page"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/packages/mesh-plugin-site-builder/components/task-panel.tsx b/packages/mesh-plugin-site-builder/components/task-panel.tsx
new file mode 100644
index 0000000000..e85b372502
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/components/task-panel.tsx
@@ -0,0 +1,319 @@
+/**
+ * Task Panel Component
+ *
+ * Collapsible task panel that shows tasks and agent sessions
+ * alongside the site preview. Reuses TaskCard from task-runner.
+ */
+
+import { useState } from "react";
+import { ChevronDown, ChevronRight, Plus, Loading02 } from "@untitledui/icons";
+import { cn } from "@deco/ui/lib/utils.ts";
+import {
+ useTasks,
+ useCreateTask,
+ useAgentSessions,
+ useSkills,
+ useWorkspace,
+ type Task,
+} from "mesh-plugin-task-runner/hooks/use-tasks";
+import { TaskCard } from "mesh-plugin-task-runner/components/task-card";
+import { toast } from "sonner";
+import { useSiteDetection } from "../hooks/use-site-detection";
+import { usePages } from "../hooks/use-pages";
+import { useDevServer } from "../hooks/use-dev-server";
+
+interface TaskPanelProps {
+ className?: string;
+}
+
+interface AgentSession {
+ sessionId: string;
+ taskId: string;
+ taskTitle: string;
+ status: "running" | "completed" | "failed" | "stopped";
+ startedAt: string;
+ completedAt?: string;
+ toolCallCount?: number;
+}
+
+interface Skill {
+ id: string;
+ name: string;
+ description?: string;
+}
+
+/** Event to open the chat panel */
+const CHAT_OPEN_EVENT = "deco:open-chat";
+/** Event to send a message to the chat */
+const CHAT_SEND_MESSAGE_EVENT = "deco:send-chat-message";
+
+/**
+ * Send a message to the chat panel
+ */
+function sendChatMessage(text: string): void {
+ // Open the chat panel
+ window.dispatchEvent(new CustomEvent(CHAT_OPEN_EVENT));
+
+ // Send the message after a brief delay to allow panel to open
+ setTimeout(() => {
+ window.dispatchEvent(
+ new CustomEvent(CHAT_SEND_MESSAGE_EVENT, {
+ detail: { text },
+ }),
+ );
+ }, 100);
+}
+
+export function TaskPanel({ className }: TaskPanelProps) {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [showNewTask, setShowNewTask] = useState(false);
+ const [newTaskTitle, setNewTaskTitle] = useState("");
+ const [selectedSkill, setSelectedSkill] = useState("");
+
+ const { data: tasks = [], isLoading: tasksLoading, refetch } = useTasks();
+ const { data: sessionData } = useAgentSessions();
+ const { data: skills = [] } = useSkills();
+ const { data: workspaceData } = useWorkspace();
+ const createTask = useCreateTask();
+
+ // Site context for agent prompts
+ const { data: detection } = useSiteDetection();
+ const { pages } = usePages();
+ const { isRunning, serverUrl } = useDevServer();
+
+ const sessions = sessionData?.sessions ?? [];
+ const runningCount = sessionData?.runningCount ?? 0;
+ const workspacePath = workspaceData?.workspace ?? undefined;
+
+ // Get running task IDs
+ const runningTaskIds = new Set(
+ sessions
+ .filter((s: AgentSession) => s.status === "running")
+ .map((s: AgentSession) => s.taskId),
+ );
+
+ const openTasks = tasks.filter(
+ (t: Task) => t.status === "open" || t.status === "in_progress",
+ );
+ const completedTasks = tasks
+ .filter((t: Task) => t.status === "closed")
+ .slice(0, 3);
+
+ const handleCreateTask = async () => {
+ if (!newTaskTitle.trim()) return;
+
+ try {
+ await createTask.mutateAsync({
+ title: newTaskTitle,
+ description: selectedSkill
+ ? `Using skill: ${skills.find((s: Skill) => s.id === selectedSkill)?.name}`
+ : undefined,
+ });
+ setNewTaskTitle("");
+ setSelectedSkill("");
+ setShowNewTask(false);
+ toast.success("Task created");
+ } catch {
+ toast.error("Failed to create task");
+ }
+ };
+
+ const handleStartWithAgent = (task: Task) => {
+ // Build site context for the agent
+ const siteContext = detection?.isDeco
+ ? {
+ isDeco: true,
+ serverUrl: isRunning ? serverUrl : undefined,
+ pages: pages.map((p) => p.path),
+ decoImports: detection.decoImports,
+ siteType: "deco",
+ }
+ : undefined;
+
+ // Build AGENT_SPAWN command with site context
+ const params = JSON.stringify({
+ taskId: task.id,
+ taskTitle: task.title,
+ taskDescription: task.description || task.title,
+ workspace: workspacePath,
+ siteContext,
+ });
+
+ sendChatMessage(`AGENT_SPAWN ${params}`);
+ };
+
+ return (
+
+ {/* Header */}
+
setIsExpanded(!isExpanded)}
+ className="flex items-center gap-2 px-3 py-2 border-b border-border hover:bg-muted/50 transition-colors"
+ >
+ {isExpanded ? : }
+ {isExpanded && (
+ <>
+ Tasks
+ {runningCount > 0 && (
+
+ {runningCount} running
+
+ )}
+ >
+ )}
+
+
+ {isExpanded && (
+
+ {/* Running Sessions */}
+ {sessions.filter((s: AgentSession) => s.status === "running").length >
+ 0 && (
+
+
+ Running
+
+
+ {sessions
+ .filter((s: AgentSession) => s.status === "running")
+ .map((session: AgentSession) => (
+
+
+
+
+ {session.taskTitle}
+
+
+ {session.toolCallCount !== undefined && (
+
+ {session.toolCallCount} tool calls
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Open Tasks */}
+
+
+
+ Open Tasks ({openTasks.length})
+
+
setShowNewTask(true)}
+ className="p-1 rounded hover:bg-muted transition-colors"
+ title="New task"
+ >
+
+
+
+
+ {showNewTask && (
+
+
setNewTaskTitle(e.target.value)}
+ placeholder="Task title..."
+ className="w-full px-2 py-1 text-sm rounded border border-border bg-background"
+ onKeyDown={(e) => e.key === "Enter" && handleCreateTask()}
+ />
+ {skills.length > 0 && (
+
setSelectedSkill(e.target.value)}
+ className="w-full mt-2 px-2 py-1 text-sm rounded border border-border bg-background"
+ >
+ No skill template
+ {skills.map((skill: Skill) => (
+
+ {skill.name}
+
+ ))}
+
+ )}
+
+
+ Create
+
+ {
+ setShowNewTask(false);
+ setNewTaskTitle("");
+ setSelectedSkill("");
+ }}
+ className="px-2 py-1 text-xs rounded hover:bg-muted"
+ >
+ Cancel
+
+
+
+ )}
+
+ {tasksLoading ? (
+
Loading...
+ ) : openTasks.length === 0 ? (
+
No open tasks
+ ) : (
+
+ {openTasks.map((task: Task) => (
+
+ ))}
+
+ )}
+
+
+ {/* Completed Tasks */}
+ {completedTasks.length > 0 && (
+
+
+ Recently Completed
+
+
+ {completedTasks.map((task: Task) => (
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/packages/mesh-plugin-site-builder/hooks/use-dev-server.ts b/packages/mesh-plugin-site-builder/hooks/use-dev-server.ts
new file mode 100644
index 0000000000..e2c3e17138
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/hooks/use-dev-server.ts
@@ -0,0 +1,122 @@
+/**
+ * Dev Server Hook
+ *
+ * Checks if the Deco site dev server is running and provides control functions.
+ * Uses DENO_TASK tool from local-fs MCP to start the server in background mode.
+ */
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { usePluginContext } from "@decocms/bindings/plugins";
+import { useState } from "react";
+import { SITE_BUILDER_BINDING } from "../lib/binding";
+import { KEYS } from "../lib/query-keys";
+
+const DEV_SERVER_PORT = 8000;
+const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
+
+export interface DevServerStatus {
+ isRunning: boolean;
+ meta: {
+ site?: string;
+ manifest?: {
+ blocks?: Record;
+ };
+ } | null;
+}
+
+interface DenoTaskResult {
+ success: boolean;
+ task: string;
+ background?: boolean;
+ pid?: number;
+ exitCode?: number;
+ stdout?: string;
+ stderr?: string;
+ message?: string;
+ error?: string;
+}
+
+export function useDevServer() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+ const [isStarting, setIsStarting] = useState(false);
+
+ // Check if DENO_TASK tool is available
+ const hasDenoTask = connection?.tools?.some((t) => t.name === "DENO_TASK");
+
+ // Check if dev server is running by pinging the meta endpoint
+ const {
+ data: status,
+ isLoading: isChecking,
+ refetch,
+ } = useQuery({
+ queryKey: KEYS.devServerStatus(connectionId ?? ""),
+ enabled: !!connectionId,
+ queryFn: async (): Promise => {
+ try {
+ const response = await fetch(`${DEV_SERVER_URL}/live/_meta`, {
+ method: "GET",
+ signal: AbortSignal.timeout(2000),
+ });
+ if (response.ok) {
+ const meta = await response.json();
+ return { isRunning: true, meta };
+ }
+ return { isRunning: false, meta: null };
+ } catch {
+ return { isRunning: false, meta: null };
+ }
+ },
+ refetchInterval: 5000, // Poll every 5 seconds
+ staleTime: 2000,
+ });
+
+ // Start the dev server using DENO_TASK tool
+ const startServer = async (): Promise => {
+ if (!hasDenoTask || !toolCaller) {
+ return null;
+ }
+
+ setIsStarting(true);
+ try {
+ // Cast for untyped tool call since DENO_TASK is checked dynamically
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ const result = (await untypedToolCaller("DENO_TASK", {
+ task: "start",
+ background: true,
+ })) as DenoTaskResult;
+
+ // Wait a bit for the server to start, then refetch status
+ setTimeout(() => {
+ if (connectionId) {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.devServerStatus(connectionId),
+ });
+ }
+ setIsStarting(false);
+ }, 3000);
+
+ return result;
+ } catch (error) {
+ setIsStarting(false);
+ throw error;
+ }
+ };
+
+ return {
+ isRunning: status?.isRunning ?? false,
+ isChecking,
+ isStarting,
+ meta: status?.meta ?? null,
+ serverUrl: DEV_SERVER_URL,
+ refetch,
+ startCommand: "deno task start",
+ startServer: hasDenoTask ? startServer : null,
+ canStart: hasDenoTask && !status?.isRunning && !isStarting,
+ };
+}
diff --git a/packages/mesh-plugin-site-builder/hooks/use-pages.ts b/packages/mesh-plugin-site-builder/hooks/use-pages.ts
new file mode 100644
index 0000000000..e0f641e75a
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/hooks/use-pages.ts
@@ -0,0 +1,84 @@
+/**
+ * Pages Hook
+ *
+ * Fetches pages from a running Deco site via the /.decofile endpoint.
+ * Pages are blocks with IDs starting with "pages-".
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { useDevServer } from "./use-dev-server";
+import { KEYS } from "../lib/query-keys";
+
+export interface PageInfo {
+ id: string;
+ name: string;
+ path: string;
+ __resolveType?: string;
+}
+
+interface DecofileBlock {
+ name?: string;
+ path?: string;
+ __resolveType?: string;
+}
+
+export function usePages() {
+ const { isRunning, serverUrl } = useDevServer();
+
+ const {
+ data: pages = [],
+ isLoading,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: KEYS.sitePages(serverUrl),
+ queryFn: async (): Promise => {
+ try {
+ const response = await fetch(`${serverUrl}/.decofile`, {
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch decofile: ${response.status}`);
+ }
+
+ const decofile = (await response.json()) as Record<
+ string,
+ DecofileBlock
+ >;
+
+ // Extract pages (blocks starting with "pages-")
+ const pagesList: PageInfo[] = [];
+
+ for (const [blockId, block] of Object.entries(decofile)) {
+ if (blockId.startsWith("pages-")) {
+ pagesList.push({
+ id: blockId,
+ name: block.name || blockId.replace("pages-", ""),
+ path: block.path || "/",
+ __resolveType: block.__resolveType,
+ });
+ }
+ }
+
+ // Sort by path
+ pagesList.sort((a, b) => a.path.localeCompare(b.path));
+
+ return pagesList;
+ } catch (e) {
+ console.error("[usePages] Failed to fetch pages:", e);
+ throw e;
+ }
+ },
+ enabled: isRunning,
+ staleTime: 10000,
+ refetchInterval: isRunning ? 30000 : false, // Refetch every 30s when running
+ });
+
+ return {
+ pages,
+ isLoading,
+ error: error as Error | null,
+ refetch,
+ };
+}
diff --git a/packages/mesh-plugin-site-builder/hooks/use-site-detection.ts b/packages/mesh-plugin-site-builder/hooks/use-site-detection.ts
new file mode 100644
index 0000000000..c8fd3ddc22
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/hooks/use-site-detection.ts
@@ -0,0 +1,105 @@
+/**
+ * Site Detection Hook
+ *
+ * Detects if a connection is a Deco site by reading deno.json
+ * and checking for deco/ imports in the imports map.
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { usePluginContext } from "@decocms/bindings/plugins";
+import { SITE_BUILDER_BINDING } from "../lib/binding";
+import { KEYS } from "../lib/query-keys";
+
+export interface SiteDetectionResult {
+ isDeco: boolean;
+ hasDenoJson: boolean;
+ decoImports: string[];
+ error?: string;
+}
+
+/**
+ * Hook to detect if the current connection is a Deco site.
+ * Reads deno.json and checks for deco/ imports.
+ */
+export function useSiteDetection() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+
+ // Check if read_file tool is available
+ const hasReadFile = connection?.tools?.some((t) => t.name === "read_file");
+
+ return useQuery({
+ queryKey: KEYS.siteDetection(connectionId ?? ""),
+ queryFn: async (): Promise => {
+ if (!hasReadFile) {
+ return {
+ isDeco: false,
+ hasDenoJson: false,
+ decoImports: [],
+ error: "Connection does not support read_file",
+ };
+ }
+
+ try {
+ // Read deno.json file
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const result = await untypedToolCaller("read_file", {
+ path: "deno.json",
+ });
+
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+
+ if (!content) {
+ return {
+ isDeco: false,
+ hasDenoJson: false,
+ decoImports: [],
+ error: "deno.json not found or empty",
+ };
+ }
+
+ // Parse deno.json
+ const denoConfig = JSON.parse(content) as {
+ imports?: Record;
+ importMap?: string;
+ };
+
+ // Check for deco/ imports in the imports map
+ const imports = denoConfig.imports || {};
+ const decoImports = Object.keys(imports).filter(
+ (key) => key.startsWith("deco/") || imports[key]?.includes("deco.cx"),
+ );
+
+ const isDeco = decoImports.length > 0;
+
+ return {
+ isDeco,
+ hasDenoJson: true,
+ decoImports,
+ };
+ } catch (error) {
+ // File doesn't exist or parse error
+ return {
+ isDeco: false,
+ hasDenoJson: false,
+ decoImports: [],
+ error:
+ error instanceof Error ? error.message : "Failed to read deno.json",
+ };
+ }
+ },
+ enabled: !!connectionId && hasReadFile,
+ staleTime: 30000, // Consider data fresh for 30 seconds
+ refetchOnMount: true,
+ refetchOnWindowFocus: false,
+ });
+}
diff --git a/packages/mesh-plugin-site-builder/index.tsx b/packages/mesh-plugin-site-builder/index.tsx
new file mode 100644
index 0000000000..541fbe6f65
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/index.tsx
@@ -0,0 +1,40 @@
+/**
+ * Site Builder Plugin
+ *
+ * Provides AI-assisted site building with live preview.
+ * Filters connections to show only local-fs MCPs with deno.json containing deco imports.
+ */
+
+import { lazy } from "react";
+import { Globe01 } from "@untitledui/icons";
+import type { Plugin, PluginSetupContext } from "@decocms/bindings/plugins";
+import { SITE_BUILDER_BINDING } from "./lib/binding";
+import { siteBuilderRouter } from "./lib/router";
+
+// Lazy load components
+const PluginHeader = lazy(() => import("./components/plugin-header"));
+const PluginEmptyState = lazy(() => import("./components/plugin-empty-state"));
+
+/**
+ * Site Builder Plugin Definition
+ */
+export const siteBuilderPlugin: Plugin = {
+ id: "site-builder",
+ description: "AI-assisted site building with live preview",
+ binding: SITE_BUILDER_BINDING,
+ renderHeader: (props) => ,
+ renderEmptyState: () => ,
+ setup: (context: PluginSetupContext) => {
+ const { registerRootSidebarItem, registerPluginRoutes } = context;
+
+ // Register sidebar item with Globe icon
+ registerRootSidebarItem({
+ icon: ,
+ label: "Sites",
+ });
+
+ // Create and register plugin routes
+ const routes = siteBuilderRouter.createRoutes(context);
+ registerPluginRoutes(routes);
+ },
+};
diff --git a/packages/mesh-plugin-site-builder/lib/binding.ts b/packages/mesh-plugin-site-builder/lib/binding.ts
new file mode 100644
index 0000000000..101ac96b3b
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/lib/binding.ts
@@ -0,0 +1,26 @@
+/**
+ * Site Builder Binding
+ *
+ * Extends OBJECT_STORAGE_BINDING to inherit file operations.
+ * Site detection is done via runtime file checks (deno.json with deco imports),
+ * not via additional binding requirements.
+ *
+ * Optional tools like DENO_TASK/EXEC are checked at runtime via connection.tools
+ * rather than being part of the binding (since they may not be available on all MCPs).
+ */
+
+import { OBJECT_STORAGE_BINDING } from "@decocms/bindings";
+import type { Binder } from "@decocms/bindings";
+
+/**
+ * Site Builder uses object storage binding.
+ * Connection filtering for "site" detection happens at runtime
+ * by checking for deno.json with deco/ imports.
+ *
+ * Optional tools (DENO_TASK, EXEC) are checked dynamically at runtime.
+ */
+export const SITE_BUILDER_BINDING = [
+ ...OBJECT_STORAGE_BINDING,
+] as const satisfies Binder;
+
+export type SiteBuilderBinding = typeof SITE_BUILDER_BINDING;
diff --git a/packages/mesh-plugin-site-builder/lib/query-keys.ts b/packages/mesh-plugin-site-builder/lib/query-keys.ts
new file mode 100644
index 0000000000..793b116079
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/lib/query-keys.ts
@@ -0,0 +1,14 @@
+/**
+ * Query Key Constants
+ *
+ * Centralized query keys for consistent cache management.
+ */
+
+export const KEYS = {
+ devServerStatus: (connectionId: string) =>
+ ["site-builder", "dev-server-status", connectionId] as const,
+ sitePages: (serverUrl: string) =>
+ ["site-builder", "site-pages", serverUrl] as const,
+ siteDetection: (connectionId: string) =>
+ ["site-builder", "site-detection", connectionId] as const,
+};
diff --git a/packages/mesh-plugin-site-builder/lib/router.ts b/packages/mesh-plugin-site-builder/lib/router.ts
new file mode 100644
index 0000000000..43bd6d6b0d
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/lib/router.ts
@@ -0,0 +1,36 @@
+/**
+ * Site Builder Plugin Router
+ *
+ * Provides typed routing for the site builder plugin.
+ * Uses search params for page selection (similar to object-storage's path param).
+ */
+
+import { createPluginRouter } from "@decocms/bindings/plugins";
+import * as z from "zod";
+
+/**
+ * Search schema for the site builder route.
+ * Uses search params to track selected page and view mode.
+ */
+const siteBuilderSearchSchema = z.object({
+ page: z.string().optional().describe("Selected page ID to preview"),
+ view: z.enum(["list", "preview"]).optional().default("list"),
+});
+
+export type SiteBuilderSearch = z.infer;
+
+/**
+ * Plugin router with typed hooks for navigation and search params.
+ */
+export const siteBuilderRouter = createPluginRouter((ctx) => {
+ const { createRoute, lazyRouteComponent } = ctx.routing;
+
+ const indexRoute = createRoute({
+ getParentRoute: () => ctx.parentRoute,
+ path: "/",
+ component: lazyRouteComponent(() => import("../components/site-list")),
+ validateSearch: siteBuilderSearchSchema,
+ });
+
+ return [indexRoute];
+});
diff --git a/packages/mesh-plugin-site-builder/package.json b/packages/mesh-plugin-site-builder/package.json
new file mode 100644
index 0000000000..26ce23f710
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "mesh-plugin-site-builder",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./index.tsx",
+ "scripts": {
+ "check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@decocms/bindings": "workspace:*",
+ "@deco/ui": "workspace:*",
+ "@tanstack/react-query": "5.90.11",
+ "@tanstack/react-router": "1.139.7",
+ "@untitledui/icons": "^0.0.19",
+ "mesh-plugin-task-runner": "workspace:*",
+ "react": "^19.2.0",
+ "sonner": "^2.0.7",
+ "zod": "^3.24.4"
+ }
+}
diff --git a/packages/mesh-plugin-site-builder/tsconfig.json b/packages/mesh-plugin-site-builder/tsconfig.json
new file mode 100644
index 0000000000..41f8237d6a
--- /dev/null
+++ b/packages/mesh-plugin-site-builder/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist"
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}
diff --git a/packages/mesh-plugin-task-runner/components/plugin-empty-state.tsx b/packages/mesh-plugin-task-runner/components/plugin-empty-state.tsx
new file mode 100644
index 0000000000..b0659e3693
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/components/plugin-empty-state.tsx
@@ -0,0 +1,27 @@
+/**
+ * Plugin Empty State Component
+ *
+ * Shown when no Task Runner connection is available.
+ */
+
+import { File04 } from "@untitledui/icons";
+
+export default function PluginEmptyState() {
+ return (
+
+
+
+
+
No Task Runner Connected
+
+ Connect a Task Runner MCP to manage tasks with Beads and run agent
+ execution loops.
+
+
+ Install the{" "}
+ mcp-task-runner MCP from
+ the registry to get started.
+
+
+ );
+}
diff --git a/packages/mesh-plugin-task-runner/components/plugin-header.tsx b/packages/mesh-plugin-task-runner/components/plugin-header.tsx
new file mode 100644
index 0000000000..d45be65fa5
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/components/plugin-header.tsx
@@ -0,0 +1,104 @@
+/**
+ * Plugin Header Component
+ *
+ * Connection selector for the task runner plugin.
+ */
+
+import type { PluginRenderHeaderProps } from "@decocms/bindings/plugins";
+import { File04, ChevronDown, Check } from "@untitledui/icons";
+import { useState, useRef } from "react";
+
+/**
+ * Simple dropdown menu for connection selection.
+ */
+function ConnectionSelector({
+ connections,
+ selectedConnectionId,
+ onConnectionChange,
+}: PluginRenderHeaderProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const selectedConnection = connections.find(
+ (c) => c.id === selectedConnectionId,
+ );
+
+ const handleBlur = (e: React.FocusEvent) => {
+ if (!dropdownRef.current?.contains(e.relatedTarget as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ if (connections.length === 1) {
+ return (
+
+ {selectedConnection?.icon ? (
+
+ ) : (
+
+ )}
+
{selectedConnection?.title || "Task Runner"}
+
+ );
+ }
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md border border-border bg-background hover:bg-accent transition-colors"
+ >
+ {selectedConnection?.icon ? (
+
+ ) : (
+
+ )}
+ {selectedConnection?.title || "Select storage"}
+
+
+
+ {isOpen && (
+
+ {connections.map((connection) => (
+
{
+ onConnectionChange(connection.id);
+ setIsOpen(false);
+ }}
+ className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-sm hover:bg-accent transition-colors"
+ >
+ {connection.icon ? (
+
+ ) : (
+
+ )}
+ {connection.title}
+ {connection.id === selectedConnectionId && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function PluginHeader(props: PluginRenderHeaderProps) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/mesh-plugin-task-runner/components/task-board.tsx b/packages/mesh-plugin-task-runner/components/task-board.tsx
new file mode 100644
index 0000000000..5bdfa1ea45
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/components/task-board.tsx
@@ -0,0 +1,1591 @@
+/**
+ * Task Board Component
+ *
+ * Main UI for the Task Runner plugin. Shows:
+ * - Workspace selector
+ * - Task/Skills tabs
+ * - Loop controls
+ */
+
+import {
+ Folder,
+ File02,
+ BookOpen01,
+ AlertCircle,
+ Check,
+ Edit02,
+} from "@untitledui/icons";
+import { TaskCard } from "./task-card";
+import { useState, useEffect } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { cn } from "@deco/ui/lib/utils.ts";
+import { useParams } from "@decocms/bindings/plugins";
+import { useSearch } from "@tanstack/react-router";
+import type { TaskBoardSearch } from "../lib/router";
+import {
+ useTasks,
+ useAgentSessions,
+ useAgentSessionDetail,
+ useSkills,
+ useWorkspace,
+ useBeadsStatus,
+ useInitBeads,
+ useCreateTask,
+ useUpdateTask,
+ useQualityGates,
+ useDetectQualityGates,
+ useQualityGatesBaseline,
+ useVerifyQualityGates,
+ useAcknowledgeQualityGates,
+ type Task,
+ type Skill,
+ type AgentSession,
+ type QualityGate,
+} from "../hooks/use-tasks";
+import { KEYS } from "../lib/query-keys";
+
+// ============================================================================
+// Icons
+// ============================================================================
+
+const PlusIcon = ({ size = 14 }: { size?: number }) => (
+
+
+
+);
+
+const LoadingIcon = ({
+ size = 14,
+ className = "",
+}: {
+ size?: number;
+ className?: string;
+}) => (
+
+
+
+);
+
+const RefreshIcon = ({ size = 14 }: { size?: number }) => (
+
+
+
+
+);
+
+// ============================================================================
+// Chat Integration
+// ============================================================================
+
+/** Event to open the chat panel */
+const CHAT_OPEN_EVENT = "deco:open-chat";
+/** Event to send a message to the chat */
+const CHAT_SEND_MESSAGE_EVENT = "deco:send-chat-message";
+
+interface ChatSendMessageEventDetail {
+ text: string;
+ virtualMcpId?: string;
+}
+
+/**
+ * Build a prompt message for the agent to work on a task
+ */
+function buildTaskPrompt(
+ task: {
+ id: string;
+ title: string;
+ description?: string;
+ },
+ workspace: string,
+): string {
+ // Use JSON to be unambiguous about tool parameters
+ const params = JSON.stringify({
+ taskId: task.id,
+ taskTitle: task.title,
+ taskDescription: task.description || task.title,
+ workspace,
+ });
+ return `AGENT_SPAWN ${params}`;
+}
+
+/**
+ * Open the chat panel and send a message to the agent
+ */
+function sendChatMessage(
+ text: string,
+ options?: { virtualMcpId?: string },
+): void {
+ // Open the chat panel
+ window.dispatchEvent(new CustomEvent(CHAT_OPEN_EVENT));
+
+ // Send the message after a brief delay to allow panel to open
+ setTimeout(() => {
+ window.dispatchEvent(
+ new CustomEvent(CHAT_SEND_MESSAGE_EVENT, {
+ detail: { text, virtualMcpId: options?.virtualMcpId },
+ }),
+ );
+ }, 100);
+}
+
+// ============================================================================
+// Workspace Display
+// ============================================================================
+
+function WorkspaceDisplay() {
+ const { data: workspaceData, isLoading } = useWorkspace();
+
+ if (isLoading) {
+ return (
+
+
+ Loading workspace...
+
+
+ );
+ }
+
+ if (!workspaceData?.workspace) {
+ return (
+
+
+
No Workspace Available
+
+ This storage connection doesn't expose a GET_ROOT tool.
+
+
+ Use a local-fs MCP with object storage tools to enable task
+ management.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {workspaceData.workspace}
+
+
+
+ From storage connection
+
+
+ );
+}
+
+// ============================================================================
+// Beads Init Banner
+// ============================================================================
+
+function BeadsInitBanner() {
+ const initBeads = useInitBeads();
+
+ return (
+
+
+
+
+
Beads Not Initialized
+
+ This workspace doesn't have Beads set up yet. Initialize Beads to
+ start tracking tasks.
+
+
initBeads.mutate()}
+ disabled={initBeads.isPending}
+ className="mt-3 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
+ >
+ {initBeads.isPending ? "Initializing..." : "Initialize Beads"}
+
+ {initBeads.isError && (
+
+ Error: {String(initBeads.error)}
+
+ )}
+
+
+
+ );
+}
+
+// ============================================================================
+// Agent Status - Shows when the Task Runner Agent is actively working
+// ============================================================================
+
+/**
+ * Format relative time (e.g., "2s ago", "1m ago")
+ */
+function formatRelativeTime(timestamp: string): string {
+ const diff = Date.now() - new Date(timestamp).getTime();
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ return `${Math.floor(minutes / 60)}h ago`;
+}
+
+/**
+ * Single agent session card with detailed status
+ */
+function AgentSessionCard({ session }: { session: AgentSession }) {
+ const sessionId = session.sessionId || session.id || "";
+ const { data: detail } = useAgentSessionDetail(sessionId);
+ const [isStopping, setIsStopping] = useState(false);
+
+ const isRunning = session.status === "running";
+ const isCompleted = session.status === "completed";
+
+ const handleStop = () => {
+ if (isStopping) return;
+ if (!confirm(`Stop agent working on "${session.taskTitle}"?`)) return;
+
+ setIsStopping(true);
+ // Send a message to the Task Runner Agent to stop the session
+ const stopMessage = `Please stop agent session ${sessionId} immediately. Call AGENT_STOP with sessionId="${sessionId}".`;
+ sendChatMessage(stopMessage);
+ toast.info("Sent stop request to agent");
+ };
+
+ // Use detailed data if available, otherwise fall back to summary
+ const toolCalls = detail?.toolCalls || session.toolCalls || [];
+ const messages = detail?.messages || session.messages || [];
+ const toolCallCount =
+ detail?.toolCallCount ?? session.toolCallCount ?? toolCalls.length;
+
+ // Get last few tool calls for display
+ const recentTools = toolCalls.slice(-5).reverse();
+
+ // Get last assistant message
+ const lastAssistant = messages
+ .filter((m) => m.role === "assistant" && m.content)
+ .slice(-1)[0];
+
+ // Calculate duration - parent component's polling triggers re-render
+ const startTime = new Date(session.startedAt).getTime();
+ const endTime = session.completedAt
+ ? new Date(session.completedAt).getTime()
+ : Date.now();
+ const durationSec = Math.floor((endTime - startTime) / 1000);
+ const durationStr =
+ durationSec < 60
+ ? `${durationSec}s`
+ : `${Math.floor(durationSec / 60)}m ${durationSec % 60}s`;
+
+ return (
+
+ {/* Header */}
+
+ {isRunning ? (
+
+ ) : isCompleted ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isRunning
+ ? "Agent Working"
+ : isCompleted
+ ? "Completed"
+ : "Failed"}
+
+
+ • {durationStr}
+
+
+
+ Task: {session.taskTitle}
+
+
+
+ {toolCallCount > 0 && (
+
+ {toolCallCount} tool calls
+
+ )}
+ {isRunning && (
+
+ {isStopping ? (
+
+ ) : (
+
+ )}
+ {isStopping ? "Stopping..." : "Stop"}
+
+ )}
+
+
+
+ {/* Last thinking/message - show for running */}
+ {lastAssistant && isRunning && (
+
+ "{lastAssistant.content.slice(0, 150)}
+ {lastAssistant.content.length > 150 ? "..." : ""}"
+
+ )}
+
+ {/* Error output - show for failed sessions */}
+ {session.status === "failed" && session.output && (
+
+
Error:
+
+ {session.output.slice(0, 300)}
+ {session.output.length > 300 ? "..." : ""}
+
+
+ )}
+
+ {/* Completion message - show for completed */}
+ {session.status === "completed" && lastAssistant && (
+
+ {lastAssistant.content.slice(0, 150)}
+ {lastAssistant.content.length > 150 ? "..." : ""}
+
+ )}
+
+ {/* Recent tool calls */}
+ {recentTools.length > 0 && (
+
+
+ Recent activity:
+
+ {recentTools.map((tool, i) => (
+
+
+
+ {tool.name}
+
+
+ {formatRelativeTime(tool.timestamp)}
+
+
+ ))}
+
+ )}
+
+ {/* No activity yet */}
+ {recentTools.length === 0 && isRunning && (
+
+ Starting up...
+
+ )}
+
+ );
+}
+
+function AgentStatus() {
+ const { data, isLoading, dataUpdatedAt } = useAgentSessions();
+ const [activeTab, setActiveTab] = useState<"current" | "history">("current");
+ const sessions = data?.sessions ?? [];
+
+ // Running sessions for "Current" tab
+ const runningSessions = sessions.filter((s) => s.status === "running");
+
+ // Recently finished sessions (within last 60 seconds) - show in Current tab
+ const recentlyFinished = sessions.filter((s) => {
+ if (s.status === "running") return false;
+ if (!s.completedAt) return false;
+ const completedTime = new Date(s.completedAt).getTime();
+ const now = Date.now();
+ const sixtySeconds = 60 * 1000;
+ return now - completedTime < sixtySeconds;
+ });
+
+ // Current tab shows running + recently finished
+ const currentSessions = [...runningSessions, ...recentlyFinished];
+
+ // Completed/failed sessions for "History" tab (all non-running)
+ const historySessions = sessions
+ .filter((s) => s.status !== "running")
+ .slice(0, 10);
+
+ const hasRunning = runningSessions.length > 0;
+ const hasRecentActivity = currentSessions.length > 0;
+
+ // Determine status indicator
+ const mostRecentSession = currentSessions[0];
+ const statusIndicator = hasRunning ? (
+
+ ) : mostRecentSession?.status === "failed" ? (
+
+ Failed
+
+ ) : mostRecentSession?.status === "completed" ? (
+
+ Done
+
+ ) : (
+
+ Idle
+
+ );
+
+ return (
+
+
+
+ Agent Status
+ {statusIndicator}
+
+ {isLoading ? (
+ Loading...
+ ) : (
+
+ {new Date(dataUpdatedAt).toLocaleTimeString()}
+
+ )}
+
+
+ {/* Tabs */}
+
+ setActiveTab("current")}
+ className={cn(
+ "px-3 py-1.5 text-xs font-medium transition-colors relative",
+ activeTab === "current"
+ ? "text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ Current
+ {hasRecentActivity && (
+
+ {currentSessions.length}
+
+ )}
+ {activeTab === "current" && (
+
+ )}
+
+ setActiveTab("history")}
+ className={cn(
+ "px-3 py-1.5 text-xs font-medium transition-colors relative",
+ activeTab === "history"
+ ? "text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ History
+ {historySessions.length > 0 && (
+
+ ({historySessions.length})
+
+ )}
+ {activeTab === "history" && (
+
+ )}
+
+
+
+ {/* Tab content */}
+
+ {activeTab === "current" && (
+ <>
+ {currentSessions.length > 0 ? (
+ currentSessions.map((session) => (
+
+ ))
+ ) : (
+
+ No agents running. Click Execute on a task to start.
+
+ )}
+ >
+ )}
+
+ {activeTab === "history" && (
+ <>
+ {historySessions.length > 0 ? (
+ historySessions.map((session) => (
+
+ ))
+ ) : (
+
+ No completed sessions yet.
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// Tasks Tab Content
+// ============================================================================
+
+function TasksTabContent({
+ onStartWithAgent,
+ workspacePath,
+ searchParams,
+ onGoToQualityGates,
+ pendingPlanningTaskIds,
+ onPlanningComplete,
+}: {
+ onStartWithAgent: (task: Task) => void;
+ workspacePath?: string;
+ searchParams?: TaskBoardSearch;
+ onGoToQualityGates?: () => void;
+ pendingPlanningTaskIds?: Set;
+ onPlanningComplete?: (taskId: string) => void;
+}) {
+ const { data: tasks, isLoading, error, refetch, isFetching } = useTasks();
+ const { data: skills } = useSkills();
+ const { data: agentData } = useAgentSessions();
+ const { data: baselineData } = useQualityGatesBaseline();
+ const createTask = useCreateTask();
+ const [newTaskTitle, setNewTaskTitle] = useState("");
+ const [selectedSkillId, setSelectedSkillId] = useState(null);
+ const [isAdding, setIsAdding] = useState(false);
+ const [hasAppliedParams, setHasAppliedParams] = useState(false);
+
+ const selectedSkill = skills?.find((s) => s.id === selectedSkillId);
+ const canCreateTasks = baselineData?.canCreateTasks ?? false;
+
+ // Handle site context params from navigation
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect
+ useEffect(() => {
+ if (hasAppliedParams || !searchParams || !skills) return;
+
+ const { skill, template, edit } = searchParams;
+
+ if (skill || template || edit) {
+ // Pre-select skill if provided
+ if (skill) {
+ const matchedSkill = skills.find((s) => s.id === skill);
+ if (matchedSkill) {
+ setSelectedSkillId(matchedSkill.id);
+ }
+ }
+
+ // Pre-fill title based on context
+ if (template) {
+ setNewTaskTitle(`Create new page based on ${template}`);
+ } else if (edit) {
+ setNewTaskTitle(`Edit page: ${edit}`);
+ } else if (skill) {
+ setNewTaskTitle("Create new landing page");
+ }
+
+ // Open the add task form
+ setIsAdding(true);
+ setHasAppliedParams(true);
+ }
+ }, [searchParams, skills, hasAppliedParams]);
+
+ // Get task IDs that have running agents
+ const runningTaskIds = new Set(
+ agentData?.sessions
+ ?.filter((s) => s.status === "running")
+ ?.map((s) => s.taskId) || [],
+ );
+
+ const handleAddTask = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (newTaskTitle.trim()) {
+ const description = selectedSkill
+ ? `Follow the instructions in skills/${selectedSkill.id}/SKILL.md`
+ : undefined;
+
+ createTask.mutate(
+ { title: newTaskTitle.trim(), description },
+ {
+ onSuccess: () => {
+ toast.success("Task created");
+ setNewTaskTitle("");
+ setSelectedSkillId(null);
+ setIsAdding(false);
+ },
+ onError: (err) => {
+ toast.error(`Failed to create task: ${String(err)}`);
+ },
+ },
+ );
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+ Loading tasks...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Error loading tasks: {String(error)}
+
+ );
+ }
+
+ // Group tasks by status
+ const grouped = {
+ open: tasks?.filter((t) => t.status === "open") ?? [],
+ in_progress: tasks?.filter((t) => t.status === "in_progress") ?? [],
+ blocked: tasks?.filter((t) => t.status === "blocked") ?? [],
+ closed: tasks?.filter((t) => t.status === "closed") ?? [],
+ };
+
+ return (
+
+
+
+ {isFetching && !isLoading && (
+
+ )}
+
+
+
refetch()}
+ disabled={isFetching}
+ className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded disabled:opacity-50"
+ title="Refresh tasks"
+ >
+
+
+
setIsAdding(true)}
+ disabled={!canCreateTasks}
+ className={cn(
+ "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border",
+ canCreateTasks
+ ? "border-border hover:bg-accent"
+ : "border-border opacity-50 cursor-not-allowed",
+ )}
+ title={
+ canCreateTasks
+ ? "Add a new task"
+ : "Verify quality gates first (see Quality Gates tab)"
+ }
+ >
+
+ Add Task
+
+
+
+
+ {/* Warning if baseline not ready */}
+ {!canCreateTasks && (
+
+
+
+
+ Quality gates not verified.
+
+
+
+ Verify Quality Gates
+
+
+ )}
+
+ {isAdding && canCreateTasks && (
+
+ )}
+
+ {/* In Progress */}
+ {grouped.in_progress.length > 0 && (
+
+
+
+ In Progress ({grouped.in_progress.length})
+
+
+ {grouped.in_progress.map((task) => (
+
+ ))}
+
+
+ )}
+
+ {/* Open */}
+ {grouped.open.length > 0 && (
+
+
+ Open ({grouped.open.length})
+
+
+ {grouped.open.map((task) => (
+ {
+ if (!isPending) onPlanningComplete?.(taskId);
+ }}
+ />
+ ))}
+
+
+ )}
+
+ {/* Blocked */}
+ {grouped.blocked.length > 0 && (
+
+
+
+ Blocked ({grouped.blocked.length})
+
+
+ {grouped.blocked.map((task) => (
+
+ ))}
+
+
+ )}
+
+ {/* Closed */}
+ {grouped.closed.length > 0 && (
+
+
+
+ Completed ({grouped.closed.length})
+
+
+ {grouped.closed.slice(0, 5).map((task) => (
+
+ ))}
+ {grouped.closed.length > 5 && (
+
+ +{grouped.closed.length - 5} more completed
+
+ )}
+
+
+ )}
+
+ {tasks?.length === 0 && (
+
+
+
No tasks yet
+
Create a task to get started
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Skills Tab Content
+// ============================================================================
+
+function SkillsTabContent() {
+ const { data: skills, isLoading } = useSkills();
+
+ if (isLoading) {
+ return (
+
+
+ Loading skills...
+
+ );
+ }
+
+ if (!skills || skills.length === 0) {
+ return (
+
+
+
No skills found
+
+ Add a skills/ directory with SKILL.md files to define agent skills.
+
+
+ );
+ }
+
+ return (
+
+
+ Skills are templates that provide context for tasks. Select a skill when
+ creating a task.
+
+ {skills.map((skill: Skill) => (
+
+
+
+
+
{skill.name}
+
+ {skill.description}
+
+
+ skills/{skill.id}/SKILL.md
+
+
+
+
+ ))}
+
+ );
+}
+
+// ============================================================================
+// Quality Gates Tab Content
+// ============================================================================
+
+const ShieldIcon = ({ size = 14 }: { size?: number }) => (
+
+
+
+
+);
+
+function QualityGatesTabContent({
+ onFixTaskCreated,
+}: {
+ onFixTaskCreated?: (taskId: string) => void;
+}) {
+ const { data: gates, isLoading } = useQualityGates();
+ const { data: baselineData, isLoading: baselineLoading } =
+ useQualityGatesBaseline();
+ const { data: workspaceData } = useWorkspace();
+ const detectGates = useDetectQualityGates();
+ const verifyGates = useVerifyQualityGates();
+ const acknowledgeGates = useAcknowledgeQualityGates();
+ const createTask = useCreateTask();
+ const [showResults, setShowResults] = useState(false);
+ const [lastResults, setLastResults] = useState<
+ Array<{ gate: string; passed: boolean; output: string }>
+ >([]);
+ const [isCreatingFixTask, setIsCreatingFixTask] = useState(false);
+
+ // Handle creating a fix task and requesting a plan
+ const handleCreateFixTask = async (gateName: string, gateOutput: string) => {
+ const workspace = workspaceData?.workspace;
+ if (!workspace) {
+ toast.error("No workspace set");
+ return;
+ }
+
+ setIsCreatingFixTask(true);
+ try {
+ const title = `Fix ${gateName} quality gate failures`;
+ const description = `Fix the following ${gateName} errors:\n\n\`\`\`\n${gateOutput}\n\`\`\``;
+
+ // Create the task and get its ID
+ const taskId = await new Promise((resolve, reject) => {
+ createTask.mutate(
+ { title, description },
+ {
+ onSuccess: (result) => {
+ // Extract task ID from the result
+ const id =
+ typeof result === "object" && result && "id" in result
+ ? (result as { id: string }).id
+ : `task-${Date.now()}`;
+ resolve(id);
+ },
+ onError: (err) => reject(err),
+ },
+ );
+ });
+
+ // Send planning prompt (same as Plan button on task card)
+ const planningPrompt = `Please analyze and create a detailed plan for task ${taskId}: "${title}"
+
+Description: ${description}
+Workspace: ${workspace}
+
+Instructions:
+1. Read the task requirements carefully
+2. Explore the codebase to understand relevant files and patterns
+3. Create a plan with:
+ - Clear, specific acceptance criteria (not generic ones)
+ - Subtasks broken down by complexity
+ - Files that will likely need modification
+ - Any risks or considerations
+
+When done, call TASK_SET_PLAN with workspace="${workspace}", taskId="${taskId}", and your plan.`;
+
+ sendChatMessage(planningPrompt);
+
+ toast.success("Fix task created - sent to agent for planning");
+ onFixTaskCreated?.(taskId);
+ } catch (error) {
+ toast.error(`Failed to create fix task: ${error}`);
+ } finally {
+ setIsCreatingFixTask(false);
+ }
+ };
+
+ const handleDetect = () => {
+ detectGates.mutate(undefined, {
+ onSuccess: (result) => {
+ toast.success(`Detected ${result.gates.length} quality gates`);
+ },
+ onError: (err) => {
+ toast.error(`Failed to detect: ${err.message}`);
+ },
+ });
+ };
+
+ const handleVerify = () => {
+ verifyGates.mutate(undefined, {
+ onSuccess: (result) => {
+ setLastResults(result.results);
+ setShowResults(true);
+ if (result.allPassed) {
+ toast.success("All quality gates pass! Ready for tasks.");
+ } else {
+ toast.warning(
+ `${result.results.filter((r) => !r.passed).length} gate(s) failing. Acknowledge to continue.`,
+ );
+ }
+ },
+ onError: (err) => {
+ toast.error(`Verification failed: ${err.message}`);
+ },
+ });
+ };
+
+ const handleAcknowledge = () => {
+ acknowledgeGates.mutate(true, {
+ onSuccess: () => {
+ toast.success(
+ "Failures acknowledged. Agents will not try to fix pre-existing issues.",
+ );
+ setShowResults(false);
+ },
+ onError: (err) => {
+ toast.error(`Failed to acknowledge: ${err.message}`);
+ },
+ });
+ };
+
+ if (isLoading || baselineLoading) {
+ return (
+
+
+ Loading quality gates...
+
+ );
+ }
+
+ const baseline = baselineData?.baseline;
+ const hasBaseline = baselineData?.hasBaseline ?? false;
+ const canCreateTasks = baselineData?.canCreateTasks ?? false;
+
+ return (
+
+ {/* Baseline Verification Section */}
+
+
+
+
+ {!hasBaseline ? (
+ <>
+
+ Quality Gates Not Verified
+ >
+ ) : baseline?.allPassed ? (
+ <>
+
+ All Gates Passing
+ >
+ ) : baseline?.acknowledged ? (
+ <>
+
+ Failures Acknowledged
+ >
+ ) : (
+ <>
+
+ Gates Failing - Action Required
+ >
+ )}
+
+
+ {!hasBaseline ? (
+ "Run verification to establish a baseline before creating tasks."
+ ) : baseline?.allPassed ? (
+ "Ready to create tasks. Agents will maintain this passing state."
+ ) : baseline?.acknowledged ? (
+ <>
+ Agents will NOT try to fix:{" "}
+
+ {baseline.failingGates.join(", ")}
+
+ >
+ ) : (
+ "Some gates are failing. Acknowledge to continue without fixing, or fix first."
+ )}
+
+
+
+ {!hasBaseline || !baseline?.allPassed ? (
+
+ {verifyGates.isPending ? (
+
+ ) : (
+
+ )}
+ {hasBaseline ? "Re-verify" : "Verify Gates"}
+
+ ) : null}
+ {hasBaseline && !baseline?.allPassed && !baseline?.acknowledged && (
+
+ {acknowledgeGates.isPending ? (
+
+ ) : (
+
+ )}
+ Acknowledge Failures
+
+ )}
+
+
+
+
+ {/* Gates Configuration */}
+
+
+ Quality gates are commands that must pass before a task is considered
+ complete.
+
+
+ {detectGates.isPending ? (
+
+ ) : (
+
+ )}
+ Auto-detect
+
+
+
+ {!gates || gates.length === 0 ? (
+
+
+
No quality gates configured
+
+ Click "Auto-detect" to find gates from package.json scripts.
+
+
+ ) : (
+
+ {gates.map((gate: QualityGate) => {
+ const isFailing = baseline?.failingGates?.includes(gate.name);
+ const result = lastResults.find((r) => r.gate === gate.name);
+ const hasResult = showResults && result;
+ return (
+
+
+
+
+ {hasResult ? (
+ result.passed ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+
+ {gate.name}
+ {gate.required && (
+
+ Required
+
+ )}
+ {isFailing && baseline?.acknowledged && (
+
+ Pre-existing failure
+
+ )}
+ {gate.source === "auto" && (
+
+ (auto-detected)
+
+ )}
+
+
+ {gate.command}
+
+
+
+ {hasResult && (
+
+ {result.passed ? "Passed" : "Failed"}
+
+ )}
+
+ {/* Show output when failed */}
+ {hasResult && !result.passed && result.output && (
+
+
+ {result.output}
+
+
+
+ handleCreateFixTask(gate.name, result.output)
+ }
+ disabled={isCreatingFixTask}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-blue-500 text-white hover:bg-blue-600 rounded transition-colors disabled:opacity-50"
+ >
+ {isCreatingFixTask ? (
+
+ ) : (
+
+ )}
+ {isCreatingFixTask ? "Creating..." : "Fix with Agent"}
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
How it works:
+
+
+ Verify first: Run gates to establish a baseline
+ before creating tasks
+
+
+ All passing: Agents maintain this state - any new
+ failures must be fixed
+
+
+ Acknowledged failures: Agents ignore pre-existing
+ issues and focus on their task
+
+
+ Agents output{" "}
+
+ <promise>COMPLETE</promise>
+ {" "}
+ when done
+
+
+
+
+ );
+}
+
+// ============================================================================
+// Main Task Board
+// ============================================================================
+
+export default function TaskBoard() {
+ const { data: workspaceData } = useWorkspace();
+ const { data: beadsStatus } = useBeadsStatus();
+ const { org: _org } = useParams({ strict: false }) as { org: string };
+ const searchParams = useSearch({ strict: false }) as TaskBoardSearch;
+ const [activeTab, setActiveTab] = useState<"tasks" | "skills" | "gates">(
+ "tasks",
+ );
+ const [pendingPlanningTaskIds, setPendingPlanningTaskIds] = useState<
+ Set
+ >(new Set());
+ const updateTask = useUpdateTask();
+ const queryClient = useQueryClient();
+
+ // Add a task to pending planning
+ const addPendingPlanningTask = (taskId: string) => {
+ setPendingPlanningTaskIds((prev) => new Set([...prev, taskId]));
+ };
+
+ // Remove a task from pending planning
+ const removePendingPlanningTask = (taskId: string) => {
+ setPendingPlanningTaskIds((prev) => {
+ const next = new Set(prev);
+ next.delete(taskId);
+ return next;
+ });
+ };
+
+ const workspace = workspaceData?.workspace;
+ const hasBeads = beadsStatus?.initialized ?? false;
+
+ /**
+ * Start task with agent - opens chat and sends the task to the agent
+ */
+ const handleStartWithAgent = (task: Task) => {
+ if (!workspace) {
+ toast.error("No workspace set");
+ return;
+ }
+
+ // Mark task as in progress
+ updateTask.mutate(
+ { taskId: task.id, status: "in_progress" },
+ {
+ onSuccess: () => {
+ // Build the prompt and send to chat
+ const prompt = buildTaskPrompt(task, workspace);
+ sendChatMessage(prompt);
+ toast.success(`Started task: ${task.title}`);
+
+ // Start polling for agent session updates after a short delay
+ // (the agent takes a moment to start)
+ setTimeout(() => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.agentSessionsBase,
+ });
+ }, 1000);
+
+ // Poll a few more times to catch the agent starting
+ setTimeout(() => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.agentSessionsBase,
+ });
+ }, 3000);
+ },
+ },
+ );
+ };
+
+ return (
+
+ {/* Workspace Display */}
+
+
+ {workspace && (
+ <>
+ {/* Show init banner if beads not initialized */}
+ {!hasBeads &&
}
+
+ {/* Loop Controls */}
+
+
+ {/* Tabs */}
+
+ {/* Tab Headers */}
+
+ setActiveTab("tasks")}
+ className={cn(
+ "flex-1 px-4 py-3 text-sm font-medium transition-colors",
+ activeTab === "tasks"
+ ? "bg-background text-foreground border-b-2 border-primary -mb-px"
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
+ )}
+ >
+
+
+ Tasks
+
+
+ setActiveTab("skills")}
+ className={cn(
+ "flex-1 px-4 py-3 text-sm font-medium transition-colors",
+ activeTab === "skills"
+ ? "bg-background text-foreground border-b-2 border-primary -mb-px"
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
+ )}
+ >
+
+
+ Skills
+
+
+ setActiveTab("gates")}
+ className={cn(
+ "flex-1 px-4 py-3 text-sm font-medium transition-colors",
+ activeTab === "gates"
+ ? "bg-background text-foreground border-b-2 border-primary -mb-px"
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
+ )}
+ >
+
+
+ Quality Gates
+
+
+
+
+ {/* Tab Content - scrollable */}
+
+ {activeTab === "tasks" && (
+ setActiveTab("gates")}
+ pendingPlanningTaskIds={pendingPlanningTaskIds}
+ onPlanningComplete={removePendingPlanningTask}
+ />
+ )}
+ {activeTab === "skills" && }
+ {activeTab === "gates" && (
+ {
+ addPendingPlanningTask(taskId);
+ setActiveTab("tasks");
+ }}
+ />
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/packages/mesh-plugin-task-runner/components/task-card.tsx b/packages/mesh-plugin-task-runner/components/task-card.tsx
new file mode 100644
index 0000000000..8ffa600752
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/components/task-card.tsx
@@ -0,0 +1,510 @@
+/**
+ * Task Card Component
+ *
+ * Displays a single task with planning, approval, and execution controls.
+ * Can be reused across plugins (task-runner, site-builder, etc.)
+ */
+
+import { useState, useEffect } from "react";
+import {
+ AlertCircle,
+ Check,
+ MessageChatSquare,
+ Edit02,
+ Trash01,
+} from "@untitledui/icons";
+import { toast } from "sonner";
+import { cn } from "@deco/ui/lib/utils.ts";
+import {
+ useUpdateTask,
+ useDeleteTask,
+ useApprovePlan,
+ type Task,
+} from "../hooks/use-tasks";
+
+// ============================================================================
+// Icons
+// ============================================================================
+
+const CircleIcon = ({ size = 14 }: { size?: number }) => (
+
+
+
+);
+
+const ClockIcon = ({ size = 14 }: { size?: number }) => (
+
+
+
+
+);
+
+const LoadingIcon = ({ size = 14 }: { size?: number }) => (
+
+
+
+);
+
+const ListIcon = ({ size = 14 }: { size?: number }) => (
+
+
+
+
+
+
+
+
+);
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface TaskCardProps {
+ task: Task;
+ /** Called when user wants to execute the task with an agent */
+ onStartWithAgent: (task: Task) => void;
+ /** Whether an agent is currently running on any task */
+ hasRunningAgent: boolean;
+ /** Workspace path for the task */
+ workspacePath?: string;
+ /** Function to refetch tasks (for polling during planning) */
+ refetchTasks?: () => void;
+ /** Function to send a message to the chat */
+ sendChatMessage: (text: string) => void;
+ /** Whether planning was requested for this task (from external action) */
+ initialPlanningRequested?: boolean;
+ /** Callback when planning state changes */
+ onPlanningStateChange?: (taskId: string, isPending: boolean) => void;
+}
+
+// ============================================================================
+// Component
+// ============================================================================
+
+export function TaskCard({
+ task,
+ onStartWithAgent,
+ hasRunningAgent,
+ workspacePath,
+ refetchTasks,
+ sendChatMessage,
+ initialPlanningRequested = false,
+ onPlanningStateChange,
+}: TaskCardProps) {
+ const updateTask = useUpdateTask();
+ const deleteTask = useDeleteTask();
+ const approvePlan = useApprovePlan();
+ const [showPlan, setShowPlan] = useState(false);
+ const [isPlanningRequested, setIsPlanningRequested] = useState(
+ initialPlanningRequested,
+ );
+ const [isSpawning, setIsSpawning] = useState(false);
+
+ // Sync with external planning state
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect
+ useEffect(() => {
+ if (initialPlanningRequested && !isPlanningRequested) {
+ setIsPlanningRequested(true);
+ }
+ }, [initialPlanningRequested, isPlanningRequested]);
+
+ // Poll for task updates while planning is in progress
+ const hasPlan = !!task.plan;
+
+ // Reset spawning state when agent starts running
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect
+ useEffect(() => {
+ if (hasRunningAgent && isSpawning) {
+ setIsSpawning(false);
+ }
+ }, [hasRunningAgent, isSpawning]);
+
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect
+ useEffect(() => {
+ if (!isPlanningRequested || hasPlan) return;
+
+ // Poll every 2 seconds while waiting for the plan
+ const interval = setInterval(() => {
+ refetchTasks?.();
+ }, 2000);
+
+ return () => clearInterval(interval);
+ }, [isPlanningRequested, hasPlan, refetchTasks]);
+
+ // Reset planning state when plan arrives
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect
+ useEffect(() => {
+ if (hasPlan && isPlanningRequested) {
+ setIsPlanningRequested(false);
+ setShowPlan(true); // Auto-expand plan when it arrives
+ toast.success("Plan ready! Review and approve to continue.");
+ onPlanningStateChange?.(task.id, false);
+ }
+ }, [hasPlan, isPlanningRequested, task.id, onPlanningStateChange]);
+
+ const statusIcon = {
+ open: ,
+ in_progress: ,
+ blocked: ,
+ closed: ,
+ };
+
+ // If task is in_progress but no agent running, show as open
+ const displayStatus =
+ task.status === "in_progress" && !hasRunningAgent ? "open" : task.status;
+
+ const handleDelete = () => {
+ if (confirm(`Delete task "${task.title}"?`)) {
+ deleteTask.mutate(
+ { taskId: task.id },
+ {
+ onSuccess: () => toast.success("Task deleted"),
+ onError: (err) => toast.error(`Failed to delete: ${err.message}`),
+ },
+ );
+ }
+ };
+
+ const handlePlan = () => {
+ if (!workspacePath) {
+ toast.error("Workspace not available yet - please wait and try again");
+ return;
+ }
+
+ // Send message to Task Runner Agent to analyze and plan the task
+ const planningPrompt = `Please analyze and create a detailed plan for task ${task.id}: "${task.title}"
+
+${task.description ? `Description: ${task.description}` : ""}
+Workspace: ${workspacePath}
+
+Instructions:
+1. Read the task requirements carefully
+2. Explore the codebase to understand relevant files and patterns
+3. Create a plan with:
+ - Clear, specific acceptance criteria (not generic ones)
+ - Subtasks broken down by complexity
+ - Files that will likely need modification
+ - Any risks or considerations
+
+When done, call TASK_SET_PLAN with workspace="${workspacePath}", taskId="${task.id}", and your plan.`;
+
+ sendChatMessage(planningPrompt);
+ setIsPlanningRequested(true);
+ toast.success("Sent to agent for planning - check the chat");
+ };
+
+ const handleApprovePlan = () => {
+ approvePlan.mutate(
+ { taskId: task.id, action: "approve" },
+ {
+ onSuccess: () => {
+ toast.success("Plan approved - ready to execute");
+ setShowPlan(false);
+ },
+ onError: (err) => toast.error(`Failed to approve: ${err.message}`),
+ },
+ );
+ };
+
+ const handleApproveAndExecute = () => {
+ if (isSpawning || hasRunningAgent) return;
+ setIsSpawning(true);
+ approvePlan.mutate(
+ { taskId: task.id, action: "approve" },
+ {
+ onSuccess: () => {
+ toast.success("Plan approved - starting execution...");
+ setShowPlan(false);
+ onStartWithAgent(task);
+ },
+ onError: (err) => {
+ setIsSpawning(false);
+ toast.error(`Failed to approve: ${err.message}`);
+ },
+ },
+ );
+ };
+
+ const handleExecute = () => {
+ if (isSpawning || hasRunningAgent) return;
+ setIsSpawning(true);
+ onStartWithAgent(task);
+ };
+
+ const planApproved = task.planStatus === "approved";
+
+ return (
+
+
+
+ {statusIcon[displayStatus]}
+
+
+
+
+ {task.id}
+
+ {task.priority !== undefined && task.priority <= 1 && (
+
+ P{task.priority}
+
+ )}
+ {hasPlan && (
+
+ {planApproved ? "Plan Approved" : "Plan Draft"}
+
+ )}
+
+
{task.title}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+ {/* Show acceptance criteria if approved */}
+ {planApproved &&
+ task.acceptanceCriteria &&
+ task.acceptanceCriteria.length > 0 && (
+
+ Criteria:
+
+ {task.acceptanceCriteria.length} items
+
+
+ )}
+
+
+ {displayStatus !== "closed" && (
+ <>
+ {displayStatus !== "in_progress" && (
+ <>
+ {/* Plan button - show if no plan or plan is draft */}
+ {!planApproved && (
+ setShowPlan(!showPlan) : handlePlan
+ }
+ disabled={isPlanningRequested && !hasPlan}
+ className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted hover:bg-muted/80 rounded-md disabled:opacity-50 transition-colors"
+ title={
+ hasPlan
+ ? "View/Edit Plan"
+ : "Ask agent to plan this task"
+ }
+ >
+ {isPlanningRequested && !hasPlan ? (
+
+ ) : (
+
+ )}
+
+ {hasPlan
+ ? "View Plan"
+ : isPlanningRequested
+ ? "Planning..."
+ : "Plan"}
+
+
+ )}
+ {/* Execute button - only show if plan is approved and no agent running */}
+ {planApproved && !hasRunningAgent && (
+
+ {isSpawning ? (
+
+ ) : (
+
+ )}
+ {isSpawning ? "Starting..." : "Execute"}
+
+ )}
+ >
+ )}
+ {
+ // TODO: Open edit modal
+ toast.info("Edit feature coming soon");
+ }}
+ disabled={updateTask.isPending || deleteTask.isPending}
+ className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded disabled:opacity-50"
+ title="Edit task"
+ >
+
+
+ >
+ )}
+
+ {deleteTask.isPending ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Plan Details Section */}
+ {showPlan && task.plan && (
+
+ {/* Scrollable content area */}
+
+
+
+ Summary
+
+
{task.plan.summary}
+
+
+
+
+ Acceptance Criteria ({task.plan.acceptanceCriteria.length})
+
+
+ {task.plan.acceptanceCriteria.map((ac) => (
+
+ •
+ {ac.description}
+
+ ))}
+
+
+
+
+
+ Subtasks ({task.plan.subtasks.length})
+
+
+ {task.plan.subtasks.map((st) => (
+
+
+ {st.estimatedComplexity}
+
+ {st.title}
+
+ ))}
+
+
+
+ {task.plan.estimatedComplexity && (
+
+ Overall complexity:{" "}
+
+ {task.plan.estimatedComplexity}
+
+
+ )}
+
+
+ {/* Action buttons - always visible */}
+
+ {hasRunningAgent ? (
+
+
+ Agent already running...
+
+ ) : (
+ <>
+
+ {approvePlan.isPending || isSpawning ? (
+
+ ) : (
+
+ )}
+ {isSpawning ? "Starting..." : "Approve & Execute"}
+
+
+
+ Approve Only
+
+ >
+ )}
+
setShowPlan(false)}
+ className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
+ >
+ Cancel
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/mesh-plugin-task-runner/hooks/use-tasks.ts b/packages/mesh-plugin-task-runner/hooks/use-tasks.ts
new file mode 100644
index 0000000000..29fa2fb535
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/hooks/use-tasks.ts
@@ -0,0 +1,1834 @@
+/**
+ * Task Runner Hooks
+ *
+ * React Query hooks for the Task Runner plugin.
+ * Uses OBJECT_STORAGE_BINDING to share connections with File Storage.
+ */
+
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { usePluginContext } from "@decocms/bindings/plugins";
+import { OBJECT_STORAGE_BINDING } from "@decocms/bindings";
+import { KEYS } from "../lib/query-keys";
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+/**
+ * Extract text content from various MCP tool response formats
+ * Handles: string, { content: string }, { content: [{ text: string }] }, { text: string }
+ */
+function extractTextContent(result: unknown): string | null {
+ if (typeof result === "string") {
+ return result;
+ }
+
+ if (typeof result !== "object" || result === null) {
+ return null;
+ }
+
+ const obj = result as Record;
+
+ // Handle { content: string }
+ if (typeof obj.content === "string") {
+ return obj.content;
+ }
+
+ // Handle { content: [{ type: 'text', text: string }] } (MCP format)
+ if (Array.isArray(obj.content)) {
+ const textParts = obj.content
+ .filter(
+ (item): item is { type: string; text: string } =>
+ typeof item === "object" &&
+ item !== null &&
+ "text" in item &&
+ typeof (item as { text: unknown }).text === "string",
+ )
+ .map((item) => item.text);
+ if (textParts.length > 0) {
+ return textParts.join("");
+ }
+ }
+
+ // Handle { text: string }
+ if (typeof obj.text === "string") {
+ return obj.text;
+ }
+
+ return null;
+}
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/**
+ * Acceptance criterion for a task
+ */
+export interface AcceptanceCriterion {
+ id: string;
+ description: string;
+ completed?: boolean;
+}
+
+/**
+ * Quality gate definition
+ */
+export interface QualityGate {
+ id: string;
+ name: string;
+ command: string;
+ description?: string;
+ required: boolean;
+ source: "auto" | "manual";
+}
+
+/**
+ * Result from running a quality gate
+ */
+export interface QualityGateResult {
+ gate: string;
+ command: string;
+ passed: boolean;
+ output: string;
+ duration: number;
+}
+
+/**
+ * Quality gates baseline - tracks verification state and acknowledged failures
+ */
+export interface QualityGatesBaseline {
+ verified: boolean;
+ verifiedAt: string;
+ allPassed: boolean;
+ acknowledged: boolean;
+ failingGates: string[];
+}
+
+/**
+ * Task Plan type
+ */
+export interface TaskPlan {
+ summary: string;
+ acceptanceCriteria: Array<{
+ id: string;
+ description: string;
+ verifiable?: boolean;
+ }>;
+ subtasks: Array<{
+ id: string;
+ title: string;
+ description: string;
+ estimatedComplexity?: "trivial" | "simple" | "moderate" | "complex";
+ filesToModify?: string[];
+ }>;
+ risks?: string[];
+ estimatedComplexity?: "trivial" | "simple" | "moderate" | "complex";
+}
+
+/**
+ * Task type for Beads
+ */
+export interface Task {
+ id: string;
+ title: string;
+ description?: string;
+ status: "open" | "in_progress" | "blocked" | "closed";
+ priority?: number;
+ createdAt?: string;
+ updatedAt?: string;
+ threadId?: string; // Chat thread ID for this task
+ acceptanceCriteria?: AcceptanceCriterion[]; // Verifiable success criteria
+ plan?: TaskPlan; // Generated plan before execution
+ planStatus?: "draft" | "approved" | "rejected"; // Plan approval status
+}
+
+/**
+ * Hook to get current workspace (root path from the storage connection)
+ * GET_ROOT is an optional tool not in OBJECT_STORAGE_BINDING, so we check for it dynamically
+ */
+export function useWorkspace() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+
+ return useQuery({
+ queryKey: KEYS.workspace,
+ queryFn: async () => {
+ // Check if GET_ROOT tool is available on this connection
+ const hasGetRoot = connection?.tools?.some((t) => t.name === "GET_ROOT");
+
+ if (!hasGetRoot) {
+ console.log("[Task Runner] Connection does not have GET_ROOT tool");
+ return { workspace: null, hasBeads: false };
+ }
+
+ try {
+ // Call GET_ROOT to get the storage root path
+ // Cast to any since GET_ROOT is not part of the typed binding
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ root: string }>;
+ const result = await untypedToolCaller("GET_ROOT", {});
+ console.log("[Task Runner] GET_ROOT result:", result);
+ return {
+ workspace: result.root,
+ hasBeads: false, // Will be checked separately
+ };
+ } catch (error) {
+ console.error("[Task Runner] GET_ROOT failed:", error);
+ return { workspace: null, hasBeads: false };
+ }
+ },
+ enabled: !!connectionId,
+ });
+}
+
+/**
+ * Hook to check if beads is initialized in the workspace
+ * This calls LIST_OBJECTS to check for .beads directory
+ */
+export function useBeadsStatus() {
+ const { connectionId, toolCaller } =
+ usePluginContext();
+
+ return useQuery({
+ queryKey: KEYS.beadsStatus,
+ queryFn: async () => {
+ try {
+ // Try to list .beads directory
+ const result = await toolCaller("LIST_OBJECTS", {
+ prefix: ".beads/",
+ maxKeys: 1,
+ });
+ return {
+ initialized:
+ result.objects.length > 0 ||
+ (result.commonPrefixes?.length ?? 0) > 0,
+ };
+ } catch {
+ return { initialized: false };
+ }
+ },
+ enabled: !!connectionId,
+ });
+}
+
+/**
+ * Hook to list tasks from .beads directory
+ * Reads .beads/tasks.json directly using read_file
+ */
+export function useTasks() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+
+ const hasReadFile = connection?.tools?.some((t) => t.name === "read_file");
+
+ return useQuery({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ queryFn: async (): Promise => {
+ if (!hasReadFile) {
+ console.log("[Task Runner] Connection does not have read_file tool");
+ return [];
+ }
+
+ try {
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const result = await untypedToolCaller("read_file", {
+ path: ".beads/tasks.json",
+ });
+
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+
+ if (!content) {
+ console.log("[Task Runner] No tasks file or empty content");
+ return [];
+ }
+
+ const data = JSON.parse(content) as { tasks: Task[] };
+ return data.tasks || [];
+ } catch (error) {
+ console.error("[Task Runner] Failed to read tasks:", error);
+ return [];
+ }
+ },
+ enabled: !!connectionId && hasReadFile,
+ staleTime: 0, // Always consider data stale
+ refetchOnMount: true,
+ refetchOnWindowFocus: true,
+ });
+}
+
+/**
+ * Tool call from agent
+ */
+export interface ToolCall {
+ name: string;
+ input?: Record;
+ timestamp: string;
+}
+
+/**
+ * Agent message
+ */
+export interface AgentMessage {
+ role: "user" | "assistant" | "tool";
+ content: string;
+ timestamp: string;
+}
+
+/**
+ * Agent session from AGENT_STATUS
+ */
+export interface AgentSession {
+ sessionId: string;
+ id?: string; // Alias
+ taskId: string;
+ taskTitle: string;
+ status: "running" | "completed" | "failed" | "stopped";
+ pid?: number;
+ startedAt: string;
+ completedAt?: string;
+ exitCode?: number;
+ toolCalls?: ToolCall[];
+ toolCallCount?: number;
+ messages?: AgentMessage[];
+ output?: string; // Raw output from the agent (for error messages)
+}
+
+/**
+ * Hook to get agent sessions by reading .beads/sessions.json directly
+ * (AGENT_STATUS tool is on task-runner MCP, not the storage connection)
+ */
+export function useAgentSessions() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+
+ // Check if read_file tool is available (from local-fs MCP)
+ const hasReadFile = connection?.tools?.some((t) => t.name === "read_file");
+
+ return useQuery({
+ queryKey: KEYS.agentSessions(connectionId ?? ""),
+ queryFn: async () => {
+ if (!toolCaller || !hasReadFile) {
+ return { sessions: [], runningCount: 0 };
+ }
+
+ try {
+ // Read sessions.json file directly using read_file tool
+ // Cast to work around typed tool caller
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string }>;
+ const result = await untypedToolCaller("read_file", {
+ path: ".beads/sessions.json",
+ });
+
+ // Parse the result
+ const content =
+ typeof result === "string"
+ ? result
+ : (result as { content?: string })?.content;
+
+ if (!content) {
+ return { sessions: [], runningCount: 0 };
+ }
+
+ // Parse the sessions file - it has { sessions: [...], lastUpdated: ... } structure
+ const parsed = JSON.parse(content) as {
+ sessions?: Array;
+ lastUpdated?: string;
+ };
+
+ const rawSessions = parsed.sessions || [];
+ const sessions = rawSessions.map((s) => ({
+ ...s,
+ sessionId: s.sessionId || s.id || "",
+ }));
+
+ // Sort by startedAt descending (most recent first)
+ sessions.sort(
+ (a, b) =>
+ new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
+ );
+
+ const runningSessions = sessions.filter(
+ (s) => s.status === "running",
+ ).length;
+
+ return {
+ sessions,
+ runningCount: runningSessions,
+ };
+ } catch {
+ // File might not exist yet
+ return { sessions: [], runningCount: 0 };
+ }
+ },
+ enabled: !!connectionId && !!toolCaller && hasReadFile,
+ refetchInterval: 5000, // Poll every 5 seconds
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
+ staleTime: 4000, // Consider data fresh for 4 seconds
+ });
+}
+
+/**
+ * Hook to get detailed session info - uses cached data from useAgentSessions
+ * No additional network requests - just filters the parent data
+ */
+export function useAgentSessionDetail(sessionId: string | null) {
+ const { data } = useAgentSessions();
+
+ const session = data?.sessions?.find(
+ (s) => s.sessionId === sessionId || s.id === sessionId,
+ );
+
+ return {
+ data: session || null,
+ isLoading: false,
+ };
+}
+
+// Legacy placeholder for components that still reference useLoopStatus
+export function useLoopStatus() {
+ const { data } = useAgentSessions();
+ const hasRunning = (data?.runningCount ?? 0) > 0;
+ const firstRunning = data?.sessions?.find((s) => s.status === "running");
+
+ return useQuery({
+ queryKey: KEYS.loopStatusLegacy,
+ queryFn: async () => ({
+ status: hasRunning ? "running" : "idle",
+ currentTask: firstRunning?.taskTitle || null,
+ iteration: 0,
+ maxIterations: 10,
+ totalTokens: 0,
+ maxTokens: 100000,
+ tasksCompleted:
+ data?.sessions
+ ?.filter((s) => s.status === "completed")
+ .map((s) => s.taskId) || [],
+ tasksFailed:
+ data?.sessions
+ ?.filter((s) => s.status === "failed")
+ .map((s) => s.taskId) || [],
+ startedAt: firstRunning?.startedAt || null,
+ lastActivity: null,
+ error: null,
+ }),
+ enabled: true,
+ });
+}
+
+export function useStartLoop() {
+ return useMutation({
+ mutationFn: async (_params?: unknown) => {
+ throw new Error("Loop not implemented yet");
+ },
+ });
+}
+
+export function usePauseLoop() {
+ return useMutation({
+ mutationFn: async () => {
+ throw new Error("Loop not implemented yet");
+ },
+ });
+}
+
+export function useStopLoop() {
+ return useMutation({
+ mutationFn: async () => {
+ throw new Error("Loop not implemented yet");
+ },
+ });
+}
+
+export interface Skill {
+ id: string;
+ name: string;
+ description: string;
+ path: string;
+}
+
+export function useSkills() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+
+ return useQuery({
+ queryKey: KEYS.skills(connectionId ?? ""),
+ queryFn: async (): Promise => {
+ // Check if SKILLS_LIST tool is available
+ const hasSkillsList = connection?.tools?.some(
+ (t) => t.name === "SKILLS_LIST",
+ );
+
+ if (!hasSkillsList) {
+ console.log("[Task Runner] Connection does not have SKILLS_LIST tool");
+ return [];
+ }
+
+ try {
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ skills: Skill[] }>;
+
+ const result = await untypedToolCaller("SKILLS_LIST", {});
+ return result.skills;
+ } catch (error) {
+ console.error("[Task Runner] SKILLS_LIST failed:", error);
+ return [];
+ }
+ },
+ enabled: !!connectionId,
+ });
+}
+
+export function useApplySkill() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (skillId: string) => {
+ // Read the skill file to get its content
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+
+ if (!hasReadFile) {
+ throw new Error("Connection doesn't support read_file");
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content: string } | string>;
+
+ // Read the skill file
+ const skillPath = `skills/${skillId}/SKILL.md`;
+ const result = await untypedToolCaller("read_file", { path: skillPath });
+
+ // Handle both structured and text responses
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && "content" in result
+ ? result.content
+ : String(result);
+
+ // Create a task based on the skill
+ const hasTaskCreate = connection?.tools?.some(
+ (t) => t.name === "TASK_CREATE",
+ );
+
+ if (!hasTaskCreate) {
+ throw new Error("Connection doesn't support TASK_CREATE");
+ }
+
+ const createToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ task: Task }>;
+
+ // Extract skill name from content (first heading after frontmatter)
+ const nameMatch = content.match(/^#\s+(.+)$/m);
+ const skillName = nameMatch?.[1] || skillId;
+
+ const taskResult = await createToolCaller("TASK_CREATE", {
+ title: `Apply skill: ${skillName}`,
+ description: `Follow the instructions in skills/${skillId}/SKILL.md`,
+ priority: 1,
+ });
+
+ return taskResult.task;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+export function useSetWorkspace() {
+ // No-op for now - workspace is determined by the connection
+ return useMutation({
+ mutationFn: async (_directory: string) => {
+ // Workspace is now determined by the storage connection
+ return { success: true };
+ },
+ });
+}
+
+export function useInitBeads() {
+ const { toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ const hasCreateDir = connection?.tools?.some(
+ (t) => t.name === "create_directory",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasWriteFile) {
+ throw new Error("This storage connection doesn't support write_file.");
+ }
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Create .beads directory if we have create_directory
+ if (hasCreateDir) {
+ try {
+ await writeToolCaller("create_directory", { path: ".beads" });
+ } catch {
+ // Directory might already exist, ignore
+ }
+ }
+
+ // Create config.json
+ const config = {
+ version: "1.0.0",
+ created: new Date().toISOString(),
+ };
+ await writeToolCaller("write_file", {
+ path: ".beads/config.json",
+ content: JSON.stringify(config, null, 2),
+ });
+
+ // Create tasks.json if it doesn't exist
+ try {
+ await writeToolCaller("write_file", {
+ path: ".beads/tasks.json",
+ content: JSON.stringify({ tasks: [] }, null, 2),
+ });
+ } catch {
+ // File might already exist
+ }
+
+ return {
+ success: true,
+ path: ".beads",
+ message: "Beads initialized successfully",
+ };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: KEYS.beadsStatus });
+ queryClient.invalidateQueries({ queryKey: KEYS.workspace });
+ },
+ });
+}
+
+export function useCreateTask() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: {
+ title: string;
+ description?: string;
+ priority?: number;
+ }) => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error(
+ "This storage connection doesn't support read_file/write_file.",
+ );
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read existing tasks
+ let tasksData: { tasks: Task[] } = { tasks: [] };
+ try {
+ const result = await untypedToolCaller("read_file", {
+ path: ".beads/tasks.json",
+ });
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+ if (content) {
+ tasksData = JSON.parse(content);
+ }
+ } catch {
+ // File doesn't exist yet, will create
+ }
+
+ // Create new task
+ const newTask: Task = {
+ id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ title: params.title,
+ description: params.description,
+ status: "open",
+ priority: params.priority,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+
+ tasksData.tasks.push(newTask);
+
+ // Write back
+ await writeToolCaller("write_file", {
+ path: ".beads/tasks.json",
+ content: JSON.stringify(tasksData, null, 2),
+ });
+
+ return newTask;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+export function useUpdateTask() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: {
+ taskId: string;
+ title?: string;
+ description?: string;
+ status?: "open" | "in_progress" | "blocked" | "closed";
+ priority?: number;
+ threadId?: string;
+ }) => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error(
+ "This storage connection doesn't support read_file/write_file.",
+ );
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read existing tasks
+ const result = await untypedToolCaller("read_file", {
+ path: ".beads/tasks.json",
+ });
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+
+ if (!content) {
+ throw new Error("Tasks file not found");
+ }
+
+ const tasksData = JSON.parse(content) as { tasks: Task[] };
+ const taskIndex = tasksData.tasks.findIndex(
+ (t) => t.id === params.taskId,
+ );
+
+ if (taskIndex === -1) {
+ throw new Error(`Task ${params.taskId} not found`);
+ }
+
+ const task = tasksData.tasks[taskIndex];
+ if (!task) {
+ throw new Error(`Task ${params.taskId} not found`);
+ }
+ if (params.title !== undefined) task.title = params.title;
+ if (params.description !== undefined)
+ task.description = params.description;
+ if (params.status !== undefined) task.status = params.status;
+ if (params.priority !== undefined) task.priority = params.priority;
+ if (params.threadId !== undefined) task.threadId = params.threadId;
+ task.updatedAt = new Date().toISOString();
+
+ tasksData.tasks[taskIndex] = task;
+
+ // Write back
+ await writeToolCaller("write_file", {
+ path: ".beads/tasks.json",
+ content: JSON.stringify(tasksData, null, 2),
+ });
+
+ return task;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+export function useCloseTasks() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: { taskIds: string[] }) => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error(
+ "This storage connection doesn't support read_file/write_file.",
+ );
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read existing tasks
+ const result = await untypedToolCaller("read_file", {
+ path: ".beads/tasks.json",
+ });
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+
+ if (!content) {
+ throw new Error("Tasks file not found");
+ }
+
+ const tasksData = JSON.parse(content) as { tasks: Task[] };
+
+ // Close matching tasks
+ const closedTasks: Task[] = [];
+ for (const taskId of params.taskIds) {
+ const taskIndex = tasksData.tasks.findIndex((t) => t.id === taskId);
+ const task = tasksData.tasks[taskIndex];
+ if (taskIndex !== -1 && task) {
+ task.status = "closed";
+ task.updatedAt = new Date().toISOString();
+ closedTasks.push(task);
+ }
+ }
+
+ // Write back
+ await writeToolCaller("write_file", {
+ path: ".beads/tasks.json",
+ content: JSON.stringify(tasksData, null, 2),
+ });
+
+ return closedTasks;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+export function useDeleteTask() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: { taskId: string }) => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error(
+ "This storage connection doesn't support read_file/write_file.",
+ );
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read existing tasks
+ const result = await untypedToolCaller("read_file", {
+ path: ".beads/tasks.json",
+ });
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+
+ if (!content) {
+ throw new Error("Tasks file not found");
+ }
+
+ const tasksData = JSON.parse(content) as { tasks: Task[] };
+ const initialLength = tasksData.tasks.length;
+ tasksData.tasks = tasksData.tasks.filter((t) => t.id !== params.taskId);
+
+ if (tasksData.tasks.length === initialLength) {
+ throw new Error(`Task ${params.taskId} not found`);
+ }
+
+ // Write back
+ await writeToolCaller("write_file", {
+ path: ".beads/tasks.json",
+ content: JSON.stringify(tasksData, null, 2),
+ });
+
+ return { success: true, deletedId: params.taskId };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+// ============================================================================
+// Quality Gates Hooks
+// ============================================================================
+
+/**
+ * Hook to get quality gates from project config
+ */
+export function useQualityGates() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const workspaceQuery = useWorkspace();
+
+ return useQuery({
+ queryKey: KEYS.qualityGates(connectionId ?? ""),
+ queryFn: async (): Promise => {
+ // Check if read_file tool is available
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+
+ if (!hasReadFile || !workspaceQuery.data?.workspace) {
+ return [];
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ try {
+ // Read project config
+ const configPath = ".beads/project-config.json";
+ const result = await untypedToolCaller("read_file", {
+ path: configPath,
+ });
+
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+
+ if (!content) return [];
+
+ const config = JSON.parse(content) as {
+ qualityGates?: QualityGate[];
+ };
+ return config.qualityGates ?? [];
+ } catch {
+ // No config file yet
+ return [];
+ }
+ },
+ enabled: !!connectionId && !!workspaceQuery.data?.workspace,
+ });
+}
+
+// Quality gate patterns to detect from package.json scripts
+const QUALITY_GATE_PATTERNS: Array<{
+ scripts: string[];
+ name: string;
+ description: string;
+}> = [
+ {
+ scripts: ["check", "typecheck", "type-check", "tsc"],
+ name: "Type Check",
+ description: "TypeScript type checking",
+ },
+ {
+ scripts: ["lint", "eslint", "oxlint"],
+ name: "Lint",
+ description: "Code linting",
+ },
+ {
+ scripts: ["test", "test:unit", "vitest", "jest"],
+ name: "Test",
+ description: "Run tests",
+ },
+ {
+ scripts: ["fmt", "fmt:check", "format", "prettier"],
+ name: "Format",
+ description: "Code formatting",
+ },
+];
+
+/**
+ * Hook to detect quality gates from package.json
+ * Reads package.json directly and finds common scripts
+ */
+export function useDetectQualityGates() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ // Check if read_file tool is available
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+
+ if (!hasReadFile) {
+ throw new Error("This storage connection doesn't support read_file.");
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ // Try to read package.json first, then deno.json
+ let scriptNames: string[] = [];
+ let runner = "npm run";
+ let configType: "package" | "deno" | null = null;
+
+ // Try package.json
+ try {
+ const pkgResult = await untypedToolCaller("read_file", {
+ path: "package.json",
+ });
+ const pkgContent = extractTextContent(pkgResult);
+
+ if (pkgContent && !pkgContent.startsWith("Error:")) {
+ const pkg = JSON.parse(pkgContent) as {
+ scripts?: Record;
+ };
+ if (pkg.scripts) {
+ scriptNames = Object.keys(pkg.scripts);
+ configType = "package";
+
+ // Detect package manager from lockfiles
+ try {
+ const bunLock = await untypedToolCaller("read_file", {
+ path: "bun.lock",
+ });
+ const bunContent = extractTextContent(bunLock);
+ if (bunContent && !bunContent.startsWith("Error:")) {
+ runner = "bun run";
+ }
+ } catch {
+ try {
+ const pnpmLock = await untypedToolCaller("read_file", {
+ path: "pnpm-lock.yaml",
+ });
+ const pnpmContent = extractTextContent(pnpmLock);
+ if (pnpmContent && !pnpmContent.startsWith("Error:")) {
+ runner = "pnpm run";
+ }
+ } catch {
+ // Default to npm
+ }
+ }
+ }
+ }
+ } catch {
+ // package.json not found or invalid
+ }
+
+ // Try deno.json if package.json didn't work
+ if (configType === null) {
+ try {
+ const denoResult = await untypedToolCaller("read_file", {
+ path: "deno.json",
+ });
+ const denoContent = extractTextContent(denoResult);
+
+ if (denoContent && !denoContent.startsWith("Error:")) {
+ const deno = JSON.parse(denoContent) as {
+ tasks?: Record;
+ };
+ if (deno.tasks) {
+ scriptNames = Object.keys(deno.tasks);
+ configType = "deno";
+ runner = "deno task";
+ }
+ }
+ } catch {
+ // deno.json not found or invalid
+ }
+ }
+
+ if (scriptNames.length === 0) {
+ return { gates: [], saved: false };
+ }
+
+ const gates: QualityGate[] = [];
+
+ // Find matching scripts/tasks
+ for (const pattern of QUALITY_GATE_PATTERNS) {
+ for (const scriptName of pattern.scripts) {
+ if (scriptNames.includes(scriptName)) {
+ gates.push({
+ id: `gate-${scriptName}`,
+ name: pattern.name,
+ command: `${runner} ${scriptName}`,
+ description: pattern.description,
+ required: true,
+ source: "auto",
+ });
+ break; // Only add one gate per pattern
+ }
+ }
+ }
+
+ // Save to .beads/project-config.json
+ if (gates.length > 0) {
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (hasWriteFile) {
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Try to read existing config first
+ let existingConfig: {
+ qualityGates?: QualityGate[];
+ completionToken?: string;
+ memoryDir?: string;
+ } = {};
+ try {
+ const configResult = await untypedToolCaller("read_file", {
+ path: ".beads/project-config.json",
+ });
+ const configContent = extractTextContent(configResult);
+ if (configContent) {
+ existingConfig = JSON.parse(configContent);
+ }
+ } catch {
+ // No existing config
+ }
+
+ // Merge with existing manual gates
+ const manualGates = (existingConfig.qualityGates ?? []).filter(
+ (g) => g.source === "manual",
+ );
+
+ const config = {
+ ...existingConfig,
+ qualityGates: [...gates, ...manualGates],
+ completionToken:
+ existingConfig.completionToken ?? "COMPLETE ",
+ memoryDir: existingConfig.memoryDir ?? "memory",
+ lastUpdated: new Date().toISOString(),
+ };
+
+ await writeToolCaller("write_file", {
+ path: ".beads/project-config.json",
+ content: JSON.stringify(config, null, 2),
+ });
+ }
+ }
+
+ return { gates, saved: gates.length > 0 };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.qualityGates(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+// ============================================================================
+// Quality Gates Baseline Hooks
+// ============================================================================
+
+/**
+ * Hook to get the quality gates baseline status
+ */
+export function useQualityGatesBaseline() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const workspaceQuery = useWorkspace();
+
+ return useQuery({
+ queryKey: KEYS.qualityGatesBaseline(connectionId ?? ""),
+ queryFn: async (): Promise<{
+ hasBaseline: boolean;
+ baseline?: QualityGatesBaseline;
+ canCreateTasks: boolean;
+ }> => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+
+ if (!hasReadFile || !workspaceQuery.data?.workspace) {
+ return { hasBaseline: false, canCreateTasks: false };
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ try {
+ const result = await untypedToolCaller("read_file", {
+ path: ".beads/project-config.json",
+ });
+
+ const content =
+ typeof result === "string"
+ ? result
+ : typeof result === "object" && result.content
+ ? result.content
+ : null;
+
+ if (!content) {
+ return { hasBaseline: false, canCreateTasks: false };
+ }
+
+ const config = JSON.parse(content) as {
+ qualityGatesBaseline?: QualityGatesBaseline;
+ };
+
+ const baseline = config.qualityGatesBaseline;
+ const hasBaseline = !!baseline?.verified;
+ const canCreateTasks =
+ hasBaseline && (baseline.allPassed || baseline.acknowledged);
+
+ return { hasBaseline, baseline, canCreateTasks };
+ } catch {
+ return { hasBaseline: false, canCreateTasks: false };
+ }
+ },
+ enabled: !!connectionId && !!workspaceQuery.data?.workspace,
+ });
+}
+
+/**
+ * Hook to verify quality gates and establish baseline
+ * Uses EXEC tool from local-fs MCP to run gate commands
+ */
+export function useVerifyQualityGates() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (): Promise<{
+ allPassed: boolean;
+ results: QualityGateResult[];
+ baseline: QualityGatesBaseline;
+ }> => {
+ const hasExec = connection?.tools?.some((t) => t.name === "EXEC");
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasExec || !hasReadFile || !hasWriteFile) {
+ throw new Error("Quality gates verification not available");
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read project config to get quality gates
+ let config: { qualityGates?: QualityGate[] } = { qualityGates: [] };
+ try {
+ const configResult = await untypedToolCaller("read_file", {
+ path: ".beads/project-config.json",
+ });
+ const content = extractTextContent(configResult);
+ if (content) {
+ config = JSON.parse(content);
+ }
+ } catch {
+ // Config doesn't exist yet, use empty gates
+ }
+
+ const gates = config.qualityGates || [];
+ const requiredGates = gates.filter((g) => g.required);
+
+ // Run each gate command using EXEC
+ const results: QualityGateResult[] = [];
+ for (const gate of requiredGates) {
+ const start = Date.now();
+ try {
+ const execResult = (await untypedToolCaller("EXEC", {
+ command: gate.command,
+ timeout: 120000, // 2 minutes for type checks
+ })) as {
+ success?: boolean;
+ exitCode?: number;
+ stdout?: string;
+ stderr?: string;
+ error?: string;
+ };
+
+ // Check success property first, then exitCode
+ const passed =
+ execResult.success === true ||
+ (execResult.success === undefined && execResult.exitCode === 0);
+
+ results.push({
+ gate: gate.name,
+ command: gate.command,
+ passed,
+ output: (
+ (execResult.stdout || "") +
+ (execResult.stderr || "") +
+ (execResult.error || "")
+ ).slice(-500),
+ duration: Date.now() - start,
+ });
+ } catch (error) {
+ results.push({
+ gate: gate.name,
+ command: gate.command,
+ passed: false,
+ output: error instanceof Error ? error.message : "Command failed",
+ duration: Date.now() - start,
+ });
+ }
+ }
+
+ const allPassed = results.every((r) => r.passed);
+ const failingGates = results.filter((r) => !r.passed).map((r) => r.gate);
+
+ // Create baseline
+ const baseline: QualityGatesBaseline = {
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ allPassed,
+ acknowledged: false,
+ failingGates,
+ };
+
+ // Save baseline to config
+ const updatedConfig = {
+ ...config,
+ qualityGatesBaseline: baseline,
+ };
+
+ await untypedToolCaller("write_file", {
+ path: ".beads/project-config.json",
+ content: JSON.stringify(updatedConfig, null, 2),
+ });
+
+ return { allPassed, results, baseline };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.qualityGatesBaseline(connectionId ?? ""),
+ });
+ queryClient.invalidateQueries({
+ queryKey: KEYS.qualityGates(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+/**
+ * Hook to acknowledge pre-existing quality gate failures
+ * Updates the baseline in project-config.json
+ */
+export function useAcknowledgeQualityGates() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (
+ acknowledge: boolean,
+ ): Promise<{
+ success: boolean;
+ baseline?: QualityGatesBaseline;
+ error?: string;
+ }> => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error("Quality gates acknowledge not available");
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read current config
+ let config: {
+ qualityGates?: QualityGate[];
+ qualityGatesBaseline?: QualityGatesBaseline;
+ } = {};
+ try {
+ const configResult = await untypedToolCaller("read_file", {
+ path: ".beads/project-config.json",
+ });
+ const content = extractTextContent(configResult);
+ if (content) {
+ config = JSON.parse(content);
+ }
+ } catch {
+ return {
+ success: false,
+ error: "No baseline exists. Run verification first.",
+ };
+ }
+
+ if (!config.qualityGatesBaseline?.verified) {
+ return {
+ success: false,
+ error: "No baseline exists. Run verification first.",
+ };
+ }
+
+ // Update baseline with acknowledged flag
+ const baseline: QualityGatesBaseline = {
+ ...config.qualityGatesBaseline,
+ acknowledged: acknowledge,
+ };
+
+ const updatedConfig = {
+ ...config,
+ qualityGatesBaseline: baseline,
+ };
+
+ await untypedToolCaller("write_file", {
+ path: ".beads/project-config.json",
+ content: JSON.stringify(updatedConfig, null, 2),
+ });
+
+ return { success: true, baseline };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.qualityGatesBaseline(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+// ============================================================================
+// Task Planning Hooks
+// ============================================================================
+
+/**
+ * Hook to generate a plan for a task
+ */
+export function useTaskPlan() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: { taskId: string }) => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error(
+ "This storage connection doesn't support read_file/write_file.",
+ );
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read the task
+ const tasksResult = await untypedToolCaller("read_file", {
+ path: ".beads/tasks.json",
+ });
+
+ const tasksContent =
+ typeof tasksResult === "string"
+ ? tasksResult
+ : typeof tasksResult === "object" && tasksResult.content
+ ? tasksResult.content
+ : null;
+
+ if (!tasksContent) {
+ throw new Error("Could not read tasks file");
+ }
+
+ const tasksData = JSON.parse(tasksContent) as { tasks: Task[] };
+ const task = tasksData.tasks.find((t) => t.id === params.taskId);
+
+ if (!task) {
+ throw new Error(`Task not found: ${params.taskId}`);
+ }
+
+ // Generate a simple plan
+ const plan: TaskPlan = {
+ summary: `Implement: ${task.title}`,
+ acceptanceCriteria: [
+ {
+ id: "ac-1",
+ description: `The feature "${task.title.slice(0, 50)}" is fully implemented`,
+ verifiable: true,
+ },
+ {
+ id: "ac-2",
+ description: "All quality gates pass (check, lint, test)",
+ verifiable: true,
+ },
+ {
+ id: "ac-3",
+ description: "Changes are committed with descriptive message",
+ verifiable: true,
+ },
+ ],
+ subtasks: [
+ {
+ id: "st-1",
+ title: "Understand requirements",
+ description: "Read task description and relevant code",
+ estimatedComplexity: "trivial",
+ },
+ {
+ id: "st-2",
+ title: "Implement the change",
+ description: task.title,
+ estimatedComplexity: "moderate",
+ },
+ {
+ id: "st-3",
+ title: "Test and verify",
+ description: "Run quality gates and verify acceptance criteria",
+ estimatedComplexity: "simple",
+ },
+ ],
+ estimatedComplexity: "moderate",
+ };
+
+ // Update task with plan
+ const taskIndex = tasksData.tasks.findIndex(
+ (t) => t.id === params.taskId,
+ );
+ tasksData.tasks[taskIndex] = {
+ ...task,
+ plan,
+ planStatus: "draft",
+ updatedAt: new Date().toISOString(),
+ } as Task;
+
+ await writeToolCaller("write_file", {
+ path: ".beads/tasks.json",
+ content: JSON.stringify(tasksData, null, 2),
+ });
+
+ return { taskId: params.taskId, plan, status: "draft" };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+/**
+ * Hook to approve/reject a task plan
+ */
+export function useApprovePlan() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: {
+ taskId: string;
+ action: "approve" | "reject";
+ modifiedCriteria?: AcceptanceCriterion[];
+ }) => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error(
+ "This storage connection doesn't support read_file/write_file.",
+ );
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read tasks
+ const tasksResult = await untypedToolCaller("read_file", {
+ path: ".beads/tasks.json",
+ });
+
+ const tasksContent =
+ typeof tasksResult === "string"
+ ? tasksResult
+ : typeof tasksResult === "object" && tasksResult.content
+ ? tasksResult.content
+ : null;
+
+ if (!tasksContent) {
+ throw new Error("Could not read tasks file");
+ }
+
+ const tasksData = JSON.parse(tasksContent) as {
+ tasks: Array;
+ };
+ const taskIndex = tasksData.tasks.findIndex(
+ (t) => t.id === params.taskId,
+ );
+
+ if (taskIndex === -1) {
+ throw new Error(`Task not found: ${params.taskId}`);
+ }
+
+ const task = tasksData.tasks[taskIndex];
+ if (!task) {
+ throw new Error(`Task not found: ${params.taskId}`);
+ }
+
+ if (params.action === "approve") {
+ task.planStatus = "approved";
+ // Copy acceptance criteria from plan to task
+ if (task.plan?.acceptanceCriteria) {
+ task.acceptanceCriteria =
+ params.modifiedCriteria ||
+ task.plan.acceptanceCriteria.map((ac) => ({
+ id: ac.id,
+ description: ac.description,
+ completed: false,
+ }));
+ }
+ } else {
+ task.planStatus = "rejected";
+ }
+
+ task.updatedAt = new Date().toISOString();
+ tasksData.tasks[taskIndex] = task;
+
+ await writeToolCaller("write_file", {
+ path: ".beads/tasks.json",
+ content: JSON.stringify(tasksData, null, 2),
+ });
+
+ return {
+ success: true,
+ taskId: params.taskId,
+ planStatus: task.planStatus,
+ };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.tasks(connectionId ?? ""),
+ });
+ },
+ });
+}
+
+/**
+ * Hook to add a custom quality gate
+ * Writes directly to .beads/project-config.json
+ */
+export function useAddQualityGate() {
+ const { connectionId, toolCaller, connection } =
+ usePluginContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: {
+ name: string;
+ command: string;
+ description?: string;
+ required?: boolean;
+ }) => {
+ const hasReadFile = connection?.tools?.some(
+ (t) => t.name === "read_file",
+ );
+ const hasWriteFile = connection?.tools?.some(
+ (t) => t.name === "write_file",
+ );
+
+ if (!hasReadFile || !hasWriteFile) {
+ throw new Error(
+ "This storage connection doesn't support read_file/write_file.",
+ );
+ }
+
+ const untypedToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise<{ content?: string } | string>;
+
+ const writeToolCaller = toolCaller as unknown as (
+ name: string,
+ args: Record,
+ ) => Promise;
+
+ // Read existing config
+ let config: {
+ qualityGates?: QualityGate[];
+ completionToken?: string;
+ memoryDir?: string;
+ lastUpdated?: string;
+ } = {
+ qualityGates: [],
+ completionToken: "COMPLETE ",
+ memoryDir: "memory",
+ };
+
+ try {
+ const configResult = await untypedToolCaller("read_file", {
+ path: ".beads/project-config.json",
+ });
+ const configContent =
+ typeof configResult === "string"
+ ? configResult
+ : typeof configResult === "object" && configResult.content
+ ? configResult.content
+ : null;
+ if (configContent) {
+ config = JSON.parse(configContent);
+ }
+ } catch {
+ // No existing config, use defaults
+ }
+
+ // Create new gate
+ const gate: QualityGate = {
+ id: `gate-${Date.now()}`,
+ name: params.name,
+ command: params.command,
+ description: params.description,
+ required: params.required ?? true,
+ source: "manual",
+ };
+
+ config.qualityGates = [...(config.qualityGates ?? []), gate];
+ config.lastUpdated = new Date().toISOString();
+
+ await writeToolCaller("write_file", {
+ path: ".beads/project-config.json",
+ content: JSON.stringify(config, null, 2),
+ });
+
+ return { success: true, gate };
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: KEYS.qualityGates(connectionId ?? ""),
+ });
+ },
+ });
+}
diff --git a/packages/mesh-plugin-task-runner/index.tsx b/packages/mesh-plugin-task-runner/index.tsx
new file mode 100644
index 0000000000..05eaea37d0
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/index.tsx
@@ -0,0 +1,41 @@
+/**
+ * Task Runner Plugin
+ *
+ * Provides a task management UI with Beads integration and agent loops.
+ * Uses OBJECT_STORAGE_BINDING to share connections with the Files plugin.
+ * The workspace is derived from the storage connection's GET_ROOT tool.
+ */
+
+import { OBJECT_STORAGE_BINDING } from "@decocms/bindings";
+import type { Plugin, PluginSetupContext } from "@decocms/bindings/plugins";
+import { File04 } from "@untitledui/icons";
+import { lazy } from "react";
+import { taskRunnerRouter } from "./lib/router";
+
+// Lazy load components
+const PluginHeader = lazy(() => import("./components/plugin-header"));
+const PluginEmptyState = lazy(() => import("./components/plugin-empty-state"));
+
+/**
+ * Task Runner Plugin Definition
+ */
+export const taskRunnerPlugin: Plugin = {
+ id: "task-runner",
+ description: "Orchestrate AI agents with Beads tasks and agent loops",
+ binding: OBJECT_STORAGE_BINDING,
+ renderHeader: (props) => ,
+ renderEmptyState: () => ,
+ setup: (context: PluginSetupContext) => {
+ const { registerRootSidebarItem, registerPluginRoutes } = context;
+
+ // Register sidebar item
+ registerRootSidebarItem({
+ icon: ,
+ label: "Tasks",
+ });
+
+ // Create and register plugin routes
+ const routes = taskRunnerRouter.createRoutes(context);
+ registerPluginRoutes(routes);
+ },
+};
diff --git a/packages/mesh-plugin-task-runner/lib/query-keys.ts b/packages/mesh-plugin-task-runner/lib/query-keys.ts
new file mode 100644
index 0000000000..4b80d74108
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/lib/query-keys.ts
@@ -0,0 +1,28 @@
+/**
+ * Query Keys for Task Runner
+ *
+ * Centralized query key definitions for React Query.
+ */
+
+export const KEYS = {
+ workspace: ["task-runner", "workspace"] as const,
+ beadsStatus: ["task-runner", "beads-status"] as const,
+ loopStatusLegacy: ["task-runner", "loop-status"] as const,
+ tasks: (connectionId: string) =>
+ ["task-runner", "tasks", connectionId] as const,
+ readyTasks: (connectionId: string) =>
+ ["task-runner", "ready", connectionId] as const,
+ loopStatus: (connectionId: string) =>
+ ["task-runner", "loop", connectionId] as const,
+ skills: (connectionId: string) =>
+ ["task-runner", "skills", connectionId] as const,
+ agentSessions: (connectionId: string) =>
+ ["task-runner", "agent-sessions", connectionId] as const,
+ agentSessionsBase: ["task-runner", "agent-sessions"] as const,
+ qualityGates: (connectionId: string) =>
+ ["task-runner", "quality-gates", connectionId] as const,
+ qualityGatesBaseline: (connectionId: string) =>
+ ["task-runner", "quality-gates-baseline", connectionId] as const,
+ projectMemory: (connectionId: string) =>
+ ["task-runner", "project-memory", connectionId] as const,
+};
diff --git a/packages/mesh-plugin-task-runner/lib/router.ts b/packages/mesh-plugin-task-runner/lib/router.ts
new file mode 100644
index 0000000000..fb566342a6
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/lib/router.ts
@@ -0,0 +1,43 @@
+/**
+ * Task Runner Plugin Router
+ *
+ * Provides typed routing utilities for the task runner plugin.
+ */
+
+import { createPluginRouter } from "@decocms/bindings/plugins";
+import * as z from "zod";
+
+/**
+ * Search schema for the task board route.
+ * Includes site context params for navigation from Sites plugin.
+ */
+const taskBoardSearchSchema = z.object({
+ view: z.enum(["board", "list"]).optional().default("board"),
+ filter: z
+ .enum(["all", "ready", "in_progress", "blocked"])
+ .optional()
+ .default("all"),
+ // Site context params (from Sites plugin navigation)
+ skill: z.string().optional(), // Pre-select skill for new task
+ template: z.string().optional(), // Page path to use as template
+ edit: z.string().optional(), // Page path to edit
+ site: z.string().optional(), // Site connection ID for context
+});
+
+export type TaskBoardSearch = z.infer;
+
+/**
+ * Plugin router with typed hooks for navigation and search params.
+ */
+export const taskRunnerRouter = createPluginRouter((ctx) => {
+ const { createRoute, lazyRouteComponent } = ctx.routing;
+
+ const indexRoute = createRoute({
+ getParentRoute: () => ctx.parentRoute,
+ path: "/",
+ component: lazyRouteComponent(() => import("../components/task-board")),
+ validateSearch: taskBoardSearchSchema,
+ });
+
+ return [indexRoute];
+});
diff --git a/packages/mesh-plugin-task-runner/package.json b/packages/mesh-plugin-task-runner/package.json
new file mode 100644
index 0000000000..cd009316f7
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "mesh-plugin-task-runner",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./index.tsx",
+ "exports": {
+ ".": "./index.tsx",
+ "./hooks/use-tasks": "./hooks/use-tasks.ts",
+ "./components/task-card": "./components/task-card.tsx"
+ },
+ "scripts": {
+ "check": "tsc --noEmit",
+ "test": "bun test"
+ },
+ "dependencies": {
+ "@decocms/bindings": "workspace:*",
+ "@deco/ui": "workspace:*",
+ "@tanstack/react-query": "5.90.11",
+ "@tanstack/react-router": "1.139.7",
+ "@untitledui/icons": "^0.0.19",
+ "react": "^19.2.0",
+ "sonner": "^2.0.7",
+ "zod": "^3.24.4"
+ }
+}
diff --git a/packages/mesh-plugin-task-runner/tsconfig.json b/packages/mesh-plugin-task-runner/tsconfig.json
new file mode 100644
index 0000000000..1f06f5c860
--- /dev/null
+++ b/packages/mesh-plugin-task-runner/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "noEmit": true
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}
diff --git a/skills/README.md b/skills/README.md
new file mode 100644
index 0000000000..919f946e2c
--- /dev/null
+++ b/skills/README.md
@@ -0,0 +1,31 @@
+# Mesh Skills
+
+This folder contains Agent Skills for AI-assisted development of the Mesh platform.
+
+## Available Skills
+
+| Skill | Description |
+|-------|-------------|
+| [mesh-development](mesh-development/SKILL.md) | Build features for MCP Mesh - coding conventions, plugins, tools, UI |
+
+## Skill Format
+
+Each skill is a folder containing:
+- `SKILL.md` - Main skill file with YAML frontmatter (`name`, `description`)
+- `references/` - Supporting documentation and examples
+
+## Using Skills
+
+Skills are automatically discovered by the Task Runner plugin when connected to this workspace via a local-fs MCP.
+
+1. Connect a local-fs MCP pointing to this repository
+2. Open the Tasks plugin in Mesh
+3. Skills appear in the Skills panel
+4. Click "Apply" to create tasks based on a skill
+
+## Creating New Skills
+
+1. Copy an existing skill folder
+2. Update the YAML frontmatter in `SKILL.md`
+3. Add relevant content and references
+4. The skill will auto-appear in the Tasks plugin
diff --git a/skills/deco-sales-pitch-pages/SKILL.md b/skills/deco-sales-pitch-pages/SKILL.md
new file mode 100644
index 0000000000..ccd4f16b82
--- /dev/null
+++ b/skills/deco-sales-pitch-pages/SKILL.md
@@ -0,0 +1,482 @@
+---
+name: deco-sales-pitch-pages
+description: Research a target company and create a personalized sales pitch landing page. Use when prospecting a specific merchant from the target list — runs Core Web Vitals analysis, CrUX data, Perplexity research, creates a pitch strategy document, then implements a customized landing page in decoCMS.
+---
+
+# Sales Pitch Page Generator
+
+Transform a target merchant from the target list into a personalized, compelling pitch landing page that demonstrates exactly how Deco can solve their specific problems.
+
+## Philosophy: From Enabler to Doer
+
+> "Software used to enable. Now it does. We don't recommend fixes — we ship them. We don't suggest optimizations — we own the outcome."
+
+The pitch page itself IS the proof. The diagnostic we show them is a "free sample" of what runs 24/7 after they sign. We're not selling a faster CMS or better tools — we're selling a **teammate that actually ships**.
+
+Key narrative beats:
+1. **This diagnostic is a free sample** — what you're reading is what our agents do continuously
+2. **Tools enable, we do** — dashboards don't deploy code, agents do
+3. **The closed loop** — CONNECT → DETECT → ACT → MEASURE → EVOLVE
+
+Reference: `context/references/2026-01-soren-larson-you-must-just-do-things.md`
+
+## Overview
+
+This skill automates the sales research and pitch creation workflow:
+
+1. **Analyze** → Core Web Vitals + CrUX historical data (only show failing metrics)
+2. **Research** → Company, stack, pain points via Perplexity
+3. **Strategize** → Create "How to Wow Them" pitch document
+4. **Implement** → Build customized landing page with closed-loop narrative
+
+## Prerequisites
+
+Before using this skill, ensure you have:
+
+- Access to the target list: `@context/02_strategy/proposals/2026-01-31-target-list-north-american-storefronts.md`
+- Perplexity MCP tools available
+- Firecrawl MCP for site analysis
+- Access to decoCMS for page creation
+
+Reference skills to read first:
+- `@context/skills/decocms-landing-pages/SKILL.md` — Page creation patterns
+- `@context/skills/deco-brand-guidelines/SKILL.md` — Brand consistency
+
+## Workflow
+
+### Phase 1: Performance Analysis
+
+#### Step 1.1: Run Core Web Vitals
+
+Use browser tools or PageSpeed Insights API to capture current metrics:
+
+```bash
+# Using curl to PageSpeed Insights API (free, no key required for basic)
+curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://[TARGET_SITE]&strategy=mobile"
+```
+
+Capture and record:
+- **LCP** (Largest Contentful Paint) — target < 2.5s
+- **INP** (Interaction to Next Paint) — target < 200ms
+- **CLS** (Cumulative Layout Shift) — target < 0.1
+- **FCP** (First Contentful Paint) — target < 1.8s
+- **TTFB** (Time to First Byte) — target < 800ms
+- **Speed Index** — target < 3.4s
+
+#### Step 1.2: Get CrUX Historical Data
+
+Chrome User Experience Report provides 28-day rolling data:
+
+```bash
+# CrUX API (requires API key)
+curl "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=[API_KEY]" \
+ -d '{"url": "https://[TARGET_SITE]"}'
+```
+
+Alternative: Use web.dev/measure or PageSpeed Insights which includes CrUX data.
+
+Record historical percentiles (p75):
+- How have their Core Web Vitals trended over past months?
+- Are they passing or failing Google's thresholds?
+- Mobile vs Desktop differences
+
+### Phase 2: Company Research
+
+Use Perplexity tools for comprehensive research.
+
+#### Step 2.1: Company Overview
+
+```
+perplexity_research: "[COMPANY_NAME] ecommerce company overview revenue funding
+team size technology stack 2025 2026"
+```
+
+Capture:
+- Founded date, HQ location
+- Funding history (if any)
+- Key executives (CEO, CTO, VP Ecommerce)
+- Employee count
+- Recent news/announcements
+
+#### Step 2.2: Technology Stack
+
+```
+perplexity_search: "[COMPANY_NAME] website technology stack Shopify headless
+CMS platform architecture"
+```
+
+Also use Firecrawl to analyze:
+- Platform (Shopify Plus, BigCommerce, custom)
+- Headless setup (Hydrogen, Next.js, etc.)
+- CMS (Sanity, Contentful, Builder.io, none)
+- Analytics/tracking tools
+- Third-party integrations
+
+#### Step 2.3: Brand Values & Identity (NEW — Critical for Pitch Resonance)
+
+```
+perplexity_research: "[COMPANY_NAME] brand identity values mission visual aesthetic
+what does the brand stand for beyond fashion/products"
+```
+
+Capture:
+- Core brand values (e.g., inclusivity, sustainability, empowerment)
+- Key brand phrases they use (e.g., "we don't promise — we prove")
+- Photography/visual style
+- Community initiatives (ambassador programs, etc.)
+- Certifications (B-Corp, etc.)
+- Founder story and why it matters
+
+**Why this matters:** The pitch resonates 10x better when you show alignment with their values. Good American cares about "proving, not promising" — so we show how our agents prove results, not just recommend them.
+
+Use this data to populate `SalesPitchBrandValues.tsx` section.
+
+#### Step 2.4: Pain Points & Opportunities
+
+```
+perplexity_search: "[COMPANY_NAME] website slow performance issues customer
+complaints reviews"
+```
+
+Look for:
+- Customer reviews mentioning site speed
+- Social media complaints about checkout
+- Job postings (hiring for performance? frontend devs?)
+- Recent replatforming discussions
+- International expansion challenges
+
+### Phase 3: Create Pitch Strategy Document
+
+Create a detailed strategy document in context:
+
+**File:** `context/02_strategy/pitches/YYYY-MM-DD-[company-slug]-pitch-strategy.md`
+
+Document structure:
+
+```markdown
+# [Company Name] Pitch Strategy
+
+**Date:** [DATE]
+**Target Contact:** [NAME, TITLE]
+**Deal Size Potential:** $[X]K/month
+**Sale Type:** CMS-Only | CMS + Hosting
+
+## Executive Summary
+[One paragraph on why they need Deco and what we'll pitch]
+
+## Company Profile
+- Industry: [X]
+- HQ: [City, Country]
+- Platform: [Shopify Plus, BigCommerce, etc.]
+- Current Stack: [Headless? CMS? Custom?]
+- Traffic: ~[X]M monthly visits
+- Revenue: Est. $[X]M annual
+
+## Performance Analysis
+
+### Current Core Web Vitals (Mobile)
+| Metric | Value | Status | Impact |
+|--------|-------|--------|--------|
+| LCP | [X]s | PASS/FAIL | [conversion impact] |
+| INP | [X]ms | PASS/FAIL | [user experience impact] |
+| CLS | [X] | PASS/FAIL | [visual stability] |
+
+### CrUX History (28-day)
+[Summary of trends — improving, declining, stable]
+
+### Estimated Impact
+- Current conversion rate: ~[X]%
+- Potential with Deco: +[X]% (based on speed improvements)
+- Revenue impact: $[X]M additional annual revenue
+
+## Pain Points Identified
+1. [Pain point 1 with evidence]
+2. [Pain point 2 with evidence]
+3. [Pain point 3 with evidence]
+
+## How Deco Solves Their Problems
+
+### Problem 1: [Specific issue]
+**Deco Solution:** [How we fix it]
+**Proof Point:** [Case study or metric from FARM/other client]
+
+### Problem 2: [Specific issue]
+**Deco Solution:** [How we fix it]
+**Proof Point:** [Case study or metric]
+
+## Competitive Positioning
+- vs Their Current CMS: [advantages]
+- vs Staying on Liquid: [advantages]
+- vs Other headless solutions: [advantages]
+
+## The "Wow" Moment
+[What specific demonstration or insight will make them say "I need this"?]
+
+## Pitch Page Sections
+1. Hero: [Personalized hook for this company]
+2. Problem: [Their specific pain, not generic]
+3. Solution: [How Deco specifically helps them]
+4. Proof: [Relevant case study / metrics]
+5. Calculator: [ROI specific to their traffic/revenue]
+6. CTA: [Specific next step]
+
+## Objection Handling
+| Objection | Response |
+|-----------|----------|
+| "We just migrated" | [Response] |
+| "Budget is tight" | [Response] |
+| "Our CMS works fine" | [Response] |
+
+## Contact Strategy
+- **Ideal Contact:** [Name, Title, LinkedIn]
+- **Opening Hook:** [Personalized first line]
+- **Meeting Request:** [Specific ask]
+```
+
+### Phase 4: Implement Pitch Landing Page
+
+Use the SalesPitch sections in decoCMS to build a customized page.
+
+#### File Locations
+
+| Type | Path |
+|------|------|
+| Page JSON | `.deco/blocks/pages-pitch-[company-slug].json` |
+| Sections | `sections/SalesPitch/*.tsx` |
+
+#### Available SalesPitch Sections
+
+See `sections/SalesPitch/README.md` for complete catalog:
+
+| Section | Purpose |
+|---------|---------|
+| `SalesPitchHero.tsx` | Personalized hero with company name |
+| `SalesPitchMetrics.tsx` | CWV scores (only failing by default) |
+| `SalesPitchProblem.tsx` | The real problem (enabling vs doing) |
+| `SalesPitchClosedLoop.tsx` | **KEY** — "This diagnostic is a free sample" |
+| `SalesPitchSolution.tsx` | From enabling to doing |
+| `SalesPitchROI.tsx` | Revenue impact calculator |
+| `SalesPitchCaseStudy.tsx` | Relevant proof point |
+| `SalesPitchCTA.tsx` | Next steps with calendar link |
+| `SalesPitchPasswordGate.tsx` | Optional protection |
+
+**Critical section: `SalesPitchClosedLoop.tsx`** — This is the narrative pivot. It shows the 5-step loop (CONNECT → DETECT → ACT → MEASURE → EVOLVE) and positions the diagnostic itself as a free sample of what runs continuously.
+
+#### Page JSON Template
+
+The section order matters — it builds the narrative:
+
+1. **Password** → Gate access
+2. **Hero** → Hook with "free sample" framing
+3. **Metrics** → Only show what's broken (failing metrics)
+4. **Problem** → The real issue: enabling vs doing
+5. **ClosedLoop** → The pivot: "This diagnostic is what runs 24/7"
+6. **Solution** → From enabling to doing
+7. **ROI** → Revenue impact
+8. **CaseStudy** → Proof it works
+9. **CTA** → Ready for a teammate that ships?
+
+```json
+{
+ "name": "[Company] | Deco Sales Pitch",
+ "path": "/pitch/[company-slug]",
+ "sections": [
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchPasswordGate.tsx",
+ "passwordHash": "[HASH]"
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchHero.tsx",
+ "companyName": "[Company Name]",
+ "headline": "Your store is leaving $[X]M on the table",
+ "subheadline": "This analysis shows what's broken. Our agents fix it — automatically, continuously, while you sleep.",
+ "eyebrow": "CLOSED-LOOP DIAGNOSTIC"
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchMetrics.tsx",
+ "sectionTitle": "What's Costing You Money",
+ "lcpCurrent": "[X]",
+ "lcpTarget": "2.0",
+ "lcpStatus": "fail",
+ "ttfbCurrent": "[X]",
+ "ttfbTarget": "0.8",
+ "ttfbStatus": "fail",
+ "showOnlyFailing": true
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchProblem.tsx",
+ "sectionTitle": "The Real Problem",
+ "subtitle": "You have [platform]. You have analytics. You have a team. But who's actually shipping fixes?",
+ "problems": [
+ { "title": "Nobody's Watching 24/7", "description": "[Specific metric drift]", "icon": "clock" },
+ { "title": "Optimization Is Manual Labor", "description": "[Specific backlog pain]", "icon": "lock" },
+ { "title": "Tools Enable, They Don't Do", "description": "Dashboards don't deploy code.", "icon": "alert" }
+ ]
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchClosedLoop.tsx",
+ "sectionTitle": "This Diagnostic Is a Free Sample",
+ "intro": "What you're reading right now? It's what our agents do continuously.",
+ "steps": [
+ { "name": "CONNECT", "description": "Agents connect to your repo, analytics, CrUX", "example": "[Their repo]" },
+ { "name": "DETECT", "description": "Every hour, scan for regressions and opportunities", "example": "[Specific finding]" },
+ { "name": "ACT", "description": "Open PRs with fixes. Run E2E. Wait for green.", "example": "PR: [specific fix]" },
+ { "name": "MEASURE", "description": "Track impact after deploy. Revenue attributed.", "example": "[Impact estimate]" },
+ { "name": "EVOLVE", "description": "System learns constraints and gets smarter.", "example": "[Brand guideline]" }
+ ],
+ "closingStatement": "While you sleep, the agent is shipping."
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchSolution.tsx",
+ "sectionTitle": "From Enabling to Doing",
+ "subtitle": "We don't recommend fixes — we ship them."
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchROI.tsx"
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchCaseStudy.tsx",
+ "clientName": "FARM Rio",
+ "testimonial": "Deco agents ship fixes while we focus on product."
+ },
+ {
+ "__resolveType": "site/sections/SalesPitch/SalesPitchCTA.tsx",
+ "headline": "Ready for a teammate that actually ships?",
+ "description": "This diagnostic was free. The next step: agents running on your store.",
+ "ctaText": "Book a Demo"
+ }
+ ]
+}
+```
+
+## Quick Reference: The Complete Flow
+
+```
+1. Pick target from target list
+ ↓
+2. Run Core Web Vitals (PageSpeed Insights)
+ ↓
+3. Get CrUX data (web.dev/measure)
+ ↓
+4. Perplexity research (company, stack, pain points)
+ ↓
+5. Perplexity research (brand values, identity, what they stand for)
+ ↓
+6. Optional: Firecrawl scrape for deeper analysis
+ ↓
+7. Create pitch strategy document
+ ↓
+8. Generate images with nano-banana (see below)
+ ↓
+9. Configure SalesPitch sections with their data
+ ↓
+10. Create page JSON
+ ↓
+11. Test and share protected URL
+```
+
+## Image Generation
+
+Use the nano-banana-agent MCP to generate on-brand images for the pitch.
+
+### Visual Style (from `context/10_design/VISUAL_STYLE.md`)
+
+- **Aesthetic:** Retro comic hero meets digital noir — starring capybaras
+- **Colors:** Monochromatic green (#121110 dark to #D0EC1A lime)
+- **Effects:** Heavy dithering, halftone dots, CRT glow, pixelation
+- **Capybara:** Calm, confident, heroic — doing the work while others plan
+
+### Recommended Images
+
+Generate 2-3 images per pitch:
+
+1. **Hero Image** — Capybara at command center, optimizing storefronts
+2. **Closed Loop Image** — Capybara walking away from optimization explosion
+3. **Brand Values Image** — Capybara with community of agents working together
+
+### Prompt Template
+
+```
+Create a landscape digital artwork (16:9) with deep dark background (hex #121110).
+
+A capybara hero [DESCRIBE ACTION — e.g., "at a command center optimizing a fashion storefront"].
+
+[OPTIONAL: Connect to brand values — e.g., "Multiple screens show diverse customers being served"]
+
+Style: 1950s comic book meets digital noir. Heavy dithering and halftone effects.
+Pixelated edges. CRT glow on screens.
+
+Bright lime-green (hex #D0EC1A) for highlights, screen glow, and data flows.
+Dark noir shadows.
+
+Monochromatic green palette. The capybara is calm, confident, heroic.
+
+No text.
+```
+
+### Tool Usage
+
+```json
+{
+ "prompt": "[your prompt]",
+ "model": "gemini-3-pro-image-preview",
+ "aspectRatio": "16:9"
+}
+```
+
+### Save Images
+
+Download generated images to `decocms/static/pitch-[company-slug]-[purpose].png`
+
+Example:
+- `/pitch-good-american-hero.png`
+- `/pitch-good-american-closed-loop.png`
+- `/pitch-good-american-community.png`
+
+## Tools Used
+
+| Tool | Purpose |
+|------|---------|
+| `perplexity_research` | Deep company research |
+| `perplexity_search` | Quick fact finding |
+| `firecrawl_scrape` | Analyze site structure |
+| PageSpeed Insights | Core Web Vitals |
+| CrUX API | Historical performance data |
+| decoCMS | Landing page creation |
+
+## Output Artifacts
+
+After running this skill, you'll have:
+
+1. **Pitch Strategy Document** → `context/02_strategy/pitches/YYYY-MM-DD-[slug]-pitch-strategy.md`
+2. **Landing Page** → `https://deco.cx/pitch/[slug]` (password protected)
+3. **Performance Data** → Captured in strategy doc
+
+## Common Variations
+
+### CMS-Only Pitch
+Focus on:
+- CMS cost comparison (Contentful/Sanity pricing vs Deco)
+- Content velocity improvements
+- Commerce-specific optimizations
+- No migration needed
+
+### Full Migration Pitch
+Focus on:
+- Complete performance transformation
+- Before/after case studies
+- Total cost of ownership
+- Migration timeline
+
+### Canadian Market Pitch
+Emphasize:
+- Toronto presence / Shopify ecosystem alignment
+- CAD pricing options
+- Canadian success stories
+- Local support
+
+## Related Skills
+
+- `@context/skills/decocms-landing-pages/SKILL.md` — Core landing page patterns
+- `@context/skills/deco-performance-audit/SKILL.md` — Performance analysis tools
+- `@context/skills/deco-writing-style/SKILL.md` — Copy guidelines
diff --git a/skills/deco-sales-pitch-pages/research-workflow.md b/skills/deco-sales-pitch-pages/research-workflow.md
new file mode 100644
index 0000000000..28b78fc00a
--- /dev/null
+++ b/skills/deco-sales-pitch-pages/research-workflow.md
@@ -0,0 +1,131 @@
+# Research Workflow Reference
+
+Quick reference for the research phase of sales pitch creation.
+
+## Step 1: Core Web Vitals
+
+### Option A: PageSpeed Insights (Recommended)
+
+Visit: `https://pagespeed.web.dev/`
+
+Enter target URL and capture:
+- LCP (target < 2.5s)
+- INP (target < 200ms)
+- CLS (target < 0.1)
+- FCP (target < 1.8s)
+- Speed Index (target < 3.4s)
+
+### Option B: Programmatic
+
+```bash
+# Basic request (no API key needed)
+curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com&strategy=mobile"
+```
+
+## Step 2: CrUX Data
+
+Chrome User Experience Report shows real user data (28-day rolling).
+
+### Option A: web.dev/measure
+
+Visit: `https://web.dev/measure/`
+- Enter URL
+- View "Origin" tab for site-wide metrics
+- Check "Field Data" section in PageSpeed
+
+### Option B: CrUX API
+
+```bash
+curl -X POST "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=[API_KEY]" \
+ -H "Content-Type: application/json" \
+ -d '{"url": "https://example.com"}'
+```
+
+## Step 3: Perplexity Research Queries
+
+### Company Overview
+```
+[COMPANY] ecommerce company overview revenue funding team size
+headquarters technology 2025 2026
+```
+
+### Technology Stack
+```
+[COMPANY] website technology stack platform Shopify headless CMS
+architecture hydrogen next.js
+```
+
+### Performance Issues
+```
+[COMPANY] website slow performance issues customer complaints
+checkout problems
+```
+
+### Recent News
+```
+[COMPANY] ecommerce news announcements funding expansion 2025 2026
+```
+
+### Job Postings (Pain Signal)
+```
+[COMPANY] hiring frontend developer ecommerce platform engineer
+job posting
+```
+
+## Step 4: Firecrawl Analysis
+
+Use firecrawl to scrape the target site for additional signals:
+
+```json
+{
+ "url": "https://example.com",
+ "formats": ["markdown"],
+ "onlyMainContent": true
+}
+```
+
+Look for:
+- Technology indicators in meta tags
+- Third-party script references
+- Performance-affecting patterns
+- Content structure
+
+## Step 5: Competitive Intelligence
+
+### BuiltWith Lookup
+Check technology profile at: `https://builtwith.com/[domain]`
+
+### SimilarWeb Traffic
+Estimate traffic at: `https://www.similarweb.com/website/[domain]`
+
+### Store Leads (Shopify specific)
+Check store details at: `https://storeleads.app/reports/shopify/`
+
+## Output: Pitch Strategy Document
+
+Create in `context/02_strategy/pitches/YYYY-MM-DD-[slug]-pitch-strategy.md`
+
+Template structure:
+1. Executive Summary
+2. Company Profile
+3. Performance Analysis (CWV data)
+4. Pain Points Identified
+5. How Deco Solves Their Problems
+6. Competitive Positioning
+7. The "Wow" Moment
+8. Pitch Page Sections Plan
+9. Objection Handling
+10. Contact Strategy
+
+## Quick Checklist
+
+- [ ] PageSpeed Insights run (mobile + desktop)
+- [ ] CrUX historical data captured
+- [ ] Perplexity company research done
+- [ ] Perplexity stack research done
+- [ ] Pain points documented with evidence
+- [ ] Revenue impact calculated
+- [ ] Pitch strategy document created
+- [ ] Landing page sections configured
+- [ ] Page JSON created in decocms
+- [ ] Password hash generated
diff --git a/skills/decocms-landing-pages/SKILL.md b/skills/decocms-landing-pages/SKILL.md
new file mode 100644
index 0000000000..57ce2f350a
--- /dev/null
+++ b/skills/decocms-landing-pages/SKILL.md
@@ -0,0 +1,546 @@
+---
+name: decocms-landing-pages
+description: Create beautiful, on-brand landing pages in decoCMS. Use when building new landing pages, proposals, internal roadmaps, hiring projects, or converting documents into polished web pages with images.
+---
+
+# Building Landing Pages in decoCMS
+
+Transform any document into a polished, on-brand landing page with custom sections, AI-generated images, and consistent styling.
+
+## Quick Start
+
+1. Find a reference page (see [reference-pages.md](reference-pages.md))
+2. Create sections for each content block
+3. Configure the page JSON
+4. Generate images with nano banana
+5. Add SEO metadata
+
+## Page Architecture
+
+### File Locations
+
+| Type | Path | Description |
+|------|------|-------------|
+| Page config | `.deco/blocks/pages-{slug}.json` | Route + sections |
+| Sections | `sections/{ComponentName}.tsx` | Reusable UI blocks |
+| Islands | `islands/{ComponentName}.tsx` | Interactive (Preact) |
+| Static | `static/` | Images, files |
+
+### Page JSON Structure
+
+```json
+{
+ "name": "Page Title",
+ "path": "/url-slug",
+ "sections": [
+ { "__resolveType": "site/sections/ComponentName.tsx", "prop": "value" }
+ ],
+ "seo": {
+ "__resolveType": "website/sections/Seo/SeoV2.tsx",
+ "title": "Page Title | deco",
+ "description": "Meta description for social sharing",
+ "image": "https://decocms.com/og-image.jpg"
+ },
+ "__resolveType": "website/pages/Page.tsx"
+}
+```
+
+## Section Anatomy
+
+Every section follows this pattern:
+
+```tsx
+export interface Props {
+ /**
+ * @title Human Label
+ * @description Shown in CMS
+ * @format textarea | image-uri | html
+ */
+ propName?: string;
+}
+
+export default function SectionName({
+ propName = "default value",
+}: Props) {
+ return (
+
+ );
+}
+
+export function Preview() {
+ return ;
+}
+```
+
+**Key patterns:**
+- Always provide defaults for all props
+- Use `max-w-[720px]` for readable content, `max-w-[1000px]` for visuals
+- Background is `bg-dc-950` (dark) by default
+- Add `Preview()` for CMS preview
+
+## Design Styles
+
+Choose style based on purpose:
+
+| Style | Use For | Container | Font Scale |
+|-------|---------|-----------|------------|
+| Flashy | Vision, investor materials | 1000-1200px | Large (display text) |
+| Elegant | Client proposals, presentations | 720-1000px | Medium-large |
+| Pragmatic | Technical specs, hiring projects | 720px | Medium |
+| **Dashboard** | Ops docs, team views, data-dense | 1000px | Medium-large |
+
+**Font sizing tip:** Start ~40% larger than you think. It's easier to read on screen and can always be reduced.
+
+### Flashy (Roadmap 2026)
+
+For dramatic internal docs, vision pieces, investor materials:
+
+```tsx
+// Animated gradient backgrounds
+
+
+// Grid overlay
+
+
+// Large display text with gradient
+
+ 2026
+
+
+// CSS animations
+
+```
+
+### Elegant (Vanto Proposal)
+
+For client-facing proposals, professional presentations:
+
+```tsx
+// Eyebrow badge
+
+
+// Quote card with accent border
+
+
"{quote}"
+
— {attribution}
+
+
+// Key point callout
+
+
+// Stat cards
+
+ Format
+ 4 sessions × 2 hours
+
+```
+
+### Action Plan (Internal Roadmap)
+
+For internal team docs derived from meetings, action plans, decision summaries:
+
+```tsx
+// Principles card with number and insight
+
+
+// Before/After comparison table
+
+
{label}
+
{before}
+
{after}
+
+
+// Action item with status badge
+
+
+
+ {owner}
+ In Progress
+
+
{task}
+
+
{timeline}
+
+
+// Status badge variants
+const statusStyles = {
+ "todo": "bg-dc-700 text-dc-300",
+ "in-progress": "bg-primary-light/20 text-primary-light",
+ "blocked": "bg-red-500/20 text-red-400",
+};
+```
+
+### Pragmatic (Hiring Project)
+
+For technical docs, hiring projects, specs:
+
+```tsx
+// Simple badge
+
+ Hiring Project
+
+
+// Checklist items
+
+
+// Highlighted section
+
+
+ Stand out
+
{title}
+
+
+
+// Arrow list items
+
+ →
+ {text}
+
+```
+
+### Dashboard (Q1 Roadmap)
+
+For ops docs, team views, structured data, weekly reviews:
+
+```tsx
+// Data table
+
+
+
+
+
+ Column
+
+
+
+
+ {rows.map((row, i) => (
+
+ {row.value}
+
+ ))}
+
+
+
+
+// Team card (compact)
+
+
+
+ Team 1
+
{teamName}
+
+
+ {count} milestones
+
+
+
+
+ {members.map((m) => (
+ {m}
+ ))}
+
+
+
+// Milestone card with owner
+
+
+
+ {id}
+
+
+
{title}
+
+ Owner:
+ {owner}
+
+
{description}
+
+
+
+
+// Quick nav links (in hero)
+
+```
+
+## Color System
+
+```
+dc-950 #121110 — Page background (darkest)
+dc-900 #1C1917 — Card backgrounds
+dc-800 #282524 — Borders, secondary bg
+dc-700 #44403C — Subtle borders
+dc-600 #56524E — Muted text
+dc-500 #78726E — Secondary text
+dc-400 #A6A09D — Body text
+dc-300 #D6D3D1 — Emphasis text
+dc-200 #E7E5E4 — Strong text
+dc-100 #F1F0EE — Headlines
+
+primary-light #D0EC1A — Accent (lime green)
+primary-dark #07401A — Dark accent
+purple-light #A595FF — Alt accent
+yellow-light #FFC116 — Alt accent
+```
+
+## Password Protection
+
+For gated pages, create island + section pair:
+
+**Island** (`islands/ProjectPasswordGate.tsx`):
+```tsx
+import { useEffect, useState } from "preact/hooks";
+
+export interface Props {
+ passwordHash?: string;
+ title?: string;
+ subtitle?: string;
+ buttonText?: string;
+}
+
+const AUTH_COOKIE = "project_auth";
+
+async function hashPassword(password: string): Promise {
+ const data = new TextEncoder().encode(password);
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+ return Array.from(new Uint8Array(hashBuffer))
+ .map((b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+export default function ProjectPasswordGate({ passwordHash, title, subtitle, buttonText = "View" }: Props) {
+ const [authState, setAuthState] = useState(null);
+ const [password, setPassword] = useState("");
+ // ... auth logic checking cookie, hashing input, setting cookie on success
+ if (authState === true) return null;
+ return (/* password form UI */);
+}
+```
+
+**Section** (`sections/ProjectPasswordGate.tsx`):
+```tsx
+import PasswordGate from "../islands/ProjectPasswordGate.tsx";
+
+export interface Props {
+ passwordHash?: string;
+}
+
+export default function ProjectPasswordGateSection({ passwordHash }: Props) {
+ return ;
+}
+```
+
+Generate hash: `echo -n "password" | shasum -a 256`
+
+## Image Generation
+
+Use nano banana agent with visual style context. Reference the style guide:
+
+```
+@context/10_design/VISUAL_STYLE.md
+```
+
+This provides the complete aesthetic (retro comic + digital noir, monochromatic green palette, dithering effects, capybara heroes).
+
+For professional/corporate imagery, use a simpler prompt:
+
+```
+Create a professional digital artwork for [PROJECT CONTEXT].
+
+[DESCRIBE THE CONCEPT] - focus on [SPECIFIC ELEMENTS].
+
+Style: Modern, professional, clean with subtle gradients.
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+Mood: Sophisticated, technical, authoritative.
+
+Dimensions: 1376x768 (landscape, 16:9 ratio)
+No text overlays. High contrast for dark theme integration.
+```
+
+See [image-generation.md](image-generation.md) for detailed prompts and examples.
+
+**After generating:**
+1. Download to `static/{project}-{concept}.png`
+2. Reference as `/{project}-{concept}.png` in sections
+3. For SEO, create optimized version:
+ ```bash
+ sips -Z 1200 --setProperty format jpeg --setProperty formatOptions 70 \
+ static/hero.png --out static/hero-og.jpg
+ ```
+
+## SEO Configuration
+
+Add to page JSON:
+
+```json
+"seo": {
+ "__resolveType": "website/sections/Seo/SeoV2.tsx",
+ "title": "Page Title | deco",
+ "description": "Description for social sharing (150-160 chars)",
+ "image": "https://decocms.com/og-image.jpg"
+}
+```
+
+**Image requirements:**
+- Use absolute URL (`https://decocms.com/...`)
+- WhatsApp/social: max ~300KB
+- Recommended: 1200×630px JPEG at 70% quality
+
+## Cross-Page Navigation
+
+For document networks (e.g., vision → roadmap → detail pages), add Related Documents links:
+
+```tsx
+export interface RelatedLink {
+ label: string;
+ url: string;
+ description?: string;
+}
+
+// In CTA or Footer section
+{relatedLinks && relatedLinks.length > 0 && (
+
+
+ Related Documents
+
+
+
+)}
+```
+
+**Tips:**
+- Use relative URLs (`/roadmap`) to stay on same domain
+- Add bidirectional links (A→B and B→A)
+- Place in CTA section before final footer text
+- Same password hash can be reused across related internal docs
+
+## Common Section Types
+
+| Section | Purpose | Width | Style |
+|---------|---------|-------|-------|
+| Hero | Title, subtitle, eyebrow | 1000px | All |
+| Problem | Pain point, context | 720px | Elegant, Pragmatic |
+| Features/Tools | Grid of capabilities | 720px | All |
+| Timeline | Visual progression | 720px | Elegant |
+| Phases/Checklists | Requirements, expectations | 720px | Pragmatic |
+| Insight/Quote | Callout with attribution | 1000px | Elegant |
+| CTA | Call to action with buttons | 720-1000px | All |
+| Footer | Resources, source info | 720-1000px | All |
+| **Teams** | Team cards with members | 1000px | Dashboard |
+| **Milestones** | Milestone list with owners | 1000px | Dashboard |
+| **People** | Per-person commitments grid | 1000px | Dashboard |
+| **Data Table** | Structured data rows | 1000px | Dashboard |
+| **Principles** | Numbered cards with insights | 1000px | Action Plan |
+| **Before/After** | Transformation comparison table | 1000px | Action Plan |
+| **Action Plan** | Grouped items with status badges | 1000px | Action Plan |
+
+## Workflow
+
+1. **Source doc** → Read and structure into sections
+2. **Choose style** → Based on purpose (see style table above)
+3. **Reference** → Find similar page for patterns
+4. **Sections** → Create/reuse TSX components
+5. **Page JSON** → Configure route and section order
+6. **Navigation** → Add Related Documents links if part of doc network
+7. **Images** → Generate 2-3 with nano banana (skip for Dashboard style)
+8. **SEO** → Add title, description, og:image
+9. **Test** → Check responsive, password gate, font sizes
+
+### Style Selection Guide
+
+| Source Document | Recommended Style |
+|-----------------|-------------------|
+| Vision/strategy doc | Flashy |
+| Client proposal | Elegant |
+| Technical spec, hiring project | Pragmatic |
+| Team roadmap, ops doc, weekly review | Dashboard |
+| Meeting-derived action plan | Action Plan |
+
+### Meeting-to-Page Workflow
+
+When deriving a landing page from a Grain meeting:
+
+1. **Fetch meeting notes + transcript** — Use Grain MCP tools
+2. **Create decision doc first** — In context repo (`02_strategy/decisions/YYYY-MM-DD-slug.md`)
+3. **Extract page structure:**
+ - **Hero**: meeting title, date, participants
+ - **Principles**: core decisions made (numbered cards)
+ - **Before/After**: transformation discussion (comparison table)
+ - **Action items**: grouped by timeline with status badges
+ - **CTA**: link to decision doc + Grain recording URL
+4. **Skip images** — Internal docs don't need generated imagery
+5. **No password** — Internal team pages can be public
+
+### Common Mistakes
+
+- **Fonts too small** — Start 40% larger than you think, scale down if needed
+- **Missing navigation** — Add Related Documents for connected pages
+- **Wrong container width** — 720px for reading, 1000px for data-dense
+- **Skipping password** — Reuse existing hash for related internal docs
+
+See [reference-pages.md](reference-pages.md) for complete examples.
diff --git a/skills/decocms-landing-pages/image-generation.md b/skills/decocms-landing-pages/image-generation.md
new file mode 100644
index 0000000000..64aa5beafa
--- /dev/null
+++ b/skills/decocms-landing-pages/image-generation.md
@@ -0,0 +1,273 @@
+# Image Generation for Landing Pages
+
+Generate professional, on-brand images for decoCMS landing pages using the nano banana agent.
+
+## Visual Style Reference
+
+Use the deco visual style guide for all imagery:
+- **Location**: `context/10_design/VISUAL_STYLE.md`
+- **Aesthetic**: Retro Comic Hero meets Digital Noir — 1950s-60s comic book art with heavy dithering, CRT effects
+- **Colors**: Monochromatic green (`#121110` background, `#D0EC1A` accents)
+- **Main Character**: Capybaras — calm, confident, capable heroes doing the work
+
+For personal brand imagery (Guilherme), see `vibegui.com/context/VISUAL_STYLE.md`.
+
+## Workflow
+
+1. **Define concepts** — 2-3 images per page (hero, supporting, CTA)
+2. **Generate** — Use nano banana with model `gemini-3-pro-image-preview`
+3. **Download** — Save to `static/` directory
+4. **Optimize** — Create SEO version if needed (JPEG, ~300KB)
+5. **Integrate** — Reference in section props
+
+**Tip**: When generating, pass the visual style file as context:
+```
+@context/10_design/VISUAL_STYLE.md
+```
+
+## Image Types
+
+| Type | Purpose | Dimensions | Location |
+|------|---------|------------|----------|
+| Hero | Main visual | 1376×768 | Top of page |
+| Supporting | Concept visualization | 1376×768 | Mid-sections |
+| CTA | Action reinforcement | 1376×768 | Footer area |
+| OG/SEO | Social sharing | 1200×630 | Meta tags |
+
+## Prompt Templates
+
+### Professional/Corporate
+
+For proposals, enterprise content:
+
+```
+Create a professional digital artwork for an executive AI workshop proposal.
+
+[CONCEPT]: Executives building AI automations with guidance from experts.
+
+Visual elements:
+- Business professionals at modern workstations
+- AI/automation visual metaphors (flows, connections, data)
+- Collaborative atmosphere, learning environment
+
+Style: Modern, clean, corporate but not sterile. Subtle gradients.
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+Mood: Empowering, professional, sophisticated.
+
+Dimensions: 1376x768 (landscape, 16:9)
+No text overlays. High contrast for dark theme.
+```
+
+### Technical/Development
+
+For hiring projects, developer-focused:
+
+```
+Create a professional digital artwork for a software development hiring project.
+
+[CONCEPT]: MCP server connecting customer data to AI agents for finance workflows.
+
+Visual elements:
+- Code/terminal aesthetics
+- Data flow connections
+- Customer context visualization
+- Modern developer environment
+
+Style: Technical but approachable. Clean lines, modern UI aesthetic.
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+Mood: Innovative, challenging, exciting opportunity.
+
+Dimensions: 1376x768 (landscape, 16:9)
+No text overlays. High contrast for dark theme.
+```
+
+### Vision/Future
+
+For roadmaps, strategic documents:
+
+```
+Create a professional digital artwork for a 2026 company roadmap.
+
+[CONCEPT]: Autonomous AI agents continuously optimizing commerce storefronts.
+
+Visual elements:
+- Continuous loop/cycle visualization
+- Agent working autonomously
+- Store/commerce evolution
+- Data flowing, improvements shipping
+
+Style: Futuristic but grounded. Vision meets execution.
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+Mood: Ambitious, confident, inevitable.
+
+Dimensions: 1376x768 (landscape, 16:9)
+No text overlays. High contrast for dark theme.
+```
+
+## Concept Mappings
+
+| Content Type | Visual Approach |
+|--------------|-----------------|
+| AI/Automation | Flowing data, neural networks, agent interfaces |
+| Learning/Workshop | People at screens, collaborative spaces |
+| Development | Code editors, terminals, architecture diagrams |
+| Strategy | Paths, growth, ascending trajectories |
+| Integration | Connections, bridges, unified systems |
+| Optimization | Metrics improving, loops closing, efficiency |
+
+## Deco Brand Colors
+
+Always specify these in prompts:
+
+```
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+```
+
+For variety, can also use:
+- Purple accent: `#A595FF`
+- Yellow accent: `#FFC116`
+
+## Generation Command
+
+Using nano banana agent MCP:
+
+```typescript
+GENERATE_IMAGE({
+ prompt: "...",
+ width: 1376,
+ height: 768,
+ model: "gemini-3-pro-image-preview" // Required model
+})
+```
+
+## Post-Generation
+
+### Download to static
+
+After generation, the image URL is returned. Download it:
+
+```bash
+curl -o static/project-concept.png "https://generated-image-url..."
+```
+
+### Optimize for SEO
+
+Social platforms have size limits (~300KB for WhatsApp). Create optimized version:
+
+```bash
+# Resize to 1200px width and convert to JPEG at 70% quality
+sips -Z 1200 --setProperty format jpeg --setProperty formatOptions 70 \
+ static/hero.png --out static/hero-og.jpg
+```
+
+### Reference in Code
+
+Sections reference images by prop:
+
+```json
+{
+ "__resolveType": "site/sections/HeroSection.tsx",
+ "heroImage": "/project-hero.png"
+}
+```
+
+Or in defaults:
+
+```tsx
+export default function HeroSection({
+ image = "/project-hero.png"
+}: Props) {
+ // ...
+}
+```
+
+## Naming Convention
+
+```
+{project}-{concept}.png
+
+Examples:
+- vanto-hero.png
+- vanto-bridge.png
+- vanto-build.png
+- hiring-hero.png
+- hiring-tools.png
+- hiring-vision.png
+- roadmap-q1-proved-model.png
+- roadmap-q4-default.png
+```
+
+## SEO Image Requirements
+
+For `og:image` / meta image:
+- **Absolute URL**: `https://decocms.com/image.jpg`
+- **Size**: Max 300KB for WhatsApp
+- **Dimensions**: 1200×630px (1.91:1 ratio)
+- **Format**: JPEG preferred for smaller size
+
+Configure in page JSON:
+
+```json
+"seo": {
+ "__resolveType": "website/sections/Seo/SeoV2.tsx",
+ "title": "Page Title | deco",
+ "description": "Description",
+ "image": "https://decocms.com/hero-og.jpg"
+}
+```
+
+## Example Prompts Used
+
+### Vanto Hero (Executive Workshop)
+
+```
+Create a professional digital artwork for an executive AI workshop.
+
+Concept: Business leaders learning to build AI automations themselves,
+guided by experts. The bridge from "70% with ChatGPT" to production-ready.
+
+Visual: Executives at modern workstations, data flowing, AI assistance visible.
+Collaborative learning atmosphere. Empowering, not intimidating.
+
+Style: Corporate sophistication meets tech innovation.
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+Mood: Confident, capable, hands-on.
+
+Dimensions: 1376x768. No text.
+```
+
+### Hiring Project Tools
+
+```
+Create a professional digital artwork for an MCP server development project.
+
+Concept: Building tools that give AI agents access to customer billing and
+usage data. The MCP as a bridge between raw data and intelligent responses.
+
+Visual: Code/terminal aesthetic, data connections, customer context flowing
+into AI. Modern developer environment.
+
+Style: Technical but approachable.
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+Mood: Challenging, innovative, opportunity.
+
+Dimensions: 1376x768. No text.
+```
+
+### Hiring Project Vision
+
+```
+Create a professional digital artwork about agentic workflows in finance.
+
+Concept: AI agents using MCP tools to autonomously handle customer inquiries.
+The 6-month vision: from single tool to orchestrated finance automation.
+
+Visual: Agent loops, multiple tools working together, scalability.
+Flow from customer question to resolution.
+
+Style: Forward-looking, architectural.
+Colors: Dark background (#121110) with lime-green accents (#D0EC1A).
+Mood: Visionary, systematic, ambitious.
+
+Dimensions: 1376x768. No text.
+```
diff --git a/skills/decocms-landing-pages/reference-pages.md b/skills/decocms-landing-pages/reference-pages.md
new file mode 100644
index 0000000000..1833381154
--- /dev/null
+++ b/skills/decocms-landing-pages/reference-pages.md
@@ -0,0 +1,677 @@
+# Reference Pages
+
+Complete examples of landing pages built in decoCMS.
+
+## Page Comparison
+
+| Page | Style | Sections | Password | Images | Purpose |
+|------|-------|----------|----------|--------|---------|
+| `/future` | Flashy | 8 | Yes | 0 | 2028 vision document |
+| `/2026` | Flashy | 10 | Yes | 4 | Retrospective/investor |
+| `/roadmap` | Dashboard | 7 | Yes | 0 | Q1 ops dashboard |
+| `/roadmap/admin-cx` | Action Plan | 5 | No | 0 | Meeting-derived action plan |
+| `/vanto-ai` | Elegant | 8 | Yes | 3 | Client proposal |
+| `/hiring-project-01` | Pragmatic | 7 | No | 3 | Hiring challenge |
+
+**Document networks:** Pages can link to each other via Related Documents sections:
+- `/future` → `/roadmap`, `/2026`
+- `/2026` → `/roadmap`, `/future`
+- `/roadmap` → `/roadmap/admin-cx`, `/future`
+- `/roadmap/admin-cx` → `/roadmap`
+
+---
+
+## Admin CX Action Plan
+
+**Purpose:** Internal team action plan derived from a meeting — decisions, transformation, action items.
+
+**Style:** Action Plan — flashy hero for inspiration, practical sections for execution.
+
+### Page Config
+
+```json
+{
+ "name": "Admin CX Roadmap",
+ "path": "/roadmap/admin-cx",
+ "sections": [
+ { "__resolveType": "site/sections/AdminCXHero.tsx",
+ "eyebrow": "ADMIN CX ROADMAP",
+ "title": "Make deco.cx Great Again",
+ "vision": "Separate what matters. Simplify what's visible." },
+ { "__resolveType": "site/sections/AdminCXPrinciples.tsx" },
+ { "__resolveType": "site/sections/AdminCXBeforeAfter.tsx" },
+ { "__resolveType": "site/sections/AdminCXActionPlan.tsx" },
+ { "__resolveType": "site/sections/AdminCXCTA.tsx",
+ "primaryButtonUrl": "https://github.com/...",
+ "secondaryButtonUrl": "https://grain.com/..." }
+ ],
+ "__resolveType": "website/pages/Page.tsx"
+}
+```
+
+### Key Section: Principles Grid
+
+Numbered cards with insights:
+
+```tsx
+
+ {principles.map((p) => (
+
+
+
+ {p.number}
+
+
{p.title}
+
+
{p.description}
+
+
+ ))}
+
+```
+
+### Key Section: Before/After Table
+
+Multi-row comparison table:
+
+```tsx
+{/* Header */}
+
+
Aspect
+
Before
+
After
+
+
+{/* Rows */}
+{items.map((item) => (
+
+
{item.label}
+
{item.before}
+
{item.after}
+
+))}
+```
+
+### Key Section: Action Plan with Status
+
+Grouped action items:
+
+```tsx
+function ActionGroup({ title, items, accent }) {
+ return (
+
+
{title}
+
+ {items.map((item) => (
+
+
+
+ {item.owner}
+
+
+
{item.task}
+
+
{item.timeline}
+
+ ))}
+
+
+ );
+}
+
+// Usage
+
+
+
+```
+
+### Meeting-to-Page Workflow
+
+When deriving a landing page from a Grain meeting:
+
+1. **Fetch meeting notes + transcript** — Use Grain MCP
+2. **Extract structure:**
+ - Hero: meeting title, date, participants
+ - Principles: decisions made
+ - Before/After: transformation discussion
+ - Action items: action items from notes
+ - CTA: link to decision doc + meeting recording
+3. **Create decision doc first** — In context repo (`02_strategy/decisions/`)
+4. **Link CTA to sources** — Decision doc + Grain recording URL
+5. **Skip images** — Internal docs don't need generated imagery
+
+---
+
+## Q1 Roadmap Dashboard
+
+**Purpose:** Ops-focused team dashboard — structured data, no flashy visuals.
+
+**Style:** Dashboard — data tables, team cards, milestone lists, utilitarian layout.
+
+### Page Config
+
+```json
+{
+ "name": "Q1 Roadmap",
+ "path": "/roadmap",
+ "sections": [
+ { "__resolveType": "site/sections/RoadmapQ1PasswordGate.tsx",
+ "passwordHash": "...",
+ "title": "Q1 2026 Roadmap",
+ "subtitle": "Internal ops dashboard — password protected." },
+ { "__resolveType": "site/sections/RoadmapQ1Hero.tsx",
+ "badge": "INTERNAL OPS DASHBOARD",
+ "title": "Q1 2026 Roadmap",
+ "lastUpdated": "2026-01-23" },
+ { "__resolveType": "site/sections/RoadmapQ1TeamChanges.tsx" },
+ { "__resolveType": "site/sections/RoadmapQ1Teams.tsx" },
+ { "__resolveType": "site/sections/RoadmapQ1Milestones.tsx" },
+ { "__resolveType": "site/sections/RoadmapQ1People.tsx" },
+ { "__resolveType": "site/sections/RoadmapQ1Footer.tsx" }
+ ],
+ "__resolveType": "website/pages/Page.tsx"
+}
+```
+
+### Key Section: Hero with Quick Nav
+
+```tsx
+
+
+ {/* Badge + Last Updated */}
+
+
+ {badge}
+
+ Last refresh: {lastUpdated}
+
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Quick Nav */}
+
+
+
+```
+
+### Key Section: Team Cards
+
+```tsx
+
+ {teams.map((team) => (
+
+
+
+ Team {team.number}
+
{team.name}
+
+
+ {team.milestoneCount} milestones
+
+
+
+ {/* Ops Lead */}
+
+
Ops Lead
+
{team.opsLead}
+
+
+ {/* Members */}
+
+ {team.members.map((m) => (
+ {m.name}
+ ))}
+
+
+ ))}
+
+```
+
+### Key Section: Milestones List
+
+```tsx
+{teams.map((team) => (
+
+
+ Team {team.teamNumber}
+ {team.teamName}
+
+
+
+ {team.milestones.map((m) => (
+
+
+
+ {m.id}
+
+
+
{m.title}
+
+ Owner:
+ {m.owner}
+
+
{m.description}
+ {m.contributors && (
+
+ {m.contributors.map((c) => (
+ {c}
+ ))}
+
+ )}
+
+
+
+ ))}
+
+
+))}
+```
+
+---
+
+## 2026 Internal Roadmap
+
+**Purpose:** Dramatic internal vision document, investor-ready presentation.
+
+**Style:** Maximum visual impact — animated backgrounds, large typography, CSS animations.
+
+### Page Config
+
+```json
+{
+ "name": "2026",
+ "path": "/2026",
+ "sections": [
+ { "__resolveType": "site/sections/Roadmap2026PasswordGate.tsx", "passwordHash": "..." },
+ { "__resolveType": "site/sections/Roadmap2026Hero.tsx",
+ "eyebrow": "INTERNAL ROADMAP",
+ "year": "2026",
+ "title": "The Year deco.cx Became Unstoppable" },
+ { "__resolveType": "site/sections/Roadmap2026Transformation.tsx" },
+ { "__resolveType": "site/sections/Roadmap2026SelfEvolving.tsx" },
+ { "__resolveType": "site/sections/Roadmap2026Story.tsx" },
+ { "__resolveType": "site/sections/Roadmap2026Platform.tsx" },
+ { "__resolveType": "site/sections/Roadmap2026Metrics.tsx" },
+ { "__resolveType": "site/sections/Roadmap2026WhatMadeItPossible.tsx" },
+ { "__resolveType": "site/sections/Roadmap2026WhatsNext.tsx" },
+ { "__resolveType": "site/sections/Roadmap2026FinalCTA.tsx" }
+ ],
+ "__resolveType": "website/pages/Page.tsx"
+}
+```
+
+### Key Section: Hero with Year Display
+
+```tsx
+// Large year with gradient text
+
+ 2026
+
+
+// Declaration card with gradient border
+
+
+
+ "Cursor for commerce — it enters your git..."
+
+
+
+```
+
+### Key Section: Self-Evolving Loop
+
+Visual loop diagram with steps 01-05:
+
+```tsx
+const steps = [
+ { number: "01", name: "CONNECT", description: "Git, observability..." },
+ { number: "02", name: "DETECT", description: "Issues identified..." },
+ // ...
+];
+
+// Step cards with visual connection
+
+ {steps.map((step, i) => (
+
+
+ {step.number}
+
+
+
{step.name}
+
{step.description}
+
+
+ ))}
+
+ {/* Continuous loop indicator */}
+
+ Continuous loop
+
+
+```
+
+---
+
+## Vanto AI Proposal
+
+**Purpose:** Client-facing executive proposal for AI workshop.
+
+**Style:** Professional elegance — clean cards, quote blocks, stat displays.
+
+### Page Config
+
+```json
+{
+ "name": "Vanto AI",
+ "path": "/vanto-ai",
+ "sections": [
+ { "__resolveType": "site/sections/VantoPasswordGate.tsx",
+ "title": "AI Self-Sufficiency Program",
+ "subtitle": "This proposal for Vanto Group is password protected.",
+ "buttonText": "View Proposal" },
+ { "__resolveType": "site/sections/VantoHero.tsx",
+ "eyebrow": "EXECUTIVE WORKSHOP",
+ "title": "AI Self-Sufficiency Program",
+ "format": "4 sessions × 2 hours",
+ "investment": "$7,500 USD",
+ "heroImage": "/vanto-hero.png" },
+ { "__resolveType": "site/sections/VantoInsight.tsx",
+ "keyPoint": "You can do it yourself. You don't need to hire a software house." },
+ { "__resolveType": "site/sections/VantoSessions.tsx" },
+ { "__resolveType": "site/sections/VantoTimeline.tsx" },
+ { "__resolveType": "site/sections/VantoDeliverables.tsx" },
+ { "__resolveType": "site/sections/VantoInvestment.tsx" },
+ { "__resolveType": "site/sections/VantoCTA.tsx", "image": "/vanto-build.png" }
+ ],
+ "__resolveType": "website/pages/Page.tsx"
+}
+```
+
+### Key Section: Insight with Quote
+
+```tsx
+// Quote with accent border
+
+
+ "{quote}"
+
+
— {attribution}
+
+
+// Key point callout (prominent)
+
+
+// Comparison cards
+
+
+
+ ✕
+
+
What you asked for
+
{askedFor}
+
+
+
+ ✓
+
+
What you need instead
+
{needInstead}
+
+
+```
+
+### Key Section: Timeline
+
+Vertical timeline with dots and week labels:
+
+```tsx
+
+
+ {/* Continuous vertical line */}
+
+
+
+ {items.map((item) => (
+
+
+ {item.week}
+
+
+
{item.description}
+
+ ))}
+
+
+
+```
+
+---
+
+## Hiring Project
+
+**Purpose:** Technical spec for hiring challenge — clear requirements, professional but pragmatic.
+
+**Style:** Minimal decoration, focus on content, checklists, and structure.
+
+### Page Config
+
+```json
+{
+ "name": "Hiring Project 01 - Customer Context Agent",
+ "path": "/hiring-project-01",
+ "sections": [
+ { "__resolveType": "site/sections/HiringProjectHero.tsx",
+ "badge": "Hiring Project",
+ "title": "Customer Context Agent",
+ "position": "Financial Dev Analyst",
+ "duration": "1-2 weeks",
+ "compensation": "R$2.000" },
+ { "__resolveType": "site/sections/HiringProjectProblem.tsx" },
+ { "__resolveType": "site/sections/HiringProjectTools.tsx" },
+ { "__resolveType": "site/sections/HiringProjectRequirements.tsx" },
+ { "__resolveType": "site/sections/HiringProjectPhases.tsx" },
+ { "__resolveType": "site/sections/HiringProjectEvaluation.tsx" },
+ { "__resolveType": "site/sections/HiringProjectFooter.tsx" }
+ ],
+ "seo": {
+ "__resolveType": "website/sections/Seo/SeoV2.tsx",
+ "title": "Customer Context Agent | Hiring Project | deco",
+ "description": "Build an MCP server that gives our finance team instant access to customer billing and usage data.",
+ "image": "https://decocms.com/hiring-hero-og.jpg"
+ },
+ "__resolveType": "website/pages/Page.tsx"
+}
+```
+
+### Key Section: Hero (Pragmatic)
+
+```tsx
+
+
+ {/* Badge */}
+
+ {badge}
+
+
+ {/* Title */}
+
{title}
+
+ {/* Position */}
+
Position: {position}
+
+ {/* Description */}
+
{description}
+
+ {/* Quick Info */}
+
+
+ Duration:
+ {duration}
+
+
+ Compensation:
+ {compensation}
+
+
+
+ {/* Hero Image */}
+ {image && (
+
+
+
+ )}
+
+
+```
+
+### Key Section: Phases with Checklists
+
+```tsx
+{/* The Minimum */}
+
+
+ Required
+
The Minimum
+
+
This gets you a passing grade:
+
+
+ {checklist.map((item) => (
+
+ ))}
+
+
+
+{/* Wow Us */}
+
+
+ Stand out
+
Wow Us
+
+
+
Questions to consider:
+
+ {questions.map((q) => (
+
+ →
+ {q.text}
+
+ ))}
+
+
+```
+
+### Key Section: Requirements with Links
+
+```tsx
+interface RequirementItem {
+ text: string;
+ link?: string;
+ linkText?: string;
+}
+
+// Render with clickable links
+{items.map((item) => (
+
+))}
+```
+
+---
+
+## Common Patterns
+
+### Eyebrow Badge (All Styles)
+
+```tsx
+// Flashy (animated)
+
+
+// Simple
+
+ {text}
+
+```
+
+### Section Title
+
+```tsx
+// With eyebrow
+
+ {eyebrow}
+
{title}
+
+
+// Simple
+{title}
+```
+
+### Card Grid
+
+```tsx
+
+ {items.map((item) => (
+
+
{item.title}
+
{item.description}
+
+ ))}
+
+```
+
+### Image with Border
+
+```tsx
+
+
+
+```
+
+### CTA Buttons
+
+```tsx
+
+```
diff --git a/skills/mesh-development/SKILL.md b/skills/mesh-development/SKILL.md
new file mode 100644
index 0000000000..9e9b539407
--- /dev/null
+++ b/skills/mesh-development/SKILL.md
@@ -0,0 +1,174 @@
+---
+name: mesh-development
+description: Build features for MCP Mesh - our full-stack MCP orchestration platform. Use when working on the mesh codebase, creating plugins, adding tools, or modifying the UI.
+---
+
+# Mesh Development Skill
+
+Build and maintain the MCP Mesh platform - a full-stack application for orchestrating MCP (Model Context Protocol) connections, tools, and AI agents.
+
+## When to Use This Skill
+
+- Building new features in the Mesh platform
+- Creating or modifying plugins
+- Adding MCP tools or bindings
+- Working on the React client UI
+- Modifying the Hono API server
+- Database migrations or storage operations
+
+## Project Structure
+
+```
+mesh/
+├── apps/
+│ ├── mesh/ # Main application (Hono + Vite/React)
+│ │ ├── src/
+│ │ │ ├── api/ # Hono server routes
+│ │ │ ├── web/ # React client
+│ │ │ ├── tools/ # MCP tool implementations
+│ │ │ └── storage/# Database operations
+│ │ └── migrations/ # Kysely migrations
+│ └── docs/ # Astro documentation site
+├── packages/
+│ ├── bindings/ # MCP bindings and connection abstractions
+│ ├── runtime/ # MCP proxy, OAuth, tools runtime
+│ ├── ui/ # Shared React components (shadcn-based)
+│ ├── cli/ # CLI tooling
+│ └── mesh-plugin-*/ # Plugin packages
+└── plugins/ # Oxlint custom plugins
+```
+
+## Quick Start
+
+1. Run `bun install` to install dependencies
+2. Run `bun run dev` to start development servers
+3. Open `http://localhost:4000` for the client
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `bun run dev` | Start client + server with HMR |
+| `bun run check` | TypeScript type checking |
+| `bun run lint` | Run oxlint with custom plugins |
+| `bun run fmt` | Format code with Biome |
+| `bun test` | Run all tests |
+
+## Coding Conventions
+
+### TypeScript & React
+- Use TypeScript types, avoid `any`
+- React 19 with React Compiler (no manual memoization)
+- Tailwind v4 for styling
+- Use design system tokens (see [design-tokens.md](references/design-tokens.md))
+
+### Naming
+- Files: `kebab-case.ts` for shared packages
+- Components/Classes: `PascalCase`
+- Hooks/Utilities: `camelCase`
+- Query keys: Use constants from `query-keys.ts`
+
+### Banned Patterns
+- No `useEffect` - use alternatives (React Query, event handlers)
+- No `useMemo`/`useCallback`/`memo` - React 19 compiler handles optimization
+- No arbitrary Tailwind values - use design tokens
+
+### Formatting
+- Two-space indentation
+- Double quotes for strings
+- Always run `bun run fmt` after changes
+
+## Creating Plugins
+
+Plugins extend Mesh with custom UI for MCP connections. See existing plugins in `packages/mesh-plugin-*/`.
+
+### Plugin Structure
+```typescript
+export const myPlugin: Plugin = {
+ id: "my-plugin",
+ description: "Description for users",
+ binding: MY_BINDING,
+ renderHeader: (props) => ,
+ renderEmptyState: () => ,
+ setup: (context) => {
+ context.registerRootSidebarItem({
+ icon: ,
+ label: "My Plugin",
+ });
+ const routes = myRouter.createRoutes(context);
+ context.registerPluginRoutes(routes);
+ },
+};
+```
+
+### Bindings
+Bindings define which MCP connections a plugin can use. Create bindings in `packages/bindings/src/well-known/`.
+
+## Adding MCP Tools
+
+Tools are server-side functions exposed via MCP. Add tools in `apps/mesh/src/tools/`.
+
+### Tool Structure
+```typescript
+export function registerMyTool(server: McpServer) {
+ server.registerTool(
+ "MY_TOOL_NAME",
+ {
+ title: "My Tool",
+ description: "What this tool does",
+ inputSchema: {
+ param: z.string().describe("Parameter description"),
+ },
+ },
+ async (args) => {
+ // Tool implementation
+ return {
+ content: [{ type: "text", text: "Result" }],
+ structuredContent: { result: "data" },
+ };
+ },
+ );
+}
+```
+
+## Database Operations
+
+Uses Kysely ORM. Migrations in `apps/mesh/migrations/`.
+
+### Creating Migrations
+```typescript
+// migrations/XXX-my-migration.ts
+import { type Kysely } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable("my_table")
+ .addColumn("id", "text", (col) => col.primaryKey())
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable("my_table").execute();
+}
+```
+
+## Testing
+
+- Co-locate tests: `my-file.test.ts` next to `my-file.ts`
+- Use Bun's test framework
+- Run `bun test` before PRs
+
+## Commit Guidelines
+
+Follow Conventional Commits:
+- `feat(scope): add new feature`
+- `fix(scope): fix bug`
+- `refactor(scope): code improvement`
+- `docs(scope): documentation update`
+- `[chore]: maintenance task`
+
+## Related Resources
+
+- [Design Tokens](references/design-tokens.md)
+- [UI Components](references/ui-components.md)
+- AGENTS.md in repository root
diff --git a/skills/mesh-development/references/design-tokens.md b/skills/mesh-development/references/design-tokens.md
new file mode 100644
index 0000000000..4b292f9303
--- /dev/null
+++ b/skills/mesh-development/references/design-tokens.md
@@ -0,0 +1,123 @@
+# Design Tokens
+
+Use these Tailwind design system tokens for consistent styling. Avoid arbitrary values.
+
+## Colors
+
+### Semantic Colors (Preferred)
+```
+background / foreground - Page background & text
+card / card-foreground - Card surfaces
+popover / popover-foreground - Dropdowns, tooltips
+primary / primary-foreground - Primary actions
+secondary / secondary-foreground - Secondary actions
+muted / muted-foreground - Disabled, subtle text
+accent / accent-foreground - Hover states
+destructive / destructive-foreground - Delete actions
+border - Borders
+input - Input borders
+ring - Focus rings
+```
+
+### Usage Examples
+```tsx
+// Good - uses design tokens
+
+
+
+
+// Bad - arbitrary colors
+
+
+```
+
+## Spacing
+
+Use Tailwind's default spacing scale:
+- `p-1` to `p-12` for padding
+- `m-1` to `m-12` for margin
+- `gap-1` to `gap-12` for flex/grid gaps
+- `space-x-1` to `space-x-12` for horizontal spacing
+- `space-y-1` to `space-y-12` for vertical spacing
+
+Common patterns:
+```tsx
+
// Standard padding
+
// Card/section padding
+
// Tight spacing
+
// Normal spacing
+
// Loose spacing
+```
+
+## Typography
+
+```
+text-xs - 12px - Captions, badges
+text-sm - 14px - Secondary text, form labels
+text-base - 16px - Body text
+text-lg - 18px - Subheadings
+text-xl - 20px - Section titles
+text-2xl - 24px - Page titles
+```
+
+Font weights:
+```
+font-normal - 400 - Body text
+font-medium - 500 - Labels, buttons
+font-semibold - 600 - Headings
+font-bold - 700 - Emphasis
+```
+
+## Border Radius
+
+```
+rounded-sm - 2px
+rounded - 4px - Buttons, inputs
+rounded-md - 6px - Cards
+rounded-lg - 8px - Modals, large cards
+rounded-xl - 12px
+rounded-full - Pills, avatars
+```
+
+## Shadows
+
+```
+shadow-sm - Subtle elevation
+shadow - Cards
+shadow-md - Dropdowns
+shadow-lg - Modals
+```
+
+## Common Patterns
+
+### Cards
+```tsx
+
+```
+
+### Buttons
+```tsx
+// Primary
+
+
+// Secondary
+
+
+// Destructive
+
+```
+
+### Inputs
+```tsx
+
+```
+
+### Muted Sections
+```tsx
+
+ Subtle content
+
+```
diff --git a/skills/mesh-development/references/ui-components.md b/skills/mesh-development/references/ui-components.md
new file mode 100644
index 0000000000..bb1a9deccb
--- /dev/null
+++ b/skills/mesh-development/references/ui-components.md
@@ -0,0 +1,190 @@
+# UI Components
+
+Mesh uses a shadcn-based component library in `packages/ui/`. Import from `@decocms/ui`.
+
+## Available Components
+
+### Layout
+- `Card`, `CardHeader`, `CardContent`, `CardFooter`
+- `Dialog`, `DialogTrigger`, `DialogContent`
+- `Sheet`, `SheetTrigger`, `SheetContent`
+- `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`
+- `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`
+
+### Forms
+- `Button` - Primary action button
+- `Input` - Text input
+- `Textarea` - Multi-line input
+- `Select`, `SelectTrigger`, `SelectContent`, `SelectItem`
+- `Checkbox`
+- `Switch`
+- `Label`
+
+### Data Display
+- `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell`
+- `Badge`
+- `Avatar`, `AvatarImage`, `AvatarFallback`
+- `Tooltip`, `TooltipTrigger`, `TooltipContent`
+
+### Feedback
+- `Alert`, `AlertTitle`, `AlertDescription`
+- `Progress`
+- `Skeleton`
+- `Toast` (via Sonner)
+
+## Usage Examples
+
+### Button Variants
+```tsx
+import { Button } from "@decocms/ui";
+
+Default
+Secondary
+Outline
+Ghost
+Delete
+Small
+Large
+```
+
+### Card
+```tsx
+import { Card, CardHeader, CardContent } from "@decocms/ui";
+
+
+
+ Card Title
+
+
+ Card content goes here
+
+
+```
+
+### Dialog
+```tsx
+import {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@decocms/ui";
+
+
+
+ Open Dialog
+
+
+
+ Dialog Title
+ Dialog description
+
+ Dialog content
+
+
+```
+
+### Select
+```tsx
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@decocms/ui";
+
+
+
+
+
+
+ Option 1
+ Option 2
+
+
+```
+
+### Toast
+```tsx
+import { toast } from "sonner";
+
+// Success
+toast.success("Operation completed");
+
+// Error
+toast.error("Something went wrong");
+
+// With description
+toast("Title", {
+ description: "More details here",
+});
+```
+
+## Icons
+
+Use icons from `@untitledui/icons`:
+
+```tsx
+import { Home, Settings, Plus, Trash02 } from "@untitledui/icons";
+
+
+
+```
+
+Common icons:
+- Navigation: `Home`, `Menu`, `ChevronLeft`, `ChevronRight`, `ChevronDown`
+- Actions: `Plus`, `Trash02`, `Edit02`, `Copy`, `Download`, `Upload`
+- Status: `Check`, `X`, `AlertCircle`, `Info`, `Loading01`
+- Files: `File04`, `Folder`, `FolderOpen`
+
+## Patterns
+
+### Loading States
+```tsx
+import { Loading01 } from "@untitledui/icons";
+
+{isLoading ? (
+
+
+ Loading...
+
+) : (
+
+)}
+```
+
+### Empty States
+```tsx
+
+
+
No items yet
+
+ Get started by creating your first item.
+
+
+
+ Create Item
+
+
+```
+
+### Form Layout
+```tsx
+
+```