Add Paperclip Triage plugin scaffold

This commit is contained in:
Dotta committed 2026-05-19 13:14:10 -05:00
1 parent 988689947a
commit daf314e372
11 files changed
+913

No files matched your search

@@ -0,0 +1,3 @@
dist
node_modules
.paperclip-sdk
+51
View File
@@ -0,0 +1,51 @@
# Paperclip Triage
Queue workbench for triaging arbitrary items with a teachable assistant.
## Development
```bash
pnpm install
pnpm dev # watch builds
pnpm dev:ui # local dev server with hot-reload events
pnpm typecheck
pnpm test
pnpm build
```
`pnpm dev` rebuilds the worker, manifest, and UI bundles into `dist/`.
When this package is installed from a local path, Paperclip watches that rebuilt
output and reloads the plugin worker. Local installs run trusted code from this
folder on your machine.
## Phase 2 Scope
This package scaffold declares:
- plugin id `paperclipai.plugin-triage`
- page, sidebar, route sidebar, and settings page slots
- managed `Triage Assistant` agent
- managed `Triage` project
- managed triage skills
- worker health data plus a settings-page reconcile action
## Install Into Paperclip
```bash
paperclipai plugin install /Users/dotta/paperclip/.paperclip/worktrees/PAP-9815-triage-plugin/packages/plugins/plugin-triage
```
## Build Options
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
- `pnpm build:rollup` uses rollup presets from the same SDK.
## Focused Verification
From the repository root:
```bash
pnpm --filter @paperclipai/plugin-triage typecheck
pnpm --filter @paperclipai/plugin-triage test
pnpm --filter @paperclipai/plugin-triage build
```
@@ -0,0 +1,17 @@
import esbuild from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
const watch = process.argv.includes("--watch");
const workerCtx = await esbuild.context(presets.esbuild.worker);
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
const uiCtx = await esbuild.context(presets.esbuild.ui);
if (watch) {
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
console.log("esbuild watch mode enabled for worker, manifest, and ui");
} else {
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
}
@@ -0,0 +1,47 @@
{
"name": "@paperclipai/plugin-triage",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Queue workbench for triaging arbitrary items with a teachable assistant.",
"files": [
"dist",
"README.md"
],
"scripts": {
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
"dev": "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"automation"
],
"author": "Paperclip",
"license": "MIT",
"devDependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"esbuild": "^0.27.3",
"rollup": "^4.38.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18"
}
}
@@ -0,0 +1,28 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
function withPlugins(config) {
if (!config) return null;
return {
...config,
plugins: [
nodeResolve({
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
}),
typescript({
tsconfig: "./tsconfig.json",
declaration: false,
declarationMap: false,
}),
],
};
}
export default [
withPlugins(presets.rollup.manifest),
withPlugins(presets.rollup.worker),
withPlugins(presets.rollup.ui),
].filter(Boolean);
@@ -0,0 +1,190 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
export const PLUGIN_ID = "paperclipai.plugin-triage";
export const TRIAGE_ASSISTANT_AGENT_KEY = "triage-assistant";
export const TRIAGE_PROJECT_KEY = "triage";
export const TRIAGE_ASSISTANT_SKILL_KEY = "triage-assistant";
export const TRIAGE_REFLECTION_SKILL_KEY = "triage-reflection";
export const TRIAGE_WORKFLOW_SKILL_KEY = "triage-workflow";
export const TRIAGE_MANAGED_SKILL_KEYS = [
TRIAGE_ASSISTANT_SKILL_KEY,
TRIAGE_REFLECTION_SKILL_KEY,
TRIAGE_WORKFLOW_SKILL_KEY,
] as const;
export const TRIAGE_MANAGED_SKILL_CANONICAL_KEYS = TRIAGE_MANAGED_SKILL_KEYS.map(managedSkillCanonicalKey);
function managedSkillCanonicalKey(skillKey: string): string {
return `plugin/paperclipai-plugin-triage/${skillKey}`;
}
function skillMarkdown(skillKey: (typeof TRIAGE_MANAGED_SKILL_KEYS)[number]): string {
if (skillKey === TRIAGE_REFLECTION_SKILL_KEY) {
return `# Triage Reflection
Use when proposing queue guidance updates after a user and assistant finish processing a queue item.
- Compare the original item, final item, chat outcome, transition decision, and current queue guidance.
- Propose guidance changes only when the next item would benefit from them.
- Never apply guidance directly; return a proposal for board/user review.
- Keep source-specific behavior out of the queue unless it is already represented in item properties or queue guidance.
`;
}
if (skillKey === TRIAGE_WORKFLOW_SKILL_KEY) {
return `# Triage Workflow
Use when reasoning about Paperclip Triage queue states, transitions, and work issue actions.
- Treat queue items as company-scoped records with opaque content and properties.
- Use only configured workflow transitions from the item's current state.
- Respect reflection gates before approving or rejecting an item.
- Limit downstream effects to create-or-update Paperclip issue actions.
- Do not call external systems or mutate upstream sources.
`;
}
return `# Triage Assistant
Use when helping a user process items in a Paperclip Triage queue.
- Work from the queue purpose, current item content, item properties, workflow state, and guidance documents.
- Keep chat-assisted edits and direct edits focused on the same queue item record.
- Ask for user approval before guidance changes or irreversible workflow decisions.
- Preserve company boundaries and keep all queue work scoped to the active company.
- Do not add source connector behavior; ingestion starts when an item is posted into a queue.
`;
}
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: "0.1.0",
displayName: "Paperclip Triage",
description: "Queue workbench for triaging arbitrary items with a teachable assistant.",
author: "Paperclip",
categories: ["automation", "ui"],
capabilities: [
"agents.managed",
"projects.managed",
"skills.managed",
"instance.settings.register",
"ui.sidebar.register",
"ui.page.register",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
agents: [
{
agentKey: TRIAGE_ASSISTANT_AGENT_KEY,
displayName: "Triage Assistant",
role: "triage-assistant",
title: "Triage Assistant",
icon: "inbox",
capabilities: "Helps process queue items, apply queue guidance, propose guidance updates, and prepare Paperclip work issue handoffs.",
adapterType: "claude_local",
adapterPreference: ["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor", "pi_local"],
adapterConfig: {
dangerouslySkipPermissions: false,
dangerouslyBypassApprovalsAndSandbox: false,
sandbox: true,
paperclipSkillSync: {
desiredSkills: TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
},
},
runtimeConfig: {
modelProfiles: {
cheap: {
purpose: "queue classification, transition suggestions, and reflection draft review",
},
},
},
permissions: {
pluginTools: [PLUGIN_ID],
},
status: "paused",
budgetMonthlyCents: 0,
instructions: {
entryFile: "AGENTS.md",
content: [
"# Triage Assistant",
"",
"You are the managed assistant for Paperclip Triage queues.",
"",
"Work inside the active company only. Use queue guidance, current item content, item properties, and workflow state as your source of truth.",
"",
"Do not build source connectors or call upstream systems. Do not apply guidance changes without explicit user acceptance. Downstream actions are limited to Paperclip issue create-or-update templates.",
].join("\n"),
},
},
],
projects: [
{
projectKey: TRIAGE_PROJECT_KEY,
displayName: "Triage",
description: "Plugin-managed project for Paperclip Triage assistant work, queue chat issues, and generated work issue defaults.",
status: "in_progress",
color: "#0f766e",
settings: {
defaultWorkflow: "new -> approved/rejected -> done",
},
},
],
skills: [
{
skillKey: TRIAGE_ASSISTANT_SKILL_KEY,
displayName: "Triage Assistant",
slug: TRIAGE_ASSISTANT_SKILL_KEY,
description: "Process queue items with user-visible guidance and company-scoped queue context.",
markdown: skillMarkdown(TRIAGE_ASSISTANT_SKILL_KEY),
},
{
skillKey: TRIAGE_REFLECTION_SKILL_KEY,
displayName: "Triage Reflection",
slug: TRIAGE_REFLECTION_SKILL_KEY,
description: "Propose queue guidance updates after item processing without applying them automatically.",
markdown: skillMarkdown(TRIAGE_REFLECTION_SKILL_KEY),
},
{
skillKey: TRIAGE_WORKFLOW_SKILL_KEY,
displayName: "Triage Workflow",
slug: TRIAGE_WORKFLOW_SKILL_KEY,
description: "Reason about queue states, reflection gates, and create-or-update issue actions.",
markdown: skillMarkdown(TRIAGE_WORKFLOW_SKILL_KEY),
},
],
ui: {
slots: [
{
type: "sidebar",
id: "triage-sidebar",
displayName: "Triage",
exportName: "SidebarLink",
order: 38,
},
{
type: "page",
id: "triage-page",
displayName: "Triage",
exportName: "TriagePage",
routePath: "triage",
},
{
type: "routeSidebar",
id: "triage-route-sidebar",
displayName: "Triage",
exportName: "TriageRouteSidebar",
routePath: "triage",
},
{
type: "settingsPage",
id: "triage-settings",
displayName: "Triage",
exportName: "SettingsPage",
},
],
},
};
export default manifest;
@@ -0,0 +1,297 @@
import {
useHostNavigation,
usePluginAction,
usePluginData,
usePluginToast,
type PluginPageProps,
type PluginRouteSidebarProps,
type PluginSettingsPageProps,
type PluginSidebarProps,
} from "@paperclipai/plugin-sdk/ui";
import { useMemo, useState, type CSSProperties, type ReactNode } from "react";
type ManagedResourceHealth = {
status: "needs_company" | "missing" | "ready";
checkedAt: string;
agent: {
resourceKey: string;
status: string;
agentId: string | null;
name: string | null;
agentStatus: string | null;
adapterType: string | null;
} | null;
project: {
resourceKey: string;
status: string;
projectId: string | null;
name: string | null;
projectStatus: string | null;
} | null;
skills: Array<{
resourceKey: string;
status: string;
skillId: string | null;
name: string | null;
key: string | null;
}>;
};
const tokens = {
border: "var(--border, oklch(0.86 0 0))",
bg: "var(--background, #ffffff)",
fg: "var(--foreground, #18181b)",
muted: "var(--muted-foreground, #71717a)",
accent: "var(--accent, #f4f4f5)",
accentFg: "var(--accent-foreground, #18181b)",
primary: "var(--primary, #18181b)",
primaryFg: "var(--primary-foreground, #ffffff)",
success: "oklch(0.55 0.13 155)",
warning: "oklch(0.64 0.14 75)",
};
const pageShell: CSSProperties = {
display: "grid",
gap: 20,
padding: 24,
color: tokens.fg,
background: tokens.bg,
minHeight: "100%",
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
};
const panelStyle: CSSProperties = {
border: `1px solid ${tokens.border}`,
borderRadius: 8,
padding: 16,
display: "grid",
gap: 12,
};
const mutedStyle: CSSProperties = {
color: tokens.muted,
fontSize: 13,
};
function useManagedResourceHealth(companyId: string | null) {
const params = useMemo(() => ({ companyId: companyId ?? "" }), [companyId]);
return usePluginData<ManagedResourceHealth>("managed-resource-health", params);
}
function statusTone(status: string): CSSProperties {
if (status === "ready" || status === "resolved" || status === "created") {
return { color: tokens.success };
}
if (status === "missing" || status === "needs_company") {
return { color: tokens.warning };
}
return { color: tokens.muted };
}
function StatusText({ status }: { status: string }) {
return (
<span style={{ ...statusTone(status), fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>
{status.replaceAll("_", " ")}
</span>
);
}
function ResourceRow({
label,
status,
detail,
}: {
label: string;
status: string;
detail?: ReactNode;
}) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(120px, 1fr) auto",
gap: 12,
alignItems: "start",
padding: "8px 0",
borderTop: `1px solid ${tokens.border}`,
}}
>
<div style={{ display: "grid", gap: 3 }}>
<strong style={{ fontSize: 13 }}>{label}</strong>
{detail ? <span style={mutedStyle}>{detail}</span> : null}
</div>
<StatusText status={status} />
</div>
);
}
function ManagedResourcePanel({
companyId,
allowReconcile,
}: {
companyId: string | null;
allowReconcile: boolean;
}) {
const { data, loading, error, refresh } = useManagedResourceHealth(companyId);
const reconcile = usePluginAction("reconcile-managed-resources");
const toast = usePluginToast();
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
async function handleReconcile() {
if (!companyId) return;
setBusy(true);
setActionError(null);
try {
await reconcile({ companyId });
refresh();
toast({ title: "Triage resources reconciled", tone: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to reconcile resources";
setActionError(message);
toast({ title: "Reconcile failed", body: message, tone: "error" });
} finally {
setBusy(false);
}
}
return (
<section style={panelStyle}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
<div>
<h2 style={{ margin: 0, fontSize: 16 }}>Managed Resources</h2>
<div style={mutedStyle}>Last checked: {data?.checkedAt ?? "not checked"}</div>
</div>
{allowReconcile ? (
<button
type="button"
onClick={() => void handleReconcile()}
disabled={!companyId || busy}
style={{
border: `1px solid ${tokens.primary}`,
borderRadius: 6,
background: tokens.primary,
color: tokens.primaryFg,
padding: "7px 10px",
fontSize: 13,
fontWeight: 600,
cursor: companyId && !busy ? "pointer" : "not-allowed",
opacity: companyId && !busy ? 1 : 0.55,
}}
>
{busy ? "Reconciling" : "Reconcile"}
</button>
) : null}
</div>
{loading ? <div style={mutedStyle}>Loading resources...</div> : null}
{error ? <div style={{ color: tokens.warning, fontSize: 13 }}>{error.message}</div> : null}
{actionError ? <div style={{ color: tokens.warning, fontSize: 13 }}>{actionError}</div> : null}
{data ? (
<div>
<ResourceRow label="Package Health" status={data.status} />
<ResourceRow
label="Triage Project"
status={data.project?.status ?? "missing"}
detail={data.project?.name ?? "Triage"}
/>
<ResourceRow
label="Triage Assistant"
status={data.agent?.status ?? "missing"}
detail={
data.agent
? `${data.agent.name ?? "Triage Assistant"} (${data.agent.agentStatus ?? "unknown"})`
: "Triage Assistant"
}
/>
{data.skills.map((skill) => (
<ResourceRow
key={skill.resourceKey}
label={skill.name ?? skill.resourceKey}
status={skill.status}
detail={skill.key ?? skill.resourceKey}
/>
))}
</div>
) : null}
</section>
);
}
export function SidebarLink({ context }: PluginSidebarProps) {
const nav = useHostNavigation();
return (
<a
{...nav.linkProps("/triage")}
style={{
color: "inherit",
textDecoration: "none",
display: "block",
padding: "6px 8px",
borderRadius: 6,
fontSize: 14,
}}
aria-label={`Open Triage for ${context.companyPrefix ?? "company"}`}
>
Triage
</a>
);
}
export function TriageRouteSidebar({ context }: PluginRouteSidebarProps) {
const nav = useHostNavigation();
const { data } = useManagedResourceHealth(context.companyId);
return (
<aside style={{ padding: 12, display: "grid", gap: 14, color: tokens.fg }}>
<a
{...nav.linkProps("/triage")}
style={{ color: "inherit", textDecoration: "none", fontWeight: 700, fontSize: 14 }}
>
Triage
</a>
<nav style={{ display: "grid", gap: 6, fontSize: 13 }}>
<a {...nav.linkProps("/triage")} style={{ color: "inherit", textDecoration: "none" }}>Queues</a>
<a {...nav.linkProps("/triage#resources")} style={{ color: "inherit", textDecoration: "none" }}>Resources</a>
</nav>
<div style={{ ...mutedStyle, borderTop: `1px solid ${tokens.border}`, paddingTop: 10 }}>
<StatusText status={data?.status ?? "missing"} />
</div>
</aside>
);
}
export function TriagePage({ context }: PluginPageProps) {
return (
<main style={pageShell}>
<header style={{ display: "grid", gap: 4 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Triage</h1>
<div style={mutedStyle}>Queue workbench scaffold</div>
</header>
<section style={{ ...panelStyle, minHeight: 220 }}>
<div style={{ display: "grid", gap: 4 }}>
<h2 style={{ margin: 0, fontSize: 16 }}>Queues</h2>
<div style={mutedStyle}>Queue data model is implemented in the next phase.</div>
</div>
</section>
<div id="resources">
<ManagedResourcePanel companyId={context.companyId} allowReconcile={false} />
</div>
</main>
);
}
export function SettingsPage({ context }: PluginSettingsPageProps) {
return (
<main style={pageShell}>
<header style={{ display: "grid", gap: 4 }}>
<h1 style={{ margin: 0, fontSize: 22 }}>Triage Settings</h1>
<div style={mutedStyle}>Managed project, assistant, and skills</div>
</header>
<ManagedResourcePanel companyId={context.companyId} allowReconcile={true} />
</main>
);
}
@@ -0,0 +1,164 @@
import {
definePlugin,
runWorker,
type PluginContext,
type PluginManagedAgentResolution,
type PluginManagedProjectResolution,
type PluginManagedSkillResolution,
} from "@paperclipai/plugin-sdk";
import {
TRIAGE_ASSISTANT_AGENT_KEY,
TRIAGE_MANAGED_SKILL_KEYS,
TRIAGE_PROJECT_KEY,
} from "./manifest.js";
type ManagedResourceHealth = {
status: "needs_company" | "missing" | "ready";
checkedAt: string;
agent: ManagedAgentHealth | null;
project: ManagedProjectHealth | null;
skills: ManagedSkillHealth[];
};
type ManagedAgentHealth = {
resourceKey: string;
status: PluginManagedAgentResolution["status"];
agentId: string | null;
name: string | null;
agentStatus: string | null;
adapterType: string | null;
};
type ManagedProjectHealth = {
resourceKey: string;
status: PluginManagedProjectResolution["status"];
projectId: string | null;
name: string | null;
projectStatus: string | null;
};
type ManagedSkillHealth = {
resourceKey: string;
status: PluginManagedSkillResolution["status"];
skillId: string | null;
name: string | null;
key: string | null;
};
function stringField(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function requireCompanyId(params: Record<string, unknown>): string {
const companyId = stringField(params.companyId);
if (!companyId) {
throw new Error("companyId is required");
}
return companyId;
}
function summarizeAgent(resolution: PluginManagedAgentResolution): ManagedAgentHealth {
return {
resourceKey: resolution.resourceKey,
status: resolution.status,
agentId: resolution.agentId,
name: resolution.agent?.name ?? null,
agentStatus: resolution.agent?.status ?? null,
adapterType: resolution.agent?.adapterType ?? null,
};
}
function summarizeProject(resolution: PluginManagedProjectResolution): ManagedProjectHealth {
return {
resourceKey: resolution.resourceKey,
status: resolution.status,
projectId: resolution.projectId,
name: resolution.project?.name ?? null,
projectStatus: resolution.project?.status ?? null,
};
}
function summarizeSkill(resolution: PluginManagedSkillResolution): ManagedSkillHealth {
return {
resourceKey: resolution.resourceKey,
status: resolution.status,
skillId: resolution.skillId,
name: resolution.skill?.name ?? null,
key: resolution.skill?.key ?? null,
};
}
async function managedResourceHealth(
ctx: PluginContext,
companyId: string,
mode: "inspect" | "reconcile",
): Promise<ManagedResourceHealth> {
const projectResolution = mode === "reconcile"
? await ctx.projects.managed.reconcile(TRIAGE_PROJECT_KEY, companyId)
: await ctx.projects.managed.get(TRIAGE_PROJECT_KEY, companyId);
const skillResolutions = await Promise.all(
TRIAGE_MANAGED_SKILL_KEYS.map((skillKey) =>
mode === "reconcile"
? ctx.skills.managed.reconcile(skillKey, companyId)
: ctx.skills.managed.get(skillKey, companyId),
),
);
const agentResolution = mode === "reconcile"
? await ctx.agents.managed.reconcile(TRIAGE_ASSISTANT_AGENT_KEY, companyId)
: await ctx.agents.managed.get(TRIAGE_ASSISTANT_AGENT_KEY, companyId);
const agent = summarizeAgent(agentResolution);
const project = summarizeProject(projectResolution);
const skills = skillResolutions.map(summarizeSkill);
const missing = [
agent.status === "missing",
project.status === "missing",
...skills.map((skill) => skill.status === "missing"),
].some(Boolean);
return {
status: missing ? "missing" : "ready",
checkedAt: new Date().toISOString(),
agent,
project,
skills,
};
}
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("managed-resource-health", async (params: Record<string, unknown>) => {
const companyId = stringField(params.companyId);
if (!companyId) {
return {
status: "needs_company",
checkedAt: new Date().toISOString(),
agent: null,
project: null,
skills: [],
} satisfies ManagedResourceHealth;
}
return managedResourceHealth(ctx, companyId, "inspect");
});
ctx.actions.register("reconcile-managed-resources", async (params: Record<string, unknown>) => {
const companyId = requireCompanyId(params);
const result = await managedResourceHealth(ctx, companyId, "reconcile");
ctx.logger.info("Reconciled Paperclip Triage managed resources", {
companyId,
agentStatus: result.agent?.status,
projectStatus: result.project?.status,
skillStatuses: result.skills.map((skill) => `${skill.resourceKey}:${skill.status}`),
});
return result;
});
},
async onHealth() {
return { status: "ok", message: "Paperclip Triage worker is running" };
},
});
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest, {
PLUGIN_ID,
TRIAGE_ASSISTANT_AGENT_KEY,
TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
TRIAGE_MANAGED_SKILL_KEYS,
TRIAGE_PROJECT_KEY,
} from "../src/manifest.js";
import plugin from "../src/worker.js";
const COMPANY_ID = "company-1";
describe("Paperclip Triage scaffold", () => {
it("declares the package manifest, UI slots, and managed resources", () => {
expect(manifest.id).toBe(PLUGIN_ID);
expect(manifest.capabilities).toEqual(expect.arrayContaining([
"agents.managed",
"projects.managed",
"skills.managed",
"instance.settings.register",
"ui.sidebar.register",
"ui.page.register",
]));
expect(manifest.ui?.slots).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "sidebar", exportName: "SidebarLink" }),
expect.objectContaining({ type: "page", exportName: "TriagePage", routePath: "triage" }),
expect.objectContaining({ type: "routeSidebar", exportName: "TriageRouteSidebar", routePath: "triage" }),
expect.objectContaining({ type: "settingsPage", exportName: "SettingsPage" }),
]));
expect(manifest.agents?.[0]).toEqual(expect.objectContaining({
agentKey: TRIAGE_ASSISTANT_AGENT_KEY,
displayName: "Triage Assistant",
status: "paused",
permissions: { pluginTools: [PLUGIN_ID] },
}));
expect(manifest.agents?.[0]?.adapterConfig?.paperclipSkillSync).toEqual({
desiredSkills: TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
});
expect(manifest.projects?.[0]).toEqual(expect.objectContaining({
projectKey: TRIAGE_PROJECT_KEY,
displayName: "Triage",
}));
expect(manifest.skills?.map((skill) => skill.skillKey)).toEqual(TRIAGE_MANAGED_SKILL_KEYS);
});
it("reports missing managed resources before reconcile", async () => {
const harness = createTestHarness({ manifest });
await plugin.definition.setup(harness.ctx);
const health = await harness.getData<{ status: string; skills: Array<{ status: string }> }>(
"managed-resource-health",
{ companyId: COMPANY_ID },
);
expect(health.status).toBe("missing");
expect(health.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length);
expect(health.skills.every((skill) => skill.status === "missing")).toBe(true);
});
it("reconciles managed project, assistant, and skills", async () => {
const harness = createTestHarness({ manifest });
await plugin.definition.setup(harness.ctx);
const result = await harness.performAction<{
status: string;
agent: { status: string; agentId: string | null };
project: { status: string; projectId: string | null };
skills: Array<{ status: string; skillId: string | null; key: string | null }>;
}>("reconcile-managed-resources", { companyId: COMPANY_ID });
expect(result.status).toBe("ready");
expect(result.agent).toEqual(expect.objectContaining({ status: "created" }));
expect(result.agent.agentId).toBeTruthy();
expect(result.project).toEqual(expect.objectContaining({ status: "created" }));
expect(result.project.projectId).toBeTruthy();
expect(result.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length);
expect(result.skills.every((skill) => skill.status === "created")).toBe(true);
expect(result.skills.map((skill) => skill.key)).toEqual(TRIAGE_MANAGED_SKILL_CANONICAL_KEYS);
});
});
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
"src",
"tests"
],
"exclude": [
"dist",
"node_modules"
]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});