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 ---------------------------------------------------------