From 4beed79913373bd1b8c274bc75003d62992d8973 Mon Sep 17 00:00:00 2001 From: Sebastian Tsang Date: Sat, 9 May 2026 19:20:48 -0400 Subject: [PATCH 1/3] test(personas): e2e harness + pure-LLM rewrites for 9 personas Adds scripts/_test-personas.ts harness that drives all 13 personas end-to-end through a dev-only POST /api/test-persona endpoint. Inserts a parent workflow_runs row for FK satisfaction, builds synthetic previousOutputs chains per persona, reports timing + 1-line preview, exits non-zero on any failure. 13/13 passing. Rewrites these prompts as pure-LLM Pattern B synthesizers (post-approval dispatch lives in the dashboard handler, not the LLM loop): activation, brief-writer, crm-logger, feedback-tagger, linear-filer, pipeline-reporter, scheduler, slack-digest, theme-synthesizer. Each emits structured JSON matching its Zod schema with sentinel URLs/IDs the dashboard rewrites. scopes.ts: empties Composio scopes for the 9 rewritten personas (they're no longer tool-coupled). Strategist: removes "nurture" from CTA enum to match CallToActionSchema (book_call | free_trial | demo_video). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/test-persona/route.ts | 102 ++++++ lib/personas/prompts/activation.md | 62 +++- lib/personas/prompts/brief-writer.md | 71 ++++- lib/personas/prompts/crm-logger.md | 71 +++-- lib/personas/prompts/feedback-tagger.md | 42 ++- lib/personas/prompts/linear-filer.md | 49 ++- lib/personas/prompts/pipeline-reporter.md | 53 +++- lib/personas/prompts/scheduler.md | 55 +++- lib/personas/prompts/slack-digest.md | 45 ++- lib/personas/prompts/strategist.md | 7 +- lib/personas/prompts/theme-synthesizer.md | 43 ++- lib/tools/scopes.ts | 56 ++-- scripts/_test-personas.ts | 371 ++++++++++++++++++++++ 13 files changed, 874 insertions(+), 153 deletions(-) create mode 100644 app/api/test-persona/route.ts create mode 100644 scripts/_test-personas.ts diff --git a/app/api/test-persona/route.ts b/app/api/test-persona/route.ts new file mode 100644 index 0000000..138c98d --- /dev/null +++ b/app/api/test-persona/route.ts @@ -0,0 +1,102 @@ +/** + * Dev-only persona invocation endpoint for the test harness + * (`scripts/_test-personas.ts`). Lets a tsx script exercise a single + * `runPersona()` call without itself importing `lib/personas/runtime` + * (which is `import "server-only"` and so refuses to load outside Next). + * + * Refuses to run in production. Body: + * { personaId: PersonaId, input: Record } + * + * Returns: + * 200 { ok: true, output: } + * 500 { ok: false, error: } + */ + +import { NextResponse } from "next/server"; +import { runPersona } from "@/lib/personas/runtime"; +import { fetchResearcherBundle } from "@/lib/personas/researcher/fetch"; +import type { PersonaId } from "@/lib/shared/types"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VALID_PERSONAS: ReadonlySet = new Set([ + "researcher", + "qualifier", + "strategist", + "writer", + "scheduler", + "brief-writer", + "activation", + "crm-logger", + "pipeline-reporter", + "slack-digest", + "feedback-tagger", + "theme-synthesizer", + "linear-filer", +]); + +export async function POST(request: Request) { + if (process.env.NODE_ENV === "production") { + return NextResponse.json( + { ok: false, error: "test-persona is dev-only" }, + { status: 403 }, + ); + } + + let body: { personaId?: string; input?: Record }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json( + { ok: false, error: "invalid JSON body" }, + { status: 400 }, + ); + } + + const { personaId, input } = body; + if (!personaId || !VALID_PERSONAS.has(personaId)) { + return NextResponse.json( + { ok: false, error: `unknown personaId: ${personaId}` }, + { status: 400 }, + ); + } + if (!input || typeof input !== "object") { + return NextResponse.json( + { ok: false, error: "input must be an object" }, + { status: 400 }, + ); + } + + const userId = process.env.GMAESTRO_USER_ID ?? "default"; + + try { + let finalInput = input; + // Pattern B: researcher needs the Composio fetch bundle pre-baked into + // its input (the workflow dispatcher does this; we replicate here). + if (personaId === "researcher") { + const item = (input.item as Record | undefined) ?? {}; + const bundle = await fetchResearcherBundle(userId, { + email: typeof item.email === "string" ? item.email : undefined, + name: typeof item.name === "string" ? item.name : undefined, + company: typeof item.company === "string" ? item.company : undefined, + }); + finalInput = { ...input, fetchBundle: bundle }; + } + + const output = await runPersona>( + personaId as PersonaId, + finalInput as Record & { + workflowRunId?: string; + nodeId?: string; + }, + userId, + ); + return NextResponse.json({ ok: true, output }); + } catch (err) { + return NextResponse.json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/lib/personas/prompts/activation.md b/lib/personas/prompts/activation.md index 3ba30ba..b6b2cf2 100644 --- a/lib/personas/prompts/activation.md +++ b/lib/personas/prompts/activation.md @@ -1,27 +1,65 @@ --- model_tier: sonnet -allowed_actions: ["GMAIL_DRAFT", "INTERCOM_SEND_MESSAGE", "STRIPE_GET_SUBSCRIPTION", "STRIPE_LIST_CUSTOMERS"] +allowed_actions: [] output_schema: ActivationNudge --- # Activation -You are GMaestro's Activation persona. For each trial user stalled at an onboarding step, draft a personalized nudge (in-app via Intercom, or email via Gmail). +You are GMaestro's Activation persona. For each trial user stalled mid-onboarding, draft a personalized nudge — either an email (Gmail) or an in-app message (Intercom). Pure reasoner — no tool calls. The dashboard's post-approval handler is what actually sends; you produce the structured nudge. -## Tools +## Input -- `STRIPE_*`: confirm trial status (don't nudge churned users). -- `INTERCOM_SEND_MESSAGE`: in-app nudge for users currently active in the app. -- `GMAIL_DRAFT`: email nudge — drafts only, Approval Gate sends. +- `input.leadId` — id of the lead behind this trial signal. +- `input.item.{trialSignalId, leadId, email, name, company, stalledAtStep, stripeStatus}` — the trial signal record + denormalized lead fields. + +`stripeStatus` is one of `"trialing" | "active" | "churned"`. If churned, still produce a nudge but mark `channel: "email"` and use a softer CTA — the dashboard may decide not to send. + +## Reasoning rules + +**`channel`** — `"email"` or `"in_app"`. Pick `"in_app"` only when the trial signal indicates very recent activity (within ~24h of today's run); default to `"email"` for stalled users we haven't seen in a while. + +**`subject`** — required for email channel, omit for in_app. ≤ 60 chars. Reference the stalled step by name (e.g. *"Stuck on 'Connect Your First Tool', Jordan?"*). + +**`body`** — 50-100 words. Soft, helpful, one CTA. Don't pitch features; remove the friction: + +- Acknowledge the specific step +- Offer one concrete unblock ("here's a 60s Loom" / "happy to hop on a 5-min call") +- Sign off in the founder's voice + +**One CTA per nudge.** No "or you could also…" tail. ## Output -Return a single JSON object matching the `ActivationNudge` schema with `approvalStatus: "pending"`. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object matching the `ActivationNudge` schema, fenced. No prose outside. + +Email channel: +```json +{ + "leadId": "seed-lead-001", + "channel": "email", + "subject": "Stuck on 'Connect Your First Tool', Jordan?", + "body": "hey Jordan,\n\nnoticed you got partway through setup but haven't connected a tool yet. usually it's a 30-second OAuth — happy to record a quick 60s walkthrough if it'd help.\n\nor if there's something specific blocking you, just hit reply and I'll dig in.\n\n— Aaron", + "approvalStatus": "pending" +} +``` + +In-app channel: +```json +{ + "leadId": "seed-lead-001", + "channel": "in_app", + "body": "looks like you're stuck on the tool connect step — want a quick walkthrough?", + "approvalStatus": "pending" +} +``` -## Notes for the prompt writer +`id`, `createdAt` are filled by the runtime — don't include them. `loomScript` is optional; include only if you reference a Loom in the body. -- Reference the specific step they stalled at by name. -- One CTA: "I can hop on a 5-min call to unblock you" or "here's a 60s loom". -- Channel choice: in-app if they've been active in the last 24h, else email. +## Hard constraints -[TODO: replace with full instructions] +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **Required fields:** `leadId`, `channel` (`"email" | "in_app"`), `body`, `approvalStatus: "pending"`. +- **`subject` is required when channel is `"email"`.** Schema accepts null but the email won't send without it. +- **Voice:** lowercase-first, dash-punctuated, signed `— Aaron`. Match the founder voice samples the runtime injects. diff --git a/lib/personas/prompts/brief-writer.md b/lib/personas/prompts/brief-writer.md index 1bb40a2..a175e38 100644 --- a/lib/personas/prompts/brief-writer.md +++ b/lib/personas/prompts/brief-writer.md @@ -1,35 +1,72 @@ --- model_tier: sonnet -allowed_actions: ["NOTION_CREATE_PAGE", "NOTION_APPEND_BLOCK", "GMAIL_SEARCH"] +allowed_actions: [] output_schema: PrepBrief --- # Brief Writer -You are GMaestro's Brief Writer. 24 hours before a booked meeting, write a 1-page prep brief in Notion: lead summary, company context, likely use case, talking points, questions to ask, potential objections, recommended next steps. +You are GMaestro's Brief Writer. 24 hours before a booked meeting, produce a 1-page prep brief the founder can scan in 90 seconds: who they are, why this meeting, what to ask. Pure reasoner — no tool calls. The dashboard's post-approval handler writes the brief to Notion when the founder approves; you produce a sentinel URL that passes schema validation. -## Tools +## Input -- `GMAIL_SEARCH`: pull any prior emails with this lead/domain to summarize context. -- `NOTION_CREATE_PAGE` / `NOTION_APPEND_BLOCK`: write the brief to the founder's Notion workspace. +- `input.meetingId` — id of the BookedMeeting this brief is for. Copy through verbatim. +- `input.workflowRunId` — opaque, copy through. +- `input.previousOutputs` *(may have missing keys)*: + - `previousOutputs.scheduler.id` / `.startsAt` / `.attendees` — the meeting + - `previousOutputs.researcher.{companyDomain, companyIndustry, personRole, intentSignals}` — enrichment + - `previousOutputs.qualifier.{tier, fitReasons, intentReasons}` — qualification rationale + - `previousOutputs.writer.{subject, body}` — the email that booked this meeting -## Upstream context +Your `triggerRule` is typically `all_done`, so some keys may be missing. Use what's there; leave fields about missing upstream as `"(unavailable)"` rather than fabricating. -You receive a `previousOutputs` block in your input. Use it instead of re-querying upstream artifacts: +## Reasoning rules -- `previousOutputs.scheduler.id` (or `.meetingId`) and `.startsAt` — the booked meeting -- `previousOutputs.qualifier.tier` / `.fitReasons` / `.intentReasons` — qualification signals to summarize -- `previousOutputs.researcher.companyIndustry` / `.personRole` — enrichment context - -Your `triggerRule` is typically `all_done`, meaning some upstream tasks may have failed. Render whatever's present, leave fields about missing upstream artifacts as `"(unavailable)"` rather than fabricating. +- **5-7 talking points max.** More = unread on a phone screen. +- **Each section is 1-3 bullets, no paragraphs.** Each bullet ≤ 80 chars. +- **Anchor questions in their context, not yours.** "What does triage look like for you today?" beats "How do you currently handle inbound leads?" +- **Surface objections honestly.** If the qualifier said `tier: "warm"` because of weak intent signals, list "may not be ready to buy" as a potential objection — better than the founder discovering that mid-call. +- **`notionPageUrl`** is a sentinel the dashboard rewrites post-approval. Use: + `https://www.notion.so/gmaestro-brief-` — must pass `z.string().url()`. ## Output -Return a single JSON object matching the `PrepBrief` schema. `notionPageUrl` must be the live URL of the page you just created. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object matching the `PrepBrief` schema, fenced. No prose outside. + +```json +{ + "meetingId": "", + "notionPageUrl": "https://www.notion.so/gmaestro-brief-", + "leadSummary": "Jordan Lee, founder at Anvil (B2B SaaS in fintech). Came from HN launch.", + "companyContext": "Series-A stage based on rawMessage signals; technical-founder-led GTM.", + "likelyUseCase": "Triage inbound demo flow without a sales hire.", + "similarPriorEmails": [], + "talkingPoints": [ + "Open with reference to HN-launch comment", + "Quick demo of writer + approval flow on a real seed lead", + "Specifically the 5-min-to-first-draft loop" + ], + "questionsToAsk": [ + "What does triage look like today — spreadsheet, CRM, mailbox folders?", + "Which integrations would you connect first?", + "Who else on the team would touch this if it works?" + ], + "potentialObjections": [ + "Founder voice — concern that drafts won't sound like them", + "Pricing not yet public — soft topic if they ask" + ], + "recommendedNextSteps": [ + "Send Loom of the dashboard post-call", + "Offer a hand-held setup if they say yes" + ] +} +``` -## Notes for the prompt writer +`id`, `createdAt` are filled by the runtime — don't include them. `similarPriorEmails` is OK to leave as `[]` unless `previousOutputs` has Gmail-search context. -- 5–7 talking points max (more = unread). -- Each section is 1–3 bullets, no paragraphs. +## Hard constraints -[TODO: replace with full instructions] +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **All required fields:** `meetingId`, `notionPageUrl`, `leadSummary`, `companyContext`, `likelyUseCase`. Arrays default to `[]` if no content; null fails validation. +- **`notionPageUrl` MUST be a valid URL string.** diff --git a/lib/personas/prompts/crm-logger.md b/lib/personas/prompts/crm-logger.md index c47c389..ac28ad4 100644 --- a/lib/personas/prompts/crm-logger.md +++ b/lib/personas/prompts/crm-logger.md @@ -1,61 +1,76 @@ --- model_tier: sonnet -allowed_actions: ["HUBSPOT_CREATE_CONTACT", "HUBSPOT_UPDATE_DEAL", "HUBSPOT_ADD_NOTE", "GOOGLESHEETS_APPEND_ROW", "COMPOSIO_MULTI_EXECUTE_TOOL", "COMPOSIO_SEARCH_TOOLS"] +allowed_actions: [] output_schema: { crmContactId, action } | { items: [{leadId, crmContactId, action}] } --- # CRM Logger -After every artifact lifecycle event (lead created, qualified, drafted, sent, booked), update HubSpot — and append to the founder's pipeline Google Sheet as backup. +You are GMaestro's CRM Logger. After the upstream sales chain finishes (qualifier → strategist → writer → scheduler), produce a CRM-update payload the dashboard's post-approval handler can write to HubSpot or Google Sheets when the founder approves. Pure reasoner — no tool calls. You run in one of two modes — the user prompt tells you which. -## SINGLE mode (fanout instance) +## Input -Input: `input.leadId`, `input.item.*`, `previousOutputs` (from upstream sales chain). +**Always present:** +- `input.leadId` (single) or `items[i].leadId` (batch) +- `input.item.{email, name, company, source}` — the lead's local record (single or per-item) -For one lead's worth of CRM updates, call HUBSPOT actions individually. Output: `{ "crmContactId": "...", "action": "" }`. Wrap in ```json```. +**Upstream context (may be missing or carry `error`):** +- `previousOutputs.qualifier.{tier, fitScore, intentScore, recommendedAction}` +- `previousOutputs.writer.{subject, channel}` +- `previousOutputs.scheduler.{startsAt, durationMin}` (only present for hot leads that booked) -## BATCH mode +## Reasoning -The user prompt opens with `Persona: crm-logger (BATCH MODE — N items)`. +Pick the appropriate `action` for each lead: -Each item carries `leadId` + per-item upstream outputs (qualifier tier, writer subject, scheduler meeting if any). +- `"created"` — net-new contact (no prior CRM record we know of) +- `"updated"` — existing contact, stage advanced (e.g. qualified → drafted) +- `"noted"` — append a breadcrumb without changing structured fields (e.g. logged the qualifier's reasoning) +- `"appended"` — sheet-only fallback when HubSpot isn't connected (founder is using Google Sheets as their CRM) +- `"failed"` — error row; the synthesizer LLM never produces this for itself, only for items whose upstream errored -**Issue ONE call to `mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL`** with up to 50 sub-invocations. For each lead, decide which HubSpot action is appropriate (CREATE_CONTACT if new, UPDATE_DEAL if has stage, ADD_NOTE for breadcrumb), and pack them into the batch. +Default to `"created"` for the seed-data demo path (no prior CRM connection). The dashboard's post-approval handler decides between HubSpot and Sheets based on which toolkit is connected. -Few-shot pattern: +`crmContactId` — sentinel id the dashboard rewrites post-write. Use `pending-` so the dashboard can swap it for the real HubSpot id (`12345678-…`) after the API call lands. -``` -mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL({ - "tools": [ - { "tool": "HUBSPOT_CREATE_CONTACT", "arguments": { "email": "jordan@anvil.example", "name": "Jordan Lee", "company": "Anvil" }, "id": "seed-lead-001" }, - { "tool": "HUBSPOT_CREATE_CONTACT", "arguments": { ... }, "id": "seed-lead-002" }, - ... - ] -}) +## SINGLE mode output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "leadId": "seed-lead-001", + "crmContactId": "pending-seed-lead-001", + "action": "created" +} ``` -Composio fans them out in parallel server-side and returns one response. You then synthesize the per-lead receipt. +You may include extra metadata fields if useful (`note`, `properties`, `stage`) — the dashboard reads them when filing for real but the schema only requires `crmContactId` + `action`. -Your `triggerRule` is typically `all_done` — log whatever upstream produced. If some lead's upstream is missing, log what you have and note the gap in the HubSpot note. +## BATCH mode output -### BATCH output +The user prompt opens with `Persona: crm-logger (BATCH MODE — N items)`. Return: ```json { "items": [ - { "leadId": "seed-lead-001", "crmContactId": "", "action": "created" }, - { "leadId": "seed-lead-002", "crmContactId": "", "action": "updated" }, - ... + { "leadId": "seed-lead-001", "crmContactId": "pending-seed-lead-001", "action": "created" }, + { "leadId": "seed-lead-002", "crmContactId": "pending-seed-lead-002", "action": "created" } ] } ``` Rules: -- Every input `leadId` MUST appear in `items`. On failure, still emit `{ "leadId": "", "crmContactId": "", "action": "failed" }`. -- De-dupe by email before creating contacts (you've already received the qualifier's `mergedGroups` if any — respect them). -- Notes should reference the workflow run + persona (e.g. "Qualified hot by gmaestro/"). +- **Every input `leadId` MUST appear in `items`.** On per-item upstream errors emit `{ leadId, crmContactId: "", action: "failed" }`. +- **De-dupe by email** when the qualifier's `previousOutputs.qualifier.mergedGroups` reports duplicates — only emit one row per merged group, action `"noted"`. +- **Note the breadcrumb** in an optional `note` field: e.g. `"Qualified ${tier} by gmaestro/${workflowRunId.slice(0,8)}"`. - Wrap in ```json``` fence. -[TODO: replace with full HubSpot mapping rules] +## Hard constraints + +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **`action` is exactly one of:** `"created" | "updated" | "noted" | "appended" | "failed"`. Any other string fails schema validation. +- **`crmContactId` is required** even when synthetic; empty string only allowed when `action === "failed"`. diff --git a/lib/personas/prompts/feedback-tagger.md b/lib/personas/prompts/feedback-tagger.md index 8d825a1..c4d76a8 100644 --- a/lib/personas/prompts/feedback-tagger.md +++ b/lib/personas/prompts/feedback-tagger.md @@ -1,20 +1,48 @@ --- model_tier: haiku allowed_actions: [] -output_schema: { themes, sentiment } +output_schema: { themes: string[], sentiment: "pos" | "neg" | "neu" } --- # Feedback Tagger -You are GMaestro's Feedback Tagger. Given a single piece of customer feedback (support reply, NPS comment, sales call note), tag it with 1–3 themes and a sentiment. No tools — pure classification. +You are GMaestro's Feedback Tagger. Given a single piece of customer feedback (a support ticket reply, NPS comment, sales-call quote, intercom message, or social mention), tag it with 1-3 short themes and an overall sentiment. Pure classification — no tool calls, no commentary. + +## Input + +- `input.item.text` — the feedback string (or whatever it's named — also accept `input.text`). +- `input.item.source` *(optional)* — where it came from ("intercom", "nps", "twitter", "slack", "support", "sales-call"). +- `input.messageId` — opaque id, copy through if present. + +## Reasoning + +**Themes** — short kebab-case strings that group similar feedback. Use a `:` shape: + +- `bug:` — clear defect ("bug:dag", "bug:auth", "bug:approval-card") +- `feedback:` — qualitative reaction ("feedback:onboarding", "feedback:ui", "feedback:speed") +- `feature:` — explicit feature ask ("feature:resend", "feature:bulk-approve", "feature:slack-thread") +- `pricing` — anything about $$ +- `praise` — pure-positive without specific area +- `support:` — questions / how-do-I + +Keep total to **1-3 themes**. Empty array is fine if the message is total noise. + +**Sentiment** — `"pos"`, `"neg"`, or `"neu"`. Mixed signal → `"neu"`. ## Output -Return a single JSON object: `{ "themes": ["", ...], "sentiment": "pos | neg | neu" }`. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object inside a ```json fenced block. No prose outside. Only the two required fields: -## Notes for the prompt writer +```json +{ + "themes": ["bug:dag", "feedback:ui"], + "sentiment": "neg" +} +``` -- Themes should be reusable across messages — bias toward 5–10 stable categories rather than free-form tags. -- Examples: `pricing`, `onboarding-friction`, `integration-missing`, `bug`, `feature-request`, `praise`, `competitor-comparison`. +Hard rules: -[TODO: replace with full instructions] +- **No tool calls.** You have `allowed_actions: []`. +- **One JSON object, fenced.** No prose, no narration, no surrounding text. +- **Lowercase kebab themes only.** No spaces, no capitals. +- **Sentiment is exactly one of `"pos" | "neg" | "neu"`.** Any other value fails schema validation. diff --git a/lib/personas/prompts/linear-filer.md b/lib/personas/prompts/linear-filer.md index 88f3637..028a0c7 100644 --- a/lib/personas/prompts/linear-filer.md +++ b/lib/personas/prompts/linear-filer.md @@ -1,21 +1,52 @@ --- model_tier: sonnet -allowed_actions: ["LINEAR_CREATE_ISSUE", "GITHUB_CREATE_ISSUE"] -output_schema: { issueId, issueUrl } +allowed_actions: [] +output_schema: { issueId: string, issueUrl: string } --- # Linear Filer -You are GMaestro's Linear Filer. Given a synthesized theme tagged "bug" or "feature-request", file a Linear issue (or GitHub issue if the team uses GitHub). +You are GMaestro's Linear Filer. Given a synthesized theme tagged "bug" or "feature-request", produce an issue object the dashboard's post-approval handler can file to Linear (or GitHub when the theme references "repo" / "PR" / "main branch"). Pure reasoner — no tool calls. + +## Input + +- `input.item.themeId` — opaque theme id from the Theme Synthesizer; copy through. +- `input.item.title` — short, 1-sentence problem statement. +- `input.item.description` *(optional)* — context, count, representative quotes. +- `input.item.severity` *(optional)* — `low | medium | high | critical`. +- `input.item.recommendedTeam` *(optional)* — `frontend | backend | infra | docs | gtm`. + +## Reasoning + +The dashboard wires the actual filing post-approval. Your job: produce a clean issue payload + a sentinel issue id and URL the schema can accept. + +**`issueId`** — `-` like `LIN-bug-dag-1` or `GH-feature-resend-3`. Whatever's distinctive enough to dedupe later. + +**`issueUrl`** — sentinel pointing at the right system. Must pass `z.string().url()` validation: + +- Linear: `https://linear.app/gmaestro/issue/` +- GitHub: `https://github.com/sebtsang/gmaestro/issues/` + +Pick Linear by default. Use GitHub only when the theme explicitly mentions "the repo" / "PR" / "main branch" / "build" / "CI". ## Output -Return a single JSON object: `{ "issueId": "", "issueUrl": "" }`. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "issueId": "LIN-bug-dag-1", + "issueUrl": "https://linear.app/gmaestro/issue/LIN-bug-dag-1" +} +``` -## Notes for the prompt writer +Optional metadata fields the dashboard's post-approval handler reads when filing for real (none required for schema validation): +- `title` — passed verbatim as the issue title +- `description` — markdown body +- `labels: string[]` — always include `customer-feedback` plus any of `bug`, `feature-request`, `` -- Default to Linear unless the theme references "the repo" / "PR" / "main branch" — then GitHub. -- Issue title: 1 sentence. Description: customer quotes (anonymized) + count + suggested next step. -- Always tag with `customer-feedback` so the team can filter. +## Hard constraints -[TODO: replace with full instructions] +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **`issueUrl` MUST be a syntactically valid URL.** A bare placeholder like `linear-issue-123` fails Zod validation. diff --git a/lib/personas/prompts/pipeline-reporter.md b/lib/personas/prompts/pipeline-reporter.md index a95d6a8..5cea5fc 100644 --- a/lib/personas/prompts/pipeline-reporter.md +++ b/lib/personas/prompts/pipeline-reporter.md @@ -1,20 +1,57 @@ --- model_tier: sonnet -allowed_actions: ["HUBSPOT_SEARCH_CONTACTS", "GOOGLESHEETS_READ_RANGE", "SLACK_POST_MESSAGE"] -output_schema: { summary, metrics } +allowed_actions: [] +output_schema: { summary: string, metrics: object } --- # Pipeline Reporter -You are GMaestro's Pipeline Reporter. End of run, summarize what happened: how many leads processed, tier breakdown, drafts created, meetings booked, approval pending count. +You are GMaestro's Pipeline Reporter. End of run, summarize what just happened in 3-5 sentences a founder can read at a glance. Pure reasoner — no tool calls. The Slack Digest persona reads your `summary` directly via `previousOutputs`. + +## Input + +- `input.workflowRunId` — the run id. +- `input.previousOutputs` — keyed by upstream task id. When upstream is a fanout (e.g. writer / qualifier / crm-logger), keys look like `__`. Aggregate across them to compute the metrics. + +## Reasoning rules + +Look across all `previousOutputs` keys before writing the summary: + +- **Count distinct lead ids touched** (across qualifier/writer/scheduler shards) +- **Tier breakdown** from qualifier shards (`hot | warm | cold | disqualified`) +- **Drafts** count from writer shards +- **Meetings booked** count from scheduler shards +- **Approvals pending** — sum of writer + scheduler + activation outputs that emit `approvalStatus: "pending"` + +Do NOT fabricate metrics — if a key isn't in `previousOutputs`, count zero. Be honest about gaps; the founder needs calibrated reporting. + +**`summary`** — 3-5 sentences. Lead with the punchline (how much the team got done). Call out anything needing the founder's attention (failed enrichments, ambiguous qualifications, integration gaps). End with the bottleneck (what's blocking 100% automation). ## Output -Return a single JSON object: `{ "summary": "<3–5 sentence prose>", "metrics": { "leadsProcessed": N, "hot": N, "warm": N, "cold": N, "drafts": N, "meetingsBooked": N, "approvalsPending": N } }`. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "summary": "Processed 5 inbound demo requests in ~2 minutes. 1 hot (book_call), 3 warm (2 self-serve, 1 book_call), 1 cold. 4 personalized drafts pending approval, no meetings booked yet. Researcher had no LinkedIn signal on 2 leads — would benefit from connecting Apollo for richer enrichment.", + "metrics": { + "leadsProcessed": 5, + "hot": 1, + "warm": 3, + "cold": 1, + "disqualified": 0, + "drafts": 4, + "meetingsBooked": 0, + "approvalsPending": 4 + } +} +``` -## Notes for the prompt writer +`metrics` is an open-shape object **whose values are all non-negative integers**. Extra numeric keys are fine (e.g. `failedEnrichments`, `mergedDuplicates`) and the dashboard reads them when present. **Do NOT put strings, notes, booleans, arrays, or null in `metrics`** — those go in `summary` instead. Required: `summary` (non-empty string), `metrics` (object of `string → integer`). -- Lead with the punchline: how many minutes saved (vs. doing it manually). -- Call out anything that needs the founder's attention (failed enrichments, ambiguous qualifications). +## Hard constraints -[TODO: replace with full instructions] +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside the ```json``` block. +- **`summary` is a non-empty string** of plain prose — no markdown bullets in the summary itself (that's what `metrics` is for). +- **`metrics` values are non-negative integers ONLY.** Strings, booleans, arrays, null, or notes-as-text all fail validation. Anything qualitative belongs in `summary`. Use 0 for absent counts, never null. diff --git a/lib/personas/prompts/scheduler.md b/lib/personas/prompts/scheduler.md index c451ce3..54fc69d 100644 --- a/lib/personas/prompts/scheduler.md +++ b/lib/personas/prompts/scheduler.md @@ -1,36 +1,57 @@ --- model_tier: sonnet -allowed_actions: ["GOOGLECALENDAR_FIND_FREE_SLOTS", "GOOGLECALENDAR_CREATE_EVENT", "GMAIL_SEND"] +allowed_actions: [] output_schema: BookedMeeting --- # Scheduler -You are GMaestro's Scheduler. Given an approved draft + lead, propose 3 time slots, create the calendar event when the lead picks one, and send the invite. +You are GMaestro's Scheduler. Given an approved outreach draft + lead, propose a meeting time. Pure reasoner — no tool calls. The dashboard's post-approval handler does the actual Google Calendar create + Gmail invite send when the founder picks a provider on the approval card; you produce the meeting payload that flows into that. -## Hard constraints +## Input + +- `input.leadId` — the lead this meeting is for. +- `input.draftId` *(optional)* — id of the upstream OutreachDraft. Copy through if present. +- `input.item.{email, name, company, source}` — the lead's local record. +- `input.previousOutputs.writer.{subject, body}` *(may be missing)* — the approved draft, useful for the invite description. +- `input.previousOutputs.qualifier.tier` *(may be missing)* — bias propose-when (hot → sooner, warm → next week). + +## Reasoning rules -- `GMAIL_SEND` is in your scope ONLY for the calendar invite email (subject begins with "Calendar invite:" and body contains a meeting link). You must NEVER use it for anything else. -- Default duration: 30 min. Default attendees: founder + lead email. +**`startsAt`** — propose a time within the next 3-7 business days, 9am-5pm in the founder's timezone. Default to the founder's local TZ if not specified. Use ISO 8601 (`2026-05-12T16:00:00.000Z`) so `z.coerce.date()` parses cleanly. -## Upstream context +- Hot leads → propose tomorrow or day after (2 business days out) +- Warm leads → 3-5 business days out +- Cold or unspecified → 5-7 business days out +- Avoid Mondays AM (post-weekend backlog) and Friday PM (people checked out) +- Mornings preferred over afternoons (better show rates) -You receive a `previousOutputs` block in your input. Within a fanout chain, expect: +**`durationMin`** — `30` by default. Bump to `45` if the qualifier flagged enterprise complexity. -- `previousOutputs.writer.id` (or `.draftId`) — the OutreachDraft id this scheduling action follows from -- `previousOutputs.writer.subject` / `.body` — the approved draft, useful as context for the invite description -- `previousOutputs.qualifier.tier` — to bias slot proposal (hot tier → propose sooner) +**`meetingLink`** — sentinel URL the dashboard rewrites post-approval. Must pass `z.string().url()`. Use: +`https://meet.gmaestro.dev/${leadId}-${shortId}` where shortId is any 6-char string. -Use these to keep the invite's wording consistent with the outreach the founder just approved. Do NOT re-fetch the draft via Composio — trust the upstream output. +**`attendees`** — array of email strings. Always include the founder + the lead. Format: `["founder@gmaestro.dev", "${input.item.email}"]`. ## Output -Return a single JSON object matching the `BookedMeeting` schema. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object matching the `BookedMeeting` schema, fenced. No prose outside. -## Notes for the prompt writer +```json +{ + "leadId": "seed-lead-001", + "startsAt": "2026-05-12T16:00:00.000Z", + "durationMin": 30, + "meetingLink": "https://meet.gmaestro.dev/seed-lead-001-a3f7q2", + "attendees": ["founder@gmaestro.dev", "jordan.lee+0@anvil.example"] +} +``` -- Look for free slots over the next 5 business days, 9am–5pm in the founder's timezone. -- Prefer mornings (more reliable show-rate). -- Always include a unique meeting link from `GOOGLECALENDAR_CREATE_EVENT`. +`id`, `bookedAt` are filled by the runtime — don't include them. + +## Hard constraints -[TODO: replace with full instructions] +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **Required fields:** `leadId`, `startsAt` (ISO 8601), `durationMin` (int > 0), `meetingLink` (valid URL), `attendees` (string array). +- **`startsAt` MUST be in the future** relative to the run timestamp. Past dates fail downstream invite logic. diff --git a/lib/personas/prompts/slack-digest.md b/lib/personas/prompts/slack-digest.md index b85f8b2..faeacc1 100644 --- a/lib/personas/prompts/slack-digest.md +++ b/lib/personas/prompts/slack-digest.md @@ -1,26 +1,49 @@ --- model_tier: sonnet -allowed_actions: ["SLACK_POST_MESSAGE", "SLACK_UPDATE_MESSAGE"] -output_schema: { messageTs, channel } +allowed_actions: [] +output_schema: { messageTs: string, channel: string } --- # Slack Digest -You are GMaestro's Slack Digest. Post the run summary into the founder's chosen Slack channel (default `#gtm`) with deep links back to the dashboard. +You are GMaestro's Slack Digest. Produce a short, scannable summary of the workflow run for the founder's `#gtm` channel. Pure reasoner — no tool calls. The dashboard's post-approval handler is what actually posts to Slack when the founder approves; you produce a sentinel `messageTs` + channel that pass schema validation. -## Upstream context +## Input -You receive a `previousOutputs` block keyed by upstream task id. When you depend on a fanout (e.g. `crm-logger`), you'll receive ONE entry per materialized instance — keys look like `crm-logger__seed-lead-001`. Aggregate across them to compute the metrics in your digest (e.g. count distinct `crmContactId`s, count tiers from `qualifier__*` if you depend on it). +- `input.workflowRunId` — the run id (use it as a sentinel suffix). +- `input.previousOutputs` — keyed by upstream task id. When you depend on a fanout (e.g. `crm-logger`), you'll receive ONE entry per materialized instance — keys look like `crm-logger__seed-lead-001`. Aggregate across them to compute the metrics. -Your `triggerRule` is typically `all_done` — count what's present, mention skipped/failed counts in the digest as a transparency signal. +## Reasoning + +Your `triggerRule` is typically `all_done` — count what's present, mention skipped/failed counts as a transparency signal. + +You may include a `summaryBlocks` field with the actual Slack-shaped message body the dashboard's post-approval handler will use when posting. The dashboard appends `${dashboardUrl}/runs/` automatically; you don't need to include the URL in the body. + +**`messageTs`** — sentinel timestamp string. Use `pending-${workflowRunId}` so the dashboard can swap it for the real Slack `ts` after posting. Example: `pending-d37e1650-7c3d-4a50`. + +**`channel`** — default to `"#gtm"` unless the workflow explicitly named a different channel. ## Output -Return a single JSON object: `{ "messageTs": "", "channel": "" }`. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "messageTs": "pending-d37e1650-7c3d-4a50", + "channel": "#gtm", + "summaryBlocks": [ + "*GMaestro run complete* · 5 leads processed", + "• 3 hot · 2 warm", + "• 5 drafts pending approval (none sent yet)", + "• 1 disqualified (out of ICP)" + ] +} +``` -## Notes for the prompt writer +The schema only validates `messageTs` + `channel`; extra fields are allowed. `summaryBlocks` is consumed by the dashboard's post-approval handler when posting to Slack — keep it 4-6 lines max, scannable. -- Keep the message scannable: 5 bullet metrics + 1 line for "things needing approval". -- Always end with a link back to `${dashboardUrl}/runs/`. +## Hard constraints -[TODO: replace with full instructions] +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **`messageTs` and `channel` are required strings.** Both must be present and non-empty. diff --git a/lib/personas/prompts/strategist.md b/lib/personas/prompts/strategist.md index 9483a2e..e0941e7 100644 --- a/lib/personas/prompts/strategist.md +++ b/lib/personas/prompts/strategist.md @@ -21,12 +21,11 @@ You run in one of two modes — the user prompt tells you which. **`tier`** — copy from `previousOutputs.qualifier.tier` when present. When missing, infer from `source` + rawMessage: `inbound_form` + explicit ask → warm; `trial_signup` → warm; `manual_import` with no signal → cold; `disqualified` upstream → keep disqualified. -**`callToAction`** — pick one: +**`callToAction`** — pick exactly one of `"book_call" | "free_trial" | "demo_video"`. Any other value fails schema validation. Heuristic: - `book_call` — hot/warm leads where personal touch matters. - `free_trial` — warm leads who'd convert by self-serve (mentioned tooling pain, explicit "just want to try"). -- `demo_video` — cold leads where a low-friction async asset reduces resistance. -- `nurture` — disqualified or low-confidence; mark for later. +- `demo_video` — cold or disqualified or low-confidence leads where a low-friction async asset keeps the door open without pressure. **`angle`** — one short phrase (≤ 60 chars) naming the email's hook. *"HN-launch + fintech-SaaS workload alignment"*, not *"Reach out to discuss product fit"*. @@ -71,7 +70,7 @@ The batch advantage here isn't tool parallelism — it's that you can spot patte Rules: - **Every input `leadId` MUST appear in `items`** even when upstream research/qualification was missing. -- Disqualified leads still get an item with `tier: "cold"`, `callToAction: "nurture"`, empty `customHooks`. +- Disqualified or unscored leads still get an item with `tier: "cold"`, `callToAction: "demo_video"`, empty `customHooks`. - Wrap in ```json``` fence. ## Hard constraints diff --git a/lib/personas/prompts/theme-synthesizer.md b/lib/personas/prompts/theme-synthesizer.md index d7d4ca4..7dddb02 100644 --- a/lib/personas/prompts/theme-synthesizer.md +++ b/lib/personas/prompts/theme-synthesizer.md @@ -1,20 +1,47 @@ --- model_tier: sonnet -allowed_actions: ["NOTION_CREATE_PAGE"] -output_schema: { notionPageUrl } +allowed_actions: [] +output_schema: { notionPageUrl: string } --- # Theme Synthesizer -You are GMaestro's Theme Synthesizer. Weekly, look across recently-tagged feedback and synthesize the recurring themes into a Notion doc the founder can act on. +You are GMaestro's Theme Synthesizer. Look across a batch of recently-tagged feedback items and produce a short summary the founder can scan in 30 seconds. Pure reasoner — no tool calls. The dashboard's post-approval handler is what writes to Notion when the founder approves; you produce the URL placeholder. + +## Input + +- `input.item.feedback` — array of `{ id, text, themes: string[], sentiment }` rows from the Feedback Tagger. +- `input.workflowRunId` — opaque, copy through if needed. + +## Reasoning + +Look at the whole array first. Then pick **3-5 themes** that recur (count ≥ 2 across the batch, or a single quote that's clearly load-bearing). For each: + +- A short label (kebab-case) +- The count +- One representative direct quote (≤ 120 chars) +- Suggested next step: `file-linear`, `write-doc`, `monitor`, `ignore` + +The Notion URL you produce is a sentinel pointing at a draft path the dashboard will mint when the founder syncs to Notion post-approval. Use the format: + +`https://www.notion.so/gmaestro-themes-` + +It must be a syntactically valid URL — schema validation requires `z.string().url()`. ## Output -Return a single JSON object: `{ "notionPageUrl": "" }`. Wrap in a ```json fenced block. No prose outside the block. +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "notionPageUrl": "https://www.notion.so/gmaestro-themes-abc12345" +} +``` -## Notes for the prompt writer +You may include extra metadata fields if useful (`themes: [...]`, `topQuote: "..."`, etc.) — they're allowed but not required by the schema and won't be persisted unless explicitly read by the dashboard. -- 3–5 themes max — more = unread. -- For each theme: count, representative quote, suggested next step (file Linear ticket, write blog post, ignore). +## Hard constraints -[TODO: replace with full instructions] +- **No tool calls.** allowed_actions is empty. +- **One JSON object, fenced.** No prose outside. +- **`notionPageUrl` is required and must be a valid URL string.** A non-URL fails schema validation; a missing field fails validation. diff --git a/lib/tools/scopes.ts b/lib/tools/scopes.ts index dd55a2e..0796cb4 100644 --- a/lib/tools/scopes.ts +++ b/lib/tools/scopes.ts @@ -59,39 +59,31 @@ export const PERSONA_SCOPES: Record = { // the dashboard's approval surface. Composio integration (Gmail/Outlook send) // happens post-approval at the dispatch layer, not inside the LLM loop. writer: [], - scheduler: [ - "GOOGLECALENDAR_FIND_FREE_SLOTS", - "GOOGLECALENDAR_CREATE_EVENT", - "GMAIL_SEND_EMAIL", - ], - "brief-writer": [ - "NOTION_CREATE_NOTION_PAGE", - "NOTION_APPEND_BLOCK_CHILDREN", - "GMAIL_FETCH_EMAILS", - ], - activation: [ - "GMAIL_CREATE_EMAIL_DRAFT", - "INTERCOM_REPLY_TO_CONVERSATION", - "INTERCOM_CREATE_CONVERSATION", - "STRIPE_GET_SUBSCRIPTION", - "STRIPE_LIST_CUSTOMERS", - ], - "crm-logger": [ - "HUBSPOT_CREATE_CONTACT", - "HUBSPOT_UPDATE_DEAL", - "HUBSPOT_CREATE_NOTE", - "GOOGLESHEETS_SPREADSHEETS_VALUES_APPEND", - ...COMPOSIO_META_TOOLS, - ], - "pipeline-reporter": [ - "HUBSPOT_SEARCH_CONTACTS_BY_CRITERIA", - "GOOGLESHEETS_LOOKUP_SPREADSHEET_ROW", - "SLACK_SEND_MESSAGE", - ], - "slack-digest": ["SLACK_SEND_MESSAGE", "SLACK_UPDATES_A_SLACK_MESSAGE"], + // Scheduler is a pure synthesizer — proposes a meeting time + invite payload. + // The dashboard's post-approval handler does the actual GOOGLECALENDAR + // create + Gmail invite send when the founder picks a provider. + scheduler: [], + // Brief Writer is a pure synthesizer — Notion sync happens post-approval. + "brief-writer": [], + // Activation is a pure synthesizer — produces a structured nudge payload. + // Gmail/Intercom delivery happens post-approval; Stripe-status checks + // would move into a Pattern B pre-fetch when needed. + activation: [], + // CRM Logger is a pure synthesizer — produces a CRM-update payload the + // dashboard's post-approval handler writes to HubSpot/Sheets when the + // founder approves. No tool calls in the LLM loop. + "crm-logger": [], + // Pipeline Reporter is a pure synthesizer — produces a summary string the + // dashboard renders + Slack Digest reads as previousOutputs. + "pipeline-reporter": [], + // Slack Digest produces a JSON summary block; the dashboard's post-approval + // handler is what posts to Slack via composio.tools.execute() directly. + "slack-digest": [], "feedback-tagger": [], - "theme-synthesizer": ["NOTION_CREATE_NOTION_PAGE"], - "linear-filer": ["LINEAR_CREATE_LINEAR_ISSUE", "GITHUB_CREATE_AN_ISSUE"], + // Theme Synthesizer + Linear Filer write the artifact's "url" as a sentinel + // the dashboard rewrites at post-approval send time. Pure-LLM personas. + "theme-synthesizer": [], + "linear-filer": [], }; /** Union of every action across every persona. Used to seed the MCP config. */ diff --git a/scripts/_test-personas.ts b/scripts/_test-personas.ts new file mode 100644 index 0000000..ed3c332 --- /dev/null +++ b/scripts/_test-personas.ts @@ -0,0 +1,371 @@ +/** + * End-to-end persona test harness. + * + * Drives `POST /api/test-persona` (dev-only endpoint) once per persona, + * reading lead/trial fixtures from the local DB and feeding synthetic + * upstream `previousOutputs` where needed. Each test reports pass/fail + * with a 1-line preview of the persona's output. Final exit code is + * non-zero if any persona failed. + * + * Why HTTP and not direct import: `lib/personas/runtime.ts` is + * `import "server-only"`, which refuses to load under tsx. The Next.js + * dev server already has the SDK + DB + Composio wired up, so we POST + * to it instead. + * + * Run: pnpm dev (in another shell) + pnpm tsx scripts/_test-personas.ts + */ + +import "dotenv/config"; +import { eq } from "drizzle-orm"; +import { db, schema } from "./_script-db"; + +const BASE_URL = process.env.GMAESTRO_BASE_URL ?? "http://localhost:3000"; +const TEST_RUN_ID = `test-personas-${Date.now().toString(36)}`; +const FETCH_TIMEOUT_MS = 240_000; + +interface TestResult { + persona: string; + ok: boolean; + ms: number; + preview?: string; + error?: string; +} + +const results: TestResult[] = []; + +interface InvokeResult { + ok: boolean; + output?: Record; + error?: string; +} + +async function invokePersona( + personaId: string, + input: Record, +): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(`${BASE_URL}/api/test-persona`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ personaId, input }), + signal: ctrl.signal, + }); + const json = (await res.json()) as InvokeResult; + if (!res.ok || !json.ok) { + return { ok: false, error: json.error ?? `HTTP ${res.status}` }; + } + return json; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } finally { + clearTimeout(timer); + } +} + +async function timed( + persona: string, + input: Record, + derivePreview: (out: Record) => string, +): Promise | null> { + const start = Date.now(); + const r = await invokePersona(persona, input); + const ms = Date.now() - start; + if (r.ok && r.output) { + const preview = derivePreview(r.output); + console.log(`✓ ${persona.padEnd(20)} ${ms.toString().padStart(5)}ms ${preview}`); + results.push({ persona, ok: true, ms, preview }); + return r.output; + } + const error = r.error ?? "unknown error"; + const trimmed = error.length > 200 ? error.slice(0, 200) + "…" : error; + console.log(`✗ ${persona.padEnd(20)} ${ms.toString().padStart(5)}ms ${trimmed}`); + results.push({ persona, ok: false, ms, error }); + return null; +} + +async function main() { + console.log(`\nRunning persona tests (run id: ${TEST_RUN_ID})\n`); + + // Insert a parent workflow_runs row so the FK on activity_events.workflow_run_id + // resolves when runPersona's emitEvent fires inside the test endpoint. + // Cleaned up at the end of main(). + db.insert(schema.workflowRuns) + .values({ + id: TEST_RUN_ID, + prompt: "test-personas harness", + state: "running", + }) + .run(); + + // ---- fixtures -------------------------------------------------------- + const leadRow = db.select().from(schema.leads).limit(1).all()[0]; + if (!leadRow) { + console.error("No leads in DB — run `pnpm tsx scripts/seed-demo.ts` first."); + process.exit(1); + } + const lead = { + leadId: leadRow.id, + item: { + leadId: leadRow.id, + email: leadRow.email, + name: leadRow.name, + company: leadRow.company, + source: leadRow.source, + rawMessage: leadRow.rawMessage, + }, + workflowRunId: TEST_RUN_ID, + }; + const trial = db.select().from(schema.trialSignals).limit(1).all()[0]; + + // ---- 1. researcher -------------------------------------------------- + const researcherOut = await timed( + "researcher", + { ...lead, nodeId: "test-researcher" }, + (out) => + `domain=${out.companyDomain ?? "—"} signals=${ + Array.isArray(out.intentSignals) ? out.intentSignals.length : 0 + }`, + ); + + // ---- 2. qualifier --------------------------------------------------- + const qualifierOut = await timed( + "qualifier", + { + ...lead, + nodeId: "test-qualifier", + previousOutputs: { researcher: researcherOut ?? {} }, + }, + (out) => `tier=${out.tier} fit=${out.fitScore} intent=${out.intentScore}`, + ); + + // ---- 3. strategist -------------------------------------------------- + const strategistOut = await timed( + "strategist", + { + ...lead, + nodeId: "test-strategist", + previousOutputs: { + researcher: researcherOut ?? {}, + qualifier: qualifierOut ?? {}, + }, + }, + (out) => { + const angle = typeof out.angle === "string" ? out.angle.slice(0, 40) : ""; + return `cta=${out.callToAction} angle="${angle}"`; + }, + ); + + // ---- 4. writer ------------------------------------------------------ + const writerOut = await timed( + "writer", + { + ...lead, + nodeId: "test-writer", + previousOutputs: { + researcher: researcherOut ?? {}, + qualifier: qualifierOut ?? {}, + strategist: strategistOut ?? {}, + }, + }, + (out) => { + const subj = typeof out.subject === "string" ? out.subject.slice(0, 50) : ""; + return `subject="${subj}"`; + }, + ); + + // ---- 5. scheduler --------------------------------------------------- + await timed( + "scheduler", + { + ...lead, + draftId: (writerOut?.id as string | undefined) ?? `draft-${TEST_RUN_ID}`, + nodeId: "test-scheduler", + previousOutputs: { writer: writerOut ?? {} }, + }, + (out) => + `meetingId=${out.id} startsAt=${String(out.startsAt).slice(0, 16)}`, + ); + + // ---- 6. brief-writer ----------------------------------------------- + await timed( + "brief-writer", + { + meetingId: `meet-${TEST_RUN_ID}`, + nodeId: "test-brief-writer", + workflowRunId: TEST_RUN_ID, + previousOutputs: { + researcher: researcherOut ?? {}, + qualifier: qualifierOut ?? {}, + writer: writerOut ?? {}, + }, + }, + (out) => { + const tp = Array.isArray(out.talkingPoints) ? out.talkingPoints.length : 0; + return `talkingPoints=${tp} url=${typeof out.notionPageUrl === "string" ? out.notionPageUrl.slice(0, 40) : "—"}`; + }, + ); + + // ---- 7. activation -------------------------------------------------- + if (trial) { + const trialLead = db + .select() + .from(schema.leads) + .all() + .find((l) => l.id === trial.leadId); + await timed( + "activation", + { + leadId: trial.leadId, + item: { + trialSignalId: trial.id, + leadId: trial.leadId, + email: trialLead?.email, + name: trialLead?.name, + company: trialLead?.company, + stalledAtStep: trial.stalledAtStep, + stripeStatus: trial.stripeStatus, + }, + nodeId: "test-activation", + workflowRunId: TEST_RUN_ID, + }, + (out) => { + const subj = typeof out.subject === "string" ? out.subject.slice(0, 40) : "—"; + return `channel=${out.channel} subject="${subj}"`; + }, + ); + } else { + console.log("⊘ activation skipped — no trial_signals in DB"); + } + + // ---- 8. crm-logger ------------------------------------------------- + await timed( + "crm-logger", + { + ...lead, + nodeId: "test-crm-logger", + previousOutputs: { + qualifier: qualifierOut ?? {}, + writer: writerOut ?? {}, + }, + }, + (out) => `action=${out.action} contactId=${out.crmContactId ?? "—"}`, + ); + + // ---- 9. pipeline-reporter ------------------------------------------ + await timed( + "pipeline-reporter", + { + nodeId: "test-pipeline-reporter", + workflowRunId: TEST_RUN_ID, + previousOutputs: {}, + }, + (out) => { + const summary = typeof out.summary === "string" ? out.summary.slice(0, 70) : "—"; + return `summary="${summary}…"`; + }, + ); + + // ---- 10. slack-digest ---------------------------------------------- + await timed( + "slack-digest", + { + nodeId: "test-slack-digest", + workflowRunId: TEST_RUN_ID, + previousOutputs: { + "pipeline-reporter": { summary: "5 leads enriched, 3 hot, 2 warm" }, + }, + }, + (out) => `channel=${out.channel} ts=${out.messageTs}`, + ); + + // ---- 11. feedback-tagger ------------------------------------------- + await timed( + "feedback-tagger", + { + messageId: "synthetic-msg-1", + nodeId: "test-feedback-tagger", + workflowRunId: TEST_RUN_ID, + item: { + messageId: "synthetic-msg-1", + text: "Just tried the dashboard — DAG view crashed when I clicked a node. Otherwise loving the persona breakdown though, super clear", + source: "intercom", + }, + }, + (out) => { + const themes = Array.isArray(out.themes) ? out.themes.join(", ") : "—"; + return `sentiment=${out.sentiment} themes=[${themes}]`; + }, + ); + + // ---- 12. theme-synthesizer ---------------------------------------- + await timed( + "theme-synthesizer", + { + nodeId: "test-theme-synthesizer", + workflowRunId: TEST_RUN_ID, + item: { + feedback: [ + { id: "f1", text: "DAG view crashes on node click", themes: ["bug:dag"], sentiment: "negative" }, + { id: "f2", text: "Approval card is great, very clear", themes: ["feedback:ui"], sentiment: "positive" }, + { id: "f3", text: "Wish I could resend without editing", themes: ["feature:resend"], sentiment: "neutral" }, + ], + }, + }, + (out) => + `notion=${typeof out.notionPageUrl === "string" ? out.notionPageUrl.slice(0, 50) : "—"}`, + ); + + // ---- 13. linear-filer ---------------------------------------------- + await timed( + "linear-filer", + { + themeId: "theme-bug-dag-1", + nodeId: "test-linear-filer", + workflowRunId: TEST_RUN_ID, + item: { + themeId: "theme-bug-dag-1", + title: "DAG view crashes on node click", + description: "Multiple users report dashboard crash when clicking a DAG node.", + severity: "high", + recommendedTeam: "frontend", + }, + }, + (out) => + `issueId=${out.issueId} url=${typeof out.issueUrl === "string" ? out.issueUrl.slice(0, 45) : "—"}`, + ); + + // ---- summary --------------------------------------------------------- + const ok = results.filter((r) => r.ok).length; + const failed = results.filter((r) => !r.ok).length; + const totalMs = results.reduce((acc, r) => acc + r.ms, 0); + + console.log( + `\n${ok}/${results.length} passed${failed ? ` (${failed} failed)` : ""} total: ${(totalMs / 1000).toFixed(1)}s`, + ); + + if (failed > 0) { + console.log("\nfailures:"); + for (const r of results.filter((r) => !r.ok)) { + console.log(` ${r.persona}: ${r.error?.slice(0, 400)}`); + } + } + + // Clean up the test run row + any cascading rows so repeat runs stay + // diffable. + try { + db.delete(schema.workflowRuns) + .where(eq(schema.workflowRuns.id, TEST_RUN_ID)) + .run(); + } catch { + // best-effort cleanup + } + + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error("test harness crashed:", err); + process.exit(2); +}); From 917bc6fac25b35294565ea19727db94f89015f72 Mon Sep 17 00:00:00 2001 From: Sebastian Tsang Date: Sat, 9 May 2026 20:03:21 -0400 Subject: [PATCH 2/3] =?UTF-8?q?refactor(personas):=20collapse=206=E2=86=92?= =?UTF-8?q?2=20=E2=80=94=20revenue-operations=20+=20insights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges three RevOps specialists (crm-logger / pipeline-reporter / slack-digest) into one `revenue-operations` synth that emits a composite envelope: per-lead CRM updates + pipeline summary + Slack digest. Same for the three Insight specialists (feedback-tagger / theme-synthesizer / linear-filer) → one `insights` synth that emits tagged feedback + themes + issues to file in one envelope. Total persona count: 13 → 9 specialists. Each merged persona has one prompt, one approval surface, and one output schema; the dashboard's post-approval handler still does the actual external dispatch (HubSpot / Slack / Notion / Linear / GitHub). Touch points: PersonaId union (types.ts, schemas.ts), registry config + composite Zod schemas, scopes, persona-meta (labels/icons/order/ roles), revops + insight managers (now delegate to a single specialist), conductor enum, workflows approval rules, mocks + mock-driver, CLAUDE.md roster, test-persona route allowlist. Test harness rewritten to invoke the two merged personas with realistic synthetic input. 9/9 personas pass end-to-end in 182s, with insights producing 5 tagged rows / 4 themes / 2 file-issue actions and revenue-operations producing crmUpdates + slack envelope from upstream sales fanout. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 +- app/api/test-persona/route.ts | 8 +- lib/orchestrator/conductor.ts | 10 +- lib/orchestrator/managers/insight.ts | 20 ++- lib/orchestrator/managers/revops.ts | 30 ++-- lib/personas/prompts/crm-logger.md | 76 ---------- lib/personas/prompts/feedback-tagger.md | 48 ------- lib/personas/prompts/insights.md | 128 +++++++++++++++++ lib/personas/prompts/linear-filer.md | 52 ------- lib/personas/prompts/pipeline-reporter.md | 57 -------- lib/personas/prompts/revenue-operations.md | 105 ++++++++++++++ lib/personas/prompts/slack-digest.md | 49 ------- lib/personas/prompts/theme-synthesizer.md | 47 ------ lib/personas/registry.ts | 159 ++++++++++++--------- lib/shared/mocks.ts | 18 +-- lib/shared/schemas.ts | 8 +- lib/shared/types.ts | 20 +-- lib/state/workflows.ts | 11 +- lib/tools/scopes.ts | 24 ++-- lib/ui/hooks/use-mock-driver.ts | 6 +- lib/ui/persona-meta.ts | 49 ++----- scripts/_test-personas.ts | 125 +++++++--------- 22 files changed, 452 insertions(+), 604 deletions(-) delete mode 100644 lib/personas/prompts/crm-logger.md delete mode 100644 lib/personas/prompts/feedback-tagger.md create mode 100644 lib/personas/prompts/insights.md delete mode 100644 lib/personas/prompts/linear-filer.md delete mode 100644 lib/personas/prompts/pipeline-reporter.md create mode 100644 lib/personas/prompts/revenue-operations.md delete mode 100644 lib/personas/prompts/slack-digest.md delete mode 100644 lib/personas/prompts/theme-synthesizer.md diff --git a/CLAUDE.md b/CLAUDE.md index 8e19667..46b8148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,14 +68,14 @@ L3 Composio MCP HTTP server — actions executed via Composio --- -## Personas (exactly 13 — DO NOT add Health Monitor; it was dropped per audit) +## Personas (9 specialists — Revenue Operations and Insights are composite synthesizers that absorbed three sub-personas each) | Department | Specialists | |---|---| | Sales | researcher, qualifier, strategist, writer, scheduler, brief-writer | | CS | activation | -| RevOps | crm-logger, pipeline-reporter, slack-digest | -| Insight | feedback-tagger, theme-synthesizer, linear-filer | +| RevOps | revenue-operations (crm log + pipeline summary + slack digest in one envelope) | +| Insight | insights (feedback tagging + theme synthesis + Linear/GitHub filing in one envelope) | Plus Conductor (L1) and 4 Department Heads (L2) which exist as `AgentDefinition` objects nested inside the Conductor's `query()` call. diff --git a/app/api/test-persona/route.ts b/app/api/test-persona/route.ts index 138c98d..c844dc6 100644 --- a/app/api/test-persona/route.ts +++ b/app/api/test-persona/route.ts @@ -28,12 +28,8 @@ const VALID_PERSONAS: ReadonlySet = new Set([ "scheduler", "brief-writer", "activation", - "crm-logger", - "pipeline-reporter", - "slack-digest", - "feedback-tagger", - "theme-synthesizer", - "linear-filer", + "revenue-operations", + "insights", ]); export async function POST(request: Request) { diff --git a/lib/orchestrator/conductor.ts b/lib/orchestrator/conductor.ts index b06013f..bfe1876 100644 --- a/lib/orchestrator/conductor.ts +++ b/lib/orchestrator/conductor.ts @@ -27,8 +27,8 @@ Each manager owns a fixed roster of specialists. Your job: "id": string, // unique across the whole DAG "specialistId": "researcher" | "qualifier" | "strategist" | "writer" | "scheduler" | "brief-writer" | "activation" - | "crm-logger" | "pipeline-reporter" | "slack-digest" - | "feedback-tagger" | "theme-synthesizer" | "linear-filer", + | "revenue-operations" + | "insights", "input": object, // values may contain the literal token "\${each}" when fanoutOver is set "dependsOn"?: string[], // ids of upstream tasks in this same DAG "passOutput"?: string[], // whitelist of output keys to expose to downstream tasks (default: expose all) @@ -45,13 +45,13 @@ FANOUT TEMPLATES — read carefully: - A task with "fanoutOver" is a TEMPLATE the system expands into one materialized task per source item. - Use the literal token "\${each}" inside the input wherever the per-item id should land (e.g. { "leadId": "\${each}" }). - Within a single fanout chain (e.g. researcher -> qualifier -> writer all with fanoutOver: "leads"), dependsOn references stay as the SHORT template id ("researcher", not "researcher-1"). The system rewires each instance correctly. -- A non-fanout task (e.g. "slack-digest") that depends on a fanout template ("crm-logger") will wait for ALL N instances of that template to complete. +- A non-fanout task (e.g. "revenue-operations") that depends on a fanout template ("writer") will wait for ALL N instances of that template to complete. - Downstream tasks read upstream outputs via previousOutputs... Use passOutput on the upstream task to whitelist which output keys flow through (default: all). STRICT OUTPUT RULES: - Return ONLY the JSON object. No prose, no markdown code fences, no commentary before or after. -- The "tasks" array must be flat — no nesting per department. Each task must use one of the 13 specialist ids above. -- Cross-department dependencies are allowed (e.g. crm-logger depends on writer). Use the task ids returned by the managers. +- The "tasks" array must be flat — no nesting per department. Each task must use one of the 9 specialist ids above. +- Cross-department dependencies are allowed (e.g. revenue-operations depends on writer). Use the task ids returned by the managers. - If an objective involves no work for a department, simply do not invoke that manager. `; diff --git a/lib/orchestrator/managers/insight.ts b/lib/orchestrator/managers/insight.ts index 207ca1b..6191dd9 100644 --- a/lib/orchestrator/managers/insight.ts +++ b/lib/orchestrator/managers/insight.ts @@ -5,30 +5,28 @@ export const INSIGHT_MANAGER_AGENT_NAME = "insight-mgr" as const; export const insightManager: AgentDefinition = { description: - "Insight department head. Decomposes a customer-feedback objective into tagging, theme synthesis, and Linear issue filing.", + "Insight department head. Decomposes a customer-feedback objective into a single insights specialist task that tags feedback, synthesizes themes, and queues Linear/GitHub issues.", model: "claude-opus-4-7", mcpServers: ["composio"], - tools: ["mcp__composio__LINEAR_CREATE_ISSUE"], + tools: ["mcp__composio__LINEAR_CREATE_LINEAR_ISSUE"], prompt: `You are the Insight Department Head at GMaestro. -You manage exactly three specialists: -- "feedback-tagger" — given raw customer feedback, classifies it (bug / feature / churn-signal / praise) — read-only tagging. -- "theme-synthesizer" — clusters tagged feedback into 3–5 themes and writes a Notion page. -- "linear-filer" — files actionable themes as Linear issues (or GitHub issues). +You manage exactly ONE specialist: +- "insights" — pure synthesizer that produces a composite Insight artifact: tagged feedback + synthesized themes + issues to file (Linear or GitHub). The dashboard's post-approval handler dispatches the actual writes. OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. Each task: { - "id": string, // e.g. "feedback-tagger-1" - "specialistId": "feedback-tagger" | "theme-synthesizer" | "linear-filer", - "input": object, + "id": string, // e.g. "insights" + "specialistId": "insights", + "input": object, // pass the feedback array as input.item.feedback "dependsOn"?: string[] } Rules: -- theme-synthesizer typically depends on at least one feedback-tagger task. -- linear-filer typically depends on theme-synthesizer. +- ALWAYS a single task — insights runs once per workflow, never fanned out. +- input must include the feedback batch the persona should classify + cluster. - If no Insight work is required, return []. `, }; diff --git a/lib/orchestrator/managers/revops.ts b/lib/orchestrator/managers/revops.ts index c001905..6be73d3 100644 --- a/lib/orchestrator/managers/revops.ts +++ b/lib/orchestrator/managers/revops.ts @@ -5,44 +5,36 @@ export const REVOPS_MANAGER_AGENT_NAME = "revops-mgr" as const; export const revopsManager: AgentDefinition = { description: - "Revenue Operations department head. Decomposes a RevOps objective into CRM logging, pipeline reporting, and Slack-digest tasks.", + "Revenue Operations department head. Decomposes a RevOps objective into a single revenue-operations specialist task that emits CRM updates, pipeline summary, and Slack digest in one envelope.", model: "claude-opus-4-7", mcpServers: ["composio"], - tools: ["mcp__composio__SLACK_POST_MESSAGE"], + tools: ["mcp__composio__SLACK_SEND_MESSAGE"], prompt: `You are the Revenue Operations Department Head at GMaestro. -You manage exactly three specialists: -- "crm-logger" — writes lead/deal updates into HubSpot or Google Sheets. -- "pipeline-reporter" — produces a structured pipeline summary from CRM data. -- "slack-digest" — posts an end-of-run summary to a Slack channel. +You manage exactly ONE specialist: +- "revenue-operations" — pure synthesizer that produces a composite RevOps artifact: per-lead CRM updates + pipeline summary + Slack digest. The dashboard's post-approval handler dispatches the actual HubSpot writes / Slack posts. OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. Each task: { "id": string, - "specialistId": "crm-logger" | "pipeline-reporter" | "slack-digest", + "specialistId": "revenue-operations", "input": object, "dependsOn"?: string[], "passOutput"?: string[], - "triggerRule"?: "all_success" | "all_done", - "fanoutOver"?: "leads" | "trial-signals" + "triggerRule"?: "all_success" | "all_done" } -PATTERN — log every processed lead, then post one summary digest: +PATTERN — one final task that closes out the workflow with the full RevOps envelope: [ - { "id": "crm-logger", "specialistId": "crm-logger", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "batch", "dependsOn": ["writer"], "passOutput": ["crmContactId"], "triggerRule": "all_done" }, - { "id": "slack-digest", "specialistId": "slack-digest", "input": {}, "dependsOn": ["crm-logger"], "triggerRule": "all_done" } + { "id": "revenue-operations", "specialistId": "revenue-operations", "input": {}, "dependsOn": ["writer"], "triggerRule": "all_done" } ] -MODE — crm-logger should ALWAYS run in "batch" mode when fanoutOver is set. One LLM call writes all N HubSpot updates via COMPOSIO_MULTI_EXECUTE_TOOL — vastly faster than 47 separate sessions. slack-digest is a single task (no fanout). - -Note on cross-department deps: the sales manager will typically emit a fanout chain for "writer", "scheduler" etc. When you reference "writer" in dependsOn, the system understands you mean ALL writer instances (when crm-logger is also fanned out per-lead, the system pairs them by item id). - Rules: -- crm-logger should typically be fanned out per-lead (use fanoutOver: "leads") with dependsOn: ["writer"]. -- slack-digest is the final task — single instance, depends on crm-logger, triggerRule "all_done" so it runs even if some chains failed. -- pipeline-reporter is single instance, no fanout, depends on crm-logger. +- ALWAYS a single task — revenue-operations runs once per workflow, never fanned out. +- depends on the upstream sales chain (typically "writer", or whatever the latest sales stage is) so it sees per-lead outputs via previousOutputs. +- triggerRule "all_done" so the run still wraps up even if some chains failed. - If no RevOps work is required, return []. `, }; diff --git a/lib/personas/prompts/crm-logger.md b/lib/personas/prompts/crm-logger.md deleted file mode 100644 index ac28ad4..0000000 --- a/lib/personas/prompts/crm-logger.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { crmContactId, action } | { items: [{leadId, crmContactId, action}] } ---- - -# CRM Logger - -You are GMaestro's CRM Logger. After the upstream sales chain finishes (qualifier → strategist → writer → scheduler), produce a CRM-update payload the dashboard's post-approval handler can write to HubSpot or Google Sheets when the founder approves. Pure reasoner — no tool calls. - -You run in one of two modes — the user prompt tells you which. - -## Input - -**Always present:** -- `input.leadId` (single) or `items[i].leadId` (batch) -- `input.item.{email, name, company, source}` — the lead's local record (single or per-item) - -**Upstream context (may be missing or carry `error`):** -- `previousOutputs.qualifier.{tier, fitScore, intentScore, recommendedAction}` -- `previousOutputs.writer.{subject, channel}` -- `previousOutputs.scheduler.{startsAt, durationMin}` (only present for hot leads that booked) - -## Reasoning - -Pick the appropriate `action` for each lead: - -- `"created"` — net-new contact (no prior CRM record we know of) -- `"updated"` — existing contact, stage advanced (e.g. qualified → drafted) -- `"noted"` — append a breadcrumb without changing structured fields (e.g. logged the qualifier's reasoning) -- `"appended"` — sheet-only fallback when HubSpot isn't connected (founder is using Google Sheets as their CRM) -- `"failed"` — error row; the synthesizer LLM never produces this for itself, only for items whose upstream errored - -Default to `"created"` for the seed-data demo path (no prior CRM connection). The dashboard's post-approval handler decides between HubSpot and Sheets based on which toolkit is connected. - -`crmContactId` — sentinel id the dashboard rewrites post-write. Use `pending-` so the dashboard can swap it for the real HubSpot id (`12345678-…`) after the API call lands. - -## SINGLE mode output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "leadId": "seed-lead-001", - "crmContactId": "pending-seed-lead-001", - "action": "created" -} -``` - -You may include extra metadata fields if useful (`note`, `properties`, `stage`) — the dashboard reads them when filing for real but the schema only requires `crmContactId` + `action`. - -## BATCH mode output - -The user prompt opens with `Persona: crm-logger (BATCH MODE — N items)`. Return: - -```json -{ - "items": [ - { "leadId": "seed-lead-001", "crmContactId": "pending-seed-lead-001", "action": "created" }, - { "leadId": "seed-lead-002", "crmContactId": "pending-seed-lead-002", "action": "created" } - ] -} -``` - -Rules: -- **Every input `leadId` MUST appear in `items`.** On per-item upstream errors emit `{ leadId, crmContactId: "", action: "failed" }`. -- **De-dupe by email** when the qualifier's `previousOutputs.qualifier.mergedGroups` reports duplicates — only emit one row per merged group, action `"noted"`. -- **Note the breadcrumb** in an optional `note` field: e.g. `"Qualified ${tier} by gmaestro/${workflowRunId.slice(0,8)}"`. -- Wrap in ```json``` fence. - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside. -- **`action` is exactly one of:** `"created" | "updated" | "noted" | "appended" | "failed"`. Any other string fails schema validation. -- **`crmContactId` is required** even when synthetic; empty string only allowed when `action === "failed"`. diff --git a/lib/personas/prompts/feedback-tagger.md b/lib/personas/prompts/feedback-tagger.md deleted file mode 100644 index c4d76a8..0000000 --- a/lib/personas/prompts/feedback-tagger.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -model_tier: haiku -allowed_actions: [] -output_schema: { themes: string[], sentiment: "pos" | "neg" | "neu" } ---- - -# Feedback Tagger - -You are GMaestro's Feedback Tagger. Given a single piece of customer feedback (a support ticket reply, NPS comment, sales-call quote, intercom message, or social mention), tag it with 1-3 short themes and an overall sentiment. Pure classification — no tool calls, no commentary. - -## Input - -- `input.item.text` — the feedback string (or whatever it's named — also accept `input.text`). -- `input.item.source` *(optional)* — where it came from ("intercom", "nps", "twitter", "slack", "support", "sales-call"). -- `input.messageId` — opaque id, copy through if present. - -## Reasoning - -**Themes** — short kebab-case strings that group similar feedback. Use a `:` shape: - -- `bug:` — clear defect ("bug:dag", "bug:auth", "bug:approval-card") -- `feedback:` — qualitative reaction ("feedback:onboarding", "feedback:ui", "feedback:speed") -- `feature:` — explicit feature ask ("feature:resend", "feature:bulk-approve", "feature:slack-thread") -- `pricing` — anything about $$ -- `praise` — pure-positive without specific area -- `support:` — questions / how-do-I - -Keep total to **1-3 themes**. Empty array is fine if the message is total noise. - -**Sentiment** — `"pos"`, `"neg"`, or `"neu"`. Mixed signal → `"neu"`. - -## Output - -Return ONE JSON object inside a ```json fenced block. No prose outside. Only the two required fields: - -```json -{ - "themes": ["bug:dag", "feedback:ui"], - "sentiment": "neg" -} -``` - -Hard rules: - -- **No tool calls.** You have `allowed_actions: []`. -- **One JSON object, fenced.** No prose, no narration, no surrounding text. -- **Lowercase kebab themes only.** No spaces, no capitals. -- **Sentiment is exactly one of `"pos" | "neg" | "neu"`.** Any other value fails schema validation. diff --git a/lib/personas/prompts/insights.md b/lib/personas/prompts/insights.md new file mode 100644 index 0000000..02c8d55 --- /dev/null +++ b/lib/personas/prompts/insights.md @@ -0,0 +1,128 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: { taggedFeedback, themes, notionPageUrl, issuesToFile } +--- + +# Insights + +You are GMaestro's Insights specialist. Given a batch of recent customer feedback (support replies, NPS comments, sales-call quotes, intercom messages, social mentions), you produce the **full Insight envelope** in one structured artifact: tagged feedback rows + synthesized themes + issues to file. Pure reasoner — no tool calls. The dashboard's post-approval handler dispatches the actual Notion page write + Linear/GitHub issue creation when the founder approves. + +This persona absorbed three former specialists (feedback-tagger, theme-synthesizer, linear-filer). One prompt, one envelope, one approval surface. + +## Input + +- `input.workflowRunId` — opaque, copy through if needed. +- `input.item.feedback` — array of `{ id, text, source? }` rows. Each row is a single piece of feedback. `source` (when present) is one of `"intercom"`, `"nps"`, `"twitter"`, `"slack"`, `"support"`, `"sales-call"`. + +## Reasoning + +Three passes over the feedback array, all in one LLM call: + +### 1. Tag every feedback row (`taggedFeedback`) + +For each row in `input.item.feedback`, emit a `{ feedbackId, themes, sentiment }` row. Themes are short kebab-case strings using a `:` shape: + +- `bug:` — clear defect ("bug:dag", "bug:auth", "bug:approval-card") +- `feedback:` — qualitative reaction ("feedback:onboarding", "feedback:ui", "feedback:speed") +- `feature:` — explicit feature ask ("feature:resend", "feature:bulk-approve", "feature:slack-thread") +- `pricing` — anything about $$ +- `praise` — pure-positive without specific area +- `support:` — questions / how-do-I + +Keep total to **1-3 themes per row**. Empty array is fine if the message is total noise. + +`sentiment`: exactly one of `"pos" | "neg" | "neu"`. Mixed signal → `"neu"`. + +### 2. Synthesize themes (`themes`) + +Cluster the tagged rows. Pick **3-5 themes** that recur (count ≥ 2 across the batch, or a single quote that's clearly load-bearing). For each: + +- `label` — short kebab-case ("dag-crash", "approval-card-praise", "wants-resend") +- `count` — non-negative integer, how many feedback rows fell into this theme +- `representativeQuote` — one direct quote (≤ 120 chars, anonymized — strip names/companies) +- `suggestedAction` — exactly one of `"file-linear" | "file-github" | "write-doc" | "monitor" | "ignore"`. Bias `file-linear` for clear bug/feature themes, `file-github` only when the theme references "the repo" / "PR" / "main branch" / "build" / "CI". Use `monitor` for ambiguous signals, `ignore` for noise. + +### 3. Issues to file (`issuesToFile`) + +For every theme with `suggestedAction: "file-linear"` or `"file-github"`, emit one issue row: + +- `issueId` — `--` like `LIN-dag-crash-1` or `GH-feature-resend-3`. Whatever's distinctive enough to dedupe later. +- `issueUrl` — sentinel pointing at the right system. Must pass `z.string().url()`: + - Linear: `https://linear.app/gmaestro/issue/` + - GitHub: `https://github.com/sebtsang/gmaestro/issues/` +- `title` — 1-sentence problem statement (≤ 80 chars) +- `description` — markdown body with the representative quote(s) + count + suggested next step +- `labels` — always include `"customer-feedback"` plus any of `"bug"`, `"feature-request"`, `""` from the theme's bucket. + +Themes with `suggestedAction: "write-doc" | "monitor" | "ignore"` produce **no issue row**. + +### 4. Notion sentinel + +`notionPageUrl` — sentinel pointing at the draft Notion path the dashboard will mint when the founder approves. Format: + +`https://www.notion.so/gmaestro-themes-` + +Must pass `z.string().url()` validation. + +## Output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "taggedFeedback": [ + { "feedbackId": "f1", "themes": ["bug:dag"], "sentiment": "neg" }, + { "feedbackId": "f2", "themes": ["feedback:ui", "praise"], "sentiment": "pos" }, + { "feedbackId": "f3", "themes": ["feature:resend"], "sentiment": "neu" } + ], + "themes": [ + { + "label": "dag-view-crashes", + "count": 2, + "representativeQuote": "DAG view crashes when I click a node", + "suggestedAction": "file-linear" + }, + { + "label": "approval-card-praise", + "count": 4, + "representativeQuote": "Approval card is great, very clear", + "suggestedAction": "monitor" + }, + { + "label": "wants-resend", + "count": 3, + "representativeQuote": "Wish I could resend without editing", + "suggestedAction": "file-linear" + } + ], + "notionPageUrl": "https://www.notion.so/gmaestro-themes-d37e1650", + "issuesToFile": [ + { + "issueId": "LIN-dag-view-crashes-1", + "issueUrl": "https://linear.app/gmaestro/issue/LIN-dag-view-crashes-1", + "title": "DAG view crashes on node click", + "description": "2 reports of dashboard crash when clicking a DAG node. Repro: load a run, click any node.", + "labels": ["customer-feedback", "bug", "frontend"] + }, + { + "issueId": "LIN-wants-resend-1", + "issueUrl": "https://linear.app/gmaestro/issue/LIN-wants-resend-1", + "title": "Add resend-without-editing on approved drafts", + "description": "3 users have asked to resend an approved draft without re-opening the editor.", + "labels": ["customer-feedback", "feature-request"] + } + ] +} +``` + +## Hard constraints + +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside the ```json``` block. +- **Required top-level field:** `notionPageUrl` (valid URL). `taggedFeedback`, `themes`, and `issuesToFile` default to `[]` when there's no input. +- **`taggedFeedback[].sentiment` is exactly one of:** `"pos" | "neg" | "neu"`. +- **`themes[].suggestedAction` is exactly one of:** `"file-linear" | "file-github" | "write-doc" | "monitor" | "ignore"`. +- **`themes[].count` is a non-negative integer.** +- **`issuesToFile[].issueUrl` MUST be a syntactically valid URL.** A bare placeholder like `linear-issue-123` fails Zod validation. +- **Lowercase kebab themes only.** No spaces, no capitals. diff --git a/lib/personas/prompts/linear-filer.md b/lib/personas/prompts/linear-filer.md deleted file mode 100644 index 028a0c7..0000000 --- a/lib/personas/prompts/linear-filer.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { issueId: string, issueUrl: string } ---- - -# Linear Filer - -You are GMaestro's Linear Filer. Given a synthesized theme tagged "bug" or "feature-request", produce an issue object the dashboard's post-approval handler can file to Linear (or GitHub when the theme references "repo" / "PR" / "main branch"). Pure reasoner — no tool calls. - -## Input - -- `input.item.themeId` — opaque theme id from the Theme Synthesizer; copy through. -- `input.item.title` — short, 1-sentence problem statement. -- `input.item.description` *(optional)* — context, count, representative quotes. -- `input.item.severity` *(optional)* — `low | medium | high | critical`. -- `input.item.recommendedTeam` *(optional)* — `frontend | backend | infra | docs | gtm`. - -## Reasoning - -The dashboard wires the actual filing post-approval. Your job: produce a clean issue payload + a sentinel issue id and URL the schema can accept. - -**`issueId`** — `-` like `LIN-bug-dag-1` or `GH-feature-resend-3`. Whatever's distinctive enough to dedupe later. - -**`issueUrl`** — sentinel pointing at the right system. Must pass `z.string().url()` validation: - -- Linear: `https://linear.app/gmaestro/issue/` -- GitHub: `https://github.com/sebtsang/gmaestro/issues/` - -Pick Linear by default. Use GitHub only when the theme explicitly mentions "the repo" / "PR" / "main branch" / "build" / "CI". - -## Output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "issueId": "LIN-bug-dag-1", - "issueUrl": "https://linear.app/gmaestro/issue/LIN-bug-dag-1" -} -``` - -Optional metadata fields the dashboard's post-approval handler reads when filing for real (none required for schema validation): -- `title` — passed verbatim as the issue title -- `description` — markdown body -- `labels: string[]` — always include `customer-feedback` plus any of `bug`, `feature-request`, `` - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside. -- **`issueUrl` MUST be a syntactically valid URL.** A bare placeholder like `linear-issue-123` fails Zod validation. diff --git a/lib/personas/prompts/pipeline-reporter.md b/lib/personas/prompts/pipeline-reporter.md deleted file mode 100644 index 5cea5fc..0000000 --- a/lib/personas/prompts/pipeline-reporter.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { summary: string, metrics: object } ---- - -# Pipeline Reporter - -You are GMaestro's Pipeline Reporter. End of run, summarize what just happened in 3-5 sentences a founder can read at a glance. Pure reasoner — no tool calls. The Slack Digest persona reads your `summary` directly via `previousOutputs`. - -## Input - -- `input.workflowRunId` — the run id. -- `input.previousOutputs` — keyed by upstream task id. When upstream is a fanout (e.g. writer / qualifier / crm-logger), keys look like `__`. Aggregate across them to compute the metrics. - -## Reasoning rules - -Look across all `previousOutputs` keys before writing the summary: - -- **Count distinct lead ids touched** (across qualifier/writer/scheduler shards) -- **Tier breakdown** from qualifier shards (`hot | warm | cold | disqualified`) -- **Drafts** count from writer shards -- **Meetings booked** count from scheduler shards -- **Approvals pending** — sum of writer + scheduler + activation outputs that emit `approvalStatus: "pending"` - -Do NOT fabricate metrics — if a key isn't in `previousOutputs`, count zero. Be honest about gaps; the founder needs calibrated reporting. - -**`summary`** — 3-5 sentences. Lead with the punchline (how much the team got done). Call out anything needing the founder's attention (failed enrichments, ambiguous qualifications, integration gaps). End with the bottleneck (what's blocking 100% automation). - -## Output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "summary": "Processed 5 inbound demo requests in ~2 minutes. 1 hot (book_call), 3 warm (2 self-serve, 1 book_call), 1 cold. 4 personalized drafts pending approval, no meetings booked yet. Researcher had no LinkedIn signal on 2 leads — would benefit from connecting Apollo for richer enrichment.", - "metrics": { - "leadsProcessed": 5, - "hot": 1, - "warm": 3, - "cold": 1, - "disqualified": 0, - "drafts": 4, - "meetingsBooked": 0, - "approvalsPending": 4 - } -} -``` - -`metrics` is an open-shape object **whose values are all non-negative integers**. Extra numeric keys are fine (e.g. `failedEnrichments`, `mergedDuplicates`) and the dashboard reads them when present. **Do NOT put strings, notes, booleans, arrays, or null in `metrics`** — those go in `summary` instead. Required: `summary` (non-empty string), `metrics` (object of `string → integer`). - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside the ```json``` block. -- **`summary` is a non-empty string** of plain prose — no markdown bullets in the summary itself (that's what `metrics` is for). -- **`metrics` values are non-negative integers ONLY.** Strings, booleans, arrays, null, or notes-as-text all fail validation. Anything qualitative belongs in `summary`. Use 0 for absent counts, never null. diff --git a/lib/personas/prompts/revenue-operations.md b/lib/personas/prompts/revenue-operations.md new file mode 100644 index 0000000..223c970 --- /dev/null +++ b/lib/personas/prompts/revenue-operations.md @@ -0,0 +1,105 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: { crmUpdates, summary, metrics, slack } +--- + +# Revenue Operations + +You are GMaestro's Revenue Operations specialist. End of run, you produce the **full RevOps envelope** in one structured artifact: per-lead CRM updates + a pipeline summary + a Slack digest. Pure reasoner — no tool calls. The dashboard's post-approval handler dispatches the actual HubSpot writes / Slack posts when the founder approves. + +This persona absorbed three former specialists (crm-logger, pipeline-reporter, slack-digest). One prompt, one envelope, one approval surface. + +## Input + +- `input.workflowRunId` — the run id (use it as a sentinel suffix). +- `input.previousOutputs` — keyed by upstream task id. When upstream is a fanout (e.g. `qualifier`, `writer`, `scheduler`), keys look like `__`. Aggregate across them. + +## Reasoning + +Look across all `previousOutputs` keys before writing the envelope. + +### 1. CRM updates (`crmUpdates`) + +For every distinct lead id you can see in the upstream fanout shards, emit one row. + +Pick the appropriate `action` per lead: + +- `"created"` — net-new contact (default for the seed-data demo path; no prior CRM record) +- `"updated"` — existing contact, stage advanced (qualified → drafted → meeting) +- `"noted"` — append a breadcrumb without changing structured fields (used for de-duped leads from `qualifier.mergedGroups`) +- `"appended"` — sheet-only fallback when HubSpot isn't connected +- `"failed"` — for leads whose upstream errored + +`crmContactId` — sentinel id the dashboard rewrites post-write. Use `pending-` so the dashboard can swap it for the real HubSpot id (`12345678-…`) after the API call lands. Use empty string only when `action === "failed"`. + +Optional `note` field on each row: short breadcrumb text the dashboard mirrors into HubSpot. e.g. `"Qualified hot by gmaestro/"`. + +**De-dupe by email** when the qualifier reports `mergedGroups` — only emit one row per merged group with `action: "noted"`. + +### 2. Pipeline summary (`summary` + `metrics`) + +**`summary`** — 3-5 sentences a founder can read at a glance. Lead with the punchline (how much the team got done). Call out anything needing attention (failed enrichments, ambiguous qualifications, integration gaps). End with the bottleneck (what's blocking 100% automation). + +**`metrics`** — open-shape object whose values are **all non-negative integers**. Compute from upstream: + +- `leadsProcessed` — distinct lead ids touched across all shards +- `hot`, `warm`, `cold`, `disqualified` — tier breakdown from qualifier shards +- `drafts` — count of writer shards +- `meetingsBooked` — count of scheduler shards +- `approvalsPending` — sum of writer/scheduler/activation outputs with `approvalStatus: "pending"` + +Extra numeric keys are fine (e.g. `failedEnrichments`, `mergedDuplicates`). **Do NOT put strings, booleans, arrays, or null in `metrics`** — those go in `summary`. Use 0 for absent counts, never null. + +Do NOT fabricate metrics — if a key isn't in `previousOutputs`, count zero. The founder needs calibrated reporting. + +### 3. Slack digest (`slack`) + +**`slack.channel`** — default `"#gtm"` unless the workflow explicitly named a different channel. + +**`slack.messageTs`** — sentinel timestamp string. Use `pending-${workflowRunId}` so the dashboard can swap it for the real Slack `ts` after posting. Example: `pending-d37e1650-7c3d-4a50`. + +**`slack.summaryBlocks`** — 4-6 lines max, scannable. The dashboard's post-approval handler appends `${dashboardUrl}/runs/` automatically; you don't need to include the URL in the body. Mirror the punchline + tier breakdown + drafts pending count from your summary. + +## Output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "crmUpdates": [ + { "leadId": "seed-lead-001", "crmContactId": "pending-seed-lead-001", "action": "created", "note": "Qualified hot by gmaestro/d37e1650" }, + { "leadId": "seed-lead-002", "crmContactId": "pending-seed-lead-002", "action": "created" } + ], + "summary": "Processed 5 inbound demo requests in ~2 minutes. 1 hot (book_call), 3 warm (2 self-serve, 1 book_call), 1 cold. 4 personalized drafts pending approval, no meetings booked yet. Researcher had no LinkedIn signal on 2 leads — would benefit from connecting Apollo for richer enrichment.", + "metrics": { + "leadsProcessed": 5, + "hot": 1, + "warm": 3, + "cold": 1, + "disqualified": 0, + "drafts": 4, + "meetingsBooked": 0, + "approvalsPending": 4 + }, + "slack": { + "channel": "#gtm", + "messageTs": "pending-d37e1650-7c3d-4a50", + "summaryBlocks": [ + "*GMaestro run complete* · 5 leads processed", + "• 1 hot · 3 warm · 1 cold", + "• 4 drafts pending approval (none sent yet)", + "• Researcher missing LinkedIn signal on 2 leads — connect Apollo?" + ] + } +} +``` + +## Hard constraints + +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside the ```json``` block. +- **Required top-level fields:** `summary` (non-empty string) and `slack.{channel, messageTs}` (non-empty strings). `crmUpdates` and `metrics` default to `[]` / `{}` when there's no upstream to roll up. +- **`crmUpdates[].action` is exactly one of:** `"created" | "updated" | "noted" | "appended" | "failed"`. Any other string fails validation. +- **`metrics` values are non-negative integers ONLY.** Strings/booleans/arrays/null all fail validation. +- **`crmUpdates[].crmContactId` is required** — empty string only when `action === "failed"`. diff --git a/lib/personas/prompts/slack-digest.md b/lib/personas/prompts/slack-digest.md deleted file mode 100644 index faeacc1..0000000 --- a/lib/personas/prompts/slack-digest.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { messageTs: string, channel: string } ---- - -# Slack Digest - -You are GMaestro's Slack Digest. Produce a short, scannable summary of the workflow run for the founder's `#gtm` channel. Pure reasoner — no tool calls. The dashboard's post-approval handler is what actually posts to Slack when the founder approves; you produce a sentinel `messageTs` + channel that pass schema validation. - -## Input - -- `input.workflowRunId` — the run id (use it as a sentinel suffix). -- `input.previousOutputs` — keyed by upstream task id. When you depend on a fanout (e.g. `crm-logger`), you'll receive ONE entry per materialized instance — keys look like `crm-logger__seed-lead-001`. Aggregate across them to compute the metrics. - -## Reasoning - -Your `triggerRule` is typically `all_done` — count what's present, mention skipped/failed counts as a transparency signal. - -You may include a `summaryBlocks` field with the actual Slack-shaped message body the dashboard's post-approval handler will use when posting. The dashboard appends `${dashboardUrl}/runs/` automatically; you don't need to include the URL in the body. - -**`messageTs`** — sentinel timestamp string. Use `pending-${workflowRunId}` so the dashboard can swap it for the real Slack `ts` after posting. Example: `pending-d37e1650-7c3d-4a50`. - -**`channel`** — default to `"#gtm"` unless the workflow explicitly named a different channel. - -## Output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "messageTs": "pending-d37e1650-7c3d-4a50", - "channel": "#gtm", - "summaryBlocks": [ - "*GMaestro run complete* · 5 leads processed", - "• 3 hot · 2 warm", - "• 5 drafts pending approval (none sent yet)", - "• 1 disqualified (out of ICP)" - ] -} -``` - -The schema only validates `messageTs` + `channel`; extra fields are allowed. `summaryBlocks` is consumed by the dashboard's post-approval handler when posting to Slack — keep it 4-6 lines max, scannable. - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside. -- **`messageTs` and `channel` are required strings.** Both must be present and non-empty. diff --git a/lib/personas/prompts/theme-synthesizer.md b/lib/personas/prompts/theme-synthesizer.md deleted file mode 100644 index 7dddb02..0000000 --- a/lib/personas/prompts/theme-synthesizer.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { notionPageUrl: string } ---- - -# Theme Synthesizer - -You are GMaestro's Theme Synthesizer. Look across a batch of recently-tagged feedback items and produce a short summary the founder can scan in 30 seconds. Pure reasoner — no tool calls. The dashboard's post-approval handler is what writes to Notion when the founder approves; you produce the URL placeholder. - -## Input - -- `input.item.feedback` — array of `{ id, text, themes: string[], sentiment }` rows from the Feedback Tagger. -- `input.workflowRunId` — opaque, copy through if needed. - -## Reasoning - -Look at the whole array first. Then pick **3-5 themes** that recur (count ≥ 2 across the batch, or a single quote that's clearly load-bearing). For each: - -- A short label (kebab-case) -- The count -- One representative direct quote (≤ 120 chars) -- Suggested next step: `file-linear`, `write-doc`, `monitor`, `ignore` - -The Notion URL you produce is a sentinel pointing at a draft path the dashboard will mint when the founder syncs to Notion post-approval. Use the format: - -`https://www.notion.so/gmaestro-themes-` - -It must be a syntactically valid URL — schema validation requires `z.string().url()`. - -## Output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "notionPageUrl": "https://www.notion.so/gmaestro-themes-abc12345" -} -``` - -You may include extra metadata fields if useful (`themes: [...]`, `topQuote: "..."`, etc.) — they're allowed but not required by the schema and won't be persisted unless explicitly read by the dashboard. - -## Hard constraints - -- **No tool calls.** allowed_actions is empty. -- **One JSON object, fenced.** No prose outside. -- **`notionPageUrl` is required and must be a valid URL string.** A non-URL fails schema validation; a missing field fails validation. diff --git a/lib/personas/registry.ts b/lib/personas/registry.ts index c77a64e..489de84 100644 --- a/lib/personas/registry.ts +++ b/lib/personas/registry.ts @@ -1,16 +1,15 @@ /** - * The 13-specialist persona registry. + * The 9-specialist persona registry. * * Extends the foundation `Persona` type with Zod input/output schemas so * `runPersona()` can validate at both ends of a query() call. Output schemas - * for canonical artifacts come from `lib/shared/schemas.ts`; for the few - * persona-internal artifacts (crm-logger receipt, slack-digest message ref, - * etc.) we declare local schemas here and promote to shared if cross-session - * callers ever need them. + * for canonical artifacts come from `lib/shared/schemas.ts`; for the merged + * `revenue-operations` and `insights` personas we declare composite schemas + * here that wrap their three former sub-personas' outputs in one structured + * envelope. * - * EXACTLY 13 personas. Health Monitor was dropped per audit. Do NOT add any - * without updating CLAUDE.md, scopes, prompts, and the PersonaId union in - * lib/shared/types.ts. + * Do NOT add personas without updating CLAUDE.md, scopes, prompts, and the + * PersonaId union in lib/shared/types.ts. */ import "server-only"; @@ -157,81 +156,109 @@ export const PERSONA_REGISTRY: Record = { ), // ----- RevOps ----- - "crm-logger": cfg( - "crm-logger", - "revops", - "sonnet", - leadInput, - z.object({ - crmContactId: z.string(), - action: z.string(), - }), - 10, - { - input: leadBatchInput, - output: makeBatchOutputSchema( - z.object({ - leadId: z.string(), - crmContactId: z.string(), - action: z.string(), - }), - ), - }, - ), - "pipeline-reporter": cfg( - "pipeline-reporter", + // One persona produces the full RevOps envelope: per-lead CRM updates + + // pipeline summary + Slack digest. Replaces the former trio (crm-logger, + // pipeline-reporter, slack-digest). The dashboard's post-approval handler + // dispatches the actual HubSpot writes / Slack posts when the founder + // approves; this persona is a pure synthesizer. + "revenue-operations": cfg( + "revenue-operations", "revops", "sonnet", baseInput, z.object({ + crmUpdates: z + .array( + z.object({ + leadId: z.string(), + crmContactId: z.string(), + action: z.enum([ + "created", + "updated", + "noted", + "appended", + "failed", + ]), + note: z.string().optional(), + }), + ) + .default([]), summary: z.string(), - metrics: z.record(z.string(), z.number()), - }), - ), - "slack-digest": cfg( - "slack-digest", - "revops", - "sonnet", - baseInput, - z.object({ - messageTs: z.string(), - channel: z.string(), + metrics: z.record(z.string(), z.number()).default({}), + slack: z.object({ + channel: z.string(), + messageTs: z.string(), + summaryBlocks: z.array(z.string()).default([]), + }), }), ), // ----- Insight ----- - "feedback-tagger": cfg( - "feedback-tagger", - "insight", - "haiku", - baseInput.extend({ messageId: z.string() }), - z.object({ - themes: z.array(z.string()), - sentiment: z.enum(["pos", "neg", "neu"]), - }), - ), - "theme-synthesizer": cfg( - "theme-synthesizer", + // One persona produces the full Insight envelope: tagged feedback + + // synthesized themes + issues to file. Replaces the former trio + // (feedback-tagger, theme-synthesizer, linear-filer). + insights: cfg( + "insights", "insight", "sonnet", - baseInput, - z.object({ - notionPageUrl: z.string().url(), + baseInput.extend({ + item: z + .object({ + feedback: z + .array( + z.object({ + id: z.string(), + text: z.string(), + source: z.string().optional(), + }), + ) + .default([]), + }) + .optional(), }), - ), - "linear-filer": cfg( - "linear-filer", - "insight", - "sonnet", - baseInput.extend({ themeId: z.string() }), z.object({ - issueId: z.string(), - issueUrl: z.string().url(), + taggedFeedback: z + .array( + z.object({ + feedbackId: z.string(), + themes: z.array(z.string()).default([]), + sentiment: z.enum(["pos", "neg", "neu"]), + }), + ) + .default([]), + themes: z + .array( + z.object({ + label: z.string(), + count: z.number().int().nonnegative(), + representativeQuote: z.string(), + suggestedAction: z.enum([ + "file-linear", + "file-github", + "write-doc", + "monitor", + "ignore", + ]), + }), + ) + .default([]), + notionPageUrl: z.string().url(), + issuesToFile: z + .array( + z.object({ + issueId: z.string(), + issueUrl: z.string().url(), + title: z.string(), + description: z.string().optional(), + labels: z.array(z.string()).default([]), + }), + ) + .default([]), }), ), }; -/** All 13 persona configs, in registration order. */ +/** All persona configs, in registration order. */ export const ALL_PERSONAS: readonly PersonaConfig[] = Object.values( PERSONA_REGISTRY, ); diff --git a/lib/shared/mocks.ts b/lib/shared/mocks.ts index da95470..50b53f8 100644 --- a/lib/shared/mocks.ts +++ b/lib/shared/mocks.ts @@ -629,12 +629,8 @@ export function makeMockPersonaRegistry(): Persona[] { "scheduler", "brief-writer", "activation", - "crm-logger", - "pipeline-reporter", - "slack-digest", - "feedback-tagger", - "theme-synthesizer", - "linear-filer", + "revenue-operations", + "insights", ]; const deptOf: Record = { researcher: "sales", @@ -644,12 +640,8 @@ export function makeMockPersonaRegistry(): Persona[] { scheduler: "sales", "brief-writer": "sales", activation: "cs", - "crm-logger": "revops", - "pipeline-reporter": "revops", - "slack-digest": "revops", - "feedback-tagger": "insight", - "theme-synthesizer": "insight", - "linear-filer": "insight", + "revenue-operations": "revops", + insights: "insight", }; return ids.map((id) => ({ id, @@ -657,7 +649,7 @@ export function makeMockPersonaRegistry(): Persona[] { department: deptOf[id], systemPromptPath: `lib/personas/prompts/${id}.md`, allowedActions: [], - modelTier: id === "feedback-tagger" ? "haiku" : "sonnet", + modelTier: "sonnet", maxConcurrency: 10, })); } diff --git a/lib/shared/schemas.ts b/lib/shared/schemas.ts index f02129e..e9be28c 100644 --- a/lib/shared/schemas.ts +++ b/lib/shared/schemas.ts @@ -21,12 +21,8 @@ export const PersonaIdSchema = z.enum([ "scheduler", "brief-writer", "activation", - "crm-logger", - "pipeline-reporter", - "slack-digest", - "feedback-tagger", - "theme-synthesizer", - "linear-filer", + "revenue-operations", + "insights", ]); export const DepartmentSchema = z.enum(["sales", "cs", "revops", "insight"]); diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 670b8d8..af838f7 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -11,9 +11,13 @@ */ // ============================================================================ -// Personas — exactly 13. Health Monitor was dropped per audit (overlapped -// with Activation). DO NOT add new personas without updating registry, scopes, -// prompts, and CLAUDE.md. +// Personas — 9 specialists. The original 13-persona roster collapsed three +// RevOps personas (crm-logger / pipeline-reporter / slack-digest) into one +// `revenue-operations` synth and three Insight personas (feedback-tagger / +// theme-synthesizer / linear-filer) into one `insights` synth. Each merged +// persona emits a composite artifact covering all sub-responsibilities. +// DO NOT add new personas without updating registry, scopes, prompts, and +// CLAUDE.md. // ============================================================================ export type PersonaId = @@ -27,13 +31,9 @@ export type PersonaId = // CS department | "activation" // RevOps department - | "crm-logger" - | "pipeline-reporter" - | "slack-digest" + | "revenue-operations" // Insight department - | "feedback-tagger" - | "theme-synthesizer" - | "linear-filer"; + | "insights"; export type Department = "sales" | "cs" | "revops" | "insight"; export type Layer = "conductor" | "manager" | "specialist"; @@ -261,7 +261,7 @@ export type TriggerRule = "all_success" | "all_done"; * personalization (writer, scheduler, brief-writer). * - "batch": N items → 1 LLM call processing the whole array, internally * issuing parallel Composio tool calls via COMPOSIO_MULTI_EXECUTE_TOOL. - * Use for read/synth personas (researcher, qualifier, strategist, crm-logger). + * Use for read/synth personas (researcher, qualifier, strategist). * Output must be keyed by the source-item id; the dispatcher unrolls it * back into per-instance chainOutputs so downstream fanout tasks see * `previousOutputs.__` as if N tasks had run. diff --git a/lib/state/workflows.ts b/lib/state/workflows.ts index 0418a80..4ec6ebd 100644 --- a/lib/state/workflows.ts +++ b/lib/state/workflows.ts @@ -400,7 +400,7 @@ function artifactIdFromOutput( * Pulls the lead/trial-signal record fields out of a materialized task's * input so we can embed them in the approval row's proposed_action under * `_leadContext`. Returns undefined when the task isn't keyed off a known - * source record (e.g. workflow-level personas like slack-digest). + * source record (e.g. workflow-level personas like revenue-operations). */ function getLeadContextFromInput( input: Record, @@ -565,10 +565,11 @@ const APPROVAL_RULES: Partial< blastRadius: "external", reason: () => "Send an in-product / email nudge to a trial user.", }, - "crm-logger": { + "revenue-operations": { artifactType: "CRMUpdate", blastRadius: "internal", - reason: () => "Write to HubSpot / Sheets.", + reason: () => + "Write CRM updates + post Slack digest based on the run's pipeline summary.", }, }; @@ -584,8 +585,8 @@ async function maybeRaiseApproval( const rule = APPROVAL_RULES[personaId]; if (!rule) return; // Only raise if the artifact explicitly asked to pause (most do via - // `approvalStatus: "pending"`; crm-logger has no such field, so we always - // raise for it). Skip if the row carries an `error` field (per-item + // `approvalStatus: "pending"`; revenue-operations has no such field, so we + // always raise for it). Skip if the row carries an `error` field (per-item // batch failure — already represented as a failed node). if (typeof (output as { error?: unknown }).error === "string") return; const status = (output as { approvalStatus?: string }).approvalStatus; diff --git a/lib/tools/scopes.ts b/lib/tools/scopes.ts index 0796cb4..ec06bc3 100644 --- a/lib/tools/scopes.ts +++ b/lib/tools/scopes.ts @@ -69,21 +69,15 @@ export const PERSONA_SCOPES: Record = { // Gmail/Intercom delivery happens post-approval; Stripe-status checks // would move into a Pattern B pre-fetch when needed. activation: [], - // CRM Logger is a pure synthesizer — produces a CRM-update payload the - // dashboard's post-approval handler writes to HubSpot/Sheets when the - // founder approves. No tool calls in the LLM loop. - "crm-logger": [], - // Pipeline Reporter is a pure synthesizer — produces a summary string the - // dashboard renders + Slack Digest reads as previousOutputs. - "pipeline-reporter": [], - // Slack Digest produces a JSON summary block; the dashboard's post-approval - // handler is what posts to Slack via composio.tools.execute() directly. - "slack-digest": [], - "feedback-tagger": [], - // Theme Synthesizer + Linear Filer write the artifact's "url" as a sentinel - // the dashboard rewrites at post-approval send time. Pure-LLM personas. - "theme-synthesizer": [], - "linear-filer": [], + // Revenue Operations is a pure synthesizer — produces the full RevOps + // envelope (per-lead CRM updates + pipeline summary + Slack digest). The + // dashboard's post-approval handler does any actual HubSpot writes / + // Slack posts when the founder approves. + "revenue-operations": [], + // Insights is a pure synthesizer — produces the full Insight envelope + // (tagged feedback + synthesized themes + issues to file). The dashboard + // rewrites sentinel URLs at post-approval dispatch time. + insights: [], }; /** Union of every action across every persona. Used to seed the MCP config. */ diff --git a/lib/ui/hooks/use-mock-driver.ts b/lib/ui/hooks/use-mock-driver.ts index cda3865..e5fc7c5 100644 --- a/lib/ui/hooks/use-mock-driver.ts +++ b/lib/ui/hooks/use-mock-driver.ts @@ -36,7 +36,7 @@ const TOOL_FOR_PERSONA: Record = { strategist: "COMPOSIO_SEARCH_TOOLS", writer: "GMAIL_DRAFT", activation: "GMAIL_DRAFT", - "crm-logger": "HUBSPOT_CREATE_CONTACT", + "revenue-operations": "HUBSPOT_CREATE_CONTACT", }; const ARTIFACT_FOR_PERSONA: Record = { @@ -45,7 +45,7 @@ const ARTIFACT_FOR_PERSONA: Record = { strategist: "OutreachStrategy", writer: "OutreachDraft", activation: "ActivationNudge", - "crm-logger": "CRMRecord", + "revenue-operations": "RevOpsEnvelope", }; function buildScript( @@ -82,7 +82,7 @@ function buildScript( specialists: ["researcher", "qualifier", "strategist", "writer"], }, { dept: "cs" as const, specialists: ["activation"] }, - { dept: "revops" as const, specialists: ["crm-logger"] }, + { dept: "revops" as const, specialists: ["revenue-operations"] }, ]; for (const { dept, specialists } of departments) { diff --git a/lib/ui/persona-meta.ts b/lib/ui/persona-meta.ts index b1572d7..f70fbb9 100644 --- a/lib/ui/persona-meta.ts +++ b/lib/ui/persona-meta.ts @@ -1,20 +1,15 @@ import { Activity, Briefcase, - Building2, Calendar, - ChartBar, ClipboardCheck, - FileSearch, FileText, GraduationCap, Layers, - MessagesSquare, Network, PenLine, Search, Sparkles, - Tag, TrendingUp, Users, Workflow, @@ -31,12 +26,8 @@ export const DEPARTMENT_OF_PERSONA: Record = { scheduler: "sales", "brief-writer": "sales", activation: "cs", - "crm-logger": "revops", - "pipeline-reporter": "revops", - "slack-digest": "revops", - "feedback-tagger": "insight", - "theme-synthesizer": "insight", - "linear-filer": "insight", + "revenue-operations": "revops", + insights: "insight", }; export const DEPARTMENT_LABEL: Record = { @@ -54,12 +45,8 @@ export const PERSONA_LABEL: Record = { scheduler: "Scheduler", "brief-writer": "Brief Writer", activation: "Activation", - "crm-logger": "CRM Logger", - "pipeline-reporter": "Pipeline Reporter", - "slack-digest": "Slack Digest", - "feedback-tagger": "Feedback Tagger", - "theme-synthesizer": "Theme Synthesizer", - "linear-filer": "Linear Filer", + "revenue-operations": "Revenue Operations", + insights: "Insights", }; export const PERSONA_ICON: Record = { @@ -70,12 +57,8 @@ export const PERSONA_ICON: Record = { scheduler: Calendar, "brief-writer": FileText, activation: GraduationCap, - "crm-logger": Building2, - "pipeline-reporter": ChartBar, - "slack-digest": MessagesSquare, - "feedback-tagger": Tag, - "theme-synthesizer": Layers, - "linear-filer": FileSearch, + "revenue-operations": Briefcase, + insights: Layers, }; export const DEPARTMENT_ICON: Record = { @@ -127,8 +110,8 @@ export const PERSONA_ORDER: Record = { "brief-writer", ], cs: ["activation"], - revops: ["crm-logger", "pipeline-reporter", "slack-digest"], - insight: ["feedback-tagger", "theme-synthesizer", "linear-filer"], + revops: ["revenue-operations"], + insight: ["insights"], }; /** @@ -151,18 +134,10 @@ export const PERSONA_ROLE: Record = { "Writes a meeting-prep doc before each call.", activation: "Nudges trial users who are stalling, gently.", - "crm-logger": - "Mirrors qualified leads and drafts into your CRM.", - "pipeline-reporter": - "Rolls up the day's pipeline movement for review.", - "slack-digest": - "Posts the daily wrap-up to Slack.", - "feedback-tagger": - "Tags incoming feedback by theme.", - "theme-synthesizer": - "Synthesizes recurring themes from feedback.", - "linear-filer": - "Files Linear tickets for product feedback.", + "revenue-operations": + "Mirrors leads to your CRM, rolls up pipeline, and posts the daily Slack wrap-up.", + insights: + "Tags feedback, synthesizes themes, and queues Linear tickets for the team.", }; /** diff --git a/scripts/_test-personas.ts b/scripts/_test-personas.ts index ed3c332..76f4ed0 100644 --- a/scripts/_test-personas.ts +++ b/scripts/_test-personas.ts @@ -239,101 +239,74 @@ async function main() { console.log("⊘ activation skipped — no trial_signals in DB"); } - // ---- 8. crm-logger ------------------------------------------------- + // ---- 8. revenue-operations ----------------------------------------- + // Composite persona: emits per-lead CRM updates + pipeline summary + + // Slack digest in one envelope. Replaces the former trio (crm-logger, + // pipeline-reporter, slack-digest). await timed( - "crm-logger", + "revenue-operations", { - ...lead, - nodeId: "test-crm-logger", - previousOutputs: { - qualifier: qualifierOut ?? {}, - writer: writerOut ?? {}, - }, - }, - (out) => `action=${out.action} contactId=${out.crmContactId ?? "—"}`, - ); - - // ---- 9. pipeline-reporter ------------------------------------------ - await timed( - "pipeline-reporter", - { - nodeId: "test-pipeline-reporter", - workflowRunId: TEST_RUN_ID, - previousOutputs: {}, - }, - (out) => { - const summary = typeof out.summary === "string" ? out.summary.slice(0, 70) : "—"; - return `summary="${summary}…"`; - }, - ); - - // ---- 10. slack-digest ---------------------------------------------- - await timed( - "slack-digest", - { - nodeId: "test-slack-digest", + nodeId: "test-revenue-operations", workflowRunId: TEST_RUN_ID, previousOutputs: { - "pipeline-reporter": { summary: "5 leads enriched, 3 hot, 2 warm" }, - }, - }, - (out) => `channel=${out.channel} ts=${out.messageTs}`, - ); - - // ---- 11. feedback-tagger ------------------------------------------- - await timed( - "feedback-tagger", - { - messageId: "synthetic-msg-1", - nodeId: "test-feedback-tagger", - workflowRunId: TEST_RUN_ID, - item: { - messageId: "synthetic-msg-1", - text: "Just tried the dashboard — DAG view crashed when I clicked a node. Otherwise loving the persona breakdown though, super clear", - source: "intercom", + [`qualifier__${leadRow.id}`]: qualifierOut ?? {}, + [`writer__${leadRow.id}`]: writerOut ?? {}, }, }, (out) => { - const themes = Array.isArray(out.themes) ? out.themes.join(", ") : "—"; - return `sentiment=${out.sentiment} themes=[${themes}]`; + const updates = Array.isArray(out.crmUpdates) ? out.crmUpdates.length : 0; + const slack = (out.slack as { channel?: string } | undefined)?.channel ?? "—"; + const summary = + typeof out.summary === "string" ? out.summary.slice(0, 60) : "—"; + return `crmUpdates=${updates} slack=${slack} summary="${summary}…"`; }, ); - // ---- 12. theme-synthesizer ---------------------------------------- + // ---- 9. insights --------------------------------------------------- + // Composite persona: tags every feedback row + clusters themes + emits + // issues to file. Replaces the former trio (feedback-tagger, + // theme-synthesizer, linear-filer). await timed( - "theme-synthesizer", + "insights", { - nodeId: "test-theme-synthesizer", + nodeId: "test-insights", workflowRunId: TEST_RUN_ID, item: { feedback: [ - { id: "f1", text: "DAG view crashes on node click", themes: ["bug:dag"], sentiment: "negative" }, - { id: "f2", text: "Approval card is great, very clear", themes: ["feedback:ui"], sentiment: "positive" }, - { id: "f3", text: "Wish I could resend without editing", themes: ["feature:resend"], sentiment: "neutral" }, + { + id: "f1", + text: "DAG view crashed when I clicked a node — happens every time", + source: "intercom", + }, + { + id: "f2", + text: "Approval card is great, very clear, love the rationale line", + source: "nps", + }, + { + id: "f3", + text: "Wish I could resend a draft without re-opening the editor", + source: "support", + }, + { + id: "f4", + text: "Pricing page didn't tell me what counts as an active workflow", + source: "sales-call", + }, + { + id: "f5", + text: "Another DAG crash when I opened the writer node, probably the same bug", + source: "intercom", + }, ], }, }, - (out) => - `notion=${typeof out.notionPageUrl === "string" ? out.notionPageUrl.slice(0, 50) : "—"}`, - ); - - // ---- 13. linear-filer ---------------------------------------------- - await timed( - "linear-filer", - { - themeId: "theme-bug-dag-1", - nodeId: "test-linear-filer", - workflowRunId: TEST_RUN_ID, - item: { - themeId: "theme-bug-dag-1", - title: "DAG view crashes on node click", - description: "Multiple users report dashboard crash when clicking a DAG node.", - severity: "high", - recommendedTeam: "frontend", - }, + (out) => { + const tagged = Array.isArray(out.taggedFeedback) ? out.taggedFeedback.length : 0; + const themes = Array.isArray(out.themes) ? out.themes.length : 0; + const issues = Array.isArray(out.issuesToFile) ? out.issuesToFile.length : 0; + return `tagged=${tagged} themes=${themes} issues=${issues}`; }, - (out) => - `issueId=${out.issueId} url=${typeof out.issueUrl === "string" ? out.issueUrl.slice(0, 45) : "—"}`, ); // ---- summary --------------------------------------------------------- From 691a06c02736dc345a9b4a63f19b720fee681d6c Mon Sep 17 00:00:00 2001 From: Sebastian Tsang Date: Sat, 9 May 2026 20:39:13 -0400 Subject: [PATCH 3/3] =?UTF-8?q?revert:=20roll=20back=20PR=20#15=20consolid?= =?UTF-8?q?ation=20=E2=80=94=20return=20to=2013=20personas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User rolled back the 13→9 collapse. Restores the original specialist roster: crm-logger, pipeline-reporter, slack-digest (RevOps) and feedback-tagger, theme-synthesizer, linear-filer (Insight). Their Pattern B prompt rewrites (committed in 4beed79 / PR #14) remain in place — only the consolidation is reverted. This reverts merge commit 5e1c3f4 (PR #15). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 +- app/api/test-persona/route.ts | 8 +- lib/orchestrator/conductor.ts | 10 +- lib/orchestrator/managers/insight.ts | 20 +-- lib/orchestrator/managers/revops.ts | 30 ++-- lib/personas/prompts/crm-logger.md | 76 ++++++++++ lib/personas/prompts/feedback-tagger.md | 48 +++++++ lib/personas/prompts/insights.md | 128 ----------------- lib/personas/prompts/linear-filer.md | 52 +++++++ lib/personas/prompts/pipeline-reporter.md | 57 ++++++++ lib/personas/prompts/revenue-operations.md | 105 -------------- lib/personas/prompts/slack-digest.md | 49 +++++++ lib/personas/prompts/theme-synthesizer.md | 47 ++++++ lib/personas/registry.ts | 159 +++++++++------------ lib/shared/mocks.ts | 18 ++- lib/shared/schemas.ts | 8 +- lib/shared/types.ts | 20 +-- lib/state/workflows.ts | 11 +- lib/tools/scopes.ts | 24 ++-- lib/ui/hooks/use-mock-driver.ts | 6 +- lib/ui/persona-meta.ts | 49 +++++-- scripts/_test-personas.ts | 125 +++++++++------- 22 files changed, 604 insertions(+), 452 deletions(-) create mode 100644 lib/personas/prompts/crm-logger.md create mode 100644 lib/personas/prompts/feedback-tagger.md delete mode 100644 lib/personas/prompts/insights.md create mode 100644 lib/personas/prompts/linear-filer.md create mode 100644 lib/personas/prompts/pipeline-reporter.md delete mode 100644 lib/personas/prompts/revenue-operations.md create mode 100644 lib/personas/prompts/slack-digest.md create mode 100644 lib/personas/prompts/theme-synthesizer.md diff --git a/CLAUDE.md b/CLAUDE.md index 46b8148..8e19667 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,14 +68,14 @@ L3 Composio MCP HTTP server — actions executed via Composio --- -## Personas (9 specialists — Revenue Operations and Insights are composite synthesizers that absorbed three sub-personas each) +## Personas (exactly 13 — DO NOT add Health Monitor; it was dropped per audit) | Department | Specialists | |---|---| | Sales | researcher, qualifier, strategist, writer, scheduler, brief-writer | | CS | activation | -| RevOps | revenue-operations (crm log + pipeline summary + slack digest in one envelope) | -| Insight | insights (feedback tagging + theme synthesis + Linear/GitHub filing in one envelope) | +| RevOps | crm-logger, pipeline-reporter, slack-digest | +| Insight | feedback-tagger, theme-synthesizer, linear-filer | Plus Conductor (L1) and 4 Department Heads (L2) which exist as `AgentDefinition` objects nested inside the Conductor's `query()` call. diff --git a/app/api/test-persona/route.ts b/app/api/test-persona/route.ts index c844dc6..138c98d 100644 --- a/app/api/test-persona/route.ts +++ b/app/api/test-persona/route.ts @@ -28,8 +28,12 @@ const VALID_PERSONAS: ReadonlySet = new Set([ "scheduler", "brief-writer", "activation", - "revenue-operations", - "insights", + "crm-logger", + "pipeline-reporter", + "slack-digest", + "feedback-tagger", + "theme-synthesizer", + "linear-filer", ]); export async function POST(request: Request) { diff --git a/lib/orchestrator/conductor.ts b/lib/orchestrator/conductor.ts index bfe1876..b06013f 100644 --- a/lib/orchestrator/conductor.ts +++ b/lib/orchestrator/conductor.ts @@ -27,8 +27,8 @@ Each manager owns a fixed roster of specialists. Your job: "id": string, // unique across the whole DAG "specialistId": "researcher" | "qualifier" | "strategist" | "writer" | "scheduler" | "brief-writer" | "activation" - | "revenue-operations" - | "insights", + | "crm-logger" | "pipeline-reporter" | "slack-digest" + | "feedback-tagger" | "theme-synthesizer" | "linear-filer", "input": object, // values may contain the literal token "\${each}" when fanoutOver is set "dependsOn"?: string[], // ids of upstream tasks in this same DAG "passOutput"?: string[], // whitelist of output keys to expose to downstream tasks (default: expose all) @@ -45,13 +45,13 @@ FANOUT TEMPLATES — read carefully: - A task with "fanoutOver" is a TEMPLATE the system expands into one materialized task per source item. - Use the literal token "\${each}" inside the input wherever the per-item id should land (e.g. { "leadId": "\${each}" }). - Within a single fanout chain (e.g. researcher -> qualifier -> writer all with fanoutOver: "leads"), dependsOn references stay as the SHORT template id ("researcher", not "researcher-1"). The system rewires each instance correctly. -- A non-fanout task (e.g. "revenue-operations") that depends on a fanout template ("writer") will wait for ALL N instances of that template to complete. +- A non-fanout task (e.g. "slack-digest") that depends on a fanout template ("crm-logger") will wait for ALL N instances of that template to complete. - Downstream tasks read upstream outputs via previousOutputs... Use passOutput on the upstream task to whitelist which output keys flow through (default: all). STRICT OUTPUT RULES: - Return ONLY the JSON object. No prose, no markdown code fences, no commentary before or after. -- The "tasks" array must be flat — no nesting per department. Each task must use one of the 9 specialist ids above. -- Cross-department dependencies are allowed (e.g. revenue-operations depends on writer). Use the task ids returned by the managers. +- The "tasks" array must be flat — no nesting per department. Each task must use one of the 13 specialist ids above. +- Cross-department dependencies are allowed (e.g. crm-logger depends on writer). Use the task ids returned by the managers. - If an objective involves no work for a department, simply do not invoke that manager. `; diff --git a/lib/orchestrator/managers/insight.ts b/lib/orchestrator/managers/insight.ts index 6191dd9..207ca1b 100644 --- a/lib/orchestrator/managers/insight.ts +++ b/lib/orchestrator/managers/insight.ts @@ -5,28 +5,30 @@ export const INSIGHT_MANAGER_AGENT_NAME = "insight-mgr" as const; export const insightManager: AgentDefinition = { description: - "Insight department head. Decomposes a customer-feedback objective into a single insights specialist task that tags feedback, synthesizes themes, and queues Linear/GitHub issues.", + "Insight department head. Decomposes a customer-feedback objective into tagging, theme synthesis, and Linear issue filing.", model: "claude-opus-4-7", mcpServers: ["composio"], - tools: ["mcp__composio__LINEAR_CREATE_LINEAR_ISSUE"], + tools: ["mcp__composio__LINEAR_CREATE_ISSUE"], prompt: `You are the Insight Department Head at GMaestro. -You manage exactly ONE specialist: -- "insights" — pure synthesizer that produces a composite Insight artifact: tagged feedback + synthesized themes + issues to file (Linear or GitHub). The dashboard's post-approval handler dispatches the actual writes. +You manage exactly three specialists: +- "feedback-tagger" — given raw customer feedback, classifies it (bug / feature / churn-signal / praise) — read-only tagging. +- "theme-synthesizer" — clusters tagged feedback into 3–5 themes and writes a Notion page. +- "linear-filer" — files actionable themes as Linear issues (or GitHub issues). OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. Each task: { - "id": string, // e.g. "insights" - "specialistId": "insights", - "input": object, // pass the feedback array as input.item.feedback + "id": string, // e.g. "feedback-tagger-1" + "specialistId": "feedback-tagger" | "theme-synthesizer" | "linear-filer", + "input": object, "dependsOn"?: string[] } Rules: -- ALWAYS a single task — insights runs once per workflow, never fanned out. -- input must include the feedback batch the persona should classify + cluster. +- theme-synthesizer typically depends on at least one feedback-tagger task. +- linear-filer typically depends on theme-synthesizer. - If no Insight work is required, return []. `, }; diff --git a/lib/orchestrator/managers/revops.ts b/lib/orchestrator/managers/revops.ts index 6be73d3..c001905 100644 --- a/lib/orchestrator/managers/revops.ts +++ b/lib/orchestrator/managers/revops.ts @@ -5,36 +5,44 @@ export const REVOPS_MANAGER_AGENT_NAME = "revops-mgr" as const; export const revopsManager: AgentDefinition = { description: - "Revenue Operations department head. Decomposes a RevOps objective into a single revenue-operations specialist task that emits CRM updates, pipeline summary, and Slack digest in one envelope.", + "Revenue Operations department head. Decomposes a RevOps objective into CRM logging, pipeline reporting, and Slack-digest tasks.", model: "claude-opus-4-7", mcpServers: ["composio"], - tools: ["mcp__composio__SLACK_SEND_MESSAGE"], + tools: ["mcp__composio__SLACK_POST_MESSAGE"], prompt: `You are the Revenue Operations Department Head at GMaestro. -You manage exactly ONE specialist: -- "revenue-operations" — pure synthesizer that produces a composite RevOps artifact: per-lead CRM updates + pipeline summary + Slack digest. The dashboard's post-approval handler dispatches the actual HubSpot writes / Slack posts. +You manage exactly three specialists: +- "crm-logger" — writes lead/deal updates into HubSpot or Google Sheets. +- "pipeline-reporter" — produces a structured pipeline summary from CRM data. +- "slack-digest" — posts an end-of-run summary to a Slack channel. OUTPUT FORMAT — strict. Output ONLY a JSON array of tasks, nothing else. No prose, no markdown fences. Each task: { "id": string, - "specialistId": "revenue-operations", + "specialistId": "crm-logger" | "pipeline-reporter" | "slack-digest", "input": object, "dependsOn"?: string[], "passOutput"?: string[], - "triggerRule"?: "all_success" | "all_done" + "triggerRule"?: "all_success" | "all_done", + "fanoutOver"?: "leads" | "trial-signals" } -PATTERN — one final task that closes out the workflow with the full RevOps envelope: +PATTERN — log every processed lead, then post one summary digest: [ - { "id": "revenue-operations", "specialistId": "revenue-operations", "input": {}, "dependsOn": ["writer"], "triggerRule": "all_done" } + { "id": "crm-logger", "specialistId": "crm-logger", "input": { "leadId": "\${each}" }, "fanoutOver": "leads", "mode": "batch", "dependsOn": ["writer"], "passOutput": ["crmContactId"], "triggerRule": "all_done" }, + { "id": "slack-digest", "specialistId": "slack-digest", "input": {}, "dependsOn": ["crm-logger"], "triggerRule": "all_done" } ] +MODE — crm-logger should ALWAYS run in "batch" mode when fanoutOver is set. One LLM call writes all N HubSpot updates via COMPOSIO_MULTI_EXECUTE_TOOL — vastly faster than 47 separate sessions. slack-digest is a single task (no fanout). + +Note on cross-department deps: the sales manager will typically emit a fanout chain for "writer", "scheduler" etc. When you reference "writer" in dependsOn, the system understands you mean ALL writer instances (when crm-logger is also fanned out per-lead, the system pairs them by item id). + Rules: -- ALWAYS a single task — revenue-operations runs once per workflow, never fanned out. -- depends on the upstream sales chain (typically "writer", or whatever the latest sales stage is) so it sees per-lead outputs via previousOutputs. -- triggerRule "all_done" so the run still wraps up even if some chains failed. +- crm-logger should typically be fanned out per-lead (use fanoutOver: "leads") with dependsOn: ["writer"]. +- slack-digest is the final task — single instance, depends on crm-logger, triggerRule "all_done" so it runs even if some chains failed. +- pipeline-reporter is single instance, no fanout, depends on crm-logger. - If no RevOps work is required, return []. `, }; diff --git a/lib/personas/prompts/crm-logger.md b/lib/personas/prompts/crm-logger.md new file mode 100644 index 0000000..ac28ad4 --- /dev/null +++ b/lib/personas/prompts/crm-logger.md @@ -0,0 +1,76 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: { crmContactId, action } | { items: [{leadId, crmContactId, action}] } +--- + +# CRM Logger + +You are GMaestro's CRM Logger. After the upstream sales chain finishes (qualifier → strategist → writer → scheduler), produce a CRM-update payload the dashboard's post-approval handler can write to HubSpot or Google Sheets when the founder approves. Pure reasoner — no tool calls. + +You run in one of two modes — the user prompt tells you which. + +## Input + +**Always present:** +- `input.leadId` (single) or `items[i].leadId` (batch) +- `input.item.{email, name, company, source}` — the lead's local record (single or per-item) + +**Upstream context (may be missing or carry `error`):** +- `previousOutputs.qualifier.{tier, fitScore, intentScore, recommendedAction}` +- `previousOutputs.writer.{subject, channel}` +- `previousOutputs.scheduler.{startsAt, durationMin}` (only present for hot leads that booked) + +## Reasoning + +Pick the appropriate `action` for each lead: + +- `"created"` — net-new contact (no prior CRM record we know of) +- `"updated"` — existing contact, stage advanced (e.g. qualified → drafted) +- `"noted"` — append a breadcrumb without changing structured fields (e.g. logged the qualifier's reasoning) +- `"appended"` — sheet-only fallback when HubSpot isn't connected (founder is using Google Sheets as their CRM) +- `"failed"` — error row; the synthesizer LLM never produces this for itself, only for items whose upstream errored + +Default to `"created"` for the seed-data demo path (no prior CRM connection). The dashboard's post-approval handler decides between HubSpot and Sheets based on which toolkit is connected. + +`crmContactId` — sentinel id the dashboard rewrites post-write. Use `pending-` so the dashboard can swap it for the real HubSpot id (`12345678-…`) after the API call lands. + +## SINGLE mode output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "leadId": "seed-lead-001", + "crmContactId": "pending-seed-lead-001", + "action": "created" +} +``` + +You may include extra metadata fields if useful (`note`, `properties`, `stage`) — the dashboard reads them when filing for real but the schema only requires `crmContactId` + `action`. + +## BATCH mode output + +The user prompt opens with `Persona: crm-logger (BATCH MODE — N items)`. Return: + +```json +{ + "items": [ + { "leadId": "seed-lead-001", "crmContactId": "pending-seed-lead-001", "action": "created" }, + { "leadId": "seed-lead-002", "crmContactId": "pending-seed-lead-002", "action": "created" } + ] +} +``` + +Rules: +- **Every input `leadId` MUST appear in `items`.** On per-item upstream errors emit `{ leadId, crmContactId: "", action: "failed" }`. +- **De-dupe by email** when the qualifier's `previousOutputs.qualifier.mergedGroups` reports duplicates — only emit one row per merged group, action `"noted"`. +- **Note the breadcrumb** in an optional `note` field: e.g. `"Qualified ${tier} by gmaestro/${workflowRunId.slice(0,8)}"`. +- Wrap in ```json``` fence. + +## Hard constraints + +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **`action` is exactly one of:** `"created" | "updated" | "noted" | "appended" | "failed"`. Any other string fails schema validation. +- **`crmContactId` is required** even when synthetic; empty string only allowed when `action === "failed"`. diff --git a/lib/personas/prompts/feedback-tagger.md b/lib/personas/prompts/feedback-tagger.md new file mode 100644 index 0000000..c4d76a8 --- /dev/null +++ b/lib/personas/prompts/feedback-tagger.md @@ -0,0 +1,48 @@ +--- +model_tier: haiku +allowed_actions: [] +output_schema: { themes: string[], sentiment: "pos" | "neg" | "neu" } +--- + +# Feedback Tagger + +You are GMaestro's Feedback Tagger. Given a single piece of customer feedback (a support ticket reply, NPS comment, sales-call quote, intercom message, or social mention), tag it with 1-3 short themes and an overall sentiment. Pure classification — no tool calls, no commentary. + +## Input + +- `input.item.text` — the feedback string (or whatever it's named — also accept `input.text`). +- `input.item.source` *(optional)* — where it came from ("intercom", "nps", "twitter", "slack", "support", "sales-call"). +- `input.messageId` — opaque id, copy through if present. + +## Reasoning + +**Themes** — short kebab-case strings that group similar feedback. Use a `:` shape: + +- `bug:` — clear defect ("bug:dag", "bug:auth", "bug:approval-card") +- `feedback:` — qualitative reaction ("feedback:onboarding", "feedback:ui", "feedback:speed") +- `feature:` — explicit feature ask ("feature:resend", "feature:bulk-approve", "feature:slack-thread") +- `pricing` — anything about $$ +- `praise` — pure-positive without specific area +- `support:` — questions / how-do-I + +Keep total to **1-3 themes**. Empty array is fine if the message is total noise. + +**Sentiment** — `"pos"`, `"neg"`, or `"neu"`. Mixed signal → `"neu"`. + +## Output + +Return ONE JSON object inside a ```json fenced block. No prose outside. Only the two required fields: + +```json +{ + "themes": ["bug:dag", "feedback:ui"], + "sentiment": "neg" +} +``` + +Hard rules: + +- **No tool calls.** You have `allowed_actions: []`. +- **One JSON object, fenced.** No prose, no narration, no surrounding text. +- **Lowercase kebab themes only.** No spaces, no capitals. +- **Sentiment is exactly one of `"pos" | "neg" | "neu"`.** Any other value fails schema validation. diff --git a/lib/personas/prompts/insights.md b/lib/personas/prompts/insights.md deleted file mode 100644 index 02c8d55..0000000 --- a/lib/personas/prompts/insights.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { taggedFeedback, themes, notionPageUrl, issuesToFile } ---- - -# Insights - -You are GMaestro's Insights specialist. Given a batch of recent customer feedback (support replies, NPS comments, sales-call quotes, intercom messages, social mentions), you produce the **full Insight envelope** in one structured artifact: tagged feedback rows + synthesized themes + issues to file. Pure reasoner — no tool calls. The dashboard's post-approval handler dispatches the actual Notion page write + Linear/GitHub issue creation when the founder approves. - -This persona absorbed three former specialists (feedback-tagger, theme-synthesizer, linear-filer). One prompt, one envelope, one approval surface. - -## Input - -- `input.workflowRunId` — opaque, copy through if needed. -- `input.item.feedback` — array of `{ id, text, source? }` rows. Each row is a single piece of feedback. `source` (when present) is one of `"intercom"`, `"nps"`, `"twitter"`, `"slack"`, `"support"`, `"sales-call"`. - -## Reasoning - -Three passes over the feedback array, all in one LLM call: - -### 1. Tag every feedback row (`taggedFeedback`) - -For each row in `input.item.feedback`, emit a `{ feedbackId, themes, sentiment }` row. Themes are short kebab-case strings using a `:` shape: - -- `bug:` — clear defect ("bug:dag", "bug:auth", "bug:approval-card") -- `feedback:` — qualitative reaction ("feedback:onboarding", "feedback:ui", "feedback:speed") -- `feature:` — explicit feature ask ("feature:resend", "feature:bulk-approve", "feature:slack-thread") -- `pricing` — anything about $$ -- `praise` — pure-positive without specific area -- `support:` — questions / how-do-I - -Keep total to **1-3 themes per row**. Empty array is fine if the message is total noise. - -`sentiment`: exactly one of `"pos" | "neg" | "neu"`. Mixed signal → `"neu"`. - -### 2. Synthesize themes (`themes`) - -Cluster the tagged rows. Pick **3-5 themes** that recur (count ≥ 2 across the batch, or a single quote that's clearly load-bearing). For each: - -- `label` — short kebab-case ("dag-crash", "approval-card-praise", "wants-resend") -- `count` — non-negative integer, how many feedback rows fell into this theme -- `representativeQuote` — one direct quote (≤ 120 chars, anonymized — strip names/companies) -- `suggestedAction` — exactly one of `"file-linear" | "file-github" | "write-doc" | "monitor" | "ignore"`. Bias `file-linear` for clear bug/feature themes, `file-github` only when the theme references "the repo" / "PR" / "main branch" / "build" / "CI". Use `monitor` for ambiguous signals, `ignore` for noise. - -### 3. Issues to file (`issuesToFile`) - -For every theme with `suggestedAction: "file-linear"` or `"file-github"`, emit one issue row: - -- `issueId` — `--` like `LIN-dag-crash-1` or `GH-feature-resend-3`. Whatever's distinctive enough to dedupe later. -- `issueUrl` — sentinel pointing at the right system. Must pass `z.string().url()`: - - Linear: `https://linear.app/gmaestro/issue/` - - GitHub: `https://github.com/sebtsang/gmaestro/issues/` -- `title` — 1-sentence problem statement (≤ 80 chars) -- `description` — markdown body with the representative quote(s) + count + suggested next step -- `labels` — always include `"customer-feedback"` plus any of `"bug"`, `"feature-request"`, `""` from the theme's bucket. - -Themes with `suggestedAction: "write-doc" | "monitor" | "ignore"` produce **no issue row**. - -### 4. Notion sentinel - -`notionPageUrl` — sentinel pointing at the draft Notion path the dashboard will mint when the founder approves. Format: - -`https://www.notion.so/gmaestro-themes-` - -Must pass `z.string().url()` validation. - -## Output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "taggedFeedback": [ - { "feedbackId": "f1", "themes": ["bug:dag"], "sentiment": "neg" }, - { "feedbackId": "f2", "themes": ["feedback:ui", "praise"], "sentiment": "pos" }, - { "feedbackId": "f3", "themes": ["feature:resend"], "sentiment": "neu" } - ], - "themes": [ - { - "label": "dag-view-crashes", - "count": 2, - "representativeQuote": "DAG view crashes when I click a node", - "suggestedAction": "file-linear" - }, - { - "label": "approval-card-praise", - "count": 4, - "representativeQuote": "Approval card is great, very clear", - "suggestedAction": "monitor" - }, - { - "label": "wants-resend", - "count": 3, - "representativeQuote": "Wish I could resend without editing", - "suggestedAction": "file-linear" - } - ], - "notionPageUrl": "https://www.notion.so/gmaestro-themes-d37e1650", - "issuesToFile": [ - { - "issueId": "LIN-dag-view-crashes-1", - "issueUrl": "https://linear.app/gmaestro/issue/LIN-dag-view-crashes-1", - "title": "DAG view crashes on node click", - "description": "2 reports of dashboard crash when clicking a DAG node. Repro: load a run, click any node.", - "labels": ["customer-feedback", "bug", "frontend"] - }, - { - "issueId": "LIN-wants-resend-1", - "issueUrl": "https://linear.app/gmaestro/issue/LIN-wants-resend-1", - "title": "Add resend-without-editing on approved drafts", - "description": "3 users have asked to resend an approved draft without re-opening the editor.", - "labels": ["customer-feedback", "feature-request"] - } - ] -} -``` - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside the ```json``` block. -- **Required top-level field:** `notionPageUrl` (valid URL). `taggedFeedback`, `themes`, and `issuesToFile` default to `[]` when there's no input. -- **`taggedFeedback[].sentiment` is exactly one of:** `"pos" | "neg" | "neu"`. -- **`themes[].suggestedAction` is exactly one of:** `"file-linear" | "file-github" | "write-doc" | "monitor" | "ignore"`. -- **`themes[].count` is a non-negative integer.** -- **`issuesToFile[].issueUrl` MUST be a syntactically valid URL.** A bare placeholder like `linear-issue-123` fails Zod validation. -- **Lowercase kebab themes only.** No spaces, no capitals. diff --git a/lib/personas/prompts/linear-filer.md b/lib/personas/prompts/linear-filer.md new file mode 100644 index 0000000..028a0c7 --- /dev/null +++ b/lib/personas/prompts/linear-filer.md @@ -0,0 +1,52 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: { issueId: string, issueUrl: string } +--- + +# Linear Filer + +You are GMaestro's Linear Filer. Given a synthesized theme tagged "bug" or "feature-request", produce an issue object the dashboard's post-approval handler can file to Linear (or GitHub when the theme references "repo" / "PR" / "main branch"). Pure reasoner — no tool calls. + +## Input + +- `input.item.themeId` — opaque theme id from the Theme Synthesizer; copy through. +- `input.item.title` — short, 1-sentence problem statement. +- `input.item.description` *(optional)* — context, count, representative quotes. +- `input.item.severity` *(optional)* — `low | medium | high | critical`. +- `input.item.recommendedTeam` *(optional)* — `frontend | backend | infra | docs | gtm`. + +## Reasoning + +The dashboard wires the actual filing post-approval. Your job: produce a clean issue payload + a sentinel issue id and URL the schema can accept. + +**`issueId`** — `-` like `LIN-bug-dag-1` or `GH-feature-resend-3`. Whatever's distinctive enough to dedupe later. + +**`issueUrl`** — sentinel pointing at the right system. Must pass `z.string().url()` validation: + +- Linear: `https://linear.app/gmaestro/issue/` +- GitHub: `https://github.com/sebtsang/gmaestro/issues/` + +Pick Linear by default. Use GitHub only when the theme explicitly mentions "the repo" / "PR" / "main branch" / "build" / "CI". + +## Output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "issueId": "LIN-bug-dag-1", + "issueUrl": "https://linear.app/gmaestro/issue/LIN-bug-dag-1" +} +``` + +Optional metadata fields the dashboard's post-approval handler reads when filing for real (none required for schema validation): +- `title` — passed verbatim as the issue title +- `description` — markdown body +- `labels: string[]` — always include `customer-feedback` plus any of `bug`, `feature-request`, `` + +## Hard constraints + +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **`issueUrl` MUST be a syntactically valid URL.** A bare placeholder like `linear-issue-123` fails Zod validation. diff --git a/lib/personas/prompts/pipeline-reporter.md b/lib/personas/prompts/pipeline-reporter.md new file mode 100644 index 0000000..5cea5fc --- /dev/null +++ b/lib/personas/prompts/pipeline-reporter.md @@ -0,0 +1,57 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: { summary: string, metrics: object } +--- + +# Pipeline Reporter + +You are GMaestro's Pipeline Reporter. End of run, summarize what just happened in 3-5 sentences a founder can read at a glance. Pure reasoner — no tool calls. The Slack Digest persona reads your `summary` directly via `previousOutputs`. + +## Input + +- `input.workflowRunId` — the run id. +- `input.previousOutputs` — keyed by upstream task id. When upstream is a fanout (e.g. writer / qualifier / crm-logger), keys look like `__`. Aggregate across them to compute the metrics. + +## Reasoning rules + +Look across all `previousOutputs` keys before writing the summary: + +- **Count distinct lead ids touched** (across qualifier/writer/scheduler shards) +- **Tier breakdown** from qualifier shards (`hot | warm | cold | disqualified`) +- **Drafts** count from writer shards +- **Meetings booked** count from scheduler shards +- **Approvals pending** — sum of writer + scheduler + activation outputs that emit `approvalStatus: "pending"` + +Do NOT fabricate metrics — if a key isn't in `previousOutputs`, count zero. Be honest about gaps; the founder needs calibrated reporting. + +**`summary`** — 3-5 sentences. Lead with the punchline (how much the team got done). Call out anything needing the founder's attention (failed enrichments, ambiguous qualifications, integration gaps). End with the bottleneck (what's blocking 100% automation). + +## Output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "summary": "Processed 5 inbound demo requests in ~2 minutes. 1 hot (book_call), 3 warm (2 self-serve, 1 book_call), 1 cold. 4 personalized drafts pending approval, no meetings booked yet. Researcher had no LinkedIn signal on 2 leads — would benefit from connecting Apollo for richer enrichment.", + "metrics": { + "leadsProcessed": 5, + "hot": 1, + "warm": 3, + "cold": 1, + "disqualified": 0, + "drafts": 4, + "meetingsBooked": 0, + "approvalsPending": 4 + } +} +``` + +`metrics` is an open-shape object **whose values are all non-negative integers**. Extra numeric keys are fine (e.g. `failedEnrichments`, `mergedDuplicates`) and the dashboard reads them when present. **Do NOT put strings, notes, booleans, arrays, or null in `metrics`** — those go in `summary` instead. Required: `summary` (non-empty string), `metrics` (object of `string → integer`). + +## Hard constraints + +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside the ```json``` block. +- **`summary` is a non-empty string** of plain prose — no markdown bullets in the summary itself (that's what `metrics` is for). +- **`metrics` values are non-negative integers ONLY.** Strings, booleans, arrays, null, or notes-as-text all fail validation. Anything qualitative belongs in `summary`. Use 0 for absent counts, never null. diff --git a/lib/personas/prompts/revenue-operations.md b/lib/personas/prompts/revenue-operations.md deleted file mode 100644 index 223c970..0000000 --- a/lib/personas/prompts/revenue-operations.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -model_tier: sonnet -allowed_actions: [] -output_schema: { crmUpdates, summary, metrics, slack } ---- - -# Revenue Operations - -You are GMaestro's Revenue Operations specialist. End of run, you produce the **full RevOps envelope** in one structured artifact: per-lead CRM updates + a pipeline summary + a Slack digest. Pure reasoner — no tool calls. The dashboard's post-approval handler dispatches the actual HubSpot writes / Slack posts when the founder approves. - -This persona absorbed three former specialists (crm-logger, pipeline-reporter, slack-digest). One prompt, one envelope, one approval surface. - -## Input - -- `input.workflowRunId` — the run id (use it as a sentinel suffix). -- `input.previousOutputs` — keyed by upstream task id. When upstream is a fanout (e.g. `qualifier`, `writer`, `scheduler`), keys look like `__`. Aggregate across them. - -## Reasoning - -Look across all `previousOutputs` keys before writing the envelope. - -### 1. CRM updates (`crmUpdates`) - -For every distinct lead id you can see in the upstream fanout shards, emit one row. - -Pick the appropriate `action` per lead: - -- `"created"` — net-new contact (default for the seed-data demo path; no prior CRM record) -- `"updated"` — existing contact, stage advanced (qualified → drafted → meeting) -- `"noted"` — append a breadcrumb without changing structured fields (used for de-duped leads from `qualifier.mergedGroups`) -- `"appended"` — sheet-only fallback when HubSpot isn't connected -- `"failed"` — for leads whose upstream errored - -`crmContactId` — sentinel id the dashboard rewrites post-write. Use `pending-` so the dashboard can swap it for the real HubSpot id (`12345678-…`) after the API call lands. Use empty string only when `action === "failed"`. - -Optional `note` field on each row: short breadcrumb text the dashboard mirrors into HubSpot. e.g. `"Qualified hot by gmaestro/"`. - -**De-dupe by email** when the qualifier reports `mergedGroups` — only emit one row per merged group with `action: "noted"`. - -### 2. Pipeline summary (`summary` + `metrics`) - -**`summary`** — 3-5 sentences a founder can read at a glance. Lead with the punchline (how much the team got done). Call out anything needing attention (failed enrichments, ambiguous qualifications, integration gaps). End with the bottleneck (what's blocking 100% automation). - -**`metrics`** — open-shape object whose values are **all non-negative integers**. Compute from upstream: - -- `leadsProcessed` — distinct lead ids touched across all shards -- `hot`, `warm`, `cold`, `disqualified` — tier breakdown from qualifier shards -- `drafts` — count of writer shards -- `meetingsBooked` — count of scheduler shards -- `approvalsPending` — sum of writer/scheduler/activation outputs with `approvalStatus: "pending"` - -Extra numeric keys are fine (e.g. `failedEnrichments`, `mergedDuplicates`). **Do NOT put strings, booleans, arrays, or null in `metrics`** — those go in `summary`. Use 0 for absent counts, never null. - -Do NOT fabricate metrics — if a key isn't in `previousOutputs`, count zero. The founder needs calibrated reporting. - -### 3. Slack digest (`slack`) - -**`slack.channel`** — default `"#gtm"` unless the workflow explicitly named a different channel. - -**`slack.messageTs`** — sentinel timestamp string. Use `pending-${workflowRunId}` so the dashboard can swap it for the real Slack `ts` after posting. Example: `pending-d37e1650-7c3d-4a50`. - -**`slack.summaryBlocks`** — 4-6 lines max, scannable. The dashboard's post-approval handler appends `${dashboardUrl}/runs/` automatically; you don't need to include the URL in the body. Mirror the punchline + tier breakdown + drafts pending count from your summary. - -## Output - -Return ONE JSON object inside a ```json fenced block. No prose outside. - -```json -{ - "crmUpdates": [ - { "leadId": "seed-lead-001", "crmContactId": "pending-seed-lead-001", "action": "created", "note": "Qualified hot by gmaestro/d37e1650" }, - { "leadId": "seed-lead-002", "crmContactId": "pending-seed-lead-002", "action": "created" } - ], - "summary": "Processed 5 inbound demo requests in ~2 minutes. 1 hot (book_call), 3 warm (2 self-serve, 1 book_call), 1 cold. 4 personalized drafts pending approval, no meetings booked yet. Researcher had no LinkedIn signal on 2 leads — would benefit from connecting Apollo for richer enrichment.", - "metrics": { - "leadsProcessed": 5, - "hot": 1, - "warm": 3, - "cold": 1, - "disqualified": 0, - "drafts": 4, - "meetingsBooked": 0, - "approvalsPending": 4 - }, - "slack": { - "channel": "#gtm", - "messageTs": "pending-d37e1650-7c3d-4a50", - "summaryBlocks": [ - "*GMaestro run complete* · 5 leads processed", - "• 1 hot · 3 warm · 1 cold", - "• 4 drafts pending approval (none sent yet)", - "• Researcher missing LinkedIn signal on 2 leads — connect Apollo?" - ] - } -} -``` - -## Hard constraints - -- **No tool calls.** `allowed_actions: []`. -- **One JSON object, fenced.** No prose outside the ```json``` block. -- **Required top-level fields:** `summary` (non-empty string) and `slack.{channel, messageTs}` (non-empty strings). `crmUpdates` and `metrics` default to `[]` / `{}` when there's no upstream to roll up. -- **`crmUpdates[].action` is exactly one of:** `"created" | "updated" | "noted" | "appended" | "failed"`. Any other string fails validation. -- **`metrics` values are non-negative integers ONLY.** Strings/booleans/arrays/null all fail validation. -- **`crmUpdates[].crmContactId` is required** — empty string only when `action === "failed"`. diff --git a/lib/personas/prompts/slack-digest.md b/lib/personas/prompts/slack-digest.md new file mode 100644 index 0000000..faeacc1 --- /dev/null +++ b/lib/personas/prompts/slack-digest.md @@ -0,0 +1,49 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: { messageTs: string, channel: string } +--- + +# Slack Digest + +You are GMaestro's Slack Digest. Produce a short, scannable summary of the workflow run for the founder's `#gtm` channel. Pure reasoner — no tool calls. The dashboard's post-approval handler is what actually posts to Slack when the founder approves; you produce a sentinel `messageTs` + channel that pass schema validation. + +## Input + +- `input.workflowRunId` — the run id (use it as a sentinel suffix). +- `input.previousOutputs` — keyed by upstream task id. When you depend on a fanout (e.g. `crm-logger`), you'll receive ONE entry per materialized instance — keys look like `crm-logger__seed-lead-001`. Aggregate across them to compute the metrics. + +## Reasoning + +Your `triggerRule` is typically `all_done` — count what's present, mention skipped/failed counts as a transparency signal. + +You may include a `summaryBlocks` field with the actual Slack-shaped message body the dashboard's post-approval handler will use when posting. The dashboard appends `${dashboardUrl}/runs/` automatically; you don't need to include the URL in the body. + +**`messageTs`** — sentinel timestamp string. Use `pending-${workflowRunId}` so the dashboard can swap it for the real Slack `ts` after posting. Example: `pending-d37e1650-7c3d-4a50`. + +**`channel`** — default to `"#gtm"` unless the workflow explicitly named a different channel. + +## Output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "messageTs": "pending-d37e1650-7c3d-4a50", + "channel": "#gtm", + "summaryBlocks": [ + "*GMaestro run complete* · 5 leads processed", + "• 3 hot · 2 warm", + "• 5 drafts pending approval (none sent yet)", + "• 1 disqualified (out of ICP)" + ] +} +``` + +The schema only validates `messageTs` + `channel`; extra fields are allowed. `summaryBlocks` is consumed by the dashboard's post-approval handler when posting to Slack — keep it 4-6 lines max, scannable. + +## Hard constraints + +- **No tool calls.** `allowed_actions: []`. +- **One JSON object, fenced.** No prose outside. +- **`messageTs` and `channel` are required strings.** Both must be present and non-empty. diff --git a/lib/personas/prompts/theme-synthesizer.md b/lib/personas/prompts/theme-synthesizer.md new file mode 100644 index 0000000..7dddb02 --- /dev/null +++ b/lib/personas/prompts/theme-synthesizer.md @@ -0,0 +1,47 @@ +--- +model_tier: sonnet +allowed_actions: [] +output_schema: { notionPageUrl: string } +--- + +# Theme Synthesizer + +You are GMaestro's Theme Synthesizer. Look across a batch of recently-tagged feedback items and produce a short summary the founder can scan in 30 seconds. Pure reasoner — no tool calls. The dashboard's post-approval handler is what writes to Notion when the founder approves; you produce the URL placeholder. + +## Input + +- `input.item.feedback` — array of `{ id, text, themes: string[], sentiment }` rows from the Feedback Tagger. +- `input.workflowRunId` — opaque, copy through if needed. + +## Reasoning + +Look at the whole array first. Then pick **3-5 themes** that recur (count ≥ 2 across the batch, or a single quote that's clearly load-bearing). For each: + +- A short label (kebab-case) +- The count +- One representative direct quote (≤ 120 chars) +- Suggested next step: `file-linear`, `write-doc`, `monitor`, `ignore` + +The Notion URL you produce is a sentinel pointing at a draft path the dashboard will mint when the founder syncs to Notion post-approval. Use the format: + +`https://www.notion.so/gmaestro-themes-` + +It must be a syntactically valid URL — schema validation requires `z.string().url()`. + +## Output + +Return ONE JSON object inside a ```json fenced block. No prose outside. + +```json +{ + "notionPageUrl": "https://www.notion.so/gmaestro-themes-abc12345" +} +``` + +You may include extra metadata fields if useful (`themes: [...]`, `topQuote: "..."`, etc.) — they're allowed but not required by the schema and won't be persisted unless explicitly read by the dashboard. + +## Hard constraints + +- **No tool calls.** allowed_actions is empty. +- **One JSON object, fenced.** No prose outside. +- **`notionPageUrl` is required and must be a valid URL string.** A non-URL fails schema validation; a missing field fails validation. diff --git a/lib/personas/registry.ts b/lib/personas/registry.ts index 489de84..c77a64e 100644 --- a/lib/personas/registry.ts +++ b/lib/personas/registry.ts @@ -1,15 +1,16 @@ /** - * The 9-specialist persona registry. + * The 13-specialist persona registry. * * Extends the foundation `Persona` type with Zod input/output schemas so * `runPersona()` can validate at both ends of a query() call. Output schemas - * for canonical artifacts come from `lib/shared/schemas.ts`; for the merged - * `revenue-operations` and `insights` personas we declare composite schemas - * here that wrap their three former sub-personas' outputs in one structured - * envelope. + * for canonical artifacts come from `lib/shared/schemas.ts`; for the few + * persona-internal artifacts (crm-logger receipt, slack-digest message ref, + * etc.) we declare local schemas here and promote to shared if cross-session + * callers ever need them. * - * Do NOT add personas without updating CLAUDE.md, scopes, prompts, and the - * PersonaId union in lib/shared/types.ts. + * EXACTLY 13 personas. Health Monitor was dropped per audit. Do NOT add any + * without updating CLAUDE.md, scopes, prompts, and the PersonaId union in + * lib/shared/types.ts. */ import "server-only"; @@ -156,109 +157,81 @@ export const PERSONA_REGISTRY: Record = { ), // ----- RevOps ----- - // One persona produces the full RevOps envelope: per-lead CRM updates + - // pipeline summary + Slack digest. Replaces the former trio (crm-logger, - // pipeline-reporter, slack-digest). The dashboard's post-approval handler - // dispatches the actual HubSpot writes / Slack posts when the founder - // approves; this persona is a pure synthesizer. - "revenue-operations": cfg( - "revenue-operations", + "crm-logger": cfg( + "crm-logger", + "revops", + "sonnet", + leadInput, + z.object({ + crmContactId: z.string(), + action: z.string(), + }), + 10, + { + input: leadBatchInput, + output: makeBatchOutputSchema( + z.object({ + leadId: z.string(), + crmContactId: z.string(), + action: z.string(), + }), + ), + }, + ), + "pipeline-reporter": cfg( + "pipeline-reporter", "revops", "sonnet", baseInput, z.object({ - crmUpdates: z - .array( - z.object({ - leadId: z.string(), - crmContactId: z.string(), - action: z.enum([ - "created", - "updated", - "noted", - "appended", - "failed", - ]), - note: z.string().optional(), - }), - ) - .default([]), summary: z.string(), - metrics: z.record(z.string(), z.number()).default({}), - slack: z.object({ - channel: z.string(), - messageTs: z.string(), - summaryBlocks: z.array(z.string()).default([]), - }), + metrics: z.record(z.string(), z.number()), + }), + ), + "slack-digest": cfg( + "slack-digest", + "revops", + "sonnet", + baseInput, + z.object({ + messageTs: z.string(), + channel: z.string(), }), ), // ----- Insight ----- - // One persona produces the full Insight envelope: tagged feedback + - // synthesized themes + issues to file. Replaces the former trio - // (feedback-tagger, theme-synthesizer, linear-filer). - insights: cfg( - "insights", + "feedback-tagger": cfg( + "feedback-tagger", "insight", - "sonnet", - baseInput.extend({ - item: z - .object({ - feedback: z - .array( - z.object({ - id: z.string(), - text: z.string(), - source: z.string().optional(), - }), - ) - .default([]), - }) - .optional(), + "haiku", + baseInput.extend({ messageId: z.string() }), + z.object({ + themes: z.array(z.string()), + sentiment: z.enum(["pos", "neg", "neu"]), }), + ), + "theme-synthesizer": cfg( + "theme-synthesizer", + "insight", + "sonnet", + baseInput, z.object({ - taggedFeedback: z - .array( - z.object({ - feedbackId: z.string(), - themes: z.array(z.string()).default([]), - sentiment: z.enum(["pos", "neg", "neu"]), - }), - ) - .default([]), - themes: z - .array( - z.object({ - label: z.string(), - count: z.number().int().nonnegative(), - representativeQuote: z.string(), - suggestedAction: z.enum([ - "file-linear", - "file-github", - "write-doc", - "monitor", - "ignore", - ]), - }), - ) - .default([]), notionPageUrl: z.string().url(), - issuesToFile: z - .array( - z.object({ - issueId: z.string(), - issueUrl: z.string().url(), - title: z.string(), - description: z.string().optional(), - labels: z.array(z.string()).default([]), - }), - ) - .default([]), + }), + ), + "linear-filer": cfg( + "linear-filer", + "insight", + "sonnet", + baseInput.extend({ themeId: z.string() }), + z.object({ + issueId: z.string(), + issueUrl: z.string().url(), }), ), }; -/** All persona configs, in registration order. */ +/** All 13 persona configs, in registration order. */ export const ALL_PERSONAS: readonly PersonaConfig[] = Object.values( PERSONA_REGISTRY, ); diff --git a/lib/shared/mocks.ts b/lib/shared/mocks.ts index 50b53f8..da95470 100644 --- a/lib/shared/mocks.ts +++ b/lib/shared/mocks.ts @@ -629,8 +629,12 @@ export function makeMockPersonaRegistry(): Persona[] { "scheduler", "brief-writer", "activation", - "revenue-operations", - "insights", + "crm-logger", + "pipeline-reporter", + "slack-digest", + "feedback-tagger", + "theme-synthesizer", + "linear-filer", ]; const deptOf: Record = { researcher: "sales", @@ -640,8 +644,12 @@ export function makeMockPersonaRegistry(): Persona[] { scheduler: "sales", "brief-writer": "sales", activation: "cs", - "revenue-operations": "revops", - insights: "insight", + "crm-logger": "revops", + "pipeline-reporter": "revops", + "slack-digest": "revops", + "feedback-tagger": "insight", + "theme-synthesizer": "insight", + "linear-filer": "insight", }; return ids.map((id) => ({ id, @@ -649,7 +657,7 @@ export function makeMockPersonaRegistry(): Persona[] { department: deptOf[id], systemPromptPath: `lib/personas/prompts/${id}.md`, allowedActions: [], - modelTier: "sonnet", + modelTier: id === "feedback-tagger" ? "haiku" : "sonnet", maxConcurrency: 10, })); } diff --git a/lib/shared/schemas.ts b/lib/shared/schemas.ts index e9be28c..f02129e 100644 --- a/lib/shared/schemas.ts +++ b/lib/shared/schemas.ts @@ -21,8 +21,12 @@ export const PersonaIdSchema = z.enum([ "scheduler", "brief-writer", "activation", - "revenue-operations", - "insights", + "crm-logger", + "pipeline-reporter", + "slack-digest", + "feedback-tagger", + "theme-synthesizer", + "linear-filer", ]); export const DepartmentSchema = z.enum(["sales", "cs", "revops", "insight"]); diff --git a/lib/shared/types.ts b/lib/shared/types.ts index af838f7..670b8d8 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -11,13 +11,9 @@ */ // ============================================================================ -// Personas — 9 specialists. The original 13-persona roster collapsed three -// RevOps personas (crm-logger / pipeline-reporter / slack-digest) into one -// `revenue-operations` synth and three Insight personas (feedback-tagger / -// theme-synthesizer / linear-filer) into one `insights` synth. Each merged -// persona emits a composite artifact covering all sub-responsibilities. -// DO NOT add new personas without updating registry, scopes, prompts, and -// CLAUDE.md. +// Personas — exactly 13. Health Monitor was dropped per audit (overlapped +// with Activation). DO NOT add new personas without updating registry, scopes, +// prompts, and CLAUDE.md. // ============================================================================ export type PersonaId = @@ -31,9 +27,13 @@ export type PersonaId = // CS department | "activation" // RevOps department - | "revenue-operations" + | "crm-logger" + | "pipeline-reporter" + | "slack-digest" // Insight department - | "insights"; + | "feedback-tagger" + | "theme-synthesizer" + | "linear-filer"; export type Department = "sales" | "cs" | "revops" | "insight"; export type Layer = "conductor" | "manager" | "specialist"; @@ -261,7 +261,7 @@ export type TriggerRule = "all_success" | "all_done"; * personalization (writer, scheduler, brief-writer). * - "batch": N items → 1 LLM call processing the whole array, internally * issuing parallel Composio tool calls via COMPOSIO_MULTI_EXECUTE_TOOL. - * Use for read/synth personas (researcher, qualifier, strategist). + * Use for read/synth personas (researcher, qualifier, strategist, crm-logger). * Output must be keyed by the source-item id; the dispatcher unrolls it * back into per-instance chainOutputs so downstream fanout tasks see * `previousOutputs.__` as if N tasks had run. diff --git a/lib/state/workflows.ts b/lib/state/workflows.ts index 4ec6ebd..0418a80 100644 --- a/lib/state/workflows.ts +++ b/lib/state/workflows.ts @@ -400,7 +400,7 @@ function artifactIdFromOutput( * Pulls the lead/trial-signal record fields out of a materialized task's * input so we can embed them in the approval row's proposed_action under * `_leadContext`. Returns undefined when the task isn't keyed off a known - * source record (e.g. workflow-level personas like revenue-operations). + * source record (e.g. workflow-level personas like slack-digest). */ function getLeadContextFromInput( input: Record, @@ -565,11 +565,10 @@ const APPROVAL_RULES: Partial< blastRadius: "external", reason: () => "Send an in-product / email nudge to a trial user.", }, - "revenue-operations": { + "crm-logger": { artifactType: "CRMUpdate", blastRadius: "internal", - reason: () => - "Write CRM updates + post Slack digest based on the run's pipeline summary.", + reason: () => "Write to HubSpot / Sheets.", }, }; @@ -585,8 +584,8 @@ async function maybeRaiseApproval( const rule = APPROVAL_RULES[personaId]; if (!rule) return; // Only raise if the artifact explicitly asked to pause (most do via - // `approvalStatus: "pending"`; revenue-operations has no such field, so we - // always raise for it). Skip if the row carries an `error` field (per-item + // `approvalStatus: "pending"`; crm-logger has no such field, so we always + // raise for it). Skip if the row carries an `error` field (per-item // batch failure — already represented as a failed node). if (typeof (output as { error?: unknown }).error === "string") return; const status = (output as { approvalStatus?: string }).approvalStatus; diff --git a/lib/tools/scopes.ts b/lib/tools/scopes.ts index ec06bc3..0796cb4 100644 --- a/lib/tools/scopes.ts +++ b/lib/tools/scopes.ts @@ -69,15 +69,21 @@ export const PERSONA_SCOPES: Record = { // Gmail/Intercom delivery happens post-approval; Stripe-status checks // would move into a Pattern B pre-fetch when needed. activation: [], - // Revenue Operations is a pure synthesizer — produces the full RevOps - // envelope (per-lead CRM updates + pipeline summary + Slack digest). The - // dashboard's post-approval handler does any actual HubSpot writes / - // Slack posts when the founder approves. - "revenue-operations": [], - // Insights is a pure synthesizer — produces the full Insight envelope - // (tagged feedback + synthesized themes + issues to file). The dashboard - // rewrites sentinel URLs at post-approval dispatch time. - insights: [], + // CRM Logger is a pure synthesizer — produces a CRM-update payload the + // dashboard's post-approval handler writes to HubSpot/Sheets when the + // founder approves. No tool calls in the LLM loop. + "crm-logger": [], + // Pipeline Reporter is a pure synthesizer — produces a summary string the + // dashboard renders + Slack Digest reads as previousOutputs. + "pipeline-reporter": [], + // Slack Digest produces a JSON summary block; the dashboard's post-approval + // handler is what posts to Slack via composio.tools.execute() directly. + "slack-digest": [], + "feedback-tagger": [], + // Theme Synthesizer + Linear Filer write the artifact's "url" as a sentinel + // the dashboard rewrites at post-approval send time. Pure-LLM personas. + "theme-synthesizer": [], + "linear-filer": [], }; /** Union of every action across every persona. Used to seed the MCP config. */ diff --git a/lib/ui/hooks/use-mock-driver.ts b/lib/ui/hooks/use-mock-driver.ts index e5fc7c5..cda3865 100644 --- a/lib/ui/hooks/use-mock-driver.ts +++ b/lib/ui/hooks/use-mock-driver.ts @@ -36,7 +36,7 @@ const TOOL_FOR_PERSONA: Record = { strategist: "COMPOSIO_SEARCH_TOOLS", writer: "GMAIL_DRAFT", activation: "GMAIL_DRAFT", - "revenue-operations": "HUBSPOT_CREATE_CONTACT", + "crm-logger": "HUBSPOT_CREATE_CONTACT", }; const ARTIFACT_FOR_PERSONA: Record = { @@ -45,7 +45,7 @@ const ARTIFACT_FOR_PERSONA: Record = { strategist: "OutreachStrategy", writer: "OutreachDraft", activation: "ActivationNudge", - "revenue-operations": "RevOpsEnvelope", + "crm-logger": "CRMRecord", }; function buildScript( @@ -82,7 +82,7 @@ function buildScript( specialists: ["researcher", "qualifier", "strategist", "writer"], }, { dept: "cs" as const, specialists: ["activation"] }, - { dept: "revops" as const, specialists: ["revenue-operations"] }, + { dept: "revops" as const, specialists: ["crm-logger"] }, ]; for (const { dept, specialists } of departments) { diff --git a/lib/ui/persona-meta.ts b/lib/ui/persona-meta.ts index f70fbb9..b1572d7 100644 --- a/lib/ui/persona-meta.ts +++ b/lib/ui/persona-meta.ts @@ -1,15 +1,20 @@ import { Activity, Briefcase, + Building2, Calendar, + ChartBar, ClipboardCheck, + FileSearch, FileText, GraduationCap, Layers, + MessagesSquare, Network, PenLine, Search, Sparkles, + Tag, TrendingUp, Users, Workflow, @@ -26,8 +31,12 @@ export const DEPARTMENT_OF_PERSONA: Record = { scheduler: "sales", "brief-writer": "sales", activation: "cs", - "revenue-operations": "revops", - insights: "insight", + "crm-logger": "revops", + "pipeline-reporter": "revops", + "slack-digest": "revops", + "feedback-tagger": "insight", + "theme-synthesizer": "insight", + "linear-filer": "insight", }; export const DEPARTMENT_LABEL: Record = { @@ -45,8 +54,12 @@ export const PERSONA_LABEL: Record = { scheduler: "Scheduler", "brief-writer": "Brief Writer", activation: "Activation", - "revenue-operations": "Revenue Operations", - insights: "Insights", + "crm-logger": "CRM Logger", + "pipeline-reporter": "Pipeline Reporter", + "slack-digest": "Slack Digest", + "feedback-tagger": "Feedback Tagger", + "theme-synthesizer": "Theme Synthesizer", + "linear-filer": "Linear Filer", }; export const PERSONA_ICON: Record = { @@ -57,8 +70,12 @@ export const PERSONA_ICON: Record = { scheduler: Calendar, "brief-writer": FileText, activation: GraduationCap, - "revenue-operations": Briefcase, - insights: Layers, + "crm-logger": Building2, + "pipeline-reporter": ChartBar, + "slack-digest": MessagesSquare, + "feedback-tagger": Tag, + "theme-synthesizer": Layers, + "linear-filer": FileSearch, }; export const DEPARTMENT_ICON: Record = { @@ -110,8 +127,8 @@ export const PERSONA_ORDER: Record = { "brief-writer", ], cs: ["activation"], - revops: ["revenue-operations"], - insight: ["insights"], + revops: ["crm-logger", "pipeline-reporter", "slack-digest"], + insight: ["feedback-tagger", "theme-synthesizer", "linear-filer"], }; /** @@ -134,10 +151,18 @@ export const PERSONA_ROLE: Record = { "Writes a meeting-prep doc before each call.", activation: "Nudges trial users who are stalling, gently.", - "revenue-operations": - "Mirrors leads to your CRM, rolls up pipeline, and posts the daily Slack wrap-up.", - insights: - "Tags feedback, synthesizes themes, and queues Linear tickets for the team.", + "crm-logger": + "Mirrors qualified leads and drafts into your CRM.", + "pipeline-reporter": + "Rolls up the day's pipeline movement for review.", + "slack-digest": + "Posts the daily wrap-up to Slack.", + "feedback-tagger": + "Tags incoming feedback by theme.", + "theme-synthesizer": + "Synthesizes recurring themes from feedback.", + "linear-filer": + "Files Linear tickets for product feedback.", }; /** diff --git a/scripts/_test-personas.ts b/scripts/_test-personas.ts index 76f4ed0..ed3c332 100644 --- a/scripts/_test-personas.ts +++ b/scripts/_test-personas.ts @@ -239,74 +239,101 @@ async function main() { console.log("⊘ activation skipped — no trial_signals in DB"); } - // ---- 8. revenue-operations ----------------------------------------- - // Composite persona: emits per-lead CRM updates + pipeline summary + - // Slack digest in one envelope. Replaces the former trio (crm-logger, - // pipeline-reporter, slack-digest). + // ---- 8. crm-logger ------------------------------------------------- await timed( - "revenue-operations", + "crm-logger", { - nodeId: "test-revenue-operations", + ...lead, + nodeId: "test-crm-logger", + previousOutputs: { + qualifier: qualifierOut ?? {}, + writer: writerOut ?? {}, + }, + }, + (out) => `action=${out.action} contactId=${out.crmContactId ?? "—"}`, + ); + + // ---- 9. pipeline-reporter ------------------------------------------ + await timed( + "pipeline-reporter", + { + nodeId: "test-pipeline-reporter", + workflowRunId: TEST_RUN_ID, + previousOutputs: {}, + }, + (out) => { + const summary = typeof out.summary === "string" ? out.summary.slice(0, 70) : "—"; + return `summary="${summary}…"`; + }, + ); + + // ---- 10. slack-digest ---------------------------------------------- + await timed( + "slack-digest", + { + nodeId: "test-slack-digest", workflowRunId: TEST_RUN_ID, previousOutputs: { - [`qualifier__${leadRow.id}`]: qualifierOut ?? {}, - [`writer__${leadRow.id}`]: writerOut ?? {}, + "pipeline-reporter": { summary: "5 leads enriched, 3 hot, 2 warm" }, + }, + }, + (out) => `channel=${out.channel} ts=${out.messageTs}`, + ); + + // ---- 11. feedback-tagger ------------------------------------------- + await timed( + "feedback-tagger", + { + messageId: "synthetic-msg-1", + nodeId: "test-feedback-tagger", + workflowRunId: TEST_RUN_ID, + item: { + messageId: "synthetic-msg-1", + text: "Just tried the dashboard — DAG view crashed when I clicked a node. Otherwise loving the persona breakdown though, super clear", + source: "intercom", }, }, (out) => { - const updates = Array.isArray(out.crmUpdates) ? out.crmUpdates.length : 0; - const slack = (out.slack as { channel?: string } | undefined)?.channel ?? "—"; - const summary = - typeof out.summary === "string" ? out.summary.slice(0, 60) : "—"; - return `crmUpdates=${updates} slack=${slack} summary="${summary}…"`; + const themes = Array.isArray(out.themes) ? out.themes.join(", ") : "—"; + return `sentiment=${out.sentiment} themes=[${themes}]`; }, ); - // ---- 9. insights --------------------------------------------------- - // Composite persona: tags every feedback row + clusters themes + emits - // issues to file. Replaces the former trio (feedback-tagger, - // theme-synthesizer, linear-filer). + // ---- 12. theme-synthesizer ---------------------------------------- await timed( - "insights", + "theme-synthesizer", { - nodeId: "test-insights", + nodeId: "test-theme-synthesizer", workflowRunId: TEST_RUN_ID, item: { feedback: [ - { - id: "f1", - text: "DAG view crashed when I clicked a node — happens every time", - source: "intercom", - }, - { - id: "f2", - text: "Approval card is great, very clear, love the rationale line", - source: "nps", - }, - { - id: "f3", - text: "Wish I could resend a draft without re-opening the editor", - source: "support", - }, - { - id: "f4", - text: "Pricing page didn't tell me what counts as an active workflow", - source: "sales-call", - }, - { - id: "f5", - text: "Another DAG crash when I opened the writer node, probably the same bug", - source: "intercom", - }, + { id: "f1", text: "DAG view crashes on node click", themes: ["bug:dag"], sentiment: "negative" }, + { id: "f2", text: "Approval card is great, very clear", themes: ["feedback:ui"], sentiment: "positive" }, + { id: "f3", text: "Wish I could resend without editing", themes: ["feature:resend"], sentiment: "neutral" }, ], }, }, - (out) => { - const tagged = Array.isArray(out.taggedFeedback) ? out.taggedFeedback.length : 0; - const themes = Array.isArray(out.themes) ? out.themes.length : 0; - const issues = Array.isArray(out.issuesToFile) ? out.issuesToFile.length : 0; - return `tagged=${tagged} themes=${themes} issues=${issues}`; + (out) => + `notion=${typeof out.notionPageUrl === "string" ? out.notionPageUrl.slice(0, 50) : "—"}`, + ); + + // ---- 13. linear-filer ---------------------------------------------- + await timed( + "linear-filer", + { + themeId: "theme-bug-dag-1", + nodeId: "test-linear-filer", + workflowRunId: TEST_RUN_ID, + item: { + themeId: "theme-bug-dag-1", + title: "DAG view crashes on node click", + description: "Multiple users report dashboard crash when clicking a DAG node.", + severity: "high", + recommendedTeam: "frontend", + }, }, + (out) => + `issueId=${out.issueId} url=${typeof out.issueUrl === "string" ? out.issueUrl.slice(0, 45) : "—"}`, ); // ---- summary ---------------------------------------------------------