@elitedcs/ghl-mcp 3.84.1 → 3.84.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.84.2 — done means read back, not echoed
6
+
7
+ Every tool that reports a thing as done, verified, ready or published was checked against a second,
8
+ independent read of what GoHighLevel actually holds. Thirteen places were claiming an outcome from
9
+ the write's own response, a counter, or the wrong field. Each now derives the claim from a read taken
10
+ after the write, or says plainly what could not be confirmed.
11
+
12
+ **A build report no longer says "Published N workflows live" for a workflow GoHighLevel kept as a
13
+ draft.** The publish step already read the workflow back and said NOT LIVE when it was not; the
14
+ build threw that answer away and counted it anyway. It is counted now, and a workflow that published
15
+ with every trigger still inactive is named as such rather than called running. The publish tool's
16
+ step and trigger counts come from the read after the write, with the number of active triggers
17
+ beside them.
18
+
19
+ **The workflow audit no longer says "ok" while dropping four kinds of warning.** Steps GoHighLevel
20
+ will not accept on save, waits in the unit it no longer takes, an alert set to notify a specific
21
+ person with nobody chosen, and steps needing a social account that is not connected were computed
22
+ for every workflow and then left out of the roll-up. They are in it, and in the client-facing report,
23
+ in plain words.
24
+
25
+ **"Trained." means the pages are trained.** Training a knowledge base settled on a status shared
26
+ with discovery, so it said Trained the moment discovery finished, for a failed run, and for pages
27
+ queued behind an earlier batch. It now settles only when every requested page is in the trained
28
+ bucket, and a crawl GoHighLevel stopped says so instead of "Discovery finished".
29
+
30
+ **The morning brief no longer calls a morning quiet when your own calendar could not be read.**
31
+ A connected Google Calendar that failed to open produced the same "Nothing needs you this morning"
32
+ as an empty one. The headline now says the calendar could not be read.
33
+
34
+ **Readiness, calendars, smart lists, keys and pages tell the truth.** A phone-agent readiness check
35
+ no longer says READY on a number pool whose numbers it cannot read. Renaming a calendar carries the
36
+ assigned staff back in the shape GoHighLevel validates and reports the stored team-member count from
37
+ a read after the update. A contact smart list is refused on the opportunity service instead of being
38
+ created where the Contacts screen never shows it. "Removed" for a sub-account key is said only when
39
+ the key really left this computer's file. A page save whose baseline could not be read is reported
40
+ as unverified rather than visible, and a page build is verified on the pointer GoHighLevel serves
41
+ the page from. Switching accounts says the workflow-builder credentials were found, not tested.
42
+
43
+ **Also in this release:** the eleven small findings deferred from the 3.84.1 reviews. Among them:
44
+ a survey trigger is checked against the account instead of refused as a dead end; three more trigger
45
+ slots are read back; a plan handed back by hand is held to the same confirmed selection as a saved
46
+ one; a half-applied older build is told to keep its undo and plan only the rest; a number an agent
47
+ held only in the old non-routing shape is named when a bind starts routing it; and the brand repaint
48
+ leaves a tint or tone as it was only when that makes the text readable, saying so honestly, and
49
+ repaints every spelling of a colour, not only the six-digit hex.
50
+
5
51
  ## 3.84.1 — the phone agent answers, the repaint tells the truth, and the build waits to be told
6
52
 
7
53
  **A phone agent you connect now actually answers.** Connecting a number to a voice agent wrote a
package/README.md CHANGED
@@ -897,7 +897,7 @@ Built by **[Elite DCs, LLC](https://elitedcs.com)** — a digital marketing and
897
897
 
898
898
  **Tech stack:** TypeScript, Node.js, esbuild, MCP SDK, Zod, GHL API v2, Firebase Auth
899
899
 
900
- **Version:** 3.84.1
900
+ **Version:** 3.84.2
901
901
 
902
902
  ---
903
903
 
package/dist/index.js CHANGED
@@ -948,20 +948,33 @@ var init_token_registry = __esm({
948
948
  const token2 = this.data.tokens[locationId2];
949
949
  if (!token2) return false;
950
950
  if (token2.companyId === companyId) return true;
951
+ const previous = token2.companyId;
951
952
  token2.companyId = companyId;
952
953
  this.save();
954
+ if (this.lastSaveError) {
955
+ if (previous === void 0) delete token2.companyId;
956
+ else token2.companyId = previous;
957
+ return false;
958
+ }
953
959
  return true;
954
960
  }
955
961
  /**
956
- * Remove a location from the registry
962
+ * Remove a location from the registry. Returns true only when the removal
963
+ * reached the key file on disk: save() swallows a write failure into
964
+ * lastSaveError, and a key still sitting in .ghl-tokens.json comes back in
965
+ * full at the next start. A caller that reports "Removed" must not be told
966
+ * that by a delete from an in-memory map alone.
957
967
  */
958
968
  removeLocation(locationId2) {
959
- if (this.data.tokens[locationId2]) {
960
- delete this.data.tokens[locationId2];
961
- this.save();
962
- return true;
969
+ const existing = this.data.tokens[locationId2];
970
+ if (!existing) return false;
971
+ delete this.data.tokens[locationId2];
972
+ this.save();
973
+ if (this.lastSaveError) {
974
+ this.data.tokens[locationId2] = existing;
975
+ return false;
963
976
  }
964
- return false;
977
+ return true;
965
978
  }
966
979
  /**
967
980
  * List all registered locations
@@ -2610,19 +2623,27 @@ ${errorBody}`
2610
2623
  const result = await this.updateWorkflow(workflowId, { status: "published", triggers });
2611
2624
  let returned = null;
2612
2625
  let readBackFailed = null;
2626
+ let savedSteps = null;
2627
+ let savedTriggers = null;
2613
2628
  try {
2614
2629
  const after = await this.getWorkflow(workflowId);
2615
2630
  returned = typeof after.status === "string" ? after.status : null;
2631
+ savedSteps = (after.workflowData?.templates ?? []).length;
2632
+ savedTriggers = after.triggers ?? [];
2616
2633
  } catch (e) {
2617
2634
  readBackFailed = e instanceof Error ? e.message : String(e);
2618
2635
  returned = isRecord2(result) && typeof result.status === "string" ? result.status : null;
2619
2636
  }
2637
+ const activeTriggers = savedTriggers ? savedTriggers.filter((t) => t.active === true).length : null;
2638
+ const inactiveTriggers = savedTriggers ? savedTriggers.filter((t) => t.active === false).length : 0;
2620
2639
  const publish_check = {
2621
2640
  published: returned === "published",
2622
2641
  status_returned: returned ?? "(GHL returned no status)",
2623
2642
  status_source: readBackFailed ? "the publish response (re-read failed)" : "the saved workflow, read back",
2624
- steps: steps.length,
2625
- triggers: triggers.length
2643
+ counts_source: savedTriggers ? "the saved workflow, read back" : "the read taken BEFORE the write (the re-read failed, so these counts do not prove what is saved)",
2644
+ steps: savedSteps ?? steps.length,
2645
+ triggers: savedTriggers ? savedTriggers.length : triggers.length,
2646
+ triggers_active: activeTriggers
2626
2647
  };
2627
2648
  if (readBackFailed) {
2628
2649
  publish_check.warning = `NOT VERIFIED. The publish was accepted but reading the workflow back failed (${readBackFailed}), so this status is only what the publish call echoed, not the saved state. Confirm with get_workflow_full.`;
@@ -2630,7 +2651,10 @@ ${errorBody}`
2630
2651
  if (returned !== "published") {
2631
2652
  publish_check.warning = `NOT LIVE. GHL returned status ${returned === null ? "nothing" : `"${returned}"`} rather than "published", so do not report this workflow as running.`;
2632
2653
  }
2633
- if (triggers.length === 0) {
2654
+ if (returned === "published" && inactiveTriggers > 0) {
2655
+ publish_check.warning = `TRIGGER NOT ACTIVE. The workflow saved as "published", but ${inactiveTriggers} of ${savedTriggers.length} trigger(s) still read active:false, so ${inactiveTriggers === savedTriggers.length ? "nothing enrols contacts into it on its own" : "those triggers enrol nobody"}. Check the trigger in GHL before reporting this workflow as running.`;
2656
+ }
2657
+ if ((savedTriggers ? savedTriggers.length : triggers.length) === 0) {
2634
2658
  publish_check.note = "This workflow has NO TRIGGER, so nothing starts it on its own. That is only correct if another workflow adds contacts to it; otherwise its trigger did not save.";
2635
2659
  }
2636
2660
  return isRecord2(result) ? { ...result, publish_check } : { result, publish_check };
@@ -5197,8 +5221,40 @@ function registerCalendarTools(server2, client) {
5197
5221
  `update_calendar: could not read calendar ${args.calendarId} before updating it (${e instanceof Error ? e.message : String(e)}). Refusing to write: GHL's calendar update does not merge, so a partial write here would silently reset this calendar's slot length, buffer and availability hours.`
5198
5222
  );
5199
5223
  }
5224
+ if (existing && existing.calendarType === "event") existing = { ...existing, teamMembers: void 0 };
5225
+ if (existing && args.teamMembers === void 0 && Array.isArray(existing.teamMembers)) {
5226
+ const stripped = existing.teamMembers.map((m) => {
5227
+ const r = m ?? {};
5228
+ const configs = Array.isArray(r.locationConfigurations) ? r.locationConfigurations.map((c) => {
5229
+ const x = c ?? {};
5230
+ return { kind: x.kind, position: x.position, ...typeof x.location === "string" ? { location: x.location } : {} };
5231
+ }) : r.locationConfigurations;
5232
+ return { userId: r.userId, priority: r.priority, isPrimary: r.isPrimary, selected: r.selected, locationConfigurations: configs };
5233
+ });
5234
+ const parsed2 = import_zod12.z.array(CalendarTeamMemberSchema).safeParse(stripped);
5235
+ if (!parsed2.success) {
5236
+ const issue = parsed2.error.issues[0];
5237
+ throw new Error(
5238
+ `update_calendar: this calendar's stored team members cannot be carried over in the shape GoHighLevel's update validates (${issue ? `${issue.path.join(".")}: ${issue.message}` : "unknown"}). Nothing was changed, because a partial write here would unassign the staff. Pass teamMembers explicitly (userId, priority 0/0.5/1, isPrimary, selected, locationConfigurations with at least one entry) and run the update again.`
5239
+ );
5240
+ }
5241
+ existing = { ...existing, teamMembers: parsed2.data };
5242
+ }
5200
5243
  const body = buildUpdateCalendarBody(mergeCalendarUpdate(existing, args));
5201
- return await client.put(`/calendars/${args.calendarId}`, { body });
5244
+ const updated = await client.put(`/calendars/${args.calendarId}`, { body });
5245
+ let storedTeamMembers;
5246
+ try {
5247
+ const after = await client.get(`/calendars/${args.calendarId}`);
5248
+ const cal = after?.calendar ?? after;
5249
+ storedTeamMembers = Array.isArray(cal?.teamMembers) ? cal.teamMembers.length : 0;
5250
+ } catch (e) {
5251
+ storedTeamMembers = `NOT VERIFIED \u2014 the calendar could not be read back after the update (${e instanceof Error ? e.message : String(e)}), so its team-member assignment is unconfirmed. Run get_calendar to check.`;
5252
+ }
5253
+ return {
5254
+ updated,
5255
+ stored_team_members: storedTeamMembers,
5256
+ stored_team_members_source: "GET /calendars/{id} taken AFTER the update \u2014 a read of what GoHighLevel now holds, not the update's own response."
5257
+ };
5202
5258
  }
5203
5259
  );
5204
5260
  safeTool(
@@ -5401,7 +5457,13 @@ var init_calendars = __esm({
5401
5457
  "appointmentPerDay",
5402
5458
  "allowBookingAfter",
5403
5459
  "allowBookingAfterUnit",
5404
- "availabilityType"
5460
+ "availabilityType",
5461
+ "teamMembers",
5462
+ "description",
5463
+ "slug",
5464
+ "widgetSlug",
5465
+ "eventTitle",
5466
+ "eventColor"
5405
5467
  ];
5406
5468
  }
5407
5469
  });
@@ -8255,6 +8317,16 @@ function normalizeCrawlStatus(raw) {
8255
8317
  urls
8256
8318
  };
8257
8319
  }
8320
+ function trainedOfRequested(s, urlIds) {
8321
+ const bucketOf = new Map(s.urls.map((u) => [u.id, u.bucket]));
8322
+ const isTrained = (id) => TRAINED_BUCKET.test(bucketOf.get(id) ?? "");
8323
+ const pending = urlIds.filter((id) => !isTrained(id));
8324
+ return {
8325
+ trained: urlIds.filter(isTrained),
8326
+ pending,
8327
+ buckets: [...new Set(pending.map((id) => bucketOf.get(id) ?? "not listed"))]
8328
+ };
8329
+ }
8258
8330
  async function readCrawlStatus(client, locationId2, knowledgeBaseId, operationId) {
8259
8331
  return normalizeCrawlStatus(await client.get("/knowledge-bases/crawler/status", { version: AI_API_VERSION, params: { locationId: locationId2, operationId, knowledgeBaseId } }));
8260
8332
  }
@@ -8306,7 +8378,7 @@ async function crawlAndTrain(client, opts) {
8306
8378
  const d = await settleCrawl(client, sleep7, opts.locationId, opts.knowledgeBaseId, operationId, opts.waitDiscover ?? 90, (x) => isSettled(x.status), { operationId, status: typeof rec.status === "string" ? rec.status : "Processing", mode, url: opts.url });
8307
8379
  if (d.pollError) notes.push(`a status poll failed (${d.pollError})`);
8308
8380
  if (!d.settled && d.s.urls.length > 0) notes.push(`${d.s.urls.length} page(s) listed while the operation still read "${d.s.status}" \u2014 the list may be incomplete; the pages below are what was trained`);
8309
- let trained = 0, trainStatus = "not started", trainSettled = false;
8381
+ let trained = 0, trainedPages = 0, trainStatus = "not started", trainSettled = false;
8310
8382
  const chosen = mode === "Exact" ? [] : pickPages(d.s.urls, opts.maxPages ?? 8);
8311
8383
  if (mode === "Exact") {
8312
8384
  notes.push("Exact mode: the single page trains itself.");
@@ -8314,17 +8386,23 @@ async function crawlAndTrain(client, opts) {
8314
8386
  notes.push("no pages were listed to train");
8315
8387
  } else {
8316
8388
  await client.post("/knowledge-bases/crawler/train", { ...v, body: { locationId: opts.locationId, urlIds: chosen.map((c) => c.id), knowledgeBaseId: opts.knowledgeBaseId, operationId }, noRetry: true });
8317
- const t = await settleCrawl(client, sleep7, opts.locationId, opts.knowledgeBaseId, operationId, opts.waitTrain ?? 120, (x) => isSettled(x.status) || x.trainedUrlsCount >= chosen.length, { operationId, status: "Training" });
8389
+ const chosenIds = chosen.map((c) => c.id);
8390
+ const t = await settleCrawl(client, sleep7, opts.locationId, opts.knowledgeBaseId, operationId, opts.waitTrain ?? 120, (x) => trainSettledOn(x, chosenIds), { operationId, status: "Training" });
8318
8391
  trained = t.s.trainedUrlsCount;
8319
8392
  trainStatus = t.s.status;
8320
8393
  trainSettled = t.settled;
8394
+ trainedPages = trainedOfRequested(t.s, chosenIds).trained.length;
8321
8395
  if (t.pollError) notes.push(`a training poll failed (${t.pollError})`);
8322
- if (!t.settled) notes.push(`training still read "${t.s.status}" after ${opts.waitTrain ?? 120}s`);
8396
+ else if (wasStopped(t.s.status)) notes.push(`GoHighLevel stopped this operation ("${t.s.status}"), so nothing here confirms these pages trained`);
8397
+ else if (!t.settled) {
8398
+ const { pending, buckets } = trainedOfRequested(t.s, chosenIds);
8399
+ notes.push(`${pending.length} of ${chosenIds.length} page(s) are still not in a "Trained" bucket after ${opts.waitTrain ?? 120}s (operation reads "${t.s.status}"; those pages read ${buckets.join(", ")})`);
8400
+ }
8323
8401
  }
8324
8402
  const listed = unwrapEnvelope(await client.get("/knowledge-bases/crawler", { ...v, params: { knowledgeBaseId: opts.knowledgeBaseId, locationId: opts.locationId, page: 1, pageLength: 50 } }));
8325
8403
  const rows2 = isRecord4(listed) && Array.isArray(listed.urls) ? listed.urls : [];
8326
8404
  const pages = rows2.filter(isRecord4).map((r) => ({ id: String(r.id ?? r._id ?? ""), url: String(r.url ?? ""), title: typeof r.title === "string" ? r.title : void 0, content: typeof r.content === "string" ? r.content : void 0 }));
8327
- return { operationId, discovered: d.s.urls.length, discoverStatus: d.s.status, discoverSettled: d.settled, trained, trainStatus, trainSettled, pages, notes };
8405
+ return { operationId, discovered: d.s.urls.length, discoverStatus: d.s.status, discoverSettled: d.settled, trained, trainedPages, trainStatus, trainSettled, pages, notes };
8328
8406
  }
8329
8407
  function registerKnowledgeBaseTools(server2, client, deps = {}) {
8330
8408
  const sleep7 = deps.sleep ?? realSleep;
@@ -8431,13 +8509,19 @@ function registerKnowledgeBaseTools(server2, client, deps = {}) {
8431
8509
  const startStatus = typeof rec.status === "string" ? rec.status : "Processing";
8432
8510
  const { s, polls, settled, pollError } = await settle2(loc, knowledgeBaseId, operationId, wait, (x) => isSettled(x.status), { operationId, status: startStatus, mode: option, url });
8433
8511
  const listed = s.urls.length;
8512
+ const outcome = crawlOutcome(s.status, settled);
8434
8513
  return {
8435
8514
  operationId,
8436
8515
  ...s,
8437
8516
  polls,
8438
8517
  settled,
8518
+ outcome,
8439
8519
  ...pollError ? { pollError } : {},
8440
- next: option === "Exact" ? "This page trains itself \u2014 do NOT call train_kb_urls on it (GoHighLevel answers 500). Check list_kb_urls in a moment." : pollError ? `A status poll failed (${pollError}); discovery may still be running \u2014 poll get_kb_crawl_status with operationId "${operationId}".` : settled ? `Discovery finished with ${listed} page(s). Pick the ones the agent should know and pass their ids to train_kb_urls with operationId "${operationId}".` : listed > 0 ? `${listed} page(s) listed so far, but the operation still reads "${s.status}" after ${wait}s, so the list may be incomplete. Train these now with train_kb_urls (operationId "${operationId}"), or poll get_kb_crawl_status for more first.` : `Discovery is still running after ${wait}s with nothing listed yet \u2014 poll get_kb_crawl_status with operationId "${operationId}".`
8520
+ next: (
8521
+ // The refusal goes first, Exact included: a page that "trains itself"
8522
+ // trains nothing when GoHighLevel stopped the operation.
8523
+ outcome === "stopped" ? `GoHighLevel stopped this crawl (${s.status}) \u2014 it did not finish reading ${url}${listed ? `, and the ${listed} page(s) listed below are whatever it had before it stopped` : ", and nothing was listed"}. Nothing here says the site was read, so do not train from this run: fix what stopped it and crawl again.` : option === "Exact" ? "This page trains itself \u2014 do NOT call train_kb_urls on it (GoHighLevel answers 500). Check list_kb_urls in a moment." : pollError ? `A status poll failed (${pollError}); discovery may still be running \u2014 poll get_kb_crawl_status with operationId "${operationId}".` : settled ? `Discovery finished with ${listed} page(s). Pick the ones the agent should know and pass their ids to train_kb_urls with operationId "${operationId}".` : listed > 0 ? `${listed} page(s) listed so far, but the operation still reads "${s.status}" after ${wait}s, so the list may be incomplete. Train these now with train_kb_urls (operationId "${operationId}"), or poll get_kb_crawl_status for more first.` : `Discovery is still running after ${wait}s with nothing listed yet \u2014 poll get_kb_crawl_status with operationId "${operationId}".`
8524
+ )
8441
8525
  };
8442
8526
  }
8443
8527
  );
@@ -8457,16 +8541,24 @@ function registerKnowledgeBaseTools(server2, client, deps = {}) {
8457
8541
  const queued = await client.post("/knowledge-bases/crawler/train", { ...v, body: { locationId: loc, urlIds, knowledgeBaseId, operationId }, noRetry: true });
8458
8542
  const wait = waitSeconds ?? 60;
8459
8543
  if (wait === 0) return { queued, note: "Queued. Poll get_kb_crawl_status for trainedUrlsCount." };
8460
- const { s, polls, settled, pollError } = await settle2(loc, knowledgeBaseId, operationId, wait, (x) => isSettled(x.status) || x.trainedUrlsCount >= urlIds.length, { operationId, status: "Training" });
8544
+ const { s, polls, settled, pollError } = await settle2(loc, knowledgeBaseId, operationId, wait, (x) => trainSettledOn(x, urlIds), { operationId, status: "Training" });
8545
+ const { trained, pending, buckets } = trainedOfRequested(s, urlIds);
8546
+ const outcome = crawlOutcome(s.status, settled);
8461
8547
  return {
8462
8548
  queued,
8463
8549
  status: s.status,
8550
+ outcome,
8551
+ trained,
8552
+ pending,
8553
+ // The operation's own counter, kept because it is what GoHighLevel
8554
+ // reports — it counts every page trained against this operationId,
8555
+ // including earlier train calls, so it is not a result for THIS one.
8464
8556
  trainedUrlsCount: s.trainedUrlsCount,
8465
8557
  requested: urlIds.length,
8466
8558
  polls,
8467
8559
  settled,
8468
8560
  ...pollError ? { pollError } : {},
8469
- note: pollError ? `Training was queued, then a status poll failed (${pollError}) \u2014 poll get_kb_crawl_status with operationId "${operationId}".` : settled ? `Trained. list_kb_urls shows each page with a link to the text the agent learned.` : `Still training after ${wait}s \u2014 poll get_kb_crawl_status.`
8561
+ note: pollError ? `Training was queued, then a status poll failed (${pollError}) \u2014 poll get_kb_crawl_status with operationId "${operationId}".` : outcome === "stopped" ? `GoHighLevel stopped this operation (${s.status}). ${trained.length} of ${urlIds.length} requested page(s) are in a "Trained" bucket; the other ${pending.length} are not, so the agent has not learned them.` : settled ? `Trained. list_kb_urls shows each page with a link to the text the agent learned.` : `Still training after ${wait}s \u2014 ${pending.length} of ${urlIds.length} requested page(s) are not in a "Trained" bucket yet (they read ${buckets.join(", ")}; the operation reads "${s.status}"). Poll get_kb_crawl_status with operationId "${operationId}".`
8470
8562
  };
8471
8563
  }
8472
8564
  );
@@ -8573,7 +8665,7 @@ function registerKnowledgeBaseTools(server2, client, deps = {}) {
8573
8665
  }
8574
8666
  );
8575
8667
  }
8576
- var import_zod34, AI_API_VERSION, KB_CAP_PER_LOCATION, realSleep, FINISHED_STATUS, isSettled, PAGE_PRIORITY, HOME_PATH, SKIP;
8668
+ var import_zod34, AI_API_VERSION, KB_CAP_PER_LOCATION, realSleep, FINISHED_STATUS, isSettled, STOPPED_STATUS, wasStopped, crawlOutcome, TRAINED_BUCKET, trainSettledOn, PAGE_PRIORITY, HOME_PATH, SKIP;
8577
8669
  var init_knowledge_base = __esm({
8578
8670
  "src/tools/knowledge-base.ts"() {
8579
8671
  "use strict";
@@ -8584,6 +8676,11 @@ var init_knowledge_base = __esm({
8584
8676
  realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
8585
8677
  FINISHED_STATUS = /^(successful|success|failed|failure|existing|restricted|cancelled|canceled|aborted|completed?)$/i;
8586
8678
  isSettled = (status) => FINISHED_STATUS.test(status.trim());
8679
+ STOPPED_STATUS = /^(failed|failure|restricted|cancelled|canceled|aborted)$/i;
8680
+ wasStopped = (status) => STOPPED_STATUS.test(status.trim());
8681
+ crawlOutcome = (status, settled) => !settled ? "running" : wasStopped(status) ? "stopped" : "finished";
8682
+ TRAINED_BUCKET = /^trained$/i;
8683
+ trainSettledOn = (s, urlIds) => wasStopped(s.status) || trainedOfRequested(s, urlIds).pending.length === 0;
8587
8684
  PAGE_PRIORITY = /about|service|treatment|program|plan|product|menu|special|offer|pricing|price|cost|fee|insurance|contact|location|direction|faq|question|help|hour|book|schedule|appointment|new[- ]?patient|patient|client|team|staff|doctor|provider|review|testimonial/i;
8588
8685
  HOME_PATH = /^\/(index(\.html?)?|home(\.html?)?)?\/?$/i;
8589
8686
  SKIP = /privacy|terms|cookie|legal|login|sign[- ]?in|cart|checkout|account|unsubscribe|\.(pdf|jpe?g|png|gif|svg|xml|json|css|js)(\?|$)|\/blog\/|\/news\/|\/tag\/|\/category\/|\/author\/|\/page\/\d/i;
@@ -9260,13 +9357,17 @@ function registerAiEmployeeDemoTools(server2, shared, deps = {}) {
9260
9357
  return {
9261
9358
  runId: run.runId,
9262
9359
  bot: { id: botId, name: upd.after.name, mode: upd.after.mode, knowledgeBaseIds: upd.after.knowledgeBaseIds, businessName: upd.after.businessName },
9263
- knowledgeBase: { id: kbId, discovered: crawl.discovered, trained: crawl.trained, learned, notes: crawl.notes },
9360
+ // trainedPages = pages GoHighLevel holds in a Trained bucket; the
9361
+ // operation counter climbs across every train call and is named for
9362
+ // what it is (delta review 3).
9363
+ knowledgeBase: { id: kbId, discovered: crawl.discovered, trainedPages: crawl.trainedPages ?? 0, operationTrainedCount: crawl.trained, learned, notes: crawl.notes },
9264
9364
  prospect: { contactId, phone: prospectPhone, fieldsVerified: Object.keys(values).length },
9265
9365
  numberToText,
9266
9366
  primaryOnSms: primary,
9267
9367
  armed: run.armed,
9268
9368
  readiness: [
9269
- crawl.trained > 0 ? `the bot has learned ${crawl.trained} page(s)` : "the bot has learned NO pages yet",
9369
+ // Per-page buckets, not the operation counter (sweep review 7).
9370
+ (crawl.trainedPages ?? 0) > 0 ? `the bot has learned ${crawl.trainedPages} page(s)` : "the bot has learned NO pages yet",
9270
9371
  armedNow && wasAutoPilot ? "WARNING: the bot was ALREADY on auto-pilot before this demo and answers on its own; end_ai_employee_demo puts it back to auto-pilot, because that is its before-state" : armedNow ? "the bot is on auto-pilot (armed here)" : armAutoPilot ? `WARNING: arming was requested but the bot reads back as "${String(upd.after.mode)}" \u2014 it is NOT answering on its own` : "the bot is NOT armed \u2014 pass armAutoPilot: true to let it answer",
9271
9372
  primary === true ? "the bot is primary on SMS" : primary === false ? "the bot is NOT primary on SMS \u2014 it will not answer texts until it is" : "could not read whether the bot is primary on SMS",
9272
9373
  numberToText ? `text ${numberToText}` : "this sub-account has no phone number",
@@ -9803,13 +9904,16 @@ function registerVoiceAiTools(server2, shared) {
9803
9904
  for (const n of held) if (!holders.has(n)) holders.set(n, { agentId: String(a.id), agentName: String(a.agentName), via: `pool ${pool}` });
9804
9905
  return { agentId: String(a.id), agentName: String(a.agentName), poolId: pool, poolNumbers: held.length ? held : "unknown \u2014 this API does not list the pool's numbers" };
9805
9906
  });
9806
- const unresolvedPool = poolBound.some((p) => typeof p.poolNumbers === "string");
9907
+ const poolRouting = poolBound.filter((p) => Array.isArray(p.poolNumbers) && p.poolNumbers.length > 0);
9908
+ const poolUnproven = poolBound.filter((p) => typeof p.poolNumbers === "string");
9909
+ const unresolvedPool = poolUnproven.length > 0;
9807
9910
  const numberRows = numbers.map((n) => ({ number: n, answeredBy: holders.get(n) ?? null }));
9808
9911
  const free = unresolvedPool ? [] : numberRows.filter((r) => !r.answeredBy && !heldUnproven.has(r.number)).map((r) => r.number);
9809
9912
  const rota = agents.filter(inBackupRota).map((a) => ({ agentId: String(a.id), agentName: String(a.agentName) }));
9810
- const ready = numberRows.some((r) => r.answeredBy) || poolBound.length > 0;
9913
+ const ready = numberRows.some((r) => r.answeredBy) || poolRouting.length > 0;
9811
9914
  const unprovenLine = unproven.length ? ` ${unproven.length} agent(s) carry a number binding NOT proven to route (${unproven.map((u) => `"${u.agentName}" on ${u.numbers.join(", ")}`).join("; ")}): see unprovenBindings for the fix.` : "";
9812
- const verdict = numbers.length === 0 ? "NOT READY: no phone number on this sub-account." : ready ? `READY: ${numberRows.filter((r) => r.answeredBy).length} number(s) bound to a voice agent${poolBound.length ? `, and ${poolBound.length} agent(s) answering through a number pool` : ""}${rota.length ? `; ${rota.length} agent(s) also sit in the backup rota` : ""}.${unprovenLine}` : unproven.length ? `NOT PROVEN TO ROUTE: no number is bound in the shape GoHighLevel routes to an agent.${unprovenLine}${rota.length ? ` ${rota.length} agent(s) sit in the backup rota (${rota.map((r) => r.agentName).join(", ")}) and may be handed unanswered calls.` : ""}` : `NUMBER PRESENT, NO AGENT BOUND BY API: ${numbers.length} number(s), none carried in inboundNumbers or as numberPoolId by any voice agent.${rota.length ? ` ${rota.length} agent(s) sit in the backup rota (${rota.map((r) => r.agentName).join(", ")}) and may be handed unanswered calls.` : ""} Calls may still reach an agent through the number's own routing, which this API cannot read.`;
9915
+ const poolUnprovenLine = poolUnproven.length ? ` ${poolUnproven.length} agent(s) answer through a number pool this API does not list that pool's numbers for (${poolUnproven.map((p) => `"${p.agentName}" on pool ${p.poolId}`).join("; ")}), so nothing here proves a call lands: open the pool in GoHighLevel and check it holds a number.` : "";
9916
+ const verdict = numbers.length === 0 ? "NOT READY: no phone number on this sub-account." : ready ? `READY: ${numberRows.filter((r) => r.answeredBy).length} number(s) bound to a voice agent${poolRouting.length ? `, and ${poolRouting.length} agent(s) answering through a number pool` : ""}${rota.length ? `; ${rota.length} agent(s) also sit in the backup rota` : ""}.${unprovenLine}${poolUnprovenLine}` : unproven.length || poolUnproven.length ? `NOT PROVEN TO ROUTE: no number is bound in the shape GoHighLevel routes to an agent.${unprovenLine}${poolUnprovenLine}${rota.length ? ` ${rota.length} agent(s) sit in the backup rota (${rota.map((r) => r.agentName).join(", ")}) and may be handed unanswered calls.` : ""}` : `NUMBER PRESENT, NO AGENT BOUND BY API: ${numbers.length} number(s), none carried in inboundNumbers or as numberPoolId by any voice agent.${rota.length ? ` ${rota.length} agent(s) sit in the backup rota (${rota.map((r) => r.agentName).join(", ")}) and may be handed unanswered calls.` : ""} Calls may still reach an agent through the number's own routing, which this API cannot read.`;
9813
9917
  return {
9814
9918
  locationId: loc,
9815
9919
  verdict,
@@ -9939,6 +10043,7 @@ function registerVoiceAiTools(server2, shared) {
9939
10043
  if (numberPoolId !== void 0) change.numberPoolId = numberPoolId;
9940
10044
  if (Object.keys(change).length === 0 && inboundNumber === void 0) throw new Error("Nothing to update \u2014 pass at least one field to change.");
9941
10045
  const before = await readVoiceAgent(client, loc, agentId);
10046
+ const bindNotes = [];
9942
10047
  if (typeof inboundNumber === "string") {
9943
10048
  const current = Array.isArray(before.inboundNumbers) ? before.inboundNumbers.filter((n) => typeof n === "string" && n.length > 0) : [];
9944
10049
  change.inboundNumbers = [.../* @__PURE__ */ new Set([...current, inboundNumber])];
@@ -9965,8 +10070,26 @@ function registerVoiceAiTools(server2, shared) {
9965
10070
  const { numbers, pools, poolObjects } = await readNumbers(client, loc);
9966
10071
  if (typeof inboundNumber === "string") {
9967
10072
  if (!numbers.includes(inboundNumber)) throw new Error(numbers.length ? `${inboundNumber} is not a number on this sub-account. Its numbers: ${numbers.join(", ")}.` : NO_NUMBER_TASK);
9968
- const clash = bindingClash(inboundNumber, await listAllVoiceAgents(client, loc), agentId, poolObjects);
10073
+ const agents = await listAllVoiceAgents(client, loc);
10074
+ const clash = bindingClash(inboundNumber, agents, agentId, poolObjects);
9969
10075
  if (clash) throw new Error(clash);
10076
+ const dormant = unprovenNumbers(before).filter((n) => n !== inboundNumber);
10077
+ const kept = [];
10078
+ for (const n of change.inboundNumbers) {
10079
+ if (n === inboundNumber) {
10080
+ kept.push(n);
10081
+ continue;
10082
+ }
10083
+ const holder = agents.find((a) => a.id !== agentId && boundNumbers(a).includes(n));
10084
+ if (holder && dormant.includes(n)) {
10085
+ bindNotes.push(`${n} was held by this agent in the shape not proven to route and now answers for agent "${String(holder.agentName)}" (${String(holder.id)}), so it was left off this agent rather than written onto two.`);
10086
+ continue;
10087
+ }
10088
+ if (holder) bindNotes.push(`${n} also answers for agent "${String(holder.agentName)}" (${String(holder.id)}) in GoHighLevel. It stays on this agent, which was routing it; GoHighLevel decides which of the two takes a call, so unbind it on one of them.`);
10089
+ kept.push(n);
10090
+ if (dormant.includes(n)) bindNotes.push(`${n} was held by this agent in the shape not proven to route (BUG-2026-09-15-02) and starts routing to it with this write.`);
10091
+ }
10092
+ change.inboundNumbers = kept;
9970
10093
  }
9971
10094
  if (typeof numberPoolId === "string") {
9972
10095
  if (!pools.includes(numberPoolId)) throw new Error(pools.length ? `Number pool ${numberPoolId} is not on this sub-account. Its pools: ${pools.join(", ")}.` : "This sub-account has no number pools (list_number_pools).");
@@ -9991,7 +10114,7 @@ function registerVoiceAiTools(server2, shared) {
9991
10114
  if (!rb.agent) return { sent: Object.keys(change), changed: [], before: {}, agent: void 0, answersOn: [], pool: null, inBackupRota: void 0, readBack: "ASSERTED", differs: void 0, notes: [READBACK_FAILED("update", agentId, rb.error ?? "unknown error")] };
9992
10115
  const after = rb.agent;
9993
10116
  const changed = diffVoiceAgentSnapshots(before, after);
9994
- const notes = [];
10117
+ const notes = [...bindNotes];
9995
10118
  const differs = agentWriteDifferences(change, after);
9996
10119
  for (const k of differs) notes.push(`${k}: sent ${JSON.stringify(change[k])}, reads back ${JSON.stringify(after[k])}.`);
9997
10120
  let asserted2 = differs.length > 0;
@@ -11619,8 +11742,10 @@ ${await response.text()}`);
11619
11742
  async ({ pageId, content, funnelId, isPublished }) => {
11620
11743
  try {
11621
11744
  let meta = {};
11745
+ let preReadOk = false;
11622
11746
  try {
11623
11747
  meta = await funnelRequest("GET", `/page/${pageId}?locationId=${client.locationId}`);
11748
+ preReadOk = true;
11624
11749
  } catch (metaErr) {
11625
11750
  if (!funnelId) throw metaErr;
11626
11751
  }
@@ -11656,14 +11781,24 @@ ${await response.text()}`);
11656
11781
  }
11657
11782
  const afterVersion = typeof after.pageVersion === "number" ? after.pageVersion : null;
11658
11783
  const becameVisible = afterUrl !== "" && afterUrl !== beforeUrl;
11659
- save_check = {
11660
- visible: becameVisible,
11661
- pageVersion_sent: pageVersion,
11662
- pageVersion_now: afterVersion,
11663
- ...becameVisible ? {} : {
11664
- warning: "SAVED BUT NOT VISIBLE. GHL accepted the write and stored the content, but the page's live pointer did not move, so visitors still see the previous version. This is GHL's behaviour on a page that already has content, not a transport error. Rebuild on a fresh step with create_funnel_page and save onto that instead \u2014 do NOT report this page as updated."
11665
- }
11666
- };
11784
+ if (!preReadOk) {
11785
+ const versionMismatch = afterVersion !== null && afterVersion !== pageVersion;
11786
+ save_check = {
11787
+ visible: null,
11788
+ pageVersion_sent: pageVersion,
11789
+ pageVersion_now: afterVersion,
11790
+ warning: "SAVE NOT VERIFIED. The page could not be read before the save, so whether the new version is the one visitors see is not verified \u2014 with no baseline pointer, a page that never moved off its old content is indistinguishable from one that took the save. Read it with get_page_full and compare before reporting it as updated." + (versionMismatch ? ` GoHighLevel now reports this page at pageVersion ${afterVersion} while the save was sent as pageVersion ${pageVersion}: the page already held content this save did not build on, which is exactly the case where GHL stores the write and keeps serving the previous version.` : "")
11791
+ };
11792
+ } else {
11793
+ save_check = {
11794
+ visible: becameVisible,
11795
+ pageVersion_sent: pageVersion,
11796
+ pageVersion_now: afterVersion,
11797
+ ...becameVisible ? {} : {
11798
+ warning: "SAVED BUT NOT VISIBLE. GHL accepted the write and stored the content, but the page's live pointer did not move, so visitors still see the previous version. This is GHL's behaviour on a page that already has content, not a transport error. Rebuild on a fresh step with create_funnel_page and save onto that instead \u2014 do NOT report this page as updated."
11799
+ }
11800
+ };
11801
+ }
11667
11802
  } catch (verifyErr) {
11668
11803
  save_check = {
11669
11804
  visible: null,
@@ -12468,6 +12603,10 @@ function pageFaces(envelope) {
12468
12603
  function derivePalette(envelope) {
12469
12604
  return derivePaletteFromUsage(usageOrHexes(envelope), pageFaces(envelope));
12470
12605
  }
12606
+ function paintsAs(literal, from) {
12607
+ if (/^#[0-9a-f]{8}$/i.test(literal)) return { hit: literal.slice(0, 7).toLowerCase() === from, keep: literal.slice(7) };
12608
+ return { hit: normaliseColour(literal)?.toLowerCase() === from, keep: "" };
12609
+ }
12471
12610
  function sectionPairs(envelope) {
12472
12611
  const groups = /* @__PURE__ */ new Map();
12473
12612
  for (const p of renderedPalette(envelope).paints) {
@@ -12517,28 +12656,47 @@ function rethemeEnvelope(envelope, brand, derived = derivePalette(envelope)) {
12517
12656
  return out;
12518
12657
  };
12519
12658
  const sayLoss = (l) => `${l.where}: ${target(l.t).toUpperCase()} type on ${target(l.bg).toUpperCase()} would be ${l.after.toFixed(2)}:1 (it was ${l.before.toFixed(2)}:1); WCAG AA wants 4.5.`;
12520
- for (let pass = 0; pass <= plan.length; pass++) {
12521
- const hit = losses().find((l) => l.involved.some((x) => x.guarded));
12522
- if (!hit) break;
12523
- for (const step of hit.involved.filter((x) => x.guarded)) {
12524
- skipped.push(`${step.role} ${step.from}: left as it was, because ${sayLoss(hit)}`);
12525
- plan.splice(plan.indexOf(step), 1);
12659
+ const guardedAtStart = plan.filter((x) => x.guarded).length;
12660
+ for (let pass = 0; pass < guardedAtStart; pass++) {
12661
+ let removed = false;
12662
+ for (const hit of losses()) {
12663
+ for (const step of hit.involved.filter((x) => x.guarded)) {
12664
+ const without = (hex) => hex === step.from ? hex : target(hex);
12665
+ if (contrastHex(without(hit.t), without(hit.bg)) < 4.5) continue;
12666
+ skipped.push(`${step.role} ${step.from}: left as it was, because ${sayLoss(hit)}`);
12667
+ plan.splice(plan.indexOf(step), 1);
12668
+ removed = true;
12669
+ break;
12670
+ }
12671
+ if (removed) break;
12526
12672
  }
12673
+ if (!removed) break;
12527
12674
  }
12528
12675
  const seen = /* @__PURE__ */ new Set();
12529
12676
  for (const l of losses()) {
12530
- const line3 = `${sayLoss(l)} Kept, because ${l.involved.map((x) => `the ${x.role}`).join(" and ")} ${l.involved.length > 1 ? "are" : "is"} the brand's own colour: check this section before a client sees it.`;
12677
+ const brandRoles = l.involved.filter((x) => !x.guarded).map((x) => `the ${x.role}`);
12678
+ const guardedRoles = l.involved.filter((x) => x.guarded).map((x) => `the ${x.role}`);
12679
+ const because = [
12680
+ brandRoles.length ? `${brandRoles.join(" and ")} ${brandRoles.length > 1 ? "are" : "is"} the brand's own colour` : "",
12681
+ guardedRoles.length ? `leaving ${guardedRoles.join(" and ")} as it was would not make it readable` : ""
12682
+ ].filter(Boolean).join(", and ");
12683
+ const line3 = `${sayLoss(l)} Kept, because ${because}: check this section before a client sees it.`;
12531
12684
  if (!seen.has(`${target(l.t)} ${target(l.bg)}`)) warnings.push(line3);
12532
12685
  seen.add(`${target(l.t)} ${target(l.bg)}`);
12533
12686
  }
12534
12687
  let text2 = JSON.stringify(envelope);
12535
12688
  const colourTokens = [];
12536
12689
  plan.forEach((step, i) => {
12537
- const re = new RegExp(escapeRe(step.from), "gi");
12538
- const occurrences = (text2.match(re) ?? []).length;
12539
- if (occurrences === 0) return;
12540
12690
  const token2 = `\0COLOUR${i}\0`;
12541
- text2 = text2.replace(re, token2);
12691
+ let occurrences = 0;
12692
+ const swapped = text2.replace(COLOUR_LITERAL_RE, (lit) => {
12693
+ const { hit, keep } = paintsAs(lit, step.from);
12694
+ if (!hit) return lit;
12695
+ occurrences++;
12696
+ return token2 + keep;
12697
+ });
12698
+ if (occurrences === 0) return;
12699
+ text2 = swapped;
12542
12700
  colourTokens.push({ token: token2, to: step.to });
12543
12701
  swaps.push({ role: step.role, from: step.from, to: step.to, occurrences });
12544
12702
  });
@@ -12581,7 +12739,7 @@ function derivePaletteAcross(envelopes) {
12581
12739
  for (const env2 of envelopes) for (const f of pageFaces(env2)) if (!fonts.includes(f)) fonts.push(f);
12582
12740
  return derivePaletteFromUsage(pooled2, fonts.slice(0, 2));
12583
12741
  }
12584
- var ICON_FONTS, BRAND_CHROMA, TONE_CHROMA, escapeRe;
12742
+ var ICON_FONTS, BRAND_CHROMA, TONE_CHROMA, escapeRe, COLOUR_LITERAL_RE;
12585
12743
  var init_retheme = __esm({
12586
12744
  "src/command-os/design/retheme.ts"() {
12587
12745
  "use strict";
@@ -12593,6 +12751,7 @@ var init_retheme = __esm({
12593
12751
  BRAND_CHROMA = 0.06;
12594
12752
  TONE_CHROMA = 0.02;
12595
12753
  escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12754
+ COLOUR_LITERAL_RE = /#[0-9a-f]{3,8}(?![0-9a-f])|rgba?\([^()]*\)|hsla?\([^()]*\)/gi;
12596
12755
  }
12597
12756
  });
12598
12757
 
@@ -17229,21 +17388,24 @@ ${await response.text()}`);
17229
17388
  integrations: computeIntegrations({ sections })
17230
17389
  };
17231
17390
  const saved = await autosaveRequest(pre.pageId, { funnelId, pageVersion: savePageVersion, pageData });
17232
- const savedDataUrl = typeof saved.pageDataUrl === "string" ? saved.pageDataUrl : null;
17391
+ const savedDataUrl = typeof saved.pageDataUrl === "string" && saved.pageDataUrl.length > 0 ? saved.pageDataUrl : null;
17233
17392
  let verified = false;
17393
+ let servedUrl = "";
17234
17394
  for (let attempt = 1; attempt <= 8 && !verified; attempt++) {
17235
17395
  await new Promise((r) => setTimeout(r, 4e3));
17236
17396
  try {
17237
17397
  const now = await funnelRequest("GET", `/page/${pre.pageId}?locationId=${client.locationId}`);
17238
- const nowUrl = typeof now.pageDataUrl === "string" ? now.pageDataUrl : "";
17239
- if (savedDataUrl !== null && nowUrl === savedDataUrl) verified = true;
17398
+ servedUrl = typeof now.pageDataDownloadUrl === "string" ? now.pageDataDownloadUrl : "";
17399
+ if (savedDataUrl !== null && servedUrl !== "" && servedUrl !== pre.priorDataUrl) verified = true;
17240
17400
  } catch {
17241
17401
  }
17242
17402
  }
17243
17403
  return {
17244
17404
  saved,
17245
17405
  verified,
17246
- ...verified ? {} : { verifyNote: "The save was accepted but the page has not yet reported the new content (eventual consistency). Re-check get_page_full in a minute before publishing; if it never advances, the save did not take." }
17406
+ ...verified ? {} : {
17407
+ verifyNote: savedDataUrl === null ? "NOT VERIFIED: GoHighLevel's autosave answered without a pageDataUrl, so this save produced no pointer to confirm against" + (servedUrl === "" ? ", and the page still reports no stored content." : ", though the page does now report stored content.") + " Re-check get_page_full in a minute before publishing; if it never advances, the save did not take." : "The save was accepted but the page has not yet reported the new content (eventual consistency). Re-check get_page_full in a minute before publishing; if it never advances, the save did not take."
17408
+ }
17247
17409
  };
17248
17410
  }
17249
17411
  async function savePageGuarded(input) {
@@ -20541,6 +20703,10 @@ function registerValidatorTools(server2, client, builderClient) {
20541
20703
  merge_field_warnings_total: 0,
20542
20704
  workflows_with_shape_warnings: 0,
20543
20705
  shape_warnings_total: 0,
20706
+ workflows_with_save_blocker_warnings: 0,
20707
+ save_blocker_warnings_total: 0,
20708
+ workflows_with_integration_warnings: 0,
20709
+ integration_warnings_total: 0,
20544
20710
  workflows_unscannable: 0,
20545
20711
  workflows_zero_references: 0,
20546
20712
  // Nothing was scanned, so no reference category was loaded or needed.
@@ -20551,6 +20717,8 @@ function registerValidatorTools(server2, client, builderClient) {
20551
20717
  workflows_with_issues: [],
20552
20718
  merge_field_warnings: [],
20553
20719
  shape_warnings: [],
20720
+ save_blocker_warnings: [],
20721
+ integration_warnings: [],
20554
20722
  unscannable: [],
20555
20723
  notes: catalog2.complete ? [] : [incompleteCatalogNote(catalog2.stopReason, 0, catalog2.reportedTotal)]
20556
20724
  });
@@ -20587,6 +20755,12 @@ function registerValidatorTools(server2, client, builderClient) {
20587
20755
  const warningsTotal = withWarnings.reduce((n, w) => n + w.warnings.length, 0);
20588
20756
  const withShapeWarnings = results.filter((r) => r.findings.some((f) => f.severity === "warning" && f.category === "action_shape")).map((r) => ({ workflowId: r.id, workflowName: r.name, status: r.status, warnings: r.findings.filter((f) => f.severity === "warning" && f.category === "action_shape") })).sort((a, b) => (a.status === "published" ? -1 : 1) - (b.status === "published" ? -1 : 1));
20589
20757
  const shapeWarningsTotal = withShapeWarnings.reduce((n, w) => n + w.warnings.length, 0);
20758
+ const SAVE_BLOCKER_CATEGORIES = /* @__PURE__ */ new Set(["legacy_step_id", "legacy_wait_unit", "missing_required_attribute"]);
20759
+ const bucket = (match) => results.filter((r) => r.findings.some(match)).map((r) => ({ workflowId: r.id, workflowName: r.name, status: r.status, warnings: r.findings.filter(match) })).sort((a, b) => (a.status === "published" ? -1 : 1) - (b.status === "published" ? -1 : 1));
20760
+ const withSaveBlockerWarnings = bucket((f) => f.severity === "warning" && SAVE_BLOCKER_CATEGORIES.has(f.category));
20761
+ const saveBlockerWarningsTotal = withSaveBlockerWarnings.reduce((n, w) => n + w.warnings.length, 0);
20762
+ const withIntegrationWarnings = bucket((f) => f.severity === "warning" && f.category === "missing_integration");
20763
+ const integrationWarningsTotal = withIntegrationWarnings.reduce((n, w) => n + w.warnings.length, 0);
20590
20764
  const unverifiedCats = ALL_CATEGORIES.filter((c) => lookups.status[c] !== "loaded");
20591
20765
  const unverifiedRefs = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === "unverified").length, 0);
20592
20766
  return jsonResponse({
@@ -20606,14 +20780,24 @@ function registerValidatorTools(server2, client, builderClient) {
20606
20780
  merge_field_warnings_total: warningsTotal,
20607
20781
  workflows_with_shape_warnings: withShapeWarnings.length,
20608
20782
  shape_warnings_total: shapeWarningsTotal,
20783
+ workflows_with_save_blocker_warnings: withSaveBlockerWarnings.length,
20784
+ save_blocker_warnings_total: saveBlockerWarningsTotal,
20785
+ workflows_with_integration_warnings: withIntegrationWarnings.length,
20786
+ integration_warnings_total: integrationWarningsTotal,
20609
20787
  workflows_unscannable: unscannable.length,
20610
20788
  workflows_zero_references: zeroRefCount,
20611
20789
  unverified: { categories_unloaded: unverifiedCats, references_unverified: unverifiedRefs },
20790
+ // Unchanged on purpose: `status` has meant "are there ERRORS" since it
20791
+ // was written, and these four categories are warnings by ruling — the
20792
+ // workflows genuinely still run. What was broken is that they were
20793
+ // invisible, not that they were ranked too low.
20612
20794
  status: errorsTotal > 0 ? "issues_found" : "ok"
20613
20795
  },
20614
20796
  workflows_with_issues: withErrors,
20615
20797
  merge_field_warnings: withWarnings,
20616
20798
  shape_warnings: withShapeWarnings,
20799
+ save_blocker_warnings: withSaveBlockerWarnings,
20800
+ integration_warnings: withIntegrationWarnings,
20617
20801
  unscannable,
20618
20802
  notes: [
20619
20803
  // NEVER advise re-running for "the next batch": GHL's list endpoint ignores
@@ -20624,6 +20808,8 @@ function registerValidatorTools(server2, client, builderClient) {
20624
20808
  ...unverifiedCats.length ? [`Could not fully load: ${unverifiedCats.join(", ")} \u2014 references to those are 'unverified', not 'broken'. Re-run.`] : [],
20625
20809
  ...warningsTotal ? [`${warningsTotal} {{contact.X}} merge tag(s) reference a field that no longer exists \u2014 these render BLANK in the message but do NOT stop the workflow (warning, not a break). See merge_field_warnings.`] : [],
20626
20810
  ...shapeWarningsTotal ? [`${shapeWarningsTotal} Create/Update Opportunity step(s) have only pipeline + stage \u2014 they cannot create a card for a new contact (see shape_warnings).`] : [],
20811
+ ...saveBlockerWarningsTotal ? [`${saveBlockerWarningsTotal} step(s) across ${withSaveBlockerWarnings.length} workflow(s) either stop the workflow being SAVED (a legacy step id, or the singular wait unit GoHighLevel no longer accepts \u2014 it still runs, it just cannot be edited) or save and run while doing nothing (a notification step with nobody selected). See save_blocker_warnings.`] : [],
20812
+ ...integrationWarningsTotal ? [`${integrationWarningsTotal} step(s) across ${withIntegrationWarnings.length} workflow(s) need a social account this location has not connected \u2014 they save, publish and run, and do nothing when reached. See integration_warnings.`] : [],
20627
20813
  "workflow_goal, goto, and unrecognized condition types are not deeply checked in this version."
20628
20814
  ]
20629
20815
  });
@@ -27524,6 +27710,7 @@ Use these authoritative normalized intake answers when provided: ${JSON.stringif
27524
27710
  8. ${STAFF_NOTIFICATION_RULE} In a hand-authored fallback plan, a notification's "to" is that user id, or "" when the account has no users.
27525
27711
  9. ${MISSING_STEP_RULE}
27526
27712
  10. ${NO_INVENTED_CAUSE_RULE}
27713
+ If apply_build_plan answers phase:"scope" (nothing is confirmed or selected for this client, or the selection changed), STOP: build nothing else and finish with RESULT: {"ok":false,"error":"<its error, word for word>"}.
27527
27714
  Finish with EXACTLY one final line:
27528
27715
  RESULT: {"ok":true,"summary":"<what was created: counts of pipelines/fields/tags/workflows, and whether the plan came from the preset or the fallback>","issues":["<anything skipped or needing a human, including every notification step waiting for a staff member>"]}` + loadPlanGuide(ctx?.industry)
27529
27716
  },
@@ -31568,7 +31755,7 @@ var require_package = __commonJS({
31568
31755
  "package.json"(exports2, module2) {
31569
31756
  module2.exports = {
31570
31757
  name: "@elitedcs/ghl-mcp",
31571
- version: "3.84.1",
31758
+ version: "3.84.2",
31572
31759
  mcpName: "io.github.drjerryrelth/ghl-command",
31573
31760
  description: "GoHighLevel MCP Server for Claude. 250 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.",
31574
31761
  main: "dist/index.js",
@@ -35255,8 +35442,8 @@ var init_capability_runs = __esm({
35255
35442
  staff: ["get_users", "get_user", "get_calendars"],
35256
35443
  phone: ["list_phone_numbers", "list_number_pools", "get_location"],
35257
35444
  workflows: ["get_workflows", "get_workflow_full", "get_trigger_registry", "get_users", "get_calendars", "get_pipelines", "get_pipeline_full", "get_custom_fields", "get_custom_values", "get_location_tags", "list_email_templates", "list_message_templates", "validate_workflow", "create_workflow", "update_workflow_actions", "audit_workflows"],
35258
- reactivation: ["get_contact_count", "search_contacts", "get_location_tags", "create_location_tag", "get_workflows", "get_workflow_full", "validate_workflow", "create_workflow", "update_workflow_actions", "create_smart_list", "list_smart_lists"],
35259
- // no bulk tagging: that is a mass write the operator does by hand
35445
+ reactivation: ["get_contact_count", "search_contacts", "get_location_tags", "create_location_tag", "get_workflows", "get_workflow_full", "validate_workflow", "create_workflow", "update_workflow_actions", "create_smart_list", "list_smart_lists", "create_contact_smart_list", "get_contact_smart_list"],
35446
+ // contact segments live on the contacts service (3.84.2); no bulk tagging: that is a mass write the operator does by hand
35260
35447
  templates: ["list_email_templates", "create_email_template", "update_email_template", "list_message_templates", "create_sms_template", "get_custom_values"],
35261
35448
  broadcasts: ["list_email_templates", "create_email_template", "update_email_template", "list_smart_lists", "get_email_campaigns"],
35262
35449
  // drafts the email as a template; sending or scheduling is the operator's click
@@ -39987,12 +40174,12 @@ function routeFirebaseForCompany(builderClient, registry2, companyId) {
39987
40174
  companyId
39988
40175
  });
39989
40176
  return `
39990
- Firebase: using ${cfg.name || `company ${companyId}`} credentials \u2014 workflow builder enabled here.`;
40177
+ Firebase: found stored ${cfg.name || `company ${companyId}`} credentials and pointed the workflow builder at them \u2014 not tested this session. To confirm they still work, run register_company_firebase for this company with test_location_id, which makes a real workflow-builder call.`;
39991
40178
  }
39992
40179
  builderClient.resetToHomeFirebase();
39993
40180
  const homeCompanyId = builderClient.getCurrentCompanyId();
39994
40181
  if (homeCompanyId === companyId) {
39995
- return "\nFirebase: home company \u2014 workflow builder enabled.";
40182
+ return "\nFirebase: this location's company is your home company, so the home credentials apply \u2014 not tested this session. To confirm the workflow builder really works here, run register_company_firebase for this company with test_location_id, which makes a real workflow-builder call.";
39996
40183
  }
39997
40184
  const registeredCompanies = registry2?.listCompanyFirebases?.() ?? [];
39998
40185
  if (registeredCompanies.length > 0) {
@@ -40223,7 +40410,17 @@ Agency-wide tools now available: list_snapshots, create_snapshot_share_link, and
40223
40410
  isError: true
40224
40411
  };
40225
40412
  }
40226
- registry2.removeLocation(locationId2);
40413
+ const removed = registry2.removeLocation(locationId2);
40414
+ const saveError = registry2.getLastSaveError();
40415
+ if (!removed || saveError) {
40416
+ return {
40417
+ content: [{
40418
+ type: "text",
40419
+ text: `${token2.name} (${locationId2}) could NOT be removed: this computer's key file could not be written${saveError ? ` (${saveError})` : ""}, so that sub-account's API key is still in it and will be back at the next start. Check storage permissions on the GHL Command app-data folder and run unregister_location again.`
40420
+ }],
40421
+ isError: true
40422
+ };
40423
+ }
40227
40424
  return {
40228
40425
  content: [{ type: "text", text: `Removed: ${token2.name} (${locationId2}) from token registry.` }]
40229
40426
  };
@@ -40513,11 +40710,13 @@ ${lines.join("\n")}
40513
40710
  try {
40514
40711
  const agencyClient = new GHLClient({ apiKey: agencyKey });
40515
40712
  await agencyClient.delete(`/locations/${location_id}`, { params: { deleteTwilioAccount: delete_twilio ?? false } });
40713
+ const hadKey = Boolean(registry2?.getToken(location_id));
40516
40714
  const wasRegistered = registry2?.removeLocation(location_id) ?? false;
40715
+ const keyLine = wasRegistered ? " Removed from the token registry." : hadKey ? ` Its API key could NOT be removed from this computer's key file${registry2?.getLastSaveError() ? ` (${registry2.getLastSaveError()})` : ""}; the key is dead with the sub-account, but run unregister_location once the app-data folder is writable so it does not linger.` : "";
40517
40716
  return {
40518
40717
  content: [{
40519
40718
  type: "text",
40520
- text: `Sub-account ${location_id} permanently deleted.${wasRegistered ? " Removed from the token registry." : ""}`
40719
+ text: `Sub-account ${location_id} permanently deleted.${keyLine}`
40521
40720
  }]
40522
40721
  };
40523
40722
  } catch (error) {
@@ -41781,6 +41980,28 @@ function buildContactSmartListBody(args) {
41781
41980
  locationId: args.locationId
41782
41981
  };
41783
41982
  }
41983
+ function findSmartListRecord(payload) {
41984
+ const looksLikeRecord = (v) => {
41985
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
41986
+ const o = v;
41987
+ return "objectKey" in o || "filters" in o || "id" in o || "_id" in o || "name" in o;
41988
+ };
41989
+ if (looksLikeRecord(payload)) return payload;
41990
+ if (payload && typeof payload === "object") {
41991
+ for (const key of ["list", "smartList", "smartlist", "data", "record"]) {
41992
+ const nested = payload[key];
41993
+ if (looksLikeRecord(nested)) return nested;
41994
+ }
41995
+ }
41996
+ return void 0;
41997
+ }
41998
+ function contactServiceNote(payload) {
41999
+ const rec = findSmartListRecord(payload);
42000
+ if (!rec) return void 0;
42001
+ const reason2 = rec.objectKey === "contacts" ? 'this record carries objectKey "contacts"' : rec.objectKey === "opportunity" ? void 0 : !("filters" in rec) ? "this record came back with no `filters` key at all" : void 0;
42002
+ if (!reason2) return void 0;
42003
+ return `This id was read from GoHighLevel's OPPORTUNITY smart-list service (/lists/dynamic) and ${reason2}. Contact smart lists do not live here and the Contacts screen never shows a record created here \u2014 create one with create_contact_smart_list, read it with get_contact_smart_list, and delete it with delete_contact_smart_list.`;
42004
+ }
41784
42005
  function registerSmartListTools(server2, builderClient) {
41785
42006
  if (!builderClient) return;
41786
42007
  const client = builderClient;
@@ -41909,7 +42130,8 @@ ${await res.text()}`);
41909
42130
  try {
41910
42131
  const loc = locationId2 ?? client.locationId;
41911
42132
  const result = await smartListRequest("GET", `/${loc}/${listId}`);
41912
- return jsonResponse(result);
42133
+ const note = contactServiceNote(result);
42134
+ return note ? jsonResponse({ smart_list: result, note }) : jsonResponse(result);
41913
42135
  } catch (error) {
41914
42136
  return errorResponse(error);
41915
42137
  }
@@ -41917,10 +42139,10 @@ ${await res.text()}`);
41917
42139
  );
41918
42140
  server2.tool(
41919
42141
  "create_smart_list",
41920
- "Create a new smart list (dynamic filter list). Required: name + objectKey. Filters define the saved-search criteria \u2014 pass an empty array to create an empty list and add filters later via update_smart_list. The shape of filters/columns is opaque here; query an existing smart list with get_smart_list to see the format GHL expects.",
42142
+ 'Create a new OPPORTUNITY smart list (dynamic filter list). objectKey "contacts" is refused here \u2014 this service cannot hold a contact segment; use create_contact_smart_list for that. Required: name + objectKey. Filters define the saved-search criteria \u2014 pass an empty array to create an empty list and add filters later via update_smart_list. The shape of filters/columns is opaque here; query an existing smart list with get_smart_list to see the format GHL expects.',
41921
42143
  {
41922
42144
  name: import_zod59.z.string().describe("Display name for the smart list."),
41923
- objectKey: import_zod59.z.enum(OBJECT_KEYS).describe("Object type the list segments over. 'contacts' or 'opportunity'."),
42145
+ objectKey: import_zod59.z.enum(OBJECT_KEYS).describe("Object type the list segments over. 'opportunity' \u2014 'contacts' is refused here and is created with create_contact_smart_list instead."),
41924
42146
  filters: import_zod59.z.array(import_zod59.z.record(import_zod59.z.string(), import_zod59.z.unknown())).optional().describe("Array of filter objects. Each object has fields like {field, operator, value} \u2014 exact shape varies by filter type. See get_smart_list on an existing list to learn the format."),
41925
42147
  columns: import_zod59.z.array(import_zod59.z.record(import_zod59.z.string(), import_zod59.z.unknown())).optional().describe("Array of column definitions for the smart list view in GHL UI. Each defines which contact/opportunity field shows as a column. Defaults to GHL's standard columns if omitted."),
41926
42148
  pipelineIds: import_zod59.z.array(import_zod59.z.string()).optional().describe("(opportunity objectKey only) Pipeline IDs to restrict this smart list to. Empty array = all pipelines."),
@@ -41929,6 +42151,11 @@ ${await res.text()}`);
41929
42151
  },
41930
42152
  async ({ name, objectKey, filters, columns, pipelineIds, defaultInPipelines, locationId: locationId2 }) => {
41931
42153
  try {
42154
+ if (objectKey === "contacts") {
42155
+ throw new Error(
42156
+ "create_smart_list writes to GoHighLevel's opportunity smart-list service, which cannot hold a contact segment \u2014 create a contact smart list with create_contact_smart_list instead."
42157
+ );
42158
+ }
41932
42159
  const loc = locationId2 ?? client.locationId;
41933
42160
  const body = { name, objectKey };
41934
42161
  if (filters !== void 0) body.filters = filters;
@@ -41966,8 +42193,13 @@ ${await res.text()}`);
41966
42193
  if (Object.keys(body).length === 0) {
41967
42194
  throw new Error("Pass at least one of: name, filters, columns, pipelineIds, defaultInPipelines. Nothing to update.");
41968
42195
  }
42196
+ let note;
42197
+ try {
42198
+ note = contactServiceNote(await smartListRequest("GET", `/${loc}/${listId}`));
42199
+ } catch {
42200
+ }
41969
42201
  const result = await smartListRequest("PUT", `/${loc}/${listId}`, body);
41970
- return jsonResponse(result);
42202
+ return note ? jsonResponse({ updated: result, note }) : jsonResponse(result);
41971
42203
  } catch (error) {
41972
42204
  return errorResponse(error);
41973
42205
  }
@@ -41984,8 +42216,13 @@ ${await res.text()}`);
41984
42216
  async ({ listId, locationId: locationId2 }) => {
41985
42217
  try {
41986
42218
  const loc = locationId2 ?? client.locationId;
42219
+ let note;
42220
+ try {
42221
+ note = contactServiceNote(await smartListRequest("GET", `/${loc}/${listId}`));
42222
+ } catch {
42223
+ }
41987
42224
  const result = await smartListRequest("DELETE", `/${loc}/${listId}`);
41988
- return jsonResponse(result);
42225
+ return note ? jsonResponse({ deleted: result, note }) : jsonResponse(result);
41989
42226
  } catch (error) {
41990
42227
  return errorResponse(error);
41991
42228
  }
@@ -48965,7 +49202,10 @@ var init_verify_findings = __esm({
48965
49202
  tag: { path: (loc) => `/locations/${loc}/tags`, ids: (raw) => idsOf(raw, "tags"), paged: true },
48966
49203
  calendar: { path: () => "/calendars/", params: byLocation, ids: (raw) => idsOf(raw, "calendars") },
48967
49204
  form: { path: () => "/forms/", params: byLocation, ids: (raw) => idsOf(raw, "forms"), paged: true },
48968
- user: { path: () => "/users/", params: byLocation, ids: (raw) => idsOf(raw, "users") }
49205
+ user: { path: () => "/users/", params: byLocation, ids: (raw) => idsOf(raw, "users") },
49206
+ // The list get_surveys reads; it takes limit/skip, so it is read the way
49207
+ // forms are, page by page and never assumed complete (3.84.2).
49208
+ survey: { path: () => "/surveys/", params: byLocation, ids: (raw) => idsOf(raw, "surveys"), paged: true }
48969
49209
  };
48970
49210
  CHECKABLE_KIND_NAMES = Object.keys(CHECKABLE_KINDS).join(", ");
48971
49211
  neverLookedAt = (id) => `"${id}" was never looked at. A step aimed at an unverified id silently kills every action after it, so this is refused rather than attempted.`;
@@ -49332,12 +49572,13 @@ function scanArgs(args, maxNodes = MAX_ARG_NODES) {
49332
49572
  continue;
49333
49573
  }
49334
49574
  if (!isRecord10(v)) continue;
49335
- const nodeType = typeof v.type === "string" && v.type ? v.type : parentType;
49575
+ const isNode = typeof v.type === "string" && v.type && (isRecord10(v.attributes) || Array.isArray(v.conditions));
49576
+ const nodeType = isNode ? v.type : parentType;
49336
49577
  const refs = [];
49337
49578
  if (typeof v.type === "string" && isRecord10(v.attributes)) extractFromAction(v, refs);
49338
49579
  if (Array.isArray(v.conditions)) extractFromTrigger(v, refs);
49339
49580
  for (const r of refs) {
49340
- if (r.byKey) continue;
49581
+ if (r.byKey || !r.id.trim()) continue;
49341
49582
  const kind = AUDIT_KIND[r.kind];
49342
49583
  if (kind) out.ids.push({ kind, id: r.id, key: r.where });
49343
49584
  else out.uncheckable.push({ kind: r.kind, id: r.id, key: r.where });
@@ -49410,6 +49651,8 @@ var init_arg_ids = __esm({
49410
49651
  formId: "form",
49411
49652
  formIds: "form",
49412
49653
  // a form_submission goal's extras (build_goal_event)
49654
+ surveyId: "survey",
49655
+ // get_surveys reads /surveys/, so a survey is read back like a form (3.84.2)
49413
49656
  userId: "user",
49414
49657
  userIds: "user",
49415
49658
  assignedUserId: "user",
@@ -49424,6 +49667,12 @@ var init_arg_ids = __esm({
49424
49667
  ...ID_ARG_KINDS,
49425
49668
  "form.id": "form",
49426
49669
  "calendar.id": "calendar",
49670
+ // The payment and task triggers name their form, calendar and assignee under
49671
+ // their own prefixes (src/trigger-schemas.ts); unread until 3.84.2.
49672
+ "payment.form.id": "form",
49673
+ "payment.calendar.id": "calendar",
49674
+ "task.assignedTo": "user",
49675
+ "survey.id": "survey",
49427
49676
  "opportunity.pipelineId": "pipeline",
49428
49677
  "opportunity.pipelineStageId": "pipeline_stage",
49429
49678
  "opportunity.assignedTo": "user",
@@ -49438,7 +49687,7 @@ var init_arg_ids = __esm({
49438
49687
  workflow: "workflow",
49439
49688
  form: "form",
49440
49689
  calendar: "calendar",
49441
- survey: null
49690
+ survey: "survey"
49442
49691
  });
49443
49692
  NOT_CHECKABLE_ID_ARGS = Object.freeze({
49444
49693
  // The account itself, not an object inside it. One account per plan is
@@ -49499,7 +49748,6 @@ var init_arg_ids = __esm({
49499
49748
  linkId: "trigger links have no read-back list here",
49500
49749
  blogId: "blogs have no read-back list here",
49501
49750
  categoryId: "categories have no read-back list here",
49502
- surveyId: "surveys have no read-back list here",
49503
49751
  submissionId: "form submissions have no read-back list here",
49504
49752
  mediaId: "media files have no read-back list here",
49505
49753
  photoAssetId: "media files have no read-back list here",
@@ -49556,6 +49804,10 @@ var init_arg_ids = __esm({
49556
49804
  // src/tools/agency-build.ts
49557
49805
  function accountMismatch(a, active) {
49558
49806
  if (!a.locationId) {
49807
+ const applied = a.applied.filter((s) => s.state === "applied").length;
49808
+ if (applied > 0) {
49809
+ return `This build was saved before builds recorded the sub-account their ids were checked against, and ${applied} of its ${a.steps.length} steps ${applied === 1 ? "is" : "are"} already applied, so nothing can say which account they belong to and re-planning it whole would run those steps twice. Keep the undo notes you have, run abandon_automation to list what is live, then plan only the remaining step${a.steps.length - applied === 1 ? "" : "s"} with plan_automation and approve that plan.`;
49810
+ }
49559
49811
  return "This build was saved before builds recorded the sub-account their ids were checked against, so nothing can say which account they belong to: plan it again (plan_automation) and approve the new plan.";
49560
49812
  }
49561
49813
  if (active === a.locationId) return null;
@@ -50235,7 +50487,7 @@ function dailyBrief(input) {
50235
50487
  );
50236
50488
  }
50237
50489
  }
50238
- const quiet = !unread.length && !halfRead.length && !today2.length && !waitingOnYou.length && !runningNow.length && !neverStarted.length && !renewals.length && !underFloor.length && !newLeads.length && !payingButQuiet.length && !floorUncheckable.length && !renewalUnknown.length && !yourDay.length;
50490
+ const quiet = !unread.length && !halfRead.length && !today2.length && !waitingOnYou.length && !runningNow.length && !neverStarted.length && !renewals.length && !underFloor.length && !newLeads.length && !payingButQuiet.length && !floorUncheckable.length && !renewalUnknown.length && !yourDay.length && !yourDayNote;
50239
50491
  let headline3;
50240
50492
  if (today2.length === 1) {
50241
50493
  headline3 = `You have one call today \u2014 ${today2[0].who ?? today2[0].client} at ${today2[0].at}.`;
@@ -50266,6 +50518,8 @@ function dailyBrief(input) {
50266
50518
  headline3 = `Nothing needs you in what I could read \u2014 but ${unread[0].client} could not be read this morning.`;
50267
50519
  } else if (unread.length > 1) {
50268
50520
  headline3 = `Nothing needs you in what I could read \u2014 but ${unread.length} accounts could not be read this morning.`;
50521
+ } else if (yourDayNote) {
50522
+ headline3 = "Nothing needs you in what I could read \u2014 but your own calendar could not be read this morning.";
50269
50523
  } else if (floorUncheckable.length) {
50270
50524
  const c = floorUncheckable[0].client;
50271
50525
  headline3 = floorUncheckable.length === 1 ? `Nothing needs you \u2014 but ${c}'s price cannot be checked against any floor, so nothing here can tell you whether they are under it.` : `Nothing needs you \u2014 but ${floorUncheckable.length} clients' prices cannot be checked against any floor, ${c} among them.`;
@@ -50916,6 +51170,45 @@ function translateWorkflowAudit(audit, inputs = {}) {
50916
51170
  evidence: [{ tool: "audit_workflows", detail: `${s.shape_warnings_total ?? shapeCount} steps` }]
50917
51171
  });
50918
51172
  }
51173
+ const blockerCount = s.workflows_with_save_blocker_warnings ?? 0;
51174
+ if (blockerCount > 0) {
51175
+ const rows2 = audit.save_blocker_warnings ?? [];
51176
+ const names = rows2.map((r) => r.workflowName ?? "").filter(Boolean);
51177
+ const cats = new Set(rows2.flatMap((r) => (r.warnings ?? []).map((w) => w.category ?? "")).filter(Boolean));
51178
+ const editBlocked = cats.has("legacy_step_id") || cats.has("legacy_wait_unit");
51179
+ const alertsNobody = cats.has("missing_required_attribute");
51180
+ const unknownKind = !editBlocked && !alertsNobody;
51181
+ const editSentence = ` Some of their steps were built before GoHighLevel tightened what it accepts. The automation runs exactly as it always has; what breaks is CHANGING it \u2014 the save is refused, and the refusal names steps nobody touched, so it reads as unrelated to the edit that triggered it.`;
51182
+ const alertSentence = ` One or more alert steps are set to notify a specific person with nobody chosen. That step saves, publishes and runs, and simply never reaches anyone \u2014 so the automation looks healthy while the person waiting on the alert hears nothing.`;
51183
+ out.push({
51184
+ id: "steps-that-block-saving",
51185
+ title: alertsNobody && !editBlocked ? `${blockerCount} automation${blockerCount === 1 ? " sends" : "s send"} an alert that reaches nobody` : editBlocked && !alertsNobody ? `${blockerCount} automation${blockerCount === 1 ? " cannot" : "s cannot"} be changed without the save being refused` : `${blockerCount} automation${blockerCount === 1 ? " carries a step" : "s carry steps"} that block changes, or alert nobody`,
51186
+ what: `${nameList(names, blockerCount)} ${blockerCount === 1 ? "has" : "have"} at least one step of this kind.` + (editBlocked || unknownKind ? editSentence : "") + (alertsNobody || unknownKind ? alertSentence : ""),
51187
+ severity: "high",
51188
+ impact: alertsNobody && !editBlocked ? "leads" : "efficiency",
51189
+ money: { confidence: "unknown", assumptions: ["Not costed: what this costs depends on which automation and who was waiting on the alert, and we would rather say so than guess."] },
51190
+ remedy: alertsNobody && !editBlocked ? "Pick the person each alert should go to, or set it to notify everyone. Then re-run this check." : editBlocked && !alertsNobody ? "Rebuild those steps so GoHighLevel will accept a save again \u2014 nobody part-way through an automation can be moved while that is done. Then re-run this check." : "Rebuild those steps so GoHighLevel will accept a save again \u2014 nobody part-way through an automation can be moved while that is done \u2014 and pick a person for any alert with nobody selected. Then re-run this check.",
51191
+ fix: "guided",
51192
+ minutes: Math.max(10, blockerCount * 8),
51193
+ evidence: [{ tool: "audit_workflows", detail: `${s.save_blocker_warnings_total ?? blockerCount} steps across ${blockerCount} workflows` }]
51194
+ });
51195
+ }
51196
+ const integrationCount = s.workflows_with_integration_warnings ?? 0;
51197
+ if (integrationCount > 0) {
51198
+ const names = (audit.integration_warnings ?? []).map((r) => r.workflowName ?? "").filter(Boolean);
51199
+ out.push({
51200
+ id: "steps-without-their-account",
51201
+ title: `${integrationCount} automation${integrationCount === 1 ? " has a step" : "s have steps"} waiting on a social account that is not connected`,
51202
+ what: `${nameList(names, integrationCount)} include a step that messages or replies through a social account this location has not connected. The automation saves, publishes and runs; that step simply does nothing when it is reached, and nothing in the history says so.`,
51203
+ severity: "high",
51204
+ impact: "leads",
51205
+ money: { confidence: "unknown", assumptions: ["Not costed: it depends how many people reach that step, which we cannot read."] },
51206
+ remedy: "Connect the account in Settings, then re-run this check. (A connected account with no messaging permission fails the same way and this check cannot see it, so send one test message through the step as well.)",
51207
+ fix: "manual",
51208
+ minutes: Math.max(10, integrationCount * 5),
51209
+ evidence: [{ tool: "audit_workflows", detail: `${s.integration_warnings_total ?? integrationCount} steps` }]
51210
+ });
51211
+ }
50919
51212
  return out;
50920
51213
  }
50921
51214
  function notCheckedFrom(audit) {
@@ -51655,6 +51948,15 @@ function publishNote(r) {
51655
51948
  if (r.publishFailed.length) {
51656
51949
  const lead = r.published.length ? `Auto-published ${r.published.length} workflow(s) live, but ` : "";
51657
51950
  const detail = r.publishFailed.map((p) => `${p.name} (${p.error})`).join("; ");
51951
+ const inert = r.publishFailed.filter((p) => p.kind === "inert"), unverified = r.publishFailed.filter((p) => p.kind === "not_verified");
51952
+ const drafts = r.publishFailed.filter((p) => p.kind !== "inert" && p.kind !== "not_verified");
51953
+ if (!drafts.length) {
51954
+ const parts = [
51955
+ inert.length ? `${inert.length} workflow(s) PUBLISHED but with every trigger still inactive, so nothing enrols contacts into them: ${inert.map((p) => `${p.name} (${p.error})`).join("; ")}. Open each in GHL and check its trigger.` : "",
51956
+ unverified.length ? `${unverified.length} workflow(s) had the publish accepted but it could not be confirmed: ${unverified.map((p) => `${p.name} (${p.error})`).join("; ")}. Open each in GHL and check it shows Published before reporting it as running.` : ""
51957
+ ].filter(Boolean).join(" ");
51958
+ return `${lead}${parts}${r.published.length ? " Any gated workflow stays DRAFT until its handoff is met." : ""}`;
51959
+ }
51658
51960
  return `${lead}${r.publishFailed.length} workflow(s) were BUILT BUT COULD NOT BE PUBLISHED. They exist in GHL as DRAFT and are NOT running: ${detail}. You already opted in, so re-running with publishWorkflows will not help. If GHL rejected the workflow's contents, publishing by hand will hit the SAME error until the cause is fixed. Read the error above, fix it, then publish (see nextManualSteps).${r.published.length ? " Any gated workflow stays DRAFT until its handoff is met." : ""}`;
51659
51961
  }
51660
51962
  if (r.published.length) {
@@ -51668,6 +51970,8 @@ function publishNote(r) {
51668
51970
  function publishSummaryClause(r) {
51669
51971
  if (r.publishFailed.length) {
51670
51972
  const also = r.published.length ? ` (${r.published.length} did publish)` : "";
51973
+ const drafts = r.publishFailed.filter((p) => p.kind !== "inert" && p.kind !== "not_verified").length;
51974
+ if (!drafts) return ` ${r.publishFailed.length} workflow(s) published but NOT confirmed running${also} (inactive trigger or unconfirmed publish), see publishNote.`;
51671
51975
  return ` ${r.publishFailed.length} workflow(s) BUILT BUT NOT PUBLISHED${also}. They are DRAFT and not running, see publishNote.`;
51672
51976
  }
51673
51977
  if (r.published.length) return ` Published ${r.published.length} workflow(s) live.`;
@@ -52864,8 +53168,14 @@ async function executeBackbone(plan, deps, opts = {}) {
52864
53168
  }
52865
53169
  if (opts.publishWorkflows && !opts.gatedWorkflowRefs?.has(wf.ref)) {
52866
53170
  try {
52867
- await deps.publishWorkflow(wfId);
52868
- published.push(wf.ref);
53171
+ const verdict = publishVerdict(await deps.publishWorkflow(wfId));
53172
+ if (!verdict.live) {
53173
+ publishFailed.push({ ref: wf.ref, name: wf.name, error: verdict.reason, kind: verdict.kind });
53174
+ manual.push({ ref: wf.ref, type: "workflow-publish", name: wf.name, reason: `[${wf.name}] ${verdict.kind === "inert" ? `published, but every trigger on it still reads inactive, so nothing enrols contacts into it \u2014 open the workflow in GHL and check its trigger (${verdict.reason})` : verdict.kind === "not_verified" ? `the publish was accepted but could not be confirmed (${verdict.reason}) \u2014 open the workflow in GHL and check it shows Published` : `built successfully but not live (${verdict.reason}) \u2014 publish it manually in GHL`}.` });
53175
+ } else {
53176
+ published.push(wf.ref);
53177
+ if (verdict.note) manual.push({ ref: wf.ref, type: "workflow-trigger", name: wf.name, reason: `[${wf.name}] published, but ${verdict.note}` });
53178
+ }
52869
53179
  } catch (e) {
52870
53180
  publishFailed.push({ ref: wf.ref, name: wf.name, error: msg2(e) });
52871
53181
  manual.push({ ref: wf.ref, type: "workflow-publish", name: wf.name, reason: `[${wf.name}] built successfully but auto-publish failed (${msg2(e)}) \u2014 publish it manually in GHL.` });
@@ -52985,6 +53295,21 @@ async function createWithReadback(create, read2, name, beforeIds, poll, wait) {
52985
53295
  function msg2(e) {
52986
53296
  return accountErrorText(e);
52987
53297
  }
53298
+ function publishVerdict(outcome) {
53299
+ if (typeof outcome !== "object" || outcome === null) return { live: true };
53300
+ const check = outcome.publish_check;
53301
+ if (typeof check !== "object" || check === null) return { live: true };
53302
+ const c = check;
53303
+ const warning = typeof c.warning === "string" && c.warning ? c.warning : "";
53304
+ if (warning.startsWith("NOT VERIFIED")) return { live: false, kind: "not_verified", reason: warning };
53305
+ if (warning.startsWith("TRIGGER NOT ACTIVE")) {
53306
+ const active = typeof c.triggers_active === "number" ? c.triggers_active : 0;
53307
+ return active > 0 ? { live: true, note: warning } : { live: false, kind: "inert", reason: warning };
53308
+ }
53309
+ if (c.published === true) return { live: true };
53310
+ const status = typeof c.status_returned === "string" ? c.status_returned : "(GHL returned no status)";
53311
+ return { live: false, kind: "not_live", reason: warning || `NOT LIVE. The workflow read back as ${status} rather than "published", so it is not running.` };
53312
+ }
52988
53313
  var norm5, sleep5, PREVIEW_SNIPPET_LENGTH, JournalHookError;
52989
53314
  var init_execute = __esm({
52990
53315
  "src/intake-to-build/execute.ts"() {
@@ -53549,9 +53874,12 @@ function makeExecuteDeps(client, builderClient, locationId2, registry2) {
53549
53874
  deleteWorkflow: async (workflowId) => {
53550
53875
  await builderClient.deleteWorkflow(workflowId);
53551
53876
  },
53552
- publishWorkflow: async (workflowId) => {
53553
- await builderClient.publishWorkflow(workflowId);
53554
- }
53877
+ // The result is RETURNED, not awaited-and-dropped. It carries the client's
53878
+ // own `publish_check` — the status read back from the SAVED workflow after
53879
+ // the write, with a NOT LIVE / NOT VERIFIED sentence when the publish did
53880
+ // not land. Discarding it is what let a workflow left in Draft be counted
53881
+ // into "Published N workflow(s) live"; the executor now reads it.
53882
+ publishWorkflow: (workflowId) => builderClient.publishWorkflow(workflowId)
53555
53883
  };
53556
53884
  }
53557
53885
  function makeRevertDeps(client, builderClient, locationId2) {
@@ -53759,7 +54087,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53759
54087
  if (saved2?.wholePreset) {
53760
54088
  scopeSource = "cockpit";
53761
54089
  } else if (!saved2 && Array.isArray(board.assessmentPurchases?.[activeLocation])) {
53762
- return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: NOT_CONFIRMED_SCOPE });
54090
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(activeLocation) });
53763
54091
  } else if (saved2 && Array.isArray(saved2.features)) {
53764
54092
  const features = saved2.features.filter(isFeatureId);
53765
54093
  if (!features.length) {
@@ -53817,11 +54145,11 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53817
54145
  if (!gate2.ok) return jsonResponse({ ok: false, phase: "plan", error: gate2.error, savedPlan: saved ? { planId: saved.planId, savedAt: saved.savedAt } : void 0 });
53818
54146
  const plan = gate2.plan;
53819
54147
  const planSource = gate2.source;
53820
- if (planSource === "saved") {
54148
+ if (!fromPreset) {
53821
54149
  const board = readBoardState();
53822
54150
  const current = board.scope?.[activeLocation];
53823
54151
  if (!current && Array.isArray(board.assessmentPurchases?.[activeLocation])) {
53824
- return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: NOT_CONFIRMED_SCOPE });
54152
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(activeLocation) });
53825
54153
  }
53826
54154
  if (current && !current.wholePreset && Array.isArray(current.features) && !current.features.some(isFeatureId)) {
53827
54155
  return jsonResponse({
@@ -53831,7 +54159,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53831
54159
  error: "No features are selected for this client, so nothing was built, not even from the saved plan. Open the client's Review screen, tick the features to build, confirm, and run the build again."
53832
54160
  });
53833
54161
  }
53834
- if (!savedPlanMatchesScope(saved, current)) {
54162
+ if (saved && planSource !== "argument-replaces-saved" && !savedPlanMatchesScope(saved, current)) {
53835
54163
  const planned = saved?.scope ? [...saved.scope.built, ...(saved.scope.omitted ?? []).map((f) => f.id)].join(", ") || "no features" : "everything in the preset";
53836
54164
  const now = current?.wholePreset ? "everything in the preset (confirmed)" : current ? current.features.filter(isFeatureId).join(", ") : "everything in the preset (nothing is saved)";
53837
54165
  return jsonResponse({
@@ -54523,7 +54851,7 @@ function intakeFormMatches(expected, actual) {
54523
54851
  const keys = ["id", "tag", "type", "hiddenFieldQueryKey", "required", "label", "options", "picklistOptions"];
54524
54852
  return expected.every((field, i) => keys.every((key) => field[key] === void 0 || JSON.stringify(field[key]) === JSON.stringify(actual[i]?.[key])));
54525
54853
  }
54526
- var import_zod81, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE;
54854
+ var import_zod81, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, notConfirmedAsk;
54527
54855
  var init_intake_to_build = __esm({
54528
54856
  "src/tools/intake-to-build.ts"() {
54529
54857
  "use strict";
@@ -54570,6 +54898,12 @@ var init_intake_to_build = __esm({
54570
54898
  }).passthrough();
54571
54899
  sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
54572
54900
  NOT_CONFIRMED_SCOPE = "What this client bought is recorded, but nobody has confirmed what to build, so nothing was built. Open the client's Review screen: the features are ticked from what they bought. Change them if you need to, save to confirm, and run the build again.";
54901
+ WHOLE_PRESET_NOT_KEPT = "Note: an earlier build on this client used everything in the preset, and that choice was not kept, so the Review screen ticks only what they bought. Tick everything again if that is still what you want.";
54902
+ notConfirmedAsk = (locationId2) => {
54903
+ const sp = readSavedPlan(locationId2);
54904
+ const fromPreset = typeof sp?.plan?.preset === "string";
54905
+ return sp && !sp.scope && fromPreset ? `${NOT_CONFIRMED_SCOPE} ${WHOLE_PRESET_NOT_KEPT}` : NOT_CONFIRMED_SCOPE;
54906
+ };
54573
54907
  }
54574
54908
  });
54575
54909
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.84.1",
3
+ "version": "3.84.2",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
5
  "description": "GoHighLevel MCP Server for Claude. 250 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",