@elitedcs/ghl-mcp 3.46.0 → 3.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/index.js +449 -61
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.48.0 — Blueprint: complete workflow-building (opportunities, branching, appointment timers, opt-in publish)
4
+
5
+ Completes the `apply_build_plan` executor so a Blueprint build produces workflows that
6
+ create and move real opportunities, branch on whether a contact has one, fire on
7
+ appointment-relative timers, and (opt-in) go live instead of staging as drafts. Every
8
+ item below was proven live on a throwaway account and read back before shipping.
9
+
10
+ - **Fixed: `create_opportunity` now actually creates an opportunity.** The executor was
11
+ emitting GHL's *update* node (`internal_update_opportunity`), which only updates an
12
+ existing opp and silently no-ops when none exists. It now emits the real
13
+ `internal_create_opportunity` node (pipeline hoisted to the attribute level, stage + name
14
+ in the custom input fields), so a new-lead workflow lands a deal in the pipeline as
15
+ intended.
16
+ - **New: `wait_appointment` action** — appointment-relative reminder timers (e.g. "24 hours
17
+ before the appointment"), emitting GHL's appointment-relative wait node. The workflow
18
+ builder's action-chain validation accepts the appointment-relative `appointmentStartAfter`
19
+ shape alongside the standard time-based `startAfter`.
20
+ - **New: optional `monetaryValue`** on `create_opportunity` / `update_opportunity` so a deal
21
+ carries its value.
22
+ - **New: `find_opportunity` multi-path branching.** The first branching logical action: it
23
+ loads a contact's latest opportunity in a pipeline, then routes to a `found` / `not-found`
24
+ branch (each with its own actions, including waits and sends). This is what makes a
25
+ move-the-opp step inside a branch work — the find loads the opp the move then acts on.
26
+ - **Fixed: backward opportunity moves (win-back / reactivation) silently no-op'd.** GHL's
27
+ "Move opportunity" defaults `allowBackward:false`, which lets a forward stage move through
28
+ but silently refuses a move to an earlier stage — breaking every reactivation build (whose
29
+ whole point is a backward move). The executor now emits `allowBackward:true` so the target
30
+ stage is honored in either direction; forward moves are unaffected.
31
+ - **New: opt-in `publishWorkflows`.** Workflows still build as DRAFT by default (the safe
32
+ default). Passing `publishWorkflows:true` publishes ungated, newly-built workflows live
33
+ (re-syncing their triggers); a workflow gated DRAFT by an unmet handoff is never
34
+ auto-published, and a publish failure never halts or rolls back the build (it surfaces a
35
+ "publish it yourself" note and the build still succeeds).
36
+ - **New: funnel page content template** (`templates/funnel-page-content-template.md`) —
37
+ brand-themeable CSS plus always-on A2P/10DLC opt-in, SEO, and AI-search (GEO) structure
38
+ for the manual funnel-page-content step.
39
+
40
+ ## 3.47.0 — Blueprint Cap-0: cross-workflow exit chaining + native trigger builders
41
+
42
+ Two foundational executor fixes so `apply_build_plan` builds workflows that actually
43
+ chain together AND actually fire — the prerequisites for a non-hollow account build.
44
+
45
+ - **Fixed: a cross-workflow `remove_from_workflow` / `add_to_workflow` halted the build.**
46
+ When one workflow's exit action targeted another workflow in the SAME plan, execute
47
+ halted, reporting the (already-built) target as unresolved — so the canonical "pull the
48
+ lead out of nurture when they book / reply" pattern (workflows that enrol each other)
49
+ could not fully execute. The executor now resolves EVERY workflow's identity in a first
50
+ pass (never-clobber bind existing, else create an empty DRAFT) before expanding any
51
+ actions, so forward, backward, AND mutual cross-workflow references all resolve. Safety
52
+ rails intact and hardened: never-clobber (existing workflows are never modified),
53
+ empty-orphan detection (now halts rather than blind-binds when an existing workflow's
54
+ action count is unreadable), verify-after, and rollback of every unsaved shell on a halt
55
+ — with any orphan id that could not be deleted SURFACED in the halt message (never
56
+ swallowed) and dropped from the returned id map.
57
+ - **New: native trigger builders for the trigger types real accounts actually use.**
58
+ Previously only `contact_tag` triggers were auto-built; every other workflow shipped
59
+ trigger-less (inactive) — the #1 reason builds were hollow. Blueprint now builds
60
+ `form_submission`, `appointment` (status-conditioned, e.g. confirmed / no-show),
61
+ `customer_reply`, `pipeline_stage_updated`, `inbound_webhook`, and `payment_received`
62
+ triggers natively. Each saved shape was CAPTURED from a real, live, UI-built trigger in a
63
+ reference account (not guessed) so the synthesized trigger reads back identical and fires.
64
+ Refs in trigger conditions (form / pipeline / stage) resolve to real GHL ids at build
65
+ time. A trigger type Blueprint doesn't yet build natively — or one missing the field it
66
+ needs (e.g. an `appointment` trigger with no status) — is still surfaced as a manual step.
67
+ - Plan validation now rejects two workflows that share a name (the executor binds workflows
68
+ by name, so duplicates would collapse onto one shell).
69
+
3
70
  ## 3.46.0 — `verify_funnel`: prove a funnel actually captures leads (+ external-funnel wiring bundle)
4
71
 
5
72
  The live-runtime companion to `audit_workflows`. `audit_workflows` finds dead workflow
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "@elitedcs/ghl-mcp",
34
- version: "3.46.0",
34
+ version: "3.48.0",
35
35
  mcpName: "io.github.drjerryrelth/ghl-command",
36
36
  description: "GoHighLevel MCP Server for Claude. 220 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
37
37
  main: "dist/index.js",
@@ -1237,7 +1237,9 @@ function validateActionChain(actions, existingIds) {
1237
1237
  if (!Array.isArray(attr.tags) || !attr.tags.length) throw new Error(`Tag action "${action.name}" missing 'tags' array.`);
1238
1238
  break;
1239
1239
  case "wait":
1240
- if (!attr.startAfter) throw new Error(`Wait action "${action.name}" missing required 'startAfter' in attributes.`);
1240
+ if (!attr.startAfter && !attr.appointmentStartAfter) {
1241
+ throw new Error(`Wait action "${action.name}" missing required 'startAfter' (or 'appointmentStartAfter' for an appointment-relative wait) in attributes.`);
1242
+ }
1241
1243
  break;
1242
1244
  case "internal_update_opportunity": {
1243
1245
  const isRoundTripped = hasId(action) && (existingIds ? existingIds.has(action.id) : true);
@@ -1288,6 +1290,31 @@ function validateActionChain(actions, existingIds) {
1288
1290
  if (!sibling || sibling.length !== 1) throw new Error(`If/else branch "${action.name}" must have exactly one sibling id.`);
1289
1291
  }
1290
1292
  }
1293
+ for (const action of actions) {
1294
+ if (action.type !== "find_opportunity") continue;
1295
+ if (existingIds && hasId(action) && existingIds.has(action.id)) continue;
1296
+ if (!action.id) throw new Error(`find_opportunity "${action.name}" missing id.`);
1297
+ const next = getStringArray(action.next);
1298
+ if (!next || next.length !== 2) {
1299
+ throw new Error(`find_opportunity "${action.name}" must have next as exactly [foundTransitionId, notFoundTransitionId].`);
1300
+ }
1301
+ for (const tid of next) {
1302
+ const t = byId.get(tid);
1303
+ if (!t || t.type !== "transition") {
1304
+ throw new Error(`find_opportunity "${action.name}" next must reference its two transition nodes.`);
1305
+ }
1306
+ if (t.parent !== action.id || t.parentKey !== action.id) {
1307
+ throw new Error(`Transition "${t.name}" of find_opportunity "${action.name}" must have parent and parentKey set to the find node id.`);
1308
+ }
1309
+ const firstChildId = typeof t.next === "string" ? t.next : void 0;
1310
+ if (firstChildId) {
1311
+ const c = byId.get(firstChildId);
1312
+ if (!c || c.parent !== tid || c.parentKey !== tid) {
1313
+ throw new Error(`First child of transition "${t.name}" must have parent and parentKey set to the transition id.`);
1314
+ }
1315
+ }
1316
+ }
1317
+ }
1291
1318
  }
1292
1319
  var WorkflowBuilderClient = class _WorkflowBuilderClient {
1293
1320
  // Active Firebase auth — swapped when operating in another company's GHL.
@@ -1891,7 +1918,12 @@ ${errorBody}`
1891
1918
  });
1892
1919
  for (let i = 0; i < linked.length; i++) {
1893
1920
  const action = linked[i];
1894
- if (action.cat || action.nodeType || action.parent) continue;
1921
+ if (action.parent || action.nodeType) continue;
1922
+ if (action.cat === "multi-path") {
1923
+ if (i > 0 && !action.parentKey) action.parentKey = linked[i - 1].id;
1924
+ continue;
1925
+ }
1926
+ if (action.cat) continue;
1895
1927
  if (i > 0 && !action.parentKey) {
1896
1928
  action.parentKey = linked[i - 1].id;
1897
1929
  }
@@ -11116,12 +11148,25 @@ var smsAssetSchema = import_zod53.z.object({
11116
11148
  mergeTags: import_zod53.z.array(import_zod53.z.string()).optional()
11117
11149
  });
11118
11150
  var waitUnit = import_zod53.z.enum(["minutes", "hours", "days"]);
11119
- var actionSchema = import_zod53.z.discriminatedUnion("type", [
11151
+ var branchActionOptions = [
11120
11152
  import_zod53.z.object({ type: import_zod53.z.literal("add_contact_tag"), tagRef: nsRef("tag") }),
11121
11153
  import_zod53.z.object({ type: import_zod53.z.literal("remove_contact_tag"), tagRef: nsRef("tag") }),
11122
11154
  import_zod53.z.object({ type: import_zod53.z.literal("send_email"), emailRef: nsRef("email") }),
11123
11155
  import_zod53.z.object({ type: import_zod53.z.literal("send_sms"), smsRef: nsRef("sms") }),
11124
11156
  import_zod53.z.object({ type: import_zod53.z.literal("wait"), value: import_zod53.z.number().positive(), unit: waitUnit }),
11157
+ // Appointment-relative wait ("wait until N BEFORE the appointment").
11158
+ // Only works when the workflow has an appointment in context (i.e. an
11159
+ // `appointment` trigger) — enforced by validateBuildPlan. Expands to GHL's
11160
+ // appointment-wait node shape (attributes.type "appointment" +
11161
+ // appointmentStartAfter, captured live from Lux Bio). Integer units only
11162
+ // (GHL stores whole minutes). Only "before" is emitted today — that's the
11163
+ // shape we captured + proved; "after" (post-appointment follow-up) is
11164
+ // deferred until its shape is captured from a real workflow.
11165
+ import_zod53.z.object({
11166
+ type: import_zod53.z.literal("wait_appointment"),
11167
+ value: import_zod53.z.number().int().positive(),
11168
+ unit: waitUnit
11169
+ }),
11125
11170
  import_zod53.z.object({
11126
11171
  type: import_zod53.z.literal("internal_notification"),
11127
11172
  to: import_zod53.z.string(),
@@ -11147,12 +11192,22 @@ var actionSchema = import_zod53.z.discriminatedUnion("type", [
11147
11192
  type: import_zod53.z.literal("create_opportunity"),
11148
11193
  pipelineRef: nsRef("pipeline"),
11149
11194
  stageRef: nsRef("stage"),
11150
- status: import_zod53.z.string().optional()
11195
+ // Opportunity name (merge fields allowed). Defaults to the contact's name.
11196
+ // Required by GHL's create node; without it the create silently no-ops.
11197
+ name: import_zod53.z.string().optional(),
11198
+ // Opportunity monetary value (the deal/sale dollar amount). A string so it
11199
+ // can be a literal ("2500") OR a merge field ("{{contact.package_value}}").
11200
+ // Optional — omitted → GHL leaves the value unset. Shape captured from Lux
11201
+ // Bio "14. Package Sale". Lux models the lifecycle by pipeline STAGE, not GHL
11202
+ // won/lost status, so a "won" opp = move to the closing stage WITH this value.
11203
+ value: import_zod53.z.string().optional()
11151
11204
  }),
11152
11205
  import_zod53.z.object({
11153
11206
  type: import_zod53.z.literal("update_opportunity"),
11154
11207
  pipelineRef: nsRef("pipeline"),
11155
- stageRef: nsRef("stage")
11208
+ stageRef: nsRef("stage"),
11209
+ // Opportunity monetary value (see create_opportunity.value). Optional.
11210
+ value: import_zod53.z.string().optional()
11156
11211
  }),
11157
11212
  import_zod53.z.object({
11158
11213
  type: import_zod53.z.literal("goal_event"),
@@ -11160,14 +11215,25 @@ var actionSchema = import_zod53.z.discriminatedUnion("type", [
11160
11215
  // GHL's GoalAction enum (extracted 2026-05-18): continue | wait | exit.
11161
11216
  action: import_zod53.z.enum(["exit", "continue", "wait"]).optional()
11162
11217
  })
11163
- ]);
11218
+ ];
11219
+ var branchActionSchema = import_zod53.z.discriminatedUnion("type", branchActionOptions);
11220
+ var findOpportunitySchema = import_zod53.z.object({
11221
+ type: import_zod53.z.literal("find_opportunity"),
11222
+ pipelineRef: nsRef("pipeline"),
11223
+ found: import_zod53.z.array(branchActionSchema).default([]),
11224
+ notFound: import_zod53.z.array(branchActionSchema).default([])
11225
+ });
11226
+ var actionSchema = import_zod53.z.discriminatedUnion("type", [...branchActionOptions, findOpportunitySchema]);
11227
+ var APPOINTMENT_STATUSES = ["new", "confirmed", "showed", "noshow", "cancelled", "invalid"];
11164
11228
  var triggerSchema = import_zod53.z.object({
11165
11229
  type: import_zod53.z.string(),
11166
11230
  formRef: nsRef("form").optional(),
11167
11231
  tagRef: nsRef("tag").optional(),
11168
11232
  calendarRef: nsRef("calendar").optional(),
11169
11233
  pipelineRef: nsRef("pipeline").optional(),
11170
- stageRef: nsRef("stage").optional()
11234
+ stageRef: nsRef("stage").optional(),
11235
+ // Required for a native `appointment` trigger (the status it fires on).
11236
+ appointmentStatus: import_zod53.z.enum(APPOINTMENT_STATUSES).optional()
11171
11237
  });
11172
11238
  var workflowSchema = import_zod53.z.object({
11173
11239
  ref: nsRef("workflow"),
@@ -11290,8 +11356,7 @@ function checkRefIntegrity(plan, defined) {
11290
11356
  check(t.pipelineRef, "pipeline", `workflows[${w.ref}].trigger.pipelineRef`);
11291
11357
  check(t.stageRef, "stage", `workflows[${w.ref}].trigger.stageRef`);
11292
11358
  }
11293
- w.actions.forEach((a, i) => {
11294
- const where = `workflows[${w.ref}].actions[${i}](${a.type})`;
11359
+ const checkActionRefs = (a, where) => {
11295
11360
  if ("tagRef" in a) check(a.tagRef, "tag", where);
11296
11361
  if ("emailRef" in a) check(a.emailRef, "email", where);
11297
11362
  if ("smsRef" in a) check(a.smsRef, "sms", where);
@@ -11299,6 +11364,17 @@ function checkRefIntegrity(plan, defined) {
11299
11364
  if ("pipelineRef" in a) check(a.pipelineRef, "pipeline", where);
11300
11365
  if ("stageRef" in a) check(a.stageRef, "stage", where);
11301
11366
  if ("workflowRef" in a) check(a.workflowRef, "workflow", where);
11367
+ };
11368
+ w.actions.forEach((a, i) => {
11369
+ const where = `workflows[${w.ref}].actions[${i}](${a.type})`;
11370
+ checkActionRefs(a, where);
11371
+ if (a.type === "find_opportunity") {
11372
+ if (i !== w.actions.length - 1) {
11373
+ errors.push(`${where}: find_opportunity must be the last action in the workflow (its Found/Not-Found branches do not rejoin a linear tail).`);
11374
+ }
11375
+ a.found.forEach((c, ci) => checkActionRefs(c, `${where}.found[${ci}](${c.type})`));
11376
+ a.notFound.forEach((c, ci) => checkActionRefs(c, `${where}.notFound[${ci}](${c.type})`));
11377
+ }
11302
11378
  });
11303
11379
  }
11304
11380
  for (const h of plan.handoffs ?? []) {
@@ -11376,7 +11452,12 @@ function validateBuildPlan(input) {
11376
11452
  ["customValues", (plan.customValues ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
11377
11453
  ["calendars", (plan.calendars ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
11378
11454
  ["forms", (plan.forms ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
11379
- ["funnels", (plan.funnels ?? []).map((x) => ({ ref: x.ref, name: x.name }))]
11455
+ ["funnels", (plan.funnels ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
11456
+ // Workflows bind by name too: the executor's two-pass build resolves each
11457
+ // workflow's identity by name (bind same-named existing, else create a shell).
11458
+ // Two plan workflows sharing a name would make the second bind to the FIRST's
11459
+ // freshly-created shell — only one would ever be saved. Reject before any write.
11460
+ ["workflows", (plan.workflows ?? []).map((x) => ({ ref: x.ref, name: x.name }))]
11380
11461
  ];
11381
11462
  for (const [type, objs] of nameGroups) {
11382
11463
  const seen = /* @__PURE__ */ new Map();
@@ -11425,6 +11506,32 @@ function validateBuildPlan(input) {
11425
11506
  );
11426
11507
  }
11427
11508
  }
11509
+ for (const wf of plan.workflows ?? []) {
11510
+ const hasApptWait = wf.actions.some((a) => a.type === "wait_appointment");
11511
+ if (hasApptWait && wf.trigger?.type !== "appointment") {
11512
+ warnings.push(
11513
+ `workflow "${wf.ref}" uses a wait_appointment ("before appointment" timer) but its trigger is ${wf.trigger?.type ? `"${wf.trigger.type}"` : "not set to an appointment trigger"} \u2014 that timer needs an appointment in context (an "appointment" trigger), or GHL silently mis-times it`
11514
+ );
11515
+ }
11516
+ }
11517
+ const MAX_NODES = 40;
11518
+ for (const wf of plan.workflows ?? []) {
11519
+ let nodeCount = 0;
11520
+ let hasBranch = false;
11521
+ for (const a of wf.actions) {
11522
+ if (a.type === "find_opportunity") {
11523
+ hasBranch = true;
11524
+ nodeCount += 3 + a.found.length + a.notFound.length;
11525
+ } else {
11526
+ nodeCount += 1;
11527
+ }
11528
+ }
11529
+ if (hasBranch && nodeCount > MAX_NODES) {
11530
+ allErrors.push(
11531
+ `workflow "${wf.ref}" expands to ${nodeCount} nodes (> ${MAX_NODES}); a workflow with a find_opportunity branch cannot be auto-split \u2014 move actions into separate workflows.`
11532
+ );
11533
+ }
11534
+ }
11428
11535
  return {
11429
11536
  valid: allErrors.length === 0,
11430
11537
  errors: allErrors,
@@ -11518,6 +11625,15 @@ function htmlWrap(text) {
11518
11625
  if (/^\s*<[a-z]/i.test(t)) return t;
11519
11626
  return `<p style="margin:0px;">${t}</p>`;
11520
11627
  }
11628
+ function monetaryValueField(value) {
11629
+ return {
11630
+ __customInputs__: { value: "numerical" },
11631
+ filterField: "monetaryValue",
11632
+ valueFieldType: "custom-input",
11633
+ value,
11634
+ dataType: "NUMERICAL"
11635
+ };
11636
+ }
11521
11637
  function resolveId(ref, idMap) {
11522
11638
  const real = idMap.get(ref);
11523
11639
  return real ?? PENDING(ref);
@@ -11600,6 +11716,41 @@ function expandAction(action, idx, idMap) {
11600
11716
  }
11601
11717
  };
11602
11718
  }
11719
+ case "wait_appointment": {
11720
+ const totalMinutes = action.value * (action.unit === "days" ? 1440 : action.unit === "hours" ? 60 : 1);
11721
+ const distributed = {
11722
+ months: 0,
11723
+ days: Math.floor(totalMinutes / 1440),
11724
+ hours: Math.floor(totalMinutes % 1440 / 60),
11725
+ minutes: totalMinutes % 60
11726
+ };
11727
+ const label = `Wait until ${action.value} ${action.unit} before appointment`;
11728
+ return {
11729
+ kind: "expanded",
11730
+ pendingRefs: [],
11731
+ native: {
11732
+ type: "wait",
11733
+ name: label,
11734
+ attributes: {
11735
+ type: "appointment",
11736
+ name: label,
11737
+ cat: "",
11738
+ isHybridAction: true,
11739
+ hybridActionType: "wait",
11740
+ convertToMultipath: false,
11741
+ transitions: [],
11742
+ appointmentStartAfter: {
11743
+ // Only "before" is emitted — the captured/proven shape. See plan.ts.
11744
+ when: "before",
11745
+ type: "minutes",
11746
+ value: totalMinutes,
11747
+ distributed
11748
+ },
11749
+ appointmentCondition: "skip"
11750
+ }
11751
+ }
11752
+ };
11753
+ }
11603
11754
  case "internal_notification": {
11604
11755
  const looksLikeUserId = /^[A-Za-z0-9]{17,}$/.test(action.to);
11605
11756
  return {
@@ -11690,7 +11841,40 @@ function expandAction(action, idx, idMap) {
11690
11841
  }
11691
11842
  };
11692
11843
  }
11693
- case "create_opportunity":
11844
+ case "create_opportunity": {
11845
+ const pipelineId = resolveId(action.pipelineRef, idMap);
11846
+ const stageId = resolveId(action.stageRef, idMap);
11847
+ const pendingRefs = [];
11848
+ if (isPending(pipelineId)) pendingRefs.push(action.pipelineRef);
11849
+ if (isPending(stageId)) pendingRefs.push(action.stageRef);
11850
+ const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
11851
+ const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
11852
+ const oppName = action.name ?? "{{contact.first_name}} {{contact.last_name}}";
11853
+ return {
11854
+ kind: "expanded",
11855
+ pendingRefs,
11856
+ native: {
11857
+ type: "internal_create_opportunity",
11858
+ name: `Create opportunity: ${pName} / ${sName}`,
11859
+ workflowsActionType: "INTERNAL",
11860
+ attributes: {
11861
+ type: "internal_create_opportunity",
11862
+ pipelineId,
11863
+ // The save-time normalizer only scaffolds internal_update_opportunity, so this
11864
+ // create node carries GHL's full shape itself (attribute-level + per-field
11865
+ // __customInputs__), matching the captured live node exactly.
11866
+ __customInputs__: {},
11867
+ __customInputFields__: [
11868
+ { __customInputs__: {}, filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
11869
+ { __customInputs__: {}, filterField: "name", value: oppName, valueFieldType: "string", dataType: "TEXT" },
11870
+ // Optional opportunity dollar value (Lux "14. Package Sale" shape). Only
11871
+ // emitted when the plan supplies a value; omitted → GHL leaves it unset.
11872
+ ...action.value ? [monetaryValueField(action.value)] : []
11873
+ ]
11874
+ }
11875
+ }
11876
+ };
11877
+ }
11694
11878
  case "update_opportunity": {
11695
11879
  const pipelineId = resolveId(action.pipelineRef, idMap);
11696
11880
  const stageId = resolveId(action.stageRef, idMap);
@@ -11699,26 +11883,119 @@ function expandAction(action, idx, idMap) {
11699
11883
  if (isPending(stageId)) pendingRefs.push(action.stageRef);
11700
11884
  const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
11701
11885
  const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
11702
- const verb = action.type === "create_opportunity" ? "Create opportunity" : "Move opportunity";
11703
11886
  return {
11704
11887
  kind: "expanded",
11705
11888
  pendingRefs,
11706
11889
  native: {
11707
11890
  type: "internal_update_opportunity",
11708
- name: `${verb}: ${pName} / ${sName}`,
11709
- // NODE-level discriminator (the normalizer also forces this; included so
11710
- // the expanded action is correct even outside the save path).
11891
+ name: `Move opportunity: ${pName} / ${sName}`,
11711
11892
  workflowsActionType: "INTERNAL",
11712
11893
  attributes: {
11713
11894
  type: "internal_update_opportunity",
11895
+ // Honor the operator's explicit target stage in EITHER direction. GHL's
11896
+ // "Move opportunity" action defaults allowBackward:false, which SILENTLY
11897
+ // refuses to move an opp to an EARLIER pipeline stage (lower position) —
11898
+ // breaking win-back / reactivation moves whose whole point is to move a
11899
+ // deal BACKWARD (e.g. Cancelled → Active). The Blueprint intent of an
11900
+ // update_opportunity action is "put this opp in this stage" regardless of
11901
+ // direction, so allow backward moves. (Live-caught on Cap-3 wf7 2026-06-24:
11902
+ // the identical node moved a deal FORWARD fine in wf6 but silently no-op'd
11903
+ // moving it BACKWARD here.) The save-time normalizer preserves an explicit
11904
+ // boolean, so this is honored end-to-end.
11905
+ allowBackward: true,
11714
11906
  __customInputFields__: [
11715
11907
  { filterField: "pipelineId", value: pipelineId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
11716
- { filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" }
11908
+ { filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
11909
+ // Optional opportunity dollar value (Lux "14. Package Sale" shape). Only
11910
+ // emitted when the plan supplies a value; omitted → GHL leaves it unchanged.
11911
+ ...action.value ? [monetaryValueField(action.value)] : []
11717
11912
  ]
11718
11913
  }
11719
11914
  }
11720
11915
  };
11721
11916
  }
11917
+ case "find_opportunity": {
11918
+ const pipelineId = resolveId(action.pipelineRef, idMap);
11919
+ const pendingRefs = [];
11920
+ if (isPending(pipelineId)) pendingRefs.push(action.pipelineRef);
11921
+ for (const child of [...action.found, ...action.notFound]) {
11922
+ const probe = expandAction(child, idx, idMap);
11923
+ if (probe.kind === "needs_content")
11924
+ return { kind: "needs_content", logicalType: `find_opportunity \u2192 ${child.type}`, reason: probe.reason };
11925
+ if (probe.kind === "manual")
11926
+ return { kind: "manual", logicalType: `find_opportunity \u2192 ${child.type}`, reason: probe.reason };
11927
+ }
11928
+ const findId = crypto.randomUUID();
11929
+ const foundTId = crypto.randomUUID();
11930
+ const notFoundTId = crypto.randomUUID();
11931
+ const wireBranch = (children, transitionId) => {
11932
+ const out = [];
11933
+ children.forEach((child, ci) => {
11934
+ const exp = expandAction(child, idx, idMap);
11935
+ if (exp.kind !== "expanded") return;
11936
+ exp.pendingRefs.forEach((r) => pendingRefs.push(r));
11937
+ const node = exp.native;
11938
+ node.id = crypto.randomUUID();
11939
+ node.parent = transitionId;
11940
+ node.parentKey = ci === 0 ? transitionId : out[ci - 1].id;
11941
+ node.order = ci;
11942
+ delete node.next;
11943
+ out.push(node);
11944
+ });
11945
+ for (let k = 0; k < out.length - 1; k++) out[k].next = out[k + 1].id;
11946
+ return out;
11947
+ };
11948
+ const foundNodes = wireBranch(action.found, foundTId);
11949
+ const notFoundNodes = wireBranch(action.notFound, notFoundTId);
11950
+ const makeTransition = (id, name, firstChildId) => ({
11951
+ id,
11952
+ type: "transition",
11953
+ name,
11954
+ cat: "transition",
11955
+ parent: findId,
11956
+ parentKey: findId,
11957
+ attributes: {},
11958
+ ...firstChildId ? { next: firstChildId } : {}
11959
+ });
11960
+ const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
11961
+ const findNode = {
11962
+ id: findId,
11963
+ type: "find_opportunity",
11964
+ name: `Find opportunity: ${pName}`,
11965
+ cat: "multi-path",
11966
+ workflowsActionType: "INTERNAL",
11967
+ next: [foundTId, notFoundTId],
11968
+ attributes: {
11969
+ sorting: "latest",
11970
+ type: "find_opportunity",
11971
+ // Filter shape is DISTINCT from the update/create node: the field name is
11972
+ // `pipeline_id` (underscore), the operator lives in `value`, the id in
11973
+ // `secondValue` (captured from Lux "01"/"02").
11974
+ __customInputFields__: [
11975
+ { __customInputs__: {}, filterField: "pipeline_id", value: "eq", secondValue: pipelineId }
11976
+ ],
11977
+ __customInputs__: {},
11978
+ cat: "multi-path",
11979
+ convertToMultipath: true,
11980
+ transitions: [
11981
+ { id: foundTId, name: "Opportunity Found", fields: [], meta: { __branchKey__: "predefined_Opportunity Found" }, conditionType: "pre-defined" },
11982
+ { id: notFoundTId, name: "Opportunity Not Found", fields: [], meta: { __branchKey__: "predefined_Opportunity Not Found" }, conditionType: "pre-defined" }
11983
+ ],
11984
+ __name__: "Find Opportunity"
11985
+ }
11986
+ };
11987
+ return {
11988
+ kind: "expanded",
11989
+ pendingRefs,
11990
+ native: findNode,
11991
+ extraNodes: [
11992
+ makeTransition(foundTId, "Opportunity Found", foundNodes[0]?.id),
11993
+ ...foundNodes,
11994
+ makeTransition(notFoundTId, "Opportunity Not Found", notFoundNodes[0]?.id),
11995
+ ...notFoundNodes
11996
+ ]
11997
+ };
11998
+ }
11722
11999
  case "goal_event": {
11723
12000
  return {
11724
12001
  kind: "expanded",
@@ -11760,47 +12037,112 @@ function triggerPlainEnglish(trigger, idx) {
11760
12037
  return `trigger type "${trigger.type}"`;
11761
12038
  }
11762
12039
  }
11763
- function expandTrigger(trigger, idx) {
11764
- if (trigger.type === "contact_tag") {
11765
- if (!trigger.tagRef) return { kind: "manual", reason: "Set this workflow's contact-tag trigger in the GHL UI (the plan trigger has no tag)." };
11766
- const tagName = idx.tagName.get(trigger.tagRef) ?? trigger.tagRef;
11767
- return {
11768
- kind: "native",
11769
- trigger: {
12040
+ function expandTrigger(trigger, idx, idMap) {
12041
+ const manual = (reason) => ({ kind: "manual", reason });
12042
+ const native = (t, pendingRefs = []) => ({ kind: "native", trigger: t, pendingRefs });
12043
+ switch (trigger.type) {
12044
+ case "contact_tag": {
12045
+ if (!trigger.tagRef) return manual("Set this workflow's contact-tag trigger in the GHL UI (the plan trigger has no tag).");
12046
+ const tagName = idx.tagName.get(trigger.tagRef) ?? trigger.tagRef;
12047
+ return native({
11770
12048
  name: "Contact Tag",
11771
12049
  type: "contact_tag",
11772
12050
  conditions: [{ operator: "index-of-true", field: "tagsAdded", value: tagName, title: "Tag Added", type: "select", id: "tag-added" }]
11773
- }
11774
- };
12051
+ });
12052
+ }
12053
+ case "form_submission":
12054
+ case "form_submitted": {
12055
+ if (!trigger.formRef) return manual("Set this workflow's form-submission trigger in the GHL UI (the plan trigger has no form).");
12056
+ const formId = resolveId(trigger.formRef, idMap);
12057
+ return native({
12058
+ name: "Form Submitted",
12059
+ type: "form_submission",
12060
+ conditions: [{ operator: "is-any-of", field: "form.id", value: [formId], title: "Form is", type: "string" }]
12061
+ }, isPending(formId) ? [trigger.formRef] : []);
12062
+ }
12063
+ case "appointment": {
12064
+ const status = trigger.appointmentStatus;
12065
+ if (!status) return manual(`Set this workflow's appointment trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)} (the plan trigger has no appointmentStatus to fire on, e.g. confirmed / noshow).`);
12066
+ return native({
12067
+ name: `Appointment Status \u2014 ${status}`,
12068
+ type: "appointment",
12069
+ conditions: [
12070
+ { operator: "==", field: "appointment.eventType", value: "normal", title: "Event Type", type: "select" },
12071
+ { operator: "==", field: "appointment.status", value: status, title: "Appointment status is", type: "select" }
12072
+ ]
12073
+ });
12074
+ }
12075
+ case "customer_reply":
12076
+ case "contact_replied": {
12077
+ return native({ name: "Customer Replied", type: "customer_reply", conditions: [] });
12078
+ }
12079
+ case "pipeline_stage_updated": {
12080
+ if (!trigger.pipelineRef || !trigger.stageRef) return manual("Set this workflow's pipeline-stage trigger in the GHL UI (the plan trigger needs both a pipeline and a stage).");
12081
+ const pipelineId = resolveId(trigger.pipelineRef, idMap);
12082
+ const stageId = resolveId(trigger.stageRef, idMap);
12083
+ const pending = [];
12084
+ if (isPending(pipelineId)) pending.push(trigger.pipelineRef);
12085
+ if (isPending(stageId)) pending.push(trigger.stageRef);
12086
+ return native({
12087
+ name: "Pipeline Stage Changed",
12088
+ type: "pipeline_stage_updated",
12089
+ conditions: [
12090
+ { operator: "==", field: "opportunity.pipelineId", value: pipelineId, title: "In pipeline", type: "select" },
12091
+ { operator: "==", field: "opportunity.pipelineStageId", value: stageId, title: "Pipeline stage", type: "select" }
12092
+ ]
12093
+ }, pending);
12094
+ }
12095
+ case "inbound_webhook": {
12096
+ return native({ name: "Inbound Webhook", type: "inbound_webhook", conditions: [] });
12097
+ }
12098
+ case "payment_received": {
12099
+ return native({ name: "Payment Received", type: "payment_received", conditions: [] });
12100
+ }
12101
+ default:
12102
+ return manual(`Set this workflow's trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)}, then publish. (Blueprint builds the workflow + all its steps as DRAFT; this trigger type isn't auto-built yet.)`);
11775
12103
  }
11776
- return {
11777
- kind: "manual",
11778
- reason: `Set this workflow's trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)}, then publish. (Blueprint builds the workflow + all its steps as DRAFT; only this trigger type isn't auto-built yet.)`
11779
- };
11780
12104
  }
11781
12105
  function expandWorkflow(workflow, idx, idMap, gatedBy) {
11782
12106
  const nativeActions = [];
11783
12107
  const manual = [];
11784
12108
  const needsContent = [];
11785
12109
  const pendingRefs = /* @__PURE__ */ new Set();
12110
+ let branchEmitted = false;
11786
12111
  workflow.actions.forEach((a, i) => {
12112
+ if (branchEmitted) {
12113
+ manual.push({ index: i, logicalType: a.type, reason: "action follows a find_opportunity branch (which must be the last action) \u2014 not emitted to avoid an orphaned node" });
12114
+ return;
12115
+ }
11787
12116
  const exp = expandAction(a, idx, idMap);
11788
12117
  if (exp.kind === "expanded") {
11789
12118
  nativeActions.push(exp.native);
11790
12119
  exp.pendingRefs.forEach((r) => pendingRefs.add(r));
12120
+ if (exp.extraNodes && exp.extraNodes.length) {
12121
+ const baseOrder = nativeActions.length - 1;
12122
+ exp.native.order = baseOrder;
12123
+ for (const n of exp.extraNodes) {
12124
+ if (n.cat === "transition") n.order = baseOrder + 1;
12125
+ nativeActions.push(n);
12126
+ }
12127
+ }
11791
12128
  } else if (exp.kind === "manual") {
11792
12129
  manual.push({ index: i, logicalType: exp.logicalType, reason: exp.reason });
11793
12130
  } else {
11794
12131
  needsContent.push({ index: i, logicalType: exp.logicalType, reason: exp.reason });
11795
12132
  }
12133
+ if (a.type === "find_opportunity") branchEmitted = true;
11796
12134
  });
11797
12135
  const splitInto = Math.max(1, Math.ceil(nativeActions.length / MAX_ACTIONS_PER_WORKFLOW));
11798
12136
  let nativeTrigger;
11799
12137
  let triggerManual;
11800
12138
  if (workflow.trigger) {
11801
- const t = expandTrigger(workflow.trigger, idx);
11802
- if (t.kind === "native") nativeTrigger = t.trigger;
11803
- else triggerManual = t.reason;
12139
+ const t = expandTrigger(workflow.trigger, idx, idMap);
12140
+ if (t.kind === "native") {
12141
+ nativeTrigger = t.trigger;
12142
+ t.pendingRefs.forEach((r) => pendingRefs.add(r));
12143
+ } else {
12144
+ triggerManual = t.reason;
12145
+ }
11804
12146
  }
11805
12147
  return {
11806
12148
  ref: workflow.ref,
@@ -12078,11 +12420,13 @@ async function executeBackbone(plan, deps, opts = {}) {
12078
12420
  const idMap = {};
12079
12421
  const built = [];
12080
12422
  const manual = [];
12423
+ const published = [];
12081
12424
  const halt = (atRef, type, reason) => ({
12082
12425
  ok: false,
12083
12426
  idMap,
12084
12427
  built,
12085
12428
  manual,
12429
+ published,
12086
12430
  halted: { atRef, type, reason },
12087
12431
  deferred: deferredSections(plan)
12088
12432
  });
@@ -12375,35 +12719,56 @@ async function executeBackbone(plan, deps, opts = {}) {
12375
12719
  }
12376
12720
  const wfIndex = buildRefIndex(plan);
12377
12721
  const wfIdMap = new Map(Object.entries(idMap));
12378
- for (const wf of plan.workflows ?? []) {
12722
+ const planWorkflows = plan.workflows ?? [];
12723
+ const createdSlots = [];
12724
+ const savedWfIds = /* @__PURE__ */ new Set();
12725
+ const rollbackUnsavedShells = async () => {
12726
+ const failedDeletes = [];
12727
+ for (const slot of createdSlots) {
12728
+ if (savedWfIds.has(slot.id)) continue;
12729
+ try {
12730
+ await deps.deleteWorkflow(slot.id);
12731
+ } catch {
12732
+ failedDeletes.push(slot.id);
12733
+ }
12734
+ delete idMap[slot.ref];
12735
+ wfIdMap.delete(slot.ref);
12736
+ }
12737
+ return failedDeletes;
12738
+ };
12739
+ const orphanSuffix = (failed) => failed.length ? ` WARNING: could not delete orphan shell workflow(s) ${failed.join(", ")} \u2014 delete them in GHL before re-running.` : "";
12740
+ const toSave = [];
12741
+ for (const wf of planWorkflows) {
12379
12742
  let workflows;
12380
12743
  try {
12381
12744
  workflows = await deps.listWorkflows();
12382
12745
  } catch (e) {
12383
- return halt(wf.ref, "workflow", `could not read existing workflows: ${msg(e)}`);
12746
+ const f = await rollbackUnsavedShells();
12747
+ return halt(wf.ref, "workflow", `could not read existing workflows: ${msg(e)}.${orphanSuffix(f)}`);
12384
12748
  }
12385
12749
  const matches = workflows.filter((w) => norm2(w.name) === norm2(wf.name));
12386
12750
  if (matches.length > 1) {
12387
- return halt(wf.ref, "workflow", `${matches.length} existing workflows are named "${wf.name}" \u2014 ambiguous, cannot safely bind ${wf.ref}. Resolve the duplicate in GHL, then re-run.`);
12388
- }
12389
- const expansion = expandWorkflow(wf, wfIndex, wfIdMap, []);
12390
- if (expansion.pendingRefs.length) {
12391
- return halt(wf.ref, "workflow", `workflow "${wf.name}" references objects that weren't built/resolved: ${expansion.pendingRefs.join(", ")}. Cannot build it.`);
12751
+ const f = await rollbackUnsavedShells();
12752
+ return halt(wf.ref, "workflow", `${matches.length} existing workflows are named "${wf.name}" \u2014 ambiguous, cannot safely bind ${wf.ref}. Resolve the duplicate in GHL, then re-run.${orphanSuffix(f)}`);
12392
12753
  }
12393
12754
  if (matches.length === 1) {
12394
12755
  const exId = matches[0].id;
12395
- if (expansion.nativeActions.length > 0) {
12396
- let existingCount = -1;
12756
+ const planHasActions = expandWorkflow(wf, wfIndex, wfIdMap, []).nativeActions.length > 0;
12757
+ if (planHasActions) {
12758
+ let existingCount;
12397
12759
  try {
12398
12760
  existingCount = await deps.getWorkflowActionCount(exId);
12399
- } catch {
12400
- existingCount = -1;
12761
+ } catch (e) {
12762
+ const f = await rollbackUnsavedShells();
12763
+ return halt(wf.ref, "workflow", `could not read the existing workflow named "${wf.name}" to tell whether it's an empty orphan (${msg(e)}) \u2014 refusing to bind it blindly (Blueprint never modifies an existing workflow, and binding an orphan would ship a stepless workflow). Re-run.${orphanSuffix(f)}`);
12401
12764
  }
12402
12765
  if (existingCount === 0) {
12403
- return halt(wf.ref, "workflow", `a workflow named "${wf.name}" already exists but has NO actions \u2014 likely an empty orphan from a failed prior run (or a name collision). Delete it in GHL, then re-run. (Blueprint never modifies an existing workflow.)`);
12766
+ const f = await rollbackUnsavedShells();
12767
+ return halt(wf.ref, "workflow", `a workflow named "${wf.name}" already exists but has NO actions \u2014 likely an empty orphan from a failed prior run (or a name collision). Delete it in GHL, then re-run. (Blueprint never modifies an existing workflow.)${orphanSuffix(f)}`);
12404
12768
  }
12405
12769
  }
12406
12770
  idMap[wf.ref] = exId;
12771
+ wfIdMap.set(wf.ref, exId);
12407
12772
  built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "existing", realId: exId });
12408
12773
  continue;
12409
12774
  }
@@ -12411,9 +12776,24 @@ async function executeBackbone(plan, deps, opts = {}) {
12411
12776
  try {
12412
12777
  wfId = await deps.createWorkflow(wf.name);
12413
12778
  } catch (e) {
12414
- return halt(wf.ref, "workflow", `create failed: ${msg(e)}`);
12779
+ const f = await rollbackUnsavedShells();
12780
+ return halt(wf.ref, "workflow", `create failed: ${msg(e)}.${orphanSuffix(f)}`);
12781
+ }
12782
+ if (!wfId) {
12783
+ const f = await rollbackUnsavedShells();
12784
+ return halt(wf.ref, "workflow", `workflow create returned no id.${orphanSuffix(f)}`);
12785
+ }
12786
+ createdSlots.push({ ref: wf.ref, id: wfId });
12787
+ idMap[wf.ref] = wfId;
12788
+ wfIdMap.set(wf.ref, wfId);
12789
+ toSave.push({ wf, id: wfId });
12790
+ }
12791
+ for (const { wf, id: wfId } of toSave) {
12792
+ const expansion = expandWorkflow(wf, wfIndex, wfIdMap, []);
12793
+ if (expansion.pendingRefs.length) {
12794
+ const f = await rollbackUnsavedShells();
12795
+ return halt(wf.ref, "workflow", `workflow "${wf.name}" references objects that weren't built/resolved: ${expansion.pendingRefs.join(", ")}. Cannot build it.${orphanSuffix(f)}`);
12415
12796
  }
12416
- if (!wfId) return halt(wf.ref, "workflow", "workflow create returned no id");
12417
12797
  const triggers = expansion.nativeTrigger ? [expansion.nativeTrigger] : [];
12418
12798
  try {
12419
12799
  await deps.saveWorkflow(wfId, { actions: expansion.nativeActions, triggers, stopOnResponse: wf.stopOnResponse });
@@ -12422,25 +12802,24 @@ async function executeBackbone(plan, deps, opts = {}) {
12422
12802
  if (count === 0) throw new Error("saved but no actions persisted (read-after-write)");
12423
12803
  }
12424
12804
  } catch (e) {
12425
- let rolledBack = false;
12805
+ const f = await rollbackUnsavedShells();
12806
+ return halt(wf.ref, "workflow", `actions/trigger save failed; the partial workflow${createdSlots.length > 1 ? " (and any other unsaved shells)" : ""} was rolled back: ${msg(e)}.${orphanSuffix(f)}`);
12807
+ }
12808
+ savedWfIds.add(wfId);
12809
+ built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "created", realId: wfId });
12810
+ if (opts.publishWorkflows && !opts.gatedWorkflowRefs?.has(wf.ref)) {
12426
12811
  try {
12427
- await deps.deleteWorkflow(wfId);
12428
- rolledBack = true;
12429
- } catch {
12812
+ await deps.publishWorkflow(wfId);
12813
+ published.push(wf.ref);
12814
+ } catch (e) {
12815
+ manual.push({ ref: wf.ref, type: "workflow-publish", name: wf.name, reason: `[${wf.name}] built successfully but auto-publish failed (${msg(e)}) \u2014 publish it manually in GHL.` });
12430
12816
  }
12431
- return halt(
12432
- wf.ref,
12433
- "workflow",
12434
- rolledBack ? `actions/trigger save failed; the partial workflow was rolled back (deleted): ${msg(e)}` : `actions/trigger save failed AND the rollback delete also failed \u2014 manually delete the orphan workflow (id ${wfId}) in GHL before re-running: ${msg(e)}`
12435
- );
12436
12817
  }
12437
- idMap[wf.ref] = wfId;
12438
- built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "created", realId: wfId });
12439
12818
  if (expansion.triggerManual) manual.push({ ref: wf.ref, type: "workflow-trigger", name: wf.name, reason: `[${wf.name}] ${expansion.triggerManual}` });
12440
12819
  for (const nc of expansion.needsContent) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${nc.reason}` });
12441
12820
  for (const m of expansion.manual) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${m.reason}` });
12442
12821
  }
12443
- return { ok: true, idMap, built, manual, deferred: deferredSections(plan) };
12822
+ return { ok: true, idMap, built, manual, published, deferred: deferredSections(plan) };
12444
12823
  }
12445
12824
  async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMap, built) {
12446
12825
  if (objects.length === 0) return null;
@@ -12854,6 +13233,9 @@ ${text2.slice(0, 300)}`);
12854
13233
  },
12855
13234
  deleteWorkflow: async (workflowId) => {
12856
13235
  await builderClient.deleteWorkflow(workflowId);
13236
+ },
13237
+ publishWorkflow: async (workflowId) => {
13238
+ await builderClient.publishWorkflow(workflowId);
12857
13239
  }
12858
13240
  };
12859
13241
  }
@@ -12986,7 +13368,11 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12986
13368
  }
12987
13369
  }
12988
13370
  const deps = makeExecuteDeps(client, builderClient, activeLocation);
12989
- const exec = await executeBackbone(typedPlan, deps);
13371
+ const gatedWorkflowRefs = new Set(result.workflows.filter((w) => w.gatedBy.length > 0).map((w) => w.ref));
13372
+ const exec = await executeBackbone(typedPlan, deps, {
13373
+ publishWorkflows: publishWorkflows ?? false,
13374
+ gatedWorkflowRefs
13375
+ });
12990
13376
  const externalWiring = buildExternalWiring(typedPlan, exec.idMap, activeLocation);
12991
13377
  const execManualLines = exec.manual.map((m) => `[${m.type}] ${m.reason}`);
12992
13378
  const manualLines = result.workflows.flatMap((w) => [
@@ -13005,12 +13391,14 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
13005
13391
  built: exec.built,
13006
13392
  manual: exec.manual,
13007
13393
  idMap: exec.idMap,
13394
+ published: exec.published,
13395
+ publishNote: exec.published.length ? `Auto-published ${exec.published.length} workflow(s) live (you opted in). Any gated workflow stays DRAFT until its handoff is met.` : exec.built.some((b) => b.type === "workflow" && b.status === "created") ? "Workflows were built as DRAFT (the safe default). Review them in GHL and publish, or re-run with publishWorkflows:true to auto-publish ungated ones." : "No workflows were built in this run (none in the plan, or all already existed and were left untouched).",
13008
13396
  externalWiring,
13009
13397
  externalWiringNote: externalWiring ? "For each target:\"external\" funnel: plug these into your self-hosted site form + lead bridge (templates/external-funnel/). custom formFields carry the VERIFIED GHL field id your form's `custom` object must send (never name-guess keys); add a triggerTag to start the speed-to-lead workflow; bookingUrl is the GHL calendar widget for the CTA. Anything in `unresolved` wasn't built yet \u2014 re-run after it is." : void 0,
13010
13398
  deferred: exec.deferred,
13011
13399
  deferredNote: 'execute builds the WHOLE plan live: CRM backbone (pipelines, custom fields, tags, custom values), calendars, forms, funnels (GHL-built = funnel + named steps), and workflows (DRAFT, with all steps incl. opportunity create/move). Remaining manual steps are surfaced per item: staff-requiring calendars in multi-user accounts, GHL funnel page content/design, EXTERNAL funnels (target:"external" \u2014 the user builds + hosts the site themselves and wires it back; not built here), workflow triggers other than contact_tag, and publishing the DRAFT workflows.',
13012
13400
  nextManualSteps: [...execManualLines, ...manualLines, ...handoffLines],
13013
- summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.manual.length ? ` ${exec.manual.length} item(s) need a manual step (see nextManualSteps).` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
13401
+ summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.published.length ? ` Published ${exec.published.length} workflow(s) live.` : exec.built.some((b) => b.type === "workflow" && b.status === "created") ? " Workflows are DRAFT (opt in with publishWorkflows to auto-publish)." : ""}${exec.manual.length ? ` ${exec.manual.length} item(s) need a manual step (see nextManualSteps).` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
13014
13402
  });
13015
13403
  }
13016
13404
  const collisions = result.items.filter((i) => i.status === "existing");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.46.0",
3
+ "version": "3.48.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
5
  "description": "GoHighLevel MCP Server for Claude. 220 tools — full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
6
6
  "main": "dist/index.js",