diff --git a/design/PAP-9846-triage-workbench/index.html b/design/PAP-9846-triage-workbench/index.html new file mode 100644 index 00000000000..d3098677379 --- /dev/null +++ b/design/PAP-9846-triage-workbench/index.html @@ -0,0 +1,596 @@ + + + + + +PAP-9846 · Paperclip Triage workbench wireframes + + + +
+
+ + + PAP-9846 · Triage workbench
+ Jump to a screen +
+ +
+ +
+ +
+
+
PAP-9846 · Wireframes · Phase 1
+

Paperclip Triage — workbench wireframes

+

Low-fi black-and-white wireframes for the eight surfaces in the approved triage plan. The centerpiece is a two-column item workbench: assistant chat on the center/left, item document and properties on the right. Direct edits and chat-assisted edits target the same item content. Reflection sits over the workbench whenever a transition requires it.

+

All component references in red callouts are real Paperclip primitives (file:line in the reuse key). Where the spec asks for something new — variable palette, diff renderer — it's flagged as a system change.

+
+ 8 screens + Desktop + mobile + Lo-fi · monochrome + Click any wireframe to zoom + From PAP-9815 plan + PAP-9845 contract +
+
+ + +
+
Map
+

Flow

+

The main triage loop is the top row (solid arrows). Configuration paths are dashed. The item workbench is the only screen with a bold border — it's the centerpiece. Ingest API is the only entry point for items in v1; there are no source connectors.

+
+
flow.svg1280×720
+ Triage workbench flow diagram +
+
+ + +
+
Component reuse
+

What we borrow from Paperclip core

+

The plugin SDK and Paperclip core already ship most of what the workbench needs. New surfaces (chat composer pinned to an item, guidance diff renderer, transition-action variable palette) are explicit additions and are called out per-screen.

+
+
MarkdownEditorItem content + guidance docs — packages/plugins/sdk/src/ui/components.ts:516
+
MarkdownBlockRead-only guidance + chat tool output — components.ts:509
+
AssigneePickerDefault assignee + action template field — components.ts:586
+
ProjectPickerQueue default project + action template field — components.ts:591
+
IssuesListLinked work issues column on item list — components.ts:581
+
IssueChatThreadLean assistant chat surface (hide tool-result toolbar, keep tool-call/result bubbles compact) — ui/src/components/IssueChatThread.tsx:1
+
usePluginData / usePluginAction / usePluginStreamAll queue/item/guidance reads + chat send — packages/plugins/sdk/src/ui/hooks.ts:43
+
useHostNavigationOpen linked work issue in host without leaving plugin — hooks.ts:139
+
LLM Wiki plugin shapeSidebar entry + page route + route sidebar contributions — packages/plugins/plugin-llm-wiki/src/manifest.ts:574 and src/ui/app.tsx:1589 / 2405
+
shadcn primitives (Button, Card, Tabs, Dialog, Sheet, Badge, Avatar, DropdownMenu, Popover, ScrollArea)All structural chrome — ui/src/components/ui/
+
+
+ + +
+
Step 1 · entry
+

1.Queue list

+

Plugin home. Route sidebar shows queues with state counts and plugin-level links (settings, reconcile, managed agent). Main area is a queue card grid: each card shows state counts as chips, the configured assistant agent, action summary, and an "open workbench" CTA. The "+ create" tile reminds the reader that an unknown queueKey from the ingest API auto-creates the queue with default workflow + guidance.md.

+
+
+
queue-list.svg1280×800 · desktop
+ Queue list desktop wireframe +
+
+
mobile375×812
+ Queue list mobile wireframe +
+
+

Annotations

+
    +
  • 1 — Route sidebar mirrors the LLM Wiki routeSidebar slot. Queues appear under QUEUES with state-count badges; plugin links sit under PLUGIN.
  • +
  • 2 — State count chips are Badge primitives with the active state filled. Counts read live via usePluginData("queues:listWithCounts").
  • +
  • 3 — A dashed create-queue tile educates users that the ingest API creates queues by default — no need to come here first.
  • +
  • 4 — Archived queues stay listed but de-emphasised; unarchive without navigation.
  • +
  • Mobile — Cards collapse to a single vertical stack; chip row becomes one compact summary line. Bottom tab nav (Queues · Chats · Settings) replaces the sidebar.
  • +
+
Component reuse: Card, Badge, Button, Input from ui/src/components/ui/; route sidebar pattern from plugin-llm-wiki.
+
Why this shape: the user's first question is "what is there to triage?". Counts by state are the single most actionable summary. We resist a kanban here — items inside one queue are the workbench's job, not the list's.
+
+
+
+ + +
+
Step 2 · pick
+

2.Item list

+

Items in one queue, grouped by workflow state. The "Start triage (next New)" CTA opens the oldest New item directly in the workbench — keeping the loop friction-free. Each row surfaces title, opaque properties summary, itemKey, age, and any linked work issue.

+
+
+
item-list.svg1280×800 · desktop
+ Item list desktop wireframe +
+
+
mobile375×812
+ Item list mobile wireframe +
+
+

Annotations

+
    +
  • 1 — State tabs are driven by the queue workflow definition. Counts live-update from the worker.
  • +
  • 2 — Row is a list-row primitive; clicking opens the item workbench. Properties shown as a free-form, opaque summary (sourceKind, priority, stats).
  • +
  • 3 — Primary CTA always opens the next actionable item. Keeps the triage loop fast.
  • +
  • 4 — Linked work issue, when present, links into Paperclip's regular issue view via useHostNavigation.
  • +
  • Mobile — Rows become full-width cards; state tabs scroll horizontally. The "open #142 in workbench" CTA is sticky at the bottom.
  • +
+
Component reuse: shadcn Tabs, Badge, Table-style list row; IssuesList for the linked-work-issue column on the right.
+
Why a table on desktop, cards on mobile: properties matter — a table puts itemKey, properties, and age in one scan line. On mobile, cards keep titles legible without horizontal scroll.
+
+
+
+ + +
+
Step 3 · the centerpiece
+

3.Item workbench — two columns, locked

+

The approved two-column work area: assistant chat on the center/left, item document and properties on the right. Both columns target the same item content — edits made directly in the markdown editor and edits made through a chat tool call are equivalent. Allowed transitions sit in the workbench bar; the route sidebar holds queue/item nav so the two columns stay the mental model.

+
+
+
item-workbench.svg1280×800 · desktop
+ Item workbench desktop wireframe +
+
+
mobile375×812
+ Item workbench mobile wireframe +
+
+

Annotations

+
    +
  • 1 · chat column — IssueChatThread, rendered against the queue's hidden chat issue, with assistant tool-call bubbles slimmed down (no comment toolbar, no reactions). Chat history dropdown + "new chat" pin to the column header.
  • +
  • 2 · document column — Title input + MarkdownEditor + properties editor + linked-work-issue card. This is the "edit directly" path.
  • +
  • 3 · pinned item card — Always visible in the chat. Pins the current item into assistant context (queue, item, state, allowed transitions, guidance files) — see composer footer for the full context strip.
  • +
  • 4 — Title is editable inline.
  • +
  • 5 · chat-assisted edit — Same content, different path. The assistant's update_item_content tool call produces a small diff card inside the chat bubble; the right-column editor updates in place. Saves count for reflection just like manual edits.
  • +
  • 6 · free-form properties — Key/value editor. No schema enforcement in v1; the contract is explicit about that.
  • +
  • 7 · composer — Single-line composer with shift+enter for multi-line. Footer surfaces the full assistant context so the user trusts the agent's view of the world.
  • +
  • 8 · linked work issue card — Dashed when none exists, with a preview of what the configured transition action will do.
  • +
  • 9 · transition controls — Allowed transitions only, rendered as Buttons in the workbench bar. Disabled with a tooltip when reflection is required and not yet resolved.
  • +
  • Mobile — The two columns become a top tab switch (Chat / Document). State strip + transitions become a sticky bottom action bar. The pinned-item card stays visible above the chat. Sheet/drawer (Sheet) holds route navigation.
  • +
+
Component reuse: IssueChatThread (slim variant), MarkdownEditor, Tabs, Button, Sheet for mobile drawer, Badge for state pill, ScrollArea for both columns.
+
Why a fixed two-column split, not collapsible: the mental model the plan locks in is "chat on the center/left, document on the right". Collapse the right column and we lose the "draft together, see the artifact live" experience the user described. Mobile is the one place we tab — there's no room — and we pay the cost there only.
+
System change requested: a slim variant of IssueChatThread that suppresses comment toolbar / reactions and renders tool-call bubbles compactly. Proposed as a prop on the existing component, not a fork.
+
+
+
+ + +
+
Step 4 · the learning loop
+

4.Reflection diff

+

Sits over the workbench when a transition requires reflection (default: new.approve and new.reject). The assistant compares the original item, edits, chat, and current guidance, then proposes a diff to one or more guidance documents. The user accepts, rejects, edits inline, or asks for a revision. Acceptance bumps the doc revision and unblocks the transition.

+
+
+
reflection-diff.svg1280×800 · desktop
+ Reflection diff desktop wireframe +
+
+
mobile375×812
+ Reflection diff mobile wireframe +
+
+

Annotations

+
    +
  • 1 · summary — The assistant's one-line take on what changed. Citable.
  • +
  • 2 · doc selector — Folder-style list of guidance docs with change counts; "+ propose new file" supports growth beyond guidance.md.
  • +
  • 3 · diff body — Unified diff in markdown. Inline edit converts the panel into a MarkdownEditor over the proposed body.
  • +
  • 4 · revise / reject / edit — All three soft paths. Revise sends a chat turn ("revise because…"); reject keeps the proposal in history for audit.
  • +
  • 5 · accept — Creates a new guidance revision and unblocks the transition. Status note explains the linkage so the user understands why this is required.
  • +
  • Mobile — Reflection becomes a full-screen sheet. Doc selector becomes scrollable tab chips. Actions sit in a sticky bottom bar with an "Edit ›" link to drop into the editor on a separate screen.
  • +
+
Component reuse: Dialog for the desktop overlay; Sheet for mobile; MarkdownBlock + custom diff renderer for the body.
+
System change requested: a small markdown-diff renderer (added/removed line markers, hunk grouping) — does not exist in core today. Lives inside the plugin until two callers want it.
+
Why blocking-by-default for reflection-required transitions: the contract makes requiresReflection a hard gate. Surfacing this as an overlay (not a separate screen) keeps the user in their item's context.
+
+
+
+ + +
+
Step 5 · queue memory
+

5.Guidance editor

+

Queue guidance lives as a folder of plugin documents, not one oversized blob. Every queue starts with guidance.md; more files (style.md, examples.md, rubric.md) can be added as the queue's voice matures. Pending reflection proposals surface in the file tree so the user can see "this doc has open work" without jumping through the chat.

+
+
+
guidance-editor.svg1280×800 · desktop
+ Guidance editor desktop wireframe +
+
+
mobile375×812
+ Guidance editor mobile wireframe +
+
+

Annotations

+
    +
  • 1 · file tree — Documents listed with revision tag and one-line purpose. Active doc highlighted. "+ new document" is permissive (path-like keys per the plan).
  • +
  • 2 · editor — Same MarkdownEditor the workbench uses. History link opens the revision drawer.
  • +
  • 3 · header — Last update attribution, revision count, save shortcut.
  • +
  • 4 · revision history strip — Recent revisions inline. Each entry is clickable and notes whether the revision came from an accepted reflection (with item link) or a manual edit.
  • +
  • Mobile — File list becomes horizontal scroll chips. Pending-proposal banner appears at the top of the active doc. Add-doc + save are sticky at the bottom.
  • +
+
Component reuse: MarkdownEditor, Tabs (mobile chips), Sheet for revision history drawer, Badge for proposal counts.
+
Why a folder, not one file: the user explicitly called out that "agents.md" patterns outgrow single files. Putting style.md/examples.md/rubric.md on the same screen normalises the practice early so reflection proposals can target the right doc without the user thinking about it.
+
+
+
+ + +
+
Step 6 · queue rules
+

6.Workflow editor

+

Per the contract, v1 ships a small internal validator — no state-machine library, no drag-and-drop builder. The editor is two tables (states, transitions) plus a read-only graph preview rendered from the records. This keeps invariants honest (terminal states have no outgoing transitions, every transition references real states) without leaking visual-editor complexity.

+
+
+
workflow-editor.svg1280×800 · desktop
+ Workflow editor desktop wireframe +
+
+
mobile375×812
+ Workflow editor mobile wireframe +
+
+

Annotations

+
    +
  • 1 · preview — Auto-generated from the records below. Terminal state has the thicker border (used as a consistent terminal marker across all screens).
  • +
  • 2 · states table — All fields from the contract: stateKey, label, terminal, visibility, sort order.
  • +
  • 3 · transitions table — From/to, label, reflection required, attached action template. Inline row actions edit per-row; bulk add via "+ Add transition".
  • +
  • 4 · action template chip — Tappable pill that opens the transition action editor (next screen) with the right transition pre-selected.
  • +
  • Mobile — Preview collapses to an accordion. States and transitions become stacked cards. Add buttons sit below the list — no FAB.
  • +
+
Component reuse: shadcn Table, Badge, Button, Collapsible for the mobile preview.
+
Why no drag-and-drop builder: the contract explicitly chose a small internal validator over a state-machine library, and the plan flags YAML editors / visual builders as later work. A table with a generated preview is the smallest thing that makes invariants visible without inviting users to build state spaghetti.
+
+
+
+ + +
+
Step 7 · hand off to Paperclip
+

7.Transition action template

+

v1's only allowed action: create_or_update_issue. The template is a constrained form — string interpolation only, fields locked to the contract's allowlist (title, description, comment, projectId, goalId, assigneeAgentId/UserId, priority, status). The variable palette + live preview against a real item make the template legible without inviting users to drop in arbitrary expressions.

+
+
+
transition-action.svg1280×800 · desktop
+ Transition action template desktop wireframe +
+
+
mobile375×812
+ Transition action template mobile wireframe +
+
+

Annotations

+
    +
  • 1 · action type chip — Locked to create_or_update_issue in v1. Surfaced as a chip so the constraint is visible, not hidden in a disabled dropdown.
  • +
  • 2 · mode segmented control — Three contract-locked modes; the body description below explains the selected mode in plain language.
  • +
  • 3 · transitions side rail — All transitions for this queue. Each shows whether it has an action so the user can tour them.
  • +
  • 4 · variable palette — Click-to-insert tokens from the four contract variable roots (queue.*, item.*, transition.*, actor.*). No JavaScript, no helpers — the palette is the entire surface area.
  • +
  • 5 · live preview — Renders the template against the most recent New item so the user can spot a missing variable or weird formatting before saving. Templating errors fail closed before any side effect runs.
  • +
  • Status restriction — The status dropdown only contains backlog / todo / in_review, matching the contract's allowlist. Disabled options aren't shown.
  • +
  • Mobile — Side rail moves into a dropdown above the form. Variable palette becomes a popover triggered by "Insert variable…". Preview lives in a compact card at the bottom.
  • +
+
Component reuse: Input, Textarea, Select, Popover, Tabs, ProjectPicker, AssigneePicker.
+
System change requested: a small string-interpolation preview helper (renders {{var}} against a context object, flags missing keys). Lives in the plugin; trivial to lift if a second caller appears.
+
Why a constrained form, not a code editor: the contract explicitly bans dynamic evaluation. A form (not a YAML pane) keeps users inside the allowlist and lets the preview catch typos before runtime audit rows surface them as failures.
+
+
+
+ + +
+
Step 8 · queue identity
+

8.Queue settings

+

Per-queue identity, defaults, and lifecycle. queueKey is immutable (it's the ingest API contract). Defaults wire the assistant agent and the project transition actions use. The strict-ingest toggle controls the contract's requireExistingQueue behaviour for this queue. Other concerns (workflow, transition actions, guidance, chat history, danger zone) are tab destinations to keep this screen scannable.

+
+
+
queue-settings.svg1280×800 · desktop
+ Queue settings desktop wireframe +
+
+
mobile375×812
+ Queue settings mobile wireframe +
+
+

Annotations

+
    +
  • 1 · immutable queueKey — Shown disabled with explicit "immutable" caption. Avoids the trap of breaking the external ingest API contract by renaming.
  • +
  • 2 · default assistant agent — Picker. Default is the managed Triage Assistant; teams can swap in a specialised agent per queue (per the plan's "executive assistant learning agent" framing).
  • +
  • 3 · default state — Constrained to the queue's workflow states.
  • +
  • 4 · ingest fallback toggle — Maps directly to requireExistingQueue in the ingest API. "Auto-create queue on unknown key" reads better to a human than the API field name.
  • +
  • Sticky save bar — Discard / Save with unsaved-changes indicator. Save bar pattern echoes the rest of the Paperclip UI to feel native, not plugin-y.
  • +
  • Mobile — Same fields, single column. Tabs scroll horizontally. Danger zone is its own card below the main form.
  • +
+
Component reuse: Tabs, Input, Textarea, Select, Switch, AssigneePicker, ProjectPicker, the standard Paperclip sticky-save-bar pattern.
+
Why settings sit alongside workflow / actions tabs (not buried in a separate page): creating a new queue is a configuration burst — the user will likely set defaults, define states, and attach an action in one sitting. Tabs keep all of it within one URL.
+
+
+
+ + +
+
For reviewer
+

Open questions for CTO/product

+
+
    +
  • Q1 · Chat history surface — Should the queue's chat history live inside the route sidebar (compact list, click to re-pin items) or inside a dedicated "Chat history" tab on the queue settings? Wireframes lean toward a "Chat history" tab plus a "+ new chat" / "history ▾" pair in the chat column header. Confirm.
  • +
  • Q2 · Reflection scope — On a transition like new.approve, the assistant may want to propose changes across multiple guidance docs (e.g. guidance.md + examples.md). The reflection screen supports that today via the file selector. Should we ship that in v1 or restrict v1 to single-doc proposals for simplicity?
  • +
  • Q3 · "Edit before accept" — Goes from the diff view into a full MarkdownEditor over the proposed body. Open: do we preserve the unaccepted diff badge once edited, or treat the edited version as a fresh manual edit (no longer "from the assistant")? Wireframes currently imply the latter.
  • +
  • Q4 · Mobile two-column posture — Mobile tabs Chat / Document instead of stacking. Stacking would preserve the "see both at once" intent but force scrolling. Confirm tabbing on phones is acceptable given that the desktop two-column is the locked invariant.
  • +
  • Q5 · Transition action preview "sample item" — Wireframes show the preview pinned to the most recent New item. Alternative: let the user pick which item to preview against. Adds power; complicates first-use. Confirm we ship the default-only version in v1.
  • +
  • Q6 · Status restriction labelling — The contract bans in_progress/done/blocked/cancelled as template statuses. Show them disabled (educational) or omit them (cleaner)? Wireframes currently omit.
  • +
+

Questions are also posted as a request_confirmation interaction on PAP-9846 so acceptance can wake the right agent.

+
+
+ + +
+
+ + + + + + diff --git a/design/PAP-9846-triage-workbench/wireframes/flow.svg b/design/PAP-9846-triage-workbench/wireframes/flow.svg new file mode 100644 index 00000000000..5c7ec4cb25d --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/flow.svg @@ -0,0 +1,238 @@ + + + + + + Paperclip Triage · workbench flow + + + Solid = main triage loop · dashed = configuration paths + + + + + MAIN TRIAGE LOOP + CONFIGURE + + + + + + + queue-list + + + + + + + + + + + + item-list + + + + + + + + + + + + + item-workbench + two-column + + + + + chat + document + + + + + + + + + + + + + + + + reflection-diff + required before Approve + + + + ## Tone + - Keep intros < 4 sentences + + Keep intros < 3 sentences + + Call out benchmarks in headline + ## Structure + + ## Headlines + + + + + + + + + + + next + item + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + open + open item + Approve + accept + finish + next New → + + + + + + + + queue-settings + + + + + + + + + + + workflow-editor + + + + + + + + + + + + + + + + + + + transition-action + create_or_update_issue + + + + + + + + + + + guidance-editor + + + + + + guid.md + style + examples + + + + + + + + + + + + ingest API + external entry · no UI + + + POST /api/plugins/ + paperclipai.plugin-triage/ + api/queues/:queueKey/items + + creates queue if missing + + + + + + + + + + + + + + + + + + + manage + workflow tab + attach action + guidance tab + items arrive → + + + + + Two-column workbench is the centerpiece (bold border). Reflection sits over the workbench when a transition requires it. + Ingest API is the only entry path for items in v1 — no source connectors. + + diff --git a/design/PAP-9846-triage-workbench/wireframes/guidance-editor-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/guidance-editor-mobile.svg new file mode 100644 index 00000000000..e549bc691d4 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/guidance-editor-mobile.svg @@ -0,0 +1,76 @@ + + + + + + ‹ Guidance + Save + + + + content-training + 4 docs · 1 pending proposal + + + + + + + guidance.md + style.md + examples + rubric + + + + + rev 7 · accepted from #141 · 1h ago + History + + + + + + Pending proposal: +8 / -2 + from item #142 · 2m ago + Review + + + + + + + + BIH1H2List + Markdown + + + # content-training · guidance + Working memory for the Triage Assistant. + ## Tone + - Plain language. + - Keep intros under 4 sentences. + ## Structure + - H2 sections for What's new, Why, + What's next. + - Headlines must include a concrete verb. + ## Tags + - See style.md. + ## Examples + - See examples.md. + | (tap to edit) + + + + + + + + + Add document + + + + Save rev 8 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/guidance-editor.svg b/design/PAP-9846-triage-workbench/wireframes/guidance-editor.svg new file mode 100644 index 00000000000..5651baabf12 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/guidance-editor.svg @@ -0,0 +1,122 @@ + + + + + Paperclip + / Triage / content-training / Guidance + + + + QUEUE + + Workbench + Items + + Guidance + Workflow + Transition actions + Settings + + + + + Guidance + Folder of plugin documents. Assistant reads these for every item in this queue. + + + + + + Documents + + + + + + guidance.md + default · rev 7 + + + style.md + tone + voice · rev 3 + + + examples.md + good/bad pairs · rev 4 + + + rubric.md + scoring · rev 2 + + + + new document + + + + + + PENDING PROPOSALS + + + + from item #142 · 2m ago + guidance.md +8 / -2 + Review → + + + + + + guidance.md + 7 revisions · last update by you · 1h ago + History + Save + + + + + + + + + BI‹/›H1H2ListLink + Markdown + + + # content-training · guidance + Working memory for the Triage Assistant on this queue. + ## Tone + - Plain language. No marketing jargon. + - Keep intros under 4 sentences. + ## Structure + - H2 sections for What's new, Why now, What's next. + - Headlines must include a concrete verb. + ## Tags + - See style.md for the canonical list. + ## Examples + - See examples.md. + + + + + + REVISION HISTORY + + + + + rev 7 · accepted from item #141 · 1h ago + rev 6 · manual edit · yesterday + rev 5 · accepted from item #138 · 2d ago + View all › + + + + + + 1 + 2 + 3 + 4 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/item-list-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/item-list-mobile.svg new file mode 100644 index 00000000000..da4b16015ce --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/item-list-mobile.svg @@ -0,0 +1,68 @@ + + + + + + ‹ content-training + ⋯ + + + + Items · 12 New + + + + + + New (12) + Approved (4) + Rejected (2) + Done (17) + + + + + + Search title or itemKey… + + + + + + #142 · Draft launch post + blog · priority medium · 2m ago + ext-9210-blog · no work issue + + + + #141 · v2 pricing intro + blog · priority high · 14m ago + ext-9209-blog · no work issue + + + + #140 · Team page rewrite + blog · views 1.2k · 1h ago + PAP-9912 › work issue + + + + #139 · Q3 announce + announce · 3h ago + no key · no work issue + + + + #138 · Beta notes + changelog · 5h ago + no key · no work issue + + + + + + + Open #142 in workbench → + + diff --git a/design/PAP-9846-triage-workbench/wireframes/item-list.svg b/design/PAP-9846-triage-workbench/wireframes/item-list.svg new file mode 100644 index 00000000000..f18f756a251 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/item-list.svg @@ -0,0 +1,136 @@ + + + + + Paperclip + / Triage / content-training / Items + + + + QUEUE + + Workbench + + Items + Guidance + Workflow + Transition actions + Settings + + + + + Items + content-training · 24 active · 17 done + + + + Start triage (next New) + + + + + + + New + 12 + + + Approved + 4 + + Rejected + 2 + + Done + 17 + + + Filter ▾ + ⋯ + + + + + + + + + + ID + TITLE + PROPERTIES + ITEM KEY + AGE + WORK ISSUE + + + + + #142Draft launch post + sourceKind: blog · priority: medium + ext-9210-blog + 2m + — + + + #141v2 pricing intro + sourceKind: blog · priority: high + ext-9209-blog + 14m + — + + + #140Team page rewrite + sourceKind: blog · stat:views=1240 + ext-9208-blog + 1h + PAP-9912 › + + + #139Q3 announce + sourceKind: announce + — + 3h + — + + + #138Beta notes + sourceKind: changelog + — + 5h + — + + + #137Press blurb + sourceKind: press + — + 8h + — + + + #136Marketing newsletter + sourceKind: newsletter + nl-2026-05-19 + 1d + — + + + 5 more + + + + + + Showing 7 of 12 New + ‹ 1 of 2 › + + + + + 1 + 2 + 3 + 4 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/item-workbench-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/item-workbench-mobile.svg new file mode 100644 index 00000000000..cb68df538bb --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/item-workbench-mobile.svg @@ -0,0 +1,87 @@ + + + + + + + ≡ + content-training · #142 + ⋯ + + + + + + + Chat + Document + + + + + + + PINNED · state: New + #142 Draft launch post + + + + + + Triage Assistant · 30s + Read item; current guidance: + "keep launch posts under 150 words" + + + + + you · 25s + Tighten intro. Mention the new bench. + + + + + Triage Assistant · 12s + Tightened intro and added bench callout. + + + tool · update_item_content + + + + + + + Ask the assistant… + ↑ + + + + + + Item 3 of 12 · state: + New + + + + Reject + + + + Approve + + + Both transitions require reflection. + + + + + + + + + A + B + + diff --git a/design/PAP-9846-triage-workbench/wireframes/item-workbench.svg b/design/PAP-9846-triage-workbench/wireframes/item-workbench.svg new file mode 100644 index 00000000000..8c093cd9205 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/item-workbench.svg @@ -0,0 +1,206 @@ + + + + + + Paperclip + / Triage / content-training / Item #142 · "Draft launch post" + Settings + + + + + QUEUES + + + + content-training + 12 + + inbox3 + drafts7 + financial-approvals2 + + New queue + + + + + ITEMS · NEW (12) + + + + #142 Draft launch post + + #141 v2 pricing intro + #140 Team page rewrite + #139 Q3 announce + #138 Beta notes + #137 Press blurb + + 6 more + + + + QUEUE + Workflow + Guidance + Transition actions + Settings + + + + + + Item 3 of 12 + ‹ + › + New + + + Allowed transitions → + Reject + Approve + + + + + + + + ASSISTANT CHAT · Triage Assistant + + New chat History ▾ + + + + + + PINNED ITEM + #142 Draft launch post · markdown · state: New + + + + + + Triage Assistant · just now + Read item · current guidance: + "keep launch posts under 150 words". + tool: read_guidance(path=guidance.md) + + + + + + you · 30s ago + Tighten the intro. Mention the new bench. + + + + + + Triage Assistant · 12s ago + Tightened the intro and added the bench callout. + + + tool: update_item_content() · 1 patch · diff applied → + + + + + + + you · just now + Good. I'll polish the closing myself. + + + + + + Ask the assistant about #142… + ↑ + Ctx: queue, item, state, allowed transitions, guidance files, work issue + + + + + ITEM DOCUMENT · edit directly + ⌘S saved · 12s + + + + + + Draft launch post + + + + + + + + + BI‹/›H1H2ListLink + Markdown + + + + # Draft launch post + # (intro — tightened by assistant) + Our new bench cut p95 latency by half. + Here's what changed and what's next. + ## What's new + - Stream-first scheduling + - Per-queue guidance + - Reflection loop after every item + ## Closing (you are editing this paragraph) + | + + + + + + PROPERTIES · free-form JSON + + + + + upstreamIdext-9210-blog + sourceKindopaque-blog + prioritymedium + + add key + + + + + + LINKED WORK ISSUE + + + + none yet — created on Approve via + create_or_update_issue · create_if_missing · project: Marketing + Open template + + + + + + + + + CHAT — center/left + DOCUMENT/ITEM — right + + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/queue-list-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/queue-list-mobile.svg new file mode 100644 index 00000000000..39fdeb3eb86 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/queue-list-mobile.svg @@ -0,0 +1,69 @@ + + + + + + + ≡ + Triage + + + + + + + Queues + 5 active + + + + + + Search… + + + + + + content-training + 12 new · 4 approved · 2 rejected · 17 done + Triage Assistant · updated 2m ago + Open workbench + Settings + + + + + inbox + 3 new · 5 done + Default catch-all queue · updated 1h ago + Open workbench + Settings + + + + + financial-approvals + 2 new · 1 approved · 4 rejected · 33 done + Finance Reviewer · creates work issue + Open workbench + Settings + + + + + + + Create a queue + Or POST a new item to a queueKey to + auto-create it with the default workflow. + Ingest API help + + + + + + Queues + Chats + Settings + + diff --git a/design/PAP-9846-triage-workbench/wireframes/queue-list.svg b/design/PAP-9846-triage-workbench/wireframes/queue-list.svg new file mode 100644 index 00000000000..a26801d3c26 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/queue-list.svg @@ -0,0 +1,178 @@ + + + + + + Paperclip + / Triage + + + + QUEUES + + + All queues24 + inbox3 + drafts7 + content-training12 + financial-approvals2 + + New queue + + + + PLUGIN + Triage settings + Reconcile resources + Managed agent: Triage Assistant + + + + + Triage queues + Process items one at a time. Teach the assistant. Move work into Paperclip. + + + + Ingest API + + + + + New queue + + + + + + Search queues by name or key… + Active ▾ + Sort: Updated ▾ + + + + + + + content-training + key: content-training · Triage Assistant + Editorial review queue. Teach the assistant tone. + + + + STATE COUNTS + + + + New 12 + + + Approved 4 + + + Rejected 2 + + + Done 17 + + Updated 2m ago · guidance.md +3 styles · 1 transition action + Open workbench + Settings + + + + + + inbox + key: inbox · Triage Assistant + Default catch-all queue. + + + + New 3 + + + Approved 0 + + + Rejected 0 + + + Done 5 + + Updated 1h ago · guidance.md · no actions + Open workbench + Settings + + + + + + financial-approvals + key: financial-approvals · Finance Reviewer + Tighter workflow with stricter reflection. + + + + New 2 + + + Approved 1 + + + Rejected 4 + + + Done 33 + + Updated 4h ago · 3 docs · creates work issue on Approve + Open workbench + Settings + + + + + + drafts + key: drafts + Blog drafts waiting for review. + + + New 7 + + Done 2 + + Open workbench + + + + + + Create a queue + Or POST to /api/plugins/paperclipai.plugin-triage/api/queues/:queueKey/items + Unknown queueKey auto-creates with default workflow + guidance.md. + + + + + reviews (archived) + key: reviews · 0 active items + Hidden from default view. + Unarchive + + + + + Showing 5 active queues + ‹ 1 › + + + + + + + + 1 + 2 + 3 + 4 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/queue-settings-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/queue-settings-mobile.svg new file mode 100644 index 00000000000..418a02d3e78 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/queue-settings-mobile.svg @@ -0,0 +1,71 @@ + + + + + + + ‹ Settings + Save + + + + + content-training + queueKey: content-training + + + + + + Overview + Workflow + Actions + Guidance + + + + DISPLAY NAME + content-training + + DESCRIPTION + Editorial review queue. + + DEFAULT ASSISTANT AGENT + + + + Triage Assistant + managed + ▾ + + + DEFAULT PROJECT + + + Marketing + for transition actions + ▾ + + + DEFAULT STATE + new ▾ + + REFLECTION + After each ▾ + + INGEST FALLBACK + + + Auto-create queue + on unknown queueKey + + + + + + + Archive queue + Hides from active list. Items keep their state. + + diff --git a/design/PAP-9846-triage-workbench/wireframes/queue-settings.svg b/design/PAP-9846-triage-workbench/wireframes/queue-settings.svg new file mode 100644 index 00000000000..04679610f37 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/queue-settings.svg @@ -0,0 +1,141 @@ + + + + + + Paperclip + / Triage / content-training / Settings + + + + QUEUE + + Workbench + Items + Guidance + Workflow + Transition actions + + Settings + Chat history + + + OTHER QUEUES + + inbox + drafts + financial-approvals + + + + + content-training + queueKey: content-training · created 2026-04-12 · 35 items lifetime + + + + + + + Overview + Workflow + Transition actions + Guidance + Chat history + Danger zone + + + + + + Identity + + DISPLAY NAME + content-training + + QUEUE KEY · immutable + content-training + + DESCRIPTION + Editorial review queue. Teach the assistant tone and structure. + + + + Defaults + + + + DEFAULT ASSISTANT AGENT + + + + Triage Assistant (managed) + paperclipai.plugin-triage · default + ▾ + + + + DEFAULT PROJECT · for transition actions + + + Marketing + Goal: Launch Q4 narrative + ▾ + + + + DEFAULT STATE FOR NEW ITEMS + + + new ▾ + + + + REFLECTION POLICY + + + After each item ▾ + + + + INGEST FALLBACK + + + + + Auto-create queue on unknown key + + + + + + Status + + + Active + Archived + + + + + + Unsaved changes · last saved 4m ago + + + + Discard + + + + Save + + + + + 1 + 2 + 3 + 4 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/reflection-diff-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/reflection-diff-mobile.svg new file mode 100644 index 00000000000..1973cb59f84 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/reflection-diff-mobile.svg @@ -0,0 +1,80 @@ + + + + + + + ✕ + Reflection + Skip + + + + Before Approve + guidance.md · +8 / -2 + + + + + + guidance.md + style.md + examples.md + + + + + + SUMMARY + "Tighten launch-post intros and call + out user-visible benchmarks in headline." + + + + + + + ## Tone + - Keep intros under 4 sentences. + + + Keep intros under 3 sentences. + + Call out user-visible benchmarks + in the first paragraph. + ## Structure + - H2 sections for What's new... + + ## Headlines + + Include the strongest concrete + number when one exists. + + Prefer verbs over noun phrases. + + + + + + + WHY · chat turns 4–6 · 1 doc edit + You shortened intro from 5 → 3 + sentences and added "cut p95 latency" + to the headline. + + + + + + + Reject + + + + Revise + + + + Accept · rev 8 + + + Accept unblocks Approve. + Edit › + + diff --git a/design/PAP-9846-triage-workbench/wireframes/reflection-diff.svg b/design/PAP-9846-triage-workbench/wireframes/reflection-diff.svg new file mode 100644 index 00000000000..59f3eb1900f --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/reflection-diff.svg @@ -0,0 +1,132 @@ + + + + + Paperclip + / Triage / content-training / Item #142 · reflection + + + + QUEUE + + Workbench + Items + Guidance + Workflow + Transition actions + + + + + + + + + + + + Reflection · before Approve + Triage Assistant compared the original item, your edits, the chat, and current guidance. + ✕ + + + + + + SUMMARY + "Tighten launch-post intros and call out user-visible benchmarks in the headline." · base revision rev-7 + + + + + + PROPOSING CHANGES TO + + + + guidance.md + +8 / -2 + + + + style.md + no change + + + + examples.md + +1 example + + + + + propose new file + + + + + DIFF · guidance.md + Unified ▾ · Markdown ▾ + + + + + ## Tone + - Use plain language. No marketing jargon. + - - Keep intros under 4 sentences. + + + - Keep intros under 3 sentences. + + - Call out user-visible benchmarks in the first paragraph. + ## Structure + - H2 sections for What's new, Why now, What's next. + + ## Headlines + + - Include the strongest concrete number when one exists. + + - Prefer verbs over noun phrases. + + + + + + WHY + + + + You shortened the intro from 5 sentences to 3 and added "cut p95 latency by half" + to the headline. The assistant noticed both edits and is generalising them. + cites · chat turns 4–6 · diff applied via update_item_content · edits in markdown editor + + + + + + + Reject proposal + + + + Ask assistant to revise + + + + Edit before accept + + + + Accept · rev 8 + + + + + Accepting unblocks the Approve transition (reflection required for new → approved). + + + + + + 1 + 2 + 3 + 4 + 5 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/transition-action-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/transition-action-mobile.svg new file mode 100644 index 00000000000..406c853849c --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/transition-action-mobile.svg @@ -0,0 +1,97 @@ + + + + + + ‹ new.approve + Save + + + + Transition action + create_or_update_issue + + + + + MODE + + + + + + create_if_missing + update + both + + + + + + TITLE + + {{item.title}} + + + + + DESCRIPTION + + + {{item.content}} + Metadata: + {{item.propertiesJson}} + + + + + + PROJECT + + {{queue.defaultProjectId}} ▾ + + + + + ASSIGNEE + + + {{queue.defaultAssigneeAgentId}} + + + PRIORITY + + medium ▾ + + + + + STATUS + + todo ▾ + + + + + + Insert variable… + ▾ + + + + + PREVIEW · #142 + + + + Draft launch post + Marketing · Triage Assistant · medium · todo + + comment: Triage item moved to approved. + + + + + Audited per transition. + + diff --git a/design/PAP-9846-triage-workbench/wireframes/transition-action.svg b/design/PAP-9846-triage-workbench/wireframes/transition-action.svg new file mode 100644 index 00000000000..42000488e90 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/transition-action.svg @@ -0,0 +1,195 @@ + + + + + Paperclip + / Triage / content-training / Transition actions / new.approve + + + + QUEUE + + Workbench + Items + Guidance + Workflow + + Transition actions + Settings + + TRANSITIONS + + + new.approve + 1 action + + + new.reject + — + + + approved.finish + — + + + rejected.finish + — + + + + + Transition action · new.approve + Runs after the user accepts reflection and clicks Approve. Allowed: create or update one Paperclip issue. + + + + + + type: create_or_update_issue + + + v1 only allows this action type. + + + + + MODE + + + + + + + + create_if_missing + update_existing + create_or_update + + + + Create a work issue only if the item has none. Otherwise the action is a no-op (audited). + + + + + + Template fields + + + + + TITLE *required when creating + + {{item.title}} + + + + DESCRIPTION + + {{item.content}} + + Metadata: {{item.propertiesJson}} + + + + COMMENT · appended after field updates + + Triage item moved to {{transition.toStateKey}}. + + + + PROJECT + + {{queue.defaultProjectId}} ▾ + + + GOAL + + — inherit from project + + + + ASSIGNEE · agent or user + + + {{queue.defaultAssigneeAgentId}} + + + PRIORITY · STATUS + + medium ▾ + + todo ▾ + + + + + Variables + + + + + CLICK TO INSERT + + + {{queue.id}} + {{queue.queueKey}} + {{queue.displayName}} + {{queue.defaultProjectId}} + {{queue.defaultAssigneeAgentId}} + {{item.id}} + {{item.title}} + {{item.content}} + {{item.contentFormat}} + {{item.itemKey}} + {{item.stateKey}} + {{item.propertiesJson}} + {{transition.transitionKey}} + {{transition.fromStateKey}} + {{transition.toStateKey}} + {{actor.type}} {{actor.agentId}} {{actor.userId}} + + + + + Preview · sample item #142 + Renders with values from this queue + the most recent New item. + + + + + TITLE + Draft launch post + DESCRIPTION + # Draft launch post + Our new bench cut p95 latency by half... + COMMENT + Triage item moved to approved. + PROJECT Marketing · ASSIGNEE Triage Assistant · PRIORITY medium · STATUS todo + + + + + + + Unsaved · audited as transition_action on every Approve + + + + Discard + + + + Save action + + + + + 1 + 2 + 3 + 4 + 5 + + diff --git a/design/PAP-9846-triage-workbench/wireframes/workflow-editor-mobile.svg b/design/PAP-9846-triage-workbench/wireframes/workflow-editor-mobile.svg new file mode 100644 index 00000000000..7bceceeb6dd --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/workflow-editor-mobile.svg @@ -0,0 +1,83 @@ + + + + + + ‹ Workflow + Save + + + + content-training + 4 states · 4 transitions + + + + + + Preview + Read-only graph view + ▾ + + + + + States + 4 + + + + new · New + active · sort 0 + ⋯ + + + + approved · Approved + archived · sort 1 + ⋯ + + + + rejected · Rejected + archived · sort 2 + ⋯ + + + + done · Done + terminal · hidden · sort 3 + ⋯ + + + + + Add state + + + + + + + Transitions + 4 + + + + Approve · new → approved + reflection required + + + create_or_update_issue + + + + + Reject · new → rejected + reflection required · no action + + + + + Add transition + + diff --git a/design/PAP-9846-triage-workbench/wireframes/workflow-editor.svg b/design/PAP-9846-triage-workbench/wireframes/workflow-editor.svg new file mode 100644 index 00000000000..2cea9728129 --- /dev/null +++ b/design/PAP-9846-triage-workbench/wireframes/workflow-editor.svg @@ -0,0 +1,123 @@ + + + + + Paperclip + / Triage / content-training / Workflow + + + + QUEUE + + Workbench + Items + Guidance + + Workflow + Transition actions + Settings + + + + + Workflow + Allowed states and transitions for content-training. Internal validator (no library). + + + + + Preview + Generated from the records below — not a drag-and-drop editor. + + + + + + new + + approved + + rejected + + done · terminal + + + + + + + + + + Approve + Reject + Finish + Finish + + + + + + States + stateKey · label · terminal · visibility · sortOrder + + + + + + KEY + LABEL + TERMINAL + VISIBILITY + SORT + ⋯ + + + newNewfalseactive0 + + approvedApprovedfalsearchived1 + + rejectedRejectedfalsearchived2 + + doneDonetruehidden3 + + + + + + Add state + + + + + Transitions + transitionKey · from → to · label · reflection? · action template + + + + + + new.approvenew → approvedApproverequired + create_or_update_issue + ⋯ + + + new.rejectnew → rejectedRejectrequired + no action + ⋯ + + + approved.finishapproved → doneFinishno + no action + ⋯ + + + + + + 1 + 2 + 3 + 4 + + diff --git a/doc/plans/2026-05-19-triage-plugin-plan.md b/doc/plans/2026-05-19-triage-plugin-plan.md new file mode 100644 index 00000000000..872046b9791 --- /dev/null +++ b/doc/plans/2026-05-19-triage-plugin-plan.md @@ -0,0 +1,715 @@ +# PAP-9815 - Paperclip Triage Plugin Plan + +## Revision Intent + +This is a fresh plan for [PAP-9815](/PAP/issues/PAP-9815), written from the original transcript and the latest board feedback. + +Changes from the rejected revision: + +- Use **queues** everywhere and treat transcription variants as references to queues. +- Make Paperclip Triage a first-party package in the monorepo. +- Keep ingestion to the minimal ingest API. Do not plan source connectors in this issue. +- Limit transition actions and auto-actions to create-or-update Paperclip issues from templates. +- Explain the motivation: this is a queue workbench where a human deals with items, teaches an assistant, and gradually turns repeated judgement into queue guidance. +- Incorporate the approved UI caveat: the item workbench is a two-column work area with chat on the center/left and the document/item on the right, editable either directly or through chat. + +## Product Thesis + +Paperclip Triage is not a source connector and not just an inbox classifier. It is a teachable queue workbench. + +The user has streams of heterogeneous objects that arrive from somewhere else. The transcript examples are examples of item shapes, not implementation targets for source integrations. The triage plugin should not care where those objects came from in v1. Its job starts when an item is posted into a queue. + +For each queue, the user should be able to: + +- see and manage the queue, +- process one item at a time with an assistant, +- edit or inspect the item's main content and metadata, +- talk to the assistant in a Paperclip-backed conversation, +- capture what the user is teaching through edits and comments, +- reflect after each item and propose updates to queue guidance, +- transition the item through the queue workflow, +- optionally create or update a normal Paperclip issue when an item enters a configured state. + +The important product loop is: + +1. An item enters a queue. +2. The user and assistant deal with the item. +3. Their conversation and edits reveal the user's taste, policy, standards, and preferences. +4. The assistant proposes a guidance update for that queue. +5. The user accepts, rejects, edits, or asks for revision. +6. The item moves to its next workflow state. +7. The next item benefits from the improved guidance. + +Over time, the assistant becomes more useful because the queue's guidance becomes more specific. Future automation can use confidence and guidance to take more control, but v1 should make the learning loop legible and safe before broad autonomy. + +## Scope Lock + +### In Scope + +- First-party monorepo package: + - directory: `packages/plugins/plugin-triage/` + - package name: `@paperclipai/plugin-triage` + - plugin id: `paperclipai.plugin-triage` + - display name: `Paperclip Triage` +- Plugin-owned database namespace for queues, items, workflows, guidance, reflection proposals, transition logs, and issue links. +- Plugin page, sidebar entry, and route sidebar using the plugin SDK. +- Managed Paperclip resources: + - a managed `Triage Assistant` agent, + - managed triage skills, + - a managed `Triage` project for plugin operation issues and default issue-action targets. +- Minimal ingest API for posting items into queues. +- Queue CRUD and queue item CRUD. +- Queue-level workflow states and allowed transitions. +- Queue-level folder-like guidance documents, with `guidance.md` created by default. +- Queue workbench: + - fixed two-column work area with assistant chat on the center/left and item document/editor on the right, + - direct item editing and chat-assisted item editing against the same item content, + - workflow/transition controls, + - reflection and guidance proposal flow. +- Hidden Paperclip issue conversation for each queue chat history. +- Optional work issue per queue item, created or updated by transition action templates. +- State transition actions limited to create-or-update Paperclip issues. +- Tests and QA acceptance for the complete queue loop. + +### Out of Scope + +- Any upstream/source connector or source-specific connection logic. +- Deciding what upstream systems create ingestions. +- Crawling, polling, OAuth, source syncs, webhooks from third-party products, or source-specific dedupe. +- External side effects in upstream systems outside Paperclip. +- Fully autonomous processing based on confidence thresholds. +- A complex visual workflow builder. +- Moving this into Paperclip core unless the plugin SDK has a concrete blocker that must be filed separately. + +## References Read + +- Repo product context: `doc/GOAL.md`, `doc/PRODUCT.md`, `doc/SPEC-implementation.md`, `doc/DEVELOPING.md`, `doc/DATABASE.md`. +- Plugin guide: `doc/plugins/PLUGIN_AUTHORING_GUIDE.md`. +- LLM Wiki package: `packages/plugins/plugin-llm-wiki/`, including manifest, worker, UI, migrations, tests, managed resources, plugin API routes, local-folder/document patterns, hidden operation issues, and route sidebar. +- Content plugin planning context from [PAP-9555](/PAP/issues/PAP-9555): the useful lesson is boundary discipline. Agents or outside systems can fetch or discover objects, while the plugin owns normalized storage, dedupe or idempotency, operation records, and write APIs. For triage v1, that becomes a minimal generic ingest API, not source-specific connectors. + +## Working Vocabulary + +### Queue + +A queue is a named workflow container keyed by a stable `queueKey`. + +Examples: + +- `inbox` +- `drafts` +- `reviews` +- `content-training` +- `financial-approvals` + +A queue owns: + +- title, description, status, and counts, +- a default workflow, +- allowed transitions, +- optional transition action templates, +- default assistant agent selection, +- queue-specific guidance documents, +- queue chat history. + +Posting to an unknown queue key creates the queue by default. Callers can request strict behavior with `requireExistingQueue: true`, which returns an error if the queue does not exist. + +### Queue Item + +A queue item is the object being triaged. + +It should be deliberately flexible: + +- `title` +- `contentFormat` such as `markdown`, `text`, or `json` +- main `content` +- free-form `properties` for upstream/downstream identifiers, stats, labels, or source metadata +- optional `itemKey` or `idempotencyKey` for upsert behavior +- current workflow state +- optional linked queue chat context +- optional linked work issue + +V1 should not enforce a queue item schema beyond basic shape and size limits. Queue-level JSON Schema or Zod-style validation can be a later enhancement after the ease-of-use path is proven. + +### Queue Guidance + +Queue guidance is the assistant's memory for how to handle that queue. + +It should behave like a folder of documents, not one oversized blob. Every queue starts with: + +- `guidance.md` + +Later, the queue may have more files: + +- `style.md` +- `review-policy.md` +- `examples.md` +- `rubric.md` + +V1 can store these documents in the plugin database with path-like keys and revision metadata. The UI should present them as a folder-like document set. A later phase can add local-folder export/import if needed. + +### Queue Chat Issue + +Paperclip already uses issues/comments as the conversation and work model. Triage should reuse that model instead of inventing a parallel chat database. + +Each queue gets its own hidden Paperclip issue conversation for assistant chat history. The queue workbench pins the current item into the chat context so the assistant stays on task while that item is active. + +Rules: + +- Do not share chat issues across queues. +- A queue can have chat history and a "new chat" action that creates a fresh hidden issue for that queue. +- The hidden chat issue is distinct from the optional work issue created for a specific queue item. +- The UI can render a lean chat surface, but persistence and agent interaction should map back to Paperclip issue/session semantics. + +### Work Issue + +A queue item may optionally be linked to a normal Paperclip issue. + +This is how a triaged object hands work to the rest of Paperclip. The work issue is not the queue chat issue. It is the concrete issue that agents can execute, track, comment on, assign, block, and finish. + +V1 transition actions should create or update this work issue from templates. + +## Minimal Ingest API + +The ingest path is just a plugin API endpoint. + +Proposed route: + +```text +POST /api/plugins/paperclipai.plugin-triage/api/queues/:queueKey/items +``` + +Auth: + +- `board-or-agent` +- company resolved from request body or active company context, following plugin API route rules + +Request shape: + +```json +{ + "companyId": "company-id", + "title": "Draft launch post", + "contentFormat": "markdown", + "content": "# Draft\n\n...", + "properties": { + "upstreamId": "external-system-id", + "sourceKind": "opaque-source-name", + "priority": "medium" + }, + "itemKey": "optional-stable-external-key", + "idempotencyKey": "optional-request-key", + "requireExistingQueue": false, + "initialStateKey": "draft" +} +``` + +Behavior: + +- If `queueKey` exists, add or upsert the item there. +- If `queueKey` is unknown and `requireExistingQueue` is false or omitted, create the queue with default settings and then create the item. +- If `queueKey` is unknown and `requireExistingQueue` is true, return a typed error. +- If `itemKey` or `idempotencyKey` matches an existing item in the queue, update the item according to the ingest policy and record an ingest event instead of duplicating it. +- Do not interpret source type beyond storing opaque metadata. Source-specific semantics are out of scope. + +The same worker logic should also be available through a plugin action for internal UI/tests, but the API endpoint is the canonical ingest path. + +## Workflow Model + +Each queue owns a workflow definition. + +Default v1 workflow: + +```text +draft -> approved +draft -> rejected +approved -> done +rejected -> done +``` + +The default can be adjusted before implementation if UX finds a better naming set, but it should stay simple. + +Workflow definition fields: + +- states: + - stable `stateKey` + - display name + - terminal flag + - queue visibility behavior, such as active, archived, or hidden +- transitions: + - from state + - to state + - label + - optional required reflection state + - optional action template + +Implementation approach: + +- Store the workflow as structured plugin data. +- Start with simple validated state/transition records rather than a visual state-machine builder. +- In the product/API contract phase, evaluate whether a lightweight state-machine library is worth adopting. If the queue UI only needs allowed next-state constraints, a small internal validator is enough for v1. +- Keep the workflow editor simple: state list, allowed next states, and transition action configuration. YAML import/export can be a later convenience, not the primary v1 UI. + +## Transition Actions + +V1 actions and auto-actions are only create-or-update Paperclip issue templates. + +Supported action type: + +```json +{ + "type": "create_or_update_issue", + "mode": "create_if_missing", + "template": { + "title": "{{item.title}}", + "description": "{{item.content}}\n\nMetadata:\n{{item.propertiesJson}}", + "comment": "Triage item moved to {{transition.toStateKey}}.", + "projectId": "{{queue.defaultProjectId}}", + "assignee": "{{queue.defaultAssignee}}", + "priority": "medium", + "status": "todo" + } +} +``` + +Modes: + +- `create_if_missing`: create a work issue only if the item has none. +- `update_existing`: require an existing work issue and add/update fields. +- `create_or_update`: create if missing, otherwise update/comment. + +Allowed Paperclip effects: + +- create an issue, +- add a comment to the linked issue, +- update assignee, +- update project, +- update status, +- update priority, +- update title or description when explicitly configured. + +Disallowed in v1: + +- external calls, +- destructive actions, +- source-specific side effects, +- autonomous state transitions based only on model confidence. + +Every transition action must write an audit row with the actor, item, transition, template, resolved issue id, and result. + +## Workbench UX + +The transcript's intended shape is a focused triage workspace, not a generic settings page. + +Main surfaces: + +- Sidebar entry: `Triage`. +- Route sidebar: + - queue list, + - item counts by state, + - create queue, + - settings/reconcile link. +- Main page: + - queue management view, + - selected queue item list, + - item workbench. + +The primary item workbench is a fixed two-column work area: + +- center/left column: assistant chat for working through the current item, +- right column: the item document/content editor and item properties. + +Queue navigation, item navigation, metadata, and settings can sit in the route sidebar or a narrow supporting rail, but they should not change the core mental model: chat on the center/left, document/item on the right. + +The right-side document panel is where the user can edit the item directly. The center/left chat panel is where the user asks the assistant to help edit, evaluate, or decide. Chat-assisted edits and direct edits both target the same item content and properties, and both are learning signal for reflection. + +Expected controls: + +- edit item content, +- edit free-form properties, +- send assistant message, +- create new queue chat, +- view queue chat history, +- view current guidance, +- request reflection, +- see proposed guidance diff, +- accept guidance update, +- reject guidance update, +- ask assistant to revise guidance update, +- manually edit guidance, +- choose next state from allowed transitions, +- view or open the linked work issue. + +Shared SDK/UI components to reuse where possible: + +- `MarkdownBlock` and `MarkdownEditor` for item content and guidance, +- `AssigneePicker` and `ProjectPicker` for issue action templates, +- `IssuesList` for linked/generated work issues, +- `ManagedRoutinesList` if maintenance routines are added, +- `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostNavigation`. + +## Assistant And Reflection Loop + +The assistant must be guided by queue context, current item context, and current guidance. + +Each assistant turn should have access to: + +- queue name and purpose, +- current item title/content/properties, +- current workflow state, +- allowed next transitions, +- current queue guidance files, +- linked work issue summary if one exists, +- recent relevant queue chat history, +- current reflection status. + +The assistant should have plugin tools or actions for: + +- reading queue guidance, +- proposing guidance changes, +- updating item content through controlled actions, +- summarizing item outcome, +- preparing transition recommendations, +- reading linked work issue context. + +The assistant should not directly apply guidance changes without user acceptance. The reflection loop is: + +1. User finishes dealing with an item. +2. Assistant compares the original item, final item, user edits, chat, decision, and current guidance. +3. Assistant proposes a guidance diff or says no change is needed. +4. User accepts, rejects, edits manually, or asks for revision. +5. Accepted guidance creates a new guidance revision. +6. User transitions the item. + +V1 should run reflection after each item. Batched reflection is a later optimization. + +## Data Model Draft + +All tables live in the plugin database namespace and include company scoping. + +- `triage_queues` + - `id`, `company_id`, `queue_key`, `display_name`, `description`, `status` + - `default_state_key` + - `workflow_version` + - `default_assistant_agent_id` + - `default_project_id` + - `settings` + - unique `(company_id, queue_key)` +- `triage_queue_states` + - `id`, `company_id`, `queue_id`, `state_key`, `display_name`, `is_terminal`, `visibility`, `sort_order` +- `triage_queue_transitions` + - `id`, `company_id`, `queue_id`, `from_state_key`, `to_state_key`, `label`, `requires_reflection`, `action_template`, `sort_order` +- `triage_items` + - `id`, `company_id`, `queue_id`, `item_key`, `idempotency_key` + - `title`, `content_format`, `content`, `properties` + - `state_key`, `status`, `linked_work_issue_id` + - `created_at`, `updated_at`, `archived_at` + - unique `(company_id, queue_id, item_key)` when `item_key` is present +- `triage_queue_chats` + - `id`, `company_id`, `queue_id`, `hidden_issue_id`, `status`, `started_at`, `ended_at` +- `triage_item_events` + - `id`, `company_id`, `queue_id`, `item_id`, `event_type`, `actor_type`, `actor_id`, `payload`, `created_at` +- `triage_guidance_documents` + - `id`, `company_id`, `queue_id`, `path`, `title`, `body`, `content_hash`, `latest_revision_id` + - unique `(company_id, queue_id, path)` +- `triage_guidance_revisions` + - `id`, `company_id`, `queue_id`, `document_id`, `body`, `content_hash`, `summary`, `created_by`, `created_at` +- `triage_guidance_proposals` + - `id`, `company_id`, `queue_id`, `item_id`, `base_revision_id`, `proposed_body`, `diff`, `status`, `review_comment`, `created_at`, `resolved_at` +- `triage_transition_actions` + - `id`, `company_id`, `queue_id`, `item_id`, `transition_id`, `action_type`, `template_snapshot`, `result`, `issue_id`, `created_at` + +## Plugin Architecture + +Manifest: + +- capabilities: + - `api.routes.register` + - `database.namespace.migrate/read/write` + - `agents.managed` + - `skills.managed` + - `projects.managed` + - `issues.create` + - `issues.update` + - `issue.comments.create` + - `issues.read` + - `agent.sessions.create/list/send/close` + - `agent.tools.register` + - `activity.log.write` + - `plugin.state.read/write` + - `ui.sidebar.register` + - `ui.page.register` +- UI slots: + - sidebar link, + - page route, + - route sidebar, + - settings page if needed for managed resources/reconcile. +- API routes: + - ingest item, + - list/create/update queues, + - list/create/update items, + - transition item, + - guidance document read/write, + - guidance proposal accept/reject/revise, + - queue chat send/history. + +Worker: + +- registers data handlers for queues, items, workflow, guidance, settings, and linked issues, +- registers actions for queue CRUD, item updates, transitions, reflection proposals, guidance updates, and managed resource reconciliation, +- implements `onApiRequest` for the minimal ingest API and any declared JSON routes, +- uses `ctx.issues` for create/update issue transition actions, +- uses agent sessions or Paperclip issue-backed comments for queue assistant chat, +- records activity and transition audit rows. + +UI: + +- self-contained plugin React under the package, +- no imports from `ui/src`, +- use SDK shared components first, +- keep route sidebar local to the triage route. + +## What Triage Borrows From LLM Wiki + +LLM Wiki is the nearest implementation pattern because it already demonstrates: + +- a first-party plugin package in `packages/plugins`, +- a manifest with page/sidebar/routeSidebar slots, +- managed agent/project/skills/routines, +- plugin database namespace migrations, +- worker API routes and actions, +- hidden plugin-operation issues, +- agent session capabilities, +- settings health/reconcile screens, +- route sidebar with plugin-local navigation, +- tests that render plugin UI with mocked bridge data. + +Triage should reuse those patterns, but with different product primitives: + +- LLM Wiki has wiki spaces and pages; Triage has queues, items, workflows, and guidance. +- LLM Wiki ingests sources; Triage receives already-prepared items through a minimal API. +- LLM Wiki creates operation issues for maintenance; Triage creates queue chat issues and optional work issues for items. +- LLM Wiki stores durable knowledge; Triage stores queue-specific operating guidance learned from item processing. + +## Relationship To Content Plugin + +The content plugin context from [PAP-9555](/PAP/issues/PAP-9555) is useful mainly as a boundary model: + +- the plugin should not make Paperclip core own specialized content workflows, +- the plugin should store normalized objects and operation history, +- agents and outside systems can do source-specific fetching, +- plugin APIs should provide simple commit/write contracts. + +For triage, the equivalent is: + +- any outside source may post a queue item, +- triage stores the generic item and queue workflow state, +- source-specific discovery and crawling stay out of scope, +- the only downstream Paperclip action is create-or-update issue from a template. + +## Acceptance Criteria + +The approved implementation is complete when: + +1. `@paperclipai/plugin-triage` exists as a monorepo package and can be built/tested like other first-party plugins. +2. The plugin declares page/sidebar/routeSidebar UI and managed triage agent/project/skills. +3. A user can create, edit, list, and archive queues. +4. Every new queue gets a default workflow and `guidance.md`. +5. The minimal ingest API can post an item to an existing queue or create the queue by default. +6. Queue items support title, content, free-form properties, idempotent upsert, and workflow state. +7. The queue workbench lets a user process an item with a queue-specific assistant chat and item editor. +8. Queue chat is backed by hidden Paperclip issue/session semantics and is not shared across queues. +9. The reflection flow can propose a guidance diff after item processing. +10. The user can accept, reject, manually edit, or request revision of guidance changes. +11. The user can transition an item only through allowed workflow transitions. +12. A configured transition action can create or update a linked Paperclip work issue from a template. +13. Transition actions are audited and company scoped. +14. No source connectors or external side-effect actions are included. +15. UX review, security review, and QA acceptance are completed before the parent is marked done. + +## Post-Approval Child Issue Plan + +The plan is approved, with the explicit two-column workbench caveat incorporated above. Create the child issue graph below and block [PAP-9815](/PAP/issues/PAP-9815) on QA acceptance. + +### Phase 0 - Product/API Contract Lock + +Owner: Senior Product Engineer. + +Blocks: all implementation phases. + +Deliverables: + +- Finalize queue/item/workflow/guidance/chat/work-issue terminology. +- Lock the minimal ingest request/response contract. +- Lock workflow representation and default states. +- Decide whether v1 uses a small internal workflow validator or an existing lightweight state-machine library. +- Lock issue action template fields and allowed Paperclip effects. +- Produce a concise contract document attached to [PAP-9815](/PAP/issues/PAP-9815) or the Phase 0 child issue. + +Verification: + +- CTO/product acceptance on the contract. +- SecurityEngineer acknowledges the scoped action surface before implementation begins. + +### Phase 1 - UX Workbench Design + +Owner: UXDesigner. + +Can start after Phase 0 has draft terminology and data model. Blocks UI implementation. + +Deliverables: + +- Wireframes for queue list, queue settings, workflow editor, item list, item workbench, reflection diff, guidance editor, and transition action template setup. +- Make the item workbench explicitly two columns: center/left chat and right-side document/item editor. +- Show both direct document editing and chat-assisted editing against the same item. +- Show desktop and constrained-width behavior. +- Include screenshots or a review artifact. +- Call out how the workbench reuses Paperclip-native components while keeping the chat surface lean. + +Verification: + +- CTO/product review accepts the workbench model. +- UXDesigner includes screenshots/artifacts in the issue. + +### Phase 2 - Package Scaffold And Managed Resources + +Owner: CodexCoder. + +Blocked by Phase 0. + +Deliverables: + +- Create `packages/plugins/plugin-triage/` package. +- Add manifest, worker, UI entrypoint, tests, bundler configs, package scripts. +- Declare managed agent, project, and skills. +- Add settings/reconcile health surface. +- Add page/sidebar/routeSidebar slots. + +Verification: + +- `pnpm --filter @paperclipai/plugin-triage typecheck` +- `pnpm --filter @paperclipai/plugin-triage test` +- `pnpm --filter @paperclipai/plugin-triage build` + +### Phase 3 - Data Model And Minimal Ingest API + +Owner: CodexCoder. + +Blocked by Phase 0 and Phase 2. + +Deliverables: + +- Add plugin namespace migrations for queues, states, transitions, items, queue chats, guidance docs/revisions/proposals, item events, and transition actions. +- Implement queue CRUD and item CRUD worker actions. +- Implement the minimal ingest API route. +- Add idempotency and queue create-on-post behavior. +- Add company-scoped access checks through plugin route company resolution and worker validation. + +Verification: + +- Focused plugin tests for queue creation, strict missing-queue error, ingest upsert, company scoping, and migration shape. + +### Phase 4 - Queue Chat And Reflection Backend + +Owner: CodexCoder, with Skill Consultant review for managed skill prompts if needed. + +Blocked by Phase 3. + +Deliverables: + +- Create/reuse hidden queue chat issues. +- Implement assistant session/send flow with current item and queue guidance context. +- Add controlled item content update action. +- Add guidance proposal generation path. +- Add accept/reject/revise/manual-edit guidance flows. +- Add managed skill/instruction files for triage assistant behavior. + +Verification: + +- Tests for queue-specific chat isolation, prompt/context assembly, proposal lifecycle, accepted guidance revisions, and rejected proposals. + +### Phase 5 - Transition Actions + +Owner: CodexCoder. + +Blocked by Phase 3. + +Deliverables: + +- Implement create-or-update issue action templates. +- Resolve template variables from queue/item/transition context. +- Create/update linked work issue with `ctx.issues`. +- Add audit rows and visible transition history. +- Enforce v1 action allowlist. + +Verification: + +- Tests for create-if-missing, update-existing, create-or-update, invalid template rejection, cross-company denial, and audit rows. + +### Phase 6 - Workbench UI Implementation + +Owner: ClaudeCoder, with CodexCoder acceptable if the implementer has stronger local context. + +Blocked by Phase 1, Phase 3, Phase 4, and Phase 5. + +Deliverables: + +- Implement queue route sidebar, queue list, item list, item workbench, content editor, chat panel, guidance panel, reflection diff, transition buttons, linked issue panel, and template editor. +- Make the item workbench match the approved two-column shape: center/left chat and right-side document/item editor. +- Support direct edits in the right column and chat-assisted edits from the center/left column. +- Use shared SDK components where available. +- Include screenshots in the implementation issue. + +Verification: + +- Component or render tests for primary UI states. +- Browser/manual screenshots for queue list and item workbench. + +### Phase 7 - Security Review + +Owner: SecurityEngineer. + +Blocked by Phase 4, Phase 5, and Phase 6. + +Deliverables: + +- Review template injection risks in create-or-update issue actions. +- Review company scoping and issue mutation permissions. +- Review hidden queue chat issue behavior. +- Review direct-edit and chat-assisted edit paths for authorization and audit gaps. +- Confirm v1 has no source connectors or external side effects. + +Verification: + +- Security review comment records findings. +- No high or medium blockers remain open before QA starts. + +### Phase 8 - QA And Acceptance + +Owner: QA. + +Blocked by Phase 6 and Phase 7. + +Deliverables: + +- Validate package install/build path. +- Validate create queue. +- Validate ingest into existing queue. +- Validate ingest creates missing queue by default. +- Validate strict ingest errors when queue is missing. +- Validate item processing with assistant. +- Validate two-column item workbench: chat center/left, document/item right. +- Validate direct item edits and chat-assisted item edits both update the same item. +- Validate guidance proposal accept/reject/edit/revise. +- Validate allowed transitions only. +- Validate create/update Paperclip issue transition action. +- Validate no source connector behavior exists. + +Verification: + +- QA comment with pass/fail matrix and screenshots for UI flows. +- Bugs filed as follow-up child issues if found. + +## Approval Request + +Approved in issue comments. Create the Phase 0-8 child issues with the owners and blockers above, then keep [PAP-9815](/PAP/issues/PAP-9815) blocked on Phase 8 QA acceptance. diff --git a/packages/plugins/plugin-triage/src/worker.ts b/packages/plugins/plugin-triage/src/worker.ts index 2898eaeef46..efa92a445a3 100644 --- a/packages/plugins/plugin-triage/src/worker.ts +++ b/packages/plugins/plugin-triage/src/worker.ts @@ -143,6 +143,26 @@ async function managedResourceHealth( } function actionActor(ctx: PluginContext, params: Record) { + const actorUserId = typeof params.actorUserId === "string" ? params.actorUserId : null; + const actorAgentId = typeof params.actorAgentId === "string" ? params.actorAgentId : null; + const actorType = typeof params.actorType === "string" ? params.actorType : null; + + if (actorType === "user" && actorUserId) { + return { + actorType, + actorId: actorUserId, + actorRunId: typeof params.actorRunId === "string" ? params.actorRunId : null, + }; + } + + if (actorType === "agent" && actorAgentId) { + return { + actorType, + actorId: actorAgentId, + actorRunId: typeof params.actorRunId === "string" ? params.actorRunId : null, + }; + } + return { actorType: "plugin-action", actorId: ctx.manifest.id, @@ -154,6 +174,7 @@ async function ensureQueueChatIssue( ctx: PluginContext, service: ReturnType, params: Record, + actor?: { actorType?: string | null; actorId?: string | null; actorRunId?: string | null }, ) { const companyId = requireCompanyId(params); const ensured = await service.ensureQueueChat(params); @@ -161,6 +182,11 @@ async function ensureQueueChatIssue( const existingIssue = hiddenIssueId ? await ctx.issues.get(hiddenIssueId, companyId) : null; if (!existingIssue) { const project = await ctx.projects.managed.reconcile(TRIAGE_PROJECT_KEY, companyId); + const issueActor = actor?.actorType === "user" + ? { actorUserId: actor.actorId ?? undefined, actorRunId: actor.actorRunId ?? undefined } + : actor?.actorType === "agent" + ? { actorAgentId: actor.actorId ?? undefined, actorRunId: actor.actorRunId ?? undefined } + : undefined; const issue = await ctx.issues.create({ companyId, projectId: project.projectId ?? undefined, @@ -175,6 +201,7 @@ async function ensureQueueChatIssue( surfaceVisibility: "plugin_operation", originKind: `plugin:${PLUGIN_ID}:operation:queue-chat`, originId: `queue-chat:${ensured.queue.id}`, + actor: issueActor, }); hiddenIssueId = issue.id; ensured.chat = await service.updateQueueChat({ @@ -381,7 +408,7 @@ export function createTriagePlugin(options: { ctx.actions.register("ensure-queue-chat", async (params: Record) => { await requireKnownCompany(ctx, requireCompanyId(params)); - return ensureQueueChatIssue(ctx, getService(), params); + return ensureQueueChatIssue(ctx, getService(), params, actionActor(ctx, params)); }); ctx.actions.register("send-assistant-message", async (params: Record) => { @@ -393,7 +420,8 @@ export function createTriagePlugin(options: { } const context = await getService().getAssistantContext(params); - const chat = await ensureQueueChatIssue(ctx, getService(), params); + const actor = actionActor(ctx, params); + const chat = await ensureQueueChatIssue(ctx, getService(), params, actor); const agentResolution = await ctx.agents.managed.reconcile(TRIAGE_ASSISTANT_AGENT_KEY, companyId); if (!agentResolution.agentId) { throw new Error("Triage Assistant managed agent is missing"); @@ -427,11 +455,16 @@ export function createTriagePlugin(options: { }, }); if (chat.hiddenIssueId) { + const commentAuthor = actor.actorType === "user" + ? { authorUserId: actor.actorId ?? undefined } + : actor.actorType === "agent" + ? { authorAgentId: actor.actorId ?? undefined } + : undefined; await ctx.issues.createComment( chat.hiddenIssueId, [`User message for item \`${context.item.title}\`:`, "", message].join("\n"), companyId, - { authorAgentId: agentResolution.agentId }, + commentAuthor, ); } return { diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 24bc871b369..a03744070f7 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1006,7 +1006,7 @@ export interface WorkerToHostMethods { result: IssueComment[], ]; "issues.createComment": [ - params: { issueId: string; body: string; companyId: string; authorAgentId?: string }, + params: { issueId: string; body: string; companyId: string; authorAgentId?: string; authorUserId?: string }, result: IssueComment, ]; "issues.createInteraction": [ @@ -1015,6 +1015,7 @@ export interface WorkerToHostMethods { companyId: string; interaction: CreateIssueThreadInteraction; authorAgentId?: string | null; + authorUserId?: string | null; }, result: IssueThreadInteraction, ]; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index de34e8ae112..1c4370aa756 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1394,6 +1394,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { async create(input) { requireCapability(manifest, capabilitySet, "issues.create"); const now = new Date(); + const actorAgentId = input.actor?.actorAgentId ?? null; + const actorUserId = input.actor?.actorUserId ?? null; const originKind = normalizePluginOriginKind( input.surfaceVisibility === "plugin_operation" && !input.originKind ? pluginOperationIssueOriginKind(manifest.id) @@ -1417,8 +1419,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, - createdByAgentId: null, - createdByUserId: null, + createdByAgentId: actorAgentId, + createdByUserId: actorUserId, issueNumber: null, identifier: null, originKind, @@ -1519,13 +1521,18 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { throw new Error(`Issue not found: ${issueId}`); } const now = new Date(); + const authorType = options?.authorAgentId + ? "agent" + : options?.authorUserId + ? "user" + : "system"; const comment: IssueComment = { id: randomUUID(), companyId: parentIssue.companyId, issueId, - authorType: options?.authorAgentId ? "agent" : "system", + authorType, authorAgentId: options?.authorAgentId ?? null, - authorUserId: null, + authorUserId: options?.authorUserId ?? null, body, presentation: null, metadata: null, @@ -1562,7 +1569,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { title: interaction.title ?? null, summary: interaction.summary ?? null, createdByAgentId: options?.authorAgentId ?? null, - createdByUserId: null, + createdByUserId: options?.authorUserId ?? null, payload: interaction.payload, result: null, createdAt: now, diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index bcbb7f4e71d..ada1c40de73 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -1412,31 +1412,31 @@ export interface PluginIssuesClient { issueId: string, body: string, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise; createInteraction( issueId: string, interaction: CreateIssueThreadInteraction, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise; suggestTasks( issueId: string, interaction: Omit, "kind">, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise; askUserQuestions( issueId: string, interaction: Omit, "kind">, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise; requestConfirmation( issueId: string, interaction: Omit, "kind">, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise; /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ documents: PluginIssueDocumentsClient; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 9bfae39e675..2058cd5829a 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -832,16 +832,33 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("issues.listComments", { issueId, companyId }); }, - async createComment(issueId: string, body: string, companyId: string, options?: { authorAgentId?: string }) { - return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId }); + async createComment( + issueId: string, + body: string, + companyId: string, + options?: { authorAgentId?: string; authorUserId?: string }, + ) { + return callHost("issues.createComment", { + issueId, + body, + companyId, + authorAgentId: options?.authorAgentId, + authorUserId: options?.authorUserId, + }); }, - async createInteraction(issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }) { + async createInteraction( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string; authorUserId?: string }, + ) { return callHost("issues.createInteraction", { issueId, companyId, interaction, authorAgentId: options?.authorAgentId, + authorUserId: options?.authorUserId, }); }, @@ -849,7 +866,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost issueId: string, interaction, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise { return callHost("issues.createInteraction", { issueId, @@ -859,6 +876,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost kind: "suggest_tasks", }, authorAgentId: options?.authorAgentId, + authorUserId: options?.authorUserId, }) as Promise; }, @@ -866,7 +884,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost issueId: string, interaction, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise { return callHost("issues.createInteraction", { issueId, @@ -876,6 +894,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost kind: "ask_user_questions", }, authorAgentId: options?.authorAgentId, + authorUserId: options?.authorUserId, }) as Promise; }, @@ -883,7 +902,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost issueId: string, interaction, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; authorUserId?: string }, ): Promise { return callHost("issues.createInteraction", { issueId, @@ -893,6 +912,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost kind: "request_confirmation", }, authorAgentId: options?.authorAgentId, + authorUserId: options?.authorUserId, }) as Promise; }, diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 22a5b3cea95..3e8561a5535 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1519,6 +1519,79 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); + it("rejects creating an issue with a project from another company", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const foreignProjectId = randomUUID(); + + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "Other company", + issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + ]); + + await db.insert(projects).values({ + id: foreignProjectId, + companyId: otherCompanyId, + name: "Foreign project", + status: "in_progress", + }); + + await expect(svc.create(companyId, { + projectId: foreignProjectId, + title: "Cross-company project link", + status: "todo", + priority: "medium", + })).rejects.toThrow("Project must belong to same company"); + }); + + it("rejects updating an issue to point at another company's project", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const foreignProjectId = randomUUID(); + + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "Other company", + issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + ]); + + await db.insert(projects).values({ + id: foreignProjectId, + companyId: otherCompanyId, + name: "Foreign project", + status: "in_progress", + }); + + const issue = await svc.create(companyId, { + title: "Safe issue", + status: "todo", + priority: "medium", + }); + + await expect(svc.update(issue.id, { + projectId: foreignProjectId, + })).rejects.toThrow("Project must belong to same company"); + }); + it("captures the assignee default environment when neither issue nor project specifies one", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 963d3cd1f15..9a199ad368b 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -622,7 +622,10 @@ describe.sequential("plugin tool and bridge authz", () => { expect(res.status).toBe(200); expect(call).toHaveBeenCalledWith(pluginId, "performAction", { key: "sync", - params: {}, + params: { + actorType: "user", + actorUserId: "admin-1", + }, renderEnvironment: null, }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 38576291ffd..7c26bcdf816 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1579,7 +1579,9 @@ export function issueRoutes( projectId: string | null; goalId: string | null; }) { - const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null); + const projectPromise = issue.projectId + ? projectsSvc.listByIds(issue.companyId, [issue.projectId]).then((rows) => rows[0] ?? null) + : Promise.resolve(null); const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null); const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]); @@ -4191,7 +4193,7 @@ export function issueRoutes( assertCompanyAccess(req, issue.companyId); if (issue.projectId) { - const project = await projectsSvc.getById(issue.projectId); + const project = await projectsSvc.listByIds(issue.companyId, [issue.projectId]).then((rows) => rows[0] ?? null); if (project?.pausedAt) { res.status(409).json({ error: diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 6d56f5aa0c0..870c880d568 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -1332,13 +1332,19 @@ export function pluginRoutes( assertPluginBridgeScope(req, body?.companyId); + const params = { ...(body?.params ?? {}) }; + if (typeof req.actor.userId === "string" && req.actor.userId.length > 0) { + params.actorType = "user"; + params.actorUserId = req.actor.userId; + } + try { const result = await bridgeDeps.workerManager.call( plugin.id, "performAction", { key, - params: body?.params ?? {}, + params, renderEnvironment: body?.renderEnvironment ?? null, }, ); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ae349906a60..c75574a996b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -3011,6 +3011,22 @@ export function issueService(db: Db) { } } + async function assertProjectInCompany( + companyId: string, + projectId: string | null | undefined, + dbOrTx: DbReader = db, + ) { + if (!projectId) return; + const project = await dbOrTx + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!project) { + throw unprocessable("Project must belong to same company"); + } + } + async function assertValidProjectWorkspace( companyId: string, projectId: string | null | undefined, @@ -4034,6 +4050,9 @@ export function issueService(db: Db) { if (data.assigneeUserId) { await assertAssignableUser(companyId, data.assigneeUserId); } + if (issueData.projectId) { + await assertProjectInCompany(companyId, issueData.projectId); + } if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } @@ -4312,6 +4331,9 @@ export function issueService(db: Db) { if (issueData.assigneeUserId) { await assertAssignableUser(existing.companyId, issueData.assigneeUserId); } + if (issueData.projectId !== undefined) { + await assertProjectInCompany(existing.companyId, issueData.projectId, dbOrTx); + } const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId; const nextProjectWorkspaceId = issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId; @@ -5368,6 +5390,7 @@ export function issueService(db: Db) { }> = []; const visited = new Set([issueId]); const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null); + const companyId = start?.companyId ?? null; let currentId = start?.parentId ?? null; while (currentId && !visited.has(currentId) && raw.length < 50) { visited.add(currentId); @@ -5376,7 +5399,11 @@ export function issueService(db: Db) { status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId, goalId: issues.goalId, parentId: issues.parentId, - }).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null); + }).from(issues).where( + companyId + ? and(eq(issues.id, currentId), eq(issues.companyId, companyId)) + : eq(issues.id, currentId), + ).then(r => r[0] ?? null); if (!parent) break; raw.push({ id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null, @@ -5430,7 +5457,14 @@ export function issueService(db: Db) { const workspaceRows = await db .select() .from(projectWorkspaces) - .where(inArray(projectWorkspaces.projectId, projectIds)) + .where( + companyId + ? and( + eq(projectWorkspaces.companyId, companyId), + inArray(projectWorkspaces.projectId, projectIds), + ) + : inArray(projectWorkspaces.projectId, projectIds), + ) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); const workspaceMap = new Map>(); for (const workspace of workspaceRows) { @@ -5442,7 +5476,11 @@ export function issueService(db: Db) { const rows = await db.select({ id: projects.id, name: projects.name, description: projects.description, status: projects.status, goalId: projects.goalId, - }).from(projects).where(inArray(projects.id, projectIds)); + }).from(projects).where( + companyId + ? and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)) + : inArray(projects.id, projectIds), + ); for (const r of rows) { const projectWorkspaceRows = workspaceMap.get(r.id) ?? []; const workspaces = projectWorkspaceRows.map((workspace) => ({ @@ -5473,7 +5511,11 @@ export function issueService(db: Db) { const rows = await db.select({ id: goals.id, title: goals.title, description: goals.description, level: goals.level, status: goals.status, - }).from(goals).where(inArray(goals.id, goalIds)); + }).from(goals).where( + companyId + ? and(eq(goals.companyId, companyId), inArray(goals.id, goalIds)) + : inArray(goals.id, goalIds), + ); for (const r of rows) goalMap.set(r.id, r); } diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index fef165dc1a8..ecdc72feda9 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1791,14 +1791,20 @@ export function buildHostServices( const comment = (await issues.addComment( params.issueId, params.body, - { agentId: params.authorAgentId }, + { + agentId: params.authorAgentId ?? undefined, + userId: params.authorUserId ?? undefined, + }, )) as IssueComment; await logPluginActivity({ companyId, action: "issue.comment.created", entityType: "issue", entityId: issue.id, - actor: { actorAgentId: params.authorAgentId ?? null }, + actor: { + actorAgentId: params.authorAgentId ?? null, + actorUserId: params.authorUserId ?? null, + }, details: { identifier: issue.identifier, commentId: comment.id, @@ -1813,13 +1819,17 @@ export function buildHostServices( const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); const interaction = await issueThreadInteractionService(db).create(issue, params.interaction as CreateIssueThreadInteraction, { agentId: params.authorAgentId ?? null, + userId: params.authorUserId ?? null, }); await logPluginActivity({ companyId, action: "issue.thread_interaction_created", entityType: "issue", entityId: issue.id, - actor: { actorAgentId: params.authorAgentId ?? null }, + actor: { + actorAgentId: params.authorAgentId ?? null, + actorUserId: params.authorUserId ?? null, + }, details: { identifier: issue.identifier, interactionId: interaction.id,