diff --git a/.changeset/fn-6759-lead-generation-workflow.md b/.changeset/fn-6759-lead-generation-workflow.md new file mode 100644 index 0000000000..f946f9ebdb --- /dev/null +++ b/.changeset/fn-6759-lead-generation-workflow.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a built-in lead-generation workflow with custom lead columns, fields, and stage prompts. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 8819abe208..00e7513baa 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -9,6 +9,9 @@ Workflow steps are reusable quality gates that run around task completion. Fusion workflows define the task lifecycle policy that moves work from an idea to delivery. The default coding path is **Plan/Triage → Execute → Workflow steps → Review → Merge**, but that path is now represented as a workflow selection rather than only as fixed engine behavior. A task with no explicit workflow resolves to `builtin:coding`; an explicit missing/corrupt custom workflow fails closed instead of silently falling back. @@ -35,6 +38,7 @@ Decision-only or investigation tasks can also declare `noCommitsExpected` / `**N | Stepwise coding | `builtin:stepwise-coding` | Graph-executor workflow that models per-step parse/execute/review/rework explicitly. | | Design | `builtin:design` | UI-heavy work path that implements, runs a gated design/UX review, then performs the standard review and merge. | | PR lifecycle | `builtin:pr-workflow` | Reusable PR lifecycle graph fragment (create PR → await review → respond → gate → merge); it is a fragment, not directly selectable as a task workflow. | +| Lead generation | `builtin:lead-generation` | Selectable business workflow for sourcing, qualifying, enriching, and contacting leads with custom lead fields and stage columns; requires the workflow graph executor for custom board columns. | ### Custom workflow authoring diff --git a/packages/core/src/__tests__/builtin-lead-generation-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-lead-generation-workflow-ir.test.ts new file mode 100644 index 0000000000..05ff8cf8f9 --- /dev/null +++ b/packages/core/src/__tests__/builtin-lead-generation-workflow-ir.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { BUILTIN_LEAD_GENERATION_WORKFLOW_IR } from "../builtin-lead-generation-workflow-ir.js"; +import { + BUILTIN_WORKFLOWS, + defaultEnabledBuiltinWorkflowIds, + getBuiltinWorkflow, +} from "../builtin-workflows.js"; +import { compileWorkflowToSteps } from "../workflow-compiler.js"; +import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js"; + +describe("built-in lead-generation workflow IR", () => { + it("registers as an enabled v2 workflow with the authored custom columns", () => { + const workflow = getBuiltinWorkflow("builtin:lead-generation"); + expect(workflow).toBeDefined(); + expect(workflow!.kind).toBe("workflow"); + expect(workflow!.ir).toBe(BUILTIN_LEAD_GENERATION_WORKFLOW_IR); + expect(BUILTIN_WORKFLOWS.some((candidate) => candidate.id === "builtin:lead-generation")).toBe(true); + expect(defaultEnabledBuiltinWorkflowIds()).toContain("builtin:lead-generation"); + + const ir = parseWorkflowIr(workflow!.ir); + expect(ir.version).toBe("v2"); + if (ir.version !== "v2") throw new Error("expected v2"); + + expect(ir.columns.map((column) => column.id)).toEqual([ + "triage", + "sourcing", + "qualification", + "enrichment", + "outreach", + "converted", + "archived", + ]); + expect(ir.columns.map((column) => column.traits.map((trait) => trait.trait))).toEqual([ + ["intake"], + ["timing"], + ["wip", "timing"], + ["timing"], + ["human-review", "stall-detection"], + ["complete"], + ["archived"], + ]); + + expect(ir.columns.filter((column) => column.traits.some((trait) => trait.trait === "intake"))).toHaveLength(1); + expect(ir.columns.filter((column) => column.traits.some((trait) => trait.trait === "complete"))).toHaveLength(1); + expect(ir.columns.filter((column) => column.traits.some((trait) => trait.trait === "archived"))).toHaveLength(1); + }); + + it("places every node in a defined column and compiles the linear prompt spine", () => { + const workflow = getBuiltinWorkflow("builtin:lead-generation")!; + const ir = parseWorkflowIr(workflow.ir); + if (ir.version !== "v2") throw new Error("expected v2"); + + const columnIds = new Set(ir.columns.map((column) => column.id)); + for (const node of ir.nodes) { + expect(node.column, node.id).toBeDefined(); + expect(columnIds.has(node.column!), node.id).toBe(true); + } + + expect(ir.nodes.filter((node) => node.kind === "start")).toHaveLength(1); + expect(ir.nodes.filter((node) => node.kind === "end")).toHaveLength(1); + expect(ir.nodes.find((node) => node.id === "start")?.column).toBe("triage"); + expect(ir.nodes.find((node) => node.id === "end")?.column).toBe("converted"); + expect(ir.nodes.find((node) => node.id === "qualification-gate")?.kind).toBe("gate"); + expect((ir.nodes.find((node) => node.id === "qualification-gate")?.config as { gateMode?: string })?.gateMode).toBe( + "advisory", + ); + for (const node of ir.nodes.filter((candidate) => candidate.kind === "prompt" || candidate.kind === "gate")) { + const config = node.config as { prompt?: string; seam?: string } | undefined; + expect(config?.seam, node.id).toBeUndefined(); + expect(config?.prompt, node.id).toEqual(expect.stringMatching(/lead|prospect|outreach|customer|company/i)); + } + + expect(compileWorkflowToSteps(ir).map((step) => step.name)).toEqual([ + "Source prospects", + "Qualify lead", + "Qualification go / no-go", + "Enrich lead", + "Draft and send outreach", + ]); + }); + + it("declares lead fields with expected types and enum options", () => { + const workflow = getBuiltinWorkflow("builtin:lead-generation")!; + const ir = parseWorkflowIr(workflow.ir); + if (ir.version !== "v2") throw new Error("expected v2"); + + expect(ir.fields?.map((field) => [field.id, field.type])).toEqual([ + ["company", "string"], + ["contactName", "string"], + ["contactEmail", "url"], + ["leadSource", "enum"], + ["leadScore", "number"], + ["leadStatus", "enum"], + ]); + + const fields = new Map(ir.fields?.map((field) => [field.id, field])); + expect(fields.get("leadSource")?.options?.map((option) => option.value)).toEqual([ + "referral", + "inbound", + "outbound", + "event", + "partner", + ]); + expect(fields.get("leadStatus")?.options?.map((option) => option.value)).toEqual([ + "new", + "qualified", + "contacted", + "responded", + "won", + "lost", + ]); + expect(fields.get("company")?.render).toEqual({ placement: "card", widget: "input" }); + expect(fields.get("leadStatus")?.render).toEqual({ placement: "card", widget: "select" }); + }); + + it("round-trips through serialize → parse unchanged", () => { + const workflow = getBuiltinWorkflow("builtin:lead-generation")!; + const serialized = serializeWorkflowIr(workflow.ir); + const reparsed = parseWorkflowIr(serialized); + expect(serializeWorkflowIr(reparsed)).toBe(serialized); + }); +}); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 86241ec8b8..ecc49eda94 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -95,6 +95,16 @@ describe("built-in workflows", () => { expect(serializeWorkflowIr(reparsed)).toBe(serialized); }); + it("includes the lead-generation built-in after existing built-ins without disturbing default order", () => { + const leadGeneration = getBuiltinWorkflow("builtin:lead-generation"); + expect(leadGeneration).toBeDefined(); + expect(leadGeneration!.kind).toBe("workflow"); + expect(defaultEnabledBuiltinWorkflowIds()).toContain("builtin:lead-generation"); + expect(BUILTIN_WORKFLOWS.findIndex((workflow) => workflow.id === "builtin:lead-generation")).toBeGreaterThan( + BUILTIN_WORKFLOWS.findIndex((workflow) => workflow.id === "builtin:pr-workflow"), + ); + }); + it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => { expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); diff --git a/packages/core/src/builtin-lead-generation-workflow-ir.ts b/packages/core/src/builtin-lead-generation-workflow-ir.ts new file mode 100644 index 0000000000..04b2bc4b0f --- /dev/null +++ b/packages/core/src/builtin-lead-generation-workflow-ir.ts @@ -0,0 +1,141 @@ +import type { WorkflowIr } from "./workflow-ir-types.js"; +import { parseWorkflowIr } from "./workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; + +/** + * FNXC:Workflows 2026-06-20-00:25: + * The lead-generation built-in must be a first-class v2 workflow with its own business pipeline columns, lead-specific task fields, and inline per-stage prompts instead of coding seams. + * The custom non-default column ids make this workflow graph-executor-oriented at runtime while still compiling its linear prompt/gate spine for legacy step materialization. + */ +const RAW_BUILTIN_LEAD_GENERATION_WORKFLOW_IR: WorkflowIr = { + version: "v2", + name: "builtin-lead-generation", + columns: [ + { id: "triage", name: "Lead intake", traits: [{ trait: "intake" }] }, + { id: "sourcing", name: "Sourcing", traits: [{ trait: "timing" }] }, + { + id: "qualification", + name: "Qualification", + traits: [ + { trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } }, + { trait: "timing" }, + ], + }, + { id: "enrichment", name: "Enrichment", traits: [{ trait: "timing" }] }, + { + id: "outreach", + name: "Outreach", + traits: [ + { trait: "human-review" }, + { trait: "stall-detection", config: { timeoutMs: 86_400_000, action: "notify" } }, + ], + }, + { id: "converted", name: "Converted", traits: [{ trait: "complete" }] }, + { id: "archived", name: "Archived", traits: [{ trait: "archived" }] }, + ], + fields: [ + { id: "company", name: "Company", type: "string", render: { placement: "card", widget: "input" } }, + { id: "contactName", name: "Contact name", type: "string", render: { placement: "detail", widget: "input" } }, + { id: "contactEmail", name: "Contact email", type: "url", render: { placement: "detail", widget: "input" } }, + { + id: "leadSource", + name: "Lead source", + type: "enum", + options: [ + { value: "referral", label: "Referral" }, + { value: "inbound", label: "Inbound" }, + { value: "outbound", label: "Outbound" }, + { value: "event", label: "Event" }, + { value: "partner", label: "Partner" }, + ], + render: { placement: "card", widget: "select" }, + }, + { id: "leadScore", name: "Lead score", type: "number", render: { placement: "card", widget: "input" } }, + { + id: "leadStatus", + name: "Lead status", + type: "enum", + options: [ + { value: "new", label: "New" }, + { value: "qualified", label: "Qualified" }, + { value: "contacted", label: "Contacted" }, + { value: "responded", label: "Responded" }, + { value: "won", label: "Won" }, + { value: "lost", label: "Lost" }, + ], + render: { placement: "card", widget: "select" }, + }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { + id: "source-prospects", + kind: "prompt", + column: "sourcing", + config: { + name: "Source prospects", + executor: "model", + prompt: + "Research and identify promising prospects for this lead-generation task. Capture target companies, likely buyer personas, trigger events, and the evidence behind each prospect so downstream qualification can judge fit.", + }, + }, + { + id: "qualify-lead", + kind: "prompt", + column: "qualification", + config: { + name: "Qualify lead", + executor: "model", + prompt: + "Evaluate each sourced prospect against the ideal customer profile. Score company fit, pain urgency, budget or buying signals, and disqualifying risks; update lead score and recommend qualified or lost status with concise rationale.", + }, + }, + { + id: "qualification-gate", + kind: "gate", + column: "qualification", + config: { + name: "Qualification go / no-go", + gateMode: "advisory", + prompt: + "Advisory check: decide whether this lead should continue to enrichment. Continue for plausible fit, but record any concerns, missing data, or reasons the lead may be low priority.", + }, + }, + { + id: "enrich-lead", + kind: "prompt", + column: "enrichment", + config: { + name: "Enrich lead", + executor: "model", + prompt: + "Enrich the qualified lead with company context and contact data. Add verified company details, relevant news or initiatives, likely stakeholders, contact name, contact email or profile URL, and personalization hooks for outreach.", + }, + }, + { + id: "draft-outreach", + kind: "prompt", + column: "outreach", + config: { + name: "Draft and send outreach", + executor: "model", + prompt: + "Draft concise personalized outreach for the enriched lead. Reference the strongest trigger or pain evidence, state the proposed value clearly, choose a low-friction call to action, and record the sent or ready-to-send message plus follow-up timing.", + }, + }, + { id: "end", kind: "end", column: "converted" }, + ], + edges: [ + { from: "start", to: "source-prospects" }, + { from: "source-prospects", to: "qualify-lead", condition: "success" }, + { from: "qualify-lead", to: "qualification-gate", condition: "success" }, + { from: "qualification-gate", to: "enrich-lead", condition: "success" }, + { from: "enrich-lead", to: "draft-outreach", condition: "success" }, + { from: "draft-outreach", to: "end", condition: "success" }, + ], + settings: BUILTIN_WORKFLOW_SETTINGS, +}; + +export const BUILTIN_LEAD_GENERATION_WORKFLOW_IR = parseWorkflowIr( + RAW_BUILTIN_LEAD_GENERATION_WORKFLOW_IR, +); diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 6d7aed26e1..26b0562cfd 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -1,4 +1,5 @@ import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +import { BUILTIN_LEAD_GENERATION_WORKFLOW_IR } from "./builtin-lead-generation-workflow-ir.js"; import { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js"; import { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js"; import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; @@ -343,6 +344,25 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ createdAt: BUILTIN_TS, updatedAt: BUILTIN_TS, }, + { + id: "builtin:lead-generation", + name: "Lead generation (built-in)", + description: + "A business pipeline for sourcing, qualifying, enriching, and contacting leads with custom lead fields and stage columns. Requires the workflow graph executor for custom board columns.", + kind: "workflow", + ir: BUILTIN_LEAD_GENERATION_WORKFLOW_IR, + layout: { + start: { x: 60, y: 160 }, + "source-prospects": { x: 230, y: 160 }, + "qualify-lead": { x: 400, y: 160 }, + "qualification-gate": { x: 570, y: 160 }, + "enrich-lead": { x: 740, y: 160 }, + "draft-outreach": { x: 910, y: 160 }, + end: { x: 1080, y: 160 }, + }, + createdAt: BUILTIN_TS, + updatedAt: BUILTIN_TS, + }, ]; const BUILTIN_BY_ID = new Map(BUILTIN_WORKFLOWS.map((wf) => [wf.id, wf])); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1b24415de7..59134a634c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,6 +122,7 @@ export { resolveWorkflowOptionalSteps } from "./workflow-optional-steps.js"; export type { ResolvedWorkflowOptionalStep } from "./workflow-optional-steps.js"; export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; export { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js"; +export { BUILTIN_LEAD_GENERATION_WORKFLOW_IR } from "./builtin-lead-generation-workflow-ir.js"; export { BUILTIN_WORKFLOW_SETTINGS, BUILTIN_MOVED_WORKFLOW_SETTINGS,