@elitedcs/ghl-mcp 3.84.0 → 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/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",
@@ -9483,12 +9584,24 @@ function boundNumbers(agent) {
9483
9584
  }
9484
9585
  return [...out];
9485
9586
  }
9587
+ function hasSingleNumberField(agent) {
9588
+ const a = isRecord7(agent) ? agent : {};
9589
+ return a.inboundNumber !== null && a.inboundNumber !== void 0 && a.inboundNumber !== "";
9590
+ }
9591
+ function routedNumbers(agent) {
9592
+ const a = isRecord7(agent) ? agent : {};
9593
+ if (hasSingleNumberField(a) || !Array.isArray(a.inboundNumbers)) return [];
9594
+ return [...new Set(a.inboundNumbers.filter((n) => typeof n === "string" && n.length > 0))];
9595
+ }
9596
+ function unprovenNumbers(agent) {
9597
+ return hasSingleNumberField(agent) ? boundNumbers(agent) : [];
9598
+ }
9486
9599
  function poolBinding(agent) {
9487
9600
  const a = isRecord7(agent) ? agent : {};
9488
9601
  return typeof a.numberPoolId === "string" && a.numberPoolId.trim() ? a.numberPoolId : null;
9489
9602
  }
9490
9603
  function isLiveBound(agent) {
9491
- return boundNumbers(agent).length > 0 || poolBinding(agent) !== null;
9604
+ return routedNumbers(agent).length > 0 || poolBinding(agent) !== null;
9492
9605
  }
9493
9606
  function inBackupRota(agent) {
9494
9607
  return isRecord7(agent) && agent.isAgentAsBackupDisabled === false;
@@ -9498,7 +9611,7 @@ function canTakeCall(agent) {
9498
9611
  }
9499
9612
  function liveDescription(agent) {
9500
9613
  const parts = [];
9501
- const nums = boundNumbers(agent);
9614
+ const nums = routedNumbers(agent);
9502
9615
  if (nums.length) parts.push(`answers on ${nums.join(", ")}`);
9503
9616
  const pool = poolBinding(agent);
9504
9617
  if (pool) parts.push(`answers through pool ${pool}`);
@@ -9506,7 +9619,7 @@ function liveDescription(agent) {
9506
9619
  return parts.length ? parts.join(", and ") : null;
9507
9620
  }
9508
9621
  function agentWriteDifferences(sent, stored) {
9509
- return Object.keys(sent).filter((k) => k !== "locationId" && k !== "inboundNumber" && k !== "numberPoolId" && !sentSubsetOfStored(sent[k], stored[k])).sort();
9622
+ return Object.keys(sent).filter((k) => k !== "locationId" && k !== "inboundNumber" && k !== "inboundNumbers" && k !== "numberPoolId" && !sentSubsetOfStored(sent[k], stored[k])).sort();
9510
9623
  }
9511
9624
  function poolNumbers(pool) {
9512
9625
  if (!isRecord7(pool)) return [];
@@ -9778,7 +9891,12 @@ function registerVoiceAiTools(server2, shared) {
9778
9891
  const { numbers, pools, poolObjects } = await readNumbers(client, loc);
9779
9892
  const agents = await listAllVoiceAgents(client, loc);
9780
9893
  const holders = /* @__PURE__ */ new Map();
9781
- for (const a of agents) for (const n of boundNumbers(a)) holders.set(n, { agentId: String(a.id), agentName: String(a.agentName), via: "inboundNumber" });
9894
+ for (const a of agents) for (const n of routedNumbers(a)) holders.set(n, { agentId: String(a.id), agentName: String(a.agentName), via: "inboundNumber" });
9895
+ const unproven = agents.filter((a) => unprovenNumbers(a).length > 0).map((a) => {
9896
+ const nums = unprovenNumbers(a);
9897
+ return { agentId: String(a.id), agentName: String(a.agentName), numbers: nums, stored: { inboundNumber: a.inboundNumber, inboundNumbers: a.inboundNumbers }, fix: UNPROVEN_BINDING_FIX(String(a.agentName), nums) };
9898
+ });
9899
+ const heldUnproven = new Set(unproven.flatMap((u) => u.numbers));
9782
9900
  const poolBound = agents.filter((a) => poolBinding(a) !== null).map((a) => {
9783
9901
  const pool = poolBinding(a);
9784
9902
  const obj = poolObjects.find((p) => poolId(p) === pool);
@@ -9786,12 +9904,16 @@ function registerVoiceAiTools(server2, shared) {
9786
9904
  for (const n of held) if (!holders.has(n)) holders.set(n, { agentId: String(a.id), agentName: String(a.agentName), via: `pool ${pool}` });
9787
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" };
9788
9906
  });
9789
- 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;
9790
9910
  const numberRows = numbers.map((n) => ({ number: n, answeredBy: holders.get(n) ?? null }));
9791
- const free = unresolvedPool ? [] : numberRows.filter((r) => !r.answeredBy).map((r) => r.number);
9911
+ const free = unresolvedPool ? [] : numberRows.filter((r) => !r.answeredBy && !heldUnproven.has(r.number)).map((r) => r.number);
9792
9912
  const rota = agents.filter(inBackupRota).map((a) => ({ agentId: String(a.id), agentName: String(a.agentName) }));
9793
- const ready = numberRows.some((r) => r.answeredBy) || poolBound.length > 0;
9794
- 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` : ""}.` : `NUMBER PRESENT, NO AGENT BOUND BY API: ${numbers.length} number(s), none carried as inboundNumber or 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.`;
9913
+ const ready = numberRows.some((r) => r.answeredBy) || poolRouting.length > 0;
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.` : "";
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.`;
9795
9917
  return {
9796
9918
  locationId: loc,
9797
9919
  verdict,
@@ -9800,10 +9922,11 @@ function registerVoiceAiTools(server2, shared) {
9800
9922
  freeNumbersNote: unresolvedPool ? "No number is called free: at least one agent answers through a pool whose numbers this API does not list. Check the pool in GoHighLevel before binding anything." : void 0,
9801
9923
  pools,
9802
9924
  poolBoundAgents: poolBound,
9925
+ ...unproven.length ? { unprovenBindings: unproven } : {},
9803
9926
  backupRotaAgents: rota,
9804
- agents: agents.map((a) => ({ id: a.id, agentName: a.agentName, businessName: a.businessName, answersOn: boundNumbers(a), pool: poolBinding(a), inBackupRota: inBackupRota(a), canTakeCall: canTakeCall(a), language: a.language, maxCallDuration: a.maxCallDuration, actions: Array.isArray(a.actions) ? a.actions.length : 0 })),
9927
+ agents: agents.map((a) => ({ id: a.id, agentName: a.agentName, businessName: a.businessName, answersOn: routedNumbers(a), ...unprovenNumbers(a).length ? { heldNotProvenToRoute: unprovenNumbers(a) } : {}, pool: poolBinding(a), inBackupRota: inBackupRota(a), canTakeCall: canTakeCall(a), language: a.language, maxCallDuration: a.maxCallDuration, actions: Array.isArray(a.actions) ? a.actions.length : 0 })),
9805
9928
  manualTask: numbers.length === 0 ? NO_NUMBER_TASK : void 0,
9806
- note: "This reads what the API exposes: each agent's inboundNumber(s) and numberPoolId, and whether it sits in the backup rota (isAgentAsBackupDisabled false). Two things it cannot see: routing set on the number itself in GoHighLevel's Phone Numbers screen, and A2P/SHAKEN-STIR status in Trust Center \u2014 check both there before promising real caller traffic. One real account seen while this was built answers calls with agents that carry no inboundNumber by API (read 2026-09-04)."
9929
+ note: "This reads what the API exposes: each agent's inboundNumbers and numberPoolId, and whether it sits in the backup rota (isAgentAsBackupDisabled false). A number counts as answered only when it is in an agent's inboundNumbers while that agent's inboundNumber is null, the shape GoHighLevel's Deploy tab stores and the one that routed a real call (2026-09-15); a number carried in inboundNumber did not. Two things it cannot see: routing set on the number itself in GoHighLevel's Phone Numbers screen, and A2P/SHAKEN-STIR status in Trust Center \u2014 check both there before promising real caller traffic. One real account seen while this was built answers calls with agents that carry no inboundNumber by API (read 2026-09-04)."
9807
9930
  };
9808
9931
  }
9809
9932
  );
@@ -9839,7 +9962,9 @@ function registerVoiceAiTools(server2, shared) {
9839
9962
  const loc = client.resolveLocationId(locationId2);
9840
9963
  const agent = await readVoiceAgent(client, loc, agentId);
9841
9964
  const live = liveDescription(agent);
9842
- return { agent, answersOn: boundNumbers(agent), pool: poolBinding(agent), inBackupRota: inBackupRota(agent), note: live ? `This agent can take calls: it ${live}.` : UNBOUND_NOTE };
9965
+ const unproven = unprovenNumbers(agent);
9966
+ const unprovenNote = unproven.length ? `Its number binding is not proven to route: ${UNPROVEN_BINDING_FIX(String(agent.agentName), unproven)}` : "";
9967
+ return { agent, answersOn: routedNumbers(agent), ...unproven.length ? { heldNotProvenToRoute: unproven } : {}, pool: poolBinding(agent), inBackupRota: inBackupRota(agent), note: live ? `This agent can take calls: it ${live}.${unprovenNote ? ` ${unprovenNote}` : ""}` : unprovenNote || UNBOUND_NOTE };
9843
9968
  }
9844
9969
  );
9845
9970
  safeTool(
@@ -9852,11 +9977,12 @@ function registerVoiceAiTools(server2, shared) {
9852
9977
  businessName: import_zod37.z.string().min(1).describe("The business it answers for, as it should say it."),
9853
9978
  ...(({ businessName: _b, isAgentAsBackupDisabled: _k, ...rest }) => rest)(AGENT_FIELDS),
9854
9979
  inboundNumber: import_zod37.z.string().nullable().optional().describe("REFUSED here. Creation never binds a number; create, read the agent, then bind with update_voice_agent."),
9980
+ inboundNumbers: import_zod37.z.array(import_zod37.z.string()).optional().describe("REFUSED here, like inboundNumber. Creation never binds a number; bind with update_voice_agent afterwards."),
9855
9981
  numberPoolId: import_zod37.z.string().nullable().optional().describe("REFUSED here. Creation never binds a pool; use update_voice_agent afterwards."),
9856
9982
  isAgentAsBackupDisabled: import_zod37.z.boolean().optional().describe("Always true at creation. false is REFUSED here; let the agent into the backup rota with update_voice_agent once you have read it.")
9857
9983
  },
9858
- async ({ locationId: locationId2, inboundNumber, numberPoolId, isAgentAsBackupDisabled, ...fields }) => {
9859
- if (inboundNumber !== void 0 || numberPoolId !== void 0) throw new Error("create_voice_agent never binds a number or a number pool \u2014 an agent is born non-live so nothing half-built can take a call. Create it, read it with get_voice_agent, then bind with update_voice_agent (inboundNumber or numberPoolId), which checks the number is this sub-account's and free.");
9984
+ async ({ locationId: locationId2, inboundNumber, inboundNumbers, numberPoolId, isAgentAsBackupDisabled, ...fields }) => {
9985
+ if (inboundNumber !== void 0 || inboundNumbers !== void 0 || numberPoolId !== void 0) throw new Error("create_voice_agent never binds a number or a number pool \u2014 an agent is born non-live so nothing half-built can take a call. Create it, read it with get_voice_agent, then bind with update_voice_agent (inboundNumber or numberPoolId), which checks the number is this sub-account's and free.");
9860
9986
  if (isAgentAsBackupDisabled === false) throw new Error("create_voice_agent always keeps the new agent OUT of the backup rota (isAgentAsBackupDisabled true). Let it in with update_voice_agent once you have read the agent.");
9861
9987
  const client = shared.pinned();
9862
9988
  const loc = client.resolveLocationId(locationId2);
@@ -9864,6 +9990,7 @@ function registerVoiceAiTools(server2, shared) {
9864
9990
  resolveResponsiveness(body);
9865
9991
  if (body.maxCallDuration === void 0) body.maxCallDuration = DEFAULT_MAX_CALL_SECONDS;
9866
9992
  delete body.inboundNumber;
9993
+ delete body.inboundNumbers;
9867
9994
  delete body.numberPoolId;
9868
9995
  body.isAgentAsBackupDisabled = true;
9869
9996
  const problem = agentBodyProblem(body);
@@ -9899,13 +10026,13 @@ function registerVoiceAiTools(server2, shared) {
9899
10026
  safeTool(
9900
10027
  server2,
9901
10028
  "update_voice_agent",
9902
- "Change any part of a Voice AI agent, including the two things creation never does: bind a number (inboundNumber, or numberPoolId) and let it into the backup rota (isAgentAsBackupDisabled: false). Send only what changes: GoHighLevel applies it as a partial update and the rest survives (read back and diffed here). A number must be this sub-account's and free; inboundNumber: null unbinds. Post-call workflows must be published. Returns what changed, the fields that differ before \u2192 after, and the agent as it now reads.",
10029
+ "Change any part of a Voice AI agent, including the two things creation never does: bind a number (inboundNumber, or numberPoolId) and let it into the backup rota (isAgentAsBackupDisabled: false). Send only what changes: GoHighLevel applies it as a partial update and the rest survives (read back and diffed here). A number must be this sub-account's and free. The number is written to the agent's inboundNumbers list with inboundNumber cleared, the shape GoHighLevel's Deploy tab stores and the one that routed a real call; it is VERIFIED only when it reads back that way. inboundNumber: null unbinds every number. Re-binding an agent's own number repairs a binding check_voice_ai_readiness calls not proven to route. Post-call workflows must be published. Returns what changed, the fields that differ before \u2192 after, and the agent as it now reads.",
9903
10030
  {
9904
10031
  agentId: import_zod37.z.string().describe("Agent id."),
9905
10032
  locationId: LOCATION,
9906
10033
  agentName: import_zod37.z.string().min(1).max(40).optional(),
9907
10034
  ...AGENT_FIELDS,
9908
- inboundNumber: import_zod37.z.string().nullable().optional().describe("Bind a number this sub-account owns that no other agent answers on; null unbinds."),
10035
+ inboundNumber: import_zod37.z.string().nullable().optional().describe("Bind a number this sub-account owns that no other agent answers on. Stored in the agent's inboundNumbers list beside any it already answers on, with GoHighLevel's single inboundNumber field cleared. null unbinds every number."),
9909
10036
  numberPoolId: import_zod37.z.string().nullable().optional().describe("Bind a number pool; null unbinds.")
9910
10037
  },
9911
10038
  async ({ agentId, locationId: locationId2, inboundNumber, numberPoolId, ...fields }) => {
@@ -9913,10 +10040,18 @@ function registerVoiceAiTools(server2, shared) {
9913
10040
  const loc = client.resolveLocationId(locationId2);
9914
10041
  const change = dropUndefined({ ...fields });
9915
10042
  resolveResponsiveness(change);
9916
- if (inboundNumber !== void 0) change.inboundNumber = inboundNumber;
9917
10043
  if (numberPoolId !== void 0) change.numberPoolId = numberPoolId;
9918
- if (Object.keys(change).length === 0) throw new Error("Nothing to update \u2014 pass at least one field to change.");
10044
+ if (Object.keys(change).length === 0 && inboundNumber === void 0) throw new Error("Nothing to update \u2014 pass at least one field to change.");
9919
10045
  const before = await readVoiceAgent(client, loc, agentId);
10046
+ const bindNotes = [];
10047
+ if (typeof inboundNumber === "string") {
10048
+ const current = Array.isArray(before.inboundNumbers) ? before.inboundNumbers.filter((n) => typeof n === "string" && n.length > 0) : [];
10049
+ change.inboundNumbers = [.../* @__PURE__ */ new Set([...current, inboundNumber])];
10050
+ if (hasSingleNumberField(before)) change.inboundNumber = null;
10051
+ } else if (inboundNumber === null) {
10052
+ change.inboundNumber = null;
10053
+ change.inboundNumbers = [];
10054
+ }
9920
10055
  const merged = { ...change };
9921
10056
  if (change.language !== void 0 || change.translation !== void 0) {
9922
10057
  if (merged.language === void 0 && typeof before.language === "string") merged.language = before.language;
@@ -9935,8 +10070,26 @@ function registerVoiceAiTools(server2, shared) {
9935
10070
  const { numbers, pools, poolObjects } = await readNumbers(client, loc);
9936
10071
  if (typeof inboundNumber === "string") {
9937
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);
9938
- 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);
9939
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;
9940
10093
  }
9941
10094
  if (typeof numberPoolId === "string") {
9942
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).");
@@ -9961,18 +10114,22 @@ function registerVoiceAiTools(server2, shared) {
9961
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")] };
9962
10115
  const after = rb.agent;
9963
10116
  const changed = diffVoiceAgentSnapshots(before, after);
9964
- const notes = [];
10117
+ const notes = [...bindNotes];
9965
10118
  const differs = agentWriteDifferences(change, after);
9966
10119
  for (const k of differs) notes.push(`${k}: sent ${JSON.stringify(change[k])}, reads back ${JSON.stringify(after[k])}.`);
9967
10120
  let asserted2 = differs.length > 0;
9968
10121
  if (typeof inboundNumber === "string" && !boundNumbers(after).includes(inboundNumber)) {
9969
10122
  asserted2 = true;
9970
10123
  notes.push(`ASSERTED ONLY: ${inboundNumber} was sent but the read-back does not show it bound.`);
10124
+ } else if (typeof inboundNumber === "string" && !routedNumbers(after).includes(inboundNumber)) {
10125
+ asserted2 = true;
10126
+ notes.push(`ASSERTED ONLY: ${inboundNumber} reads back, but not in the shape proven to route a call (inboundNumber ${JSON.stringify(after.inboundNumber)}, inboundNumbers ${JSON.stringify(after.inboundNumbers)}). Check the agent's Deploy tab in GoHighLevel before telling anyone it answers.`);
9971
10127
  }
9972
10128
  if (inboundNumber === null && boundNumbers(after).length) {
9973
10129
  asserted2 = true;
9974
10130
  notes.push(`ASSERTED ONLY: unbind was sent but the read-back still shows ${boundNumbers(after).join(", ")}.`);
9975
10131
  }
10132
+ if (inboundNumber === void 0 && unprovenNumbers(after).length) notes.push(`Its number binding is not proven to route: ${UNPROVEN_BINDING_FIX(String(after.agentName), unprovenNumbers(after))}`);
9976
10133
  if (typeof numberPoolId === "string" && poolBinding(after) !== numberPoolId) {
9977
10134
  asserted2 = true;
9978
10135
  notes.push(`ASSERTED ONLY: number pool ${numberPoolId} was sent but the read-back shows ${JSON.stringify(after.numberPoolId)}.`);
@@ -9981,7 +10138,7 @@ function registerVoiceAiTools(server2, shared) {
9981
10138
  asserted2 = true;
9982
10139
  notes.push(`ASSERTED ONLY: pool unbind was sent but the read-back still shows pool ${poolBinding(after)}.`);
9983
10140
  }
9984
- const bound = boundNumbers(after);
10141
+ const bound = routedNumbers(after);
9985
10142
  const live = liveDescription(after);
9986
10143
  if (change.isAgentAsBackupDisabled === false && inBackupRota(after)) notes.push("This agent is now in the backup rota: GoHighLevel may hand it unanswered calls on this sub-account's numbers even with nothing bound.");
9987
10144
  if (!canTakeCall(after)) notes.push(UNBOUND_NOTE);
@@ -10191,7 +10348,7 @@ function registerVoiceAiTools(server2, shared) {
10191
10348
  }
10192
10349
  );
10193
10350
  }
10194
- var import_zod37, VOICE_LANGUAGES, PATIENCE_LEVELS, VOICE_ACTION_TYPES, PATIENCE_TO_RESPONSIVENESS, ACTION_NAME, TRANSFER_NUMBER, IANA_TZ, MAX_CALL_SECONDS, DEFAULT_MAX_CALL_SECONDS, VOICE_AGENT_COMPARABLE, READBACK_FAILED, AGENT_PAGE_SIZE, AGENT_PAGE_CAP, NO_NUMBER_TASK, WorkingHours, PostCallNotify, Translation, AGENT_FIELDS;
10351
+ var import_zod37, VOICE_LANGUAGES, PATIENCE_LEVELS, VOICE_ACTION_TYPES, PATIENCE_TO_RESPONSIVENESS, ACTION_NAME, TRANSFER_NUMBER, IANA_TZ, MAX_CALL_SECONDS, DEFAULT_MAX_CALL_SECONDS, VOICE_AGENT_COMPARABLE, UNPROVEN_BINDING_FIX, READBACK_FAILED, AGENT_PAGE_SIZE, AGENT_PAGE_CAP, NO_NUMBER_TASK, WorkingHours, PostCallNotify, Translation, AGENT_FIELDS;
10195
10352
  var init_voice_ai = __esm({
10196
10353
  "src/tools/voice-ai.ts"() {
10197
10354
  "use strict";
@@ -10228,6 +10385,7 @@ var init_voice_ai = __esm({
10228
10385
  "isAgentAsBackupDisabled",
10229
10386
  "translation"
10230
10387
  ];
10388
+ UNPROVEN_BINDING_FIX = (agentName, numbers) => `"${agentName}" carries ${numbers.join(", ")} in a shape GoHighLevel did not route to the agent when tested (a call went to voicemail, 2026-09-15). Rebind it with update_voice_agent (inboundNumber "${numbers[0]}"), or connect the number in the agent's Deploy tab in GoHighLevel, then run check_voice_ai_readiness again.`;
10231
10389
  READBACK_FAILED = (what, id, error) => `GoHighLevel accepted the ${what} (${id}) but the read after it failed: ${error.slice(0, 200)}. The write is ASSERTED, not proven. Do NOT repeat the write \u2014 read get_voice_agent first.`;
10232
10390
  AGENT_PAGE_SIZE = 50;
10233
10391
  AGENT_PAGE_CAP = 100;
@@ -11584,8 +11742,10 @@ ${await response.text()}`);
11584
11742
  async ({ pageId, content, funnelId, isPublished }) => {
11585
11743
  try {
11586
11744
  let meta = {};
11745
+ let preReadOk = false;
11587
11746
  try {
11588
11747
  meta = await funnelRequest("GET", `/page/${pageId}?locationId=${client.locationId}`);
11748
+ preReadOk = true;
11589
11749
  } catch (metaErr) {
11590
11750
  if (!funnelId) throw metaErr;
11591
11751
  }
@@ -11621,14 +11781,24 @@ ${await response.text()}`);
11621
11781
  }
11622
11782
  const afterVersion = typeof after.pageVersion === "number" ? after.pageVersion : null;
11623
11783
  const becameVisible = afterUrl !== "" && afterUrl !== beforeUrl;
11624
- save_check = {
11625
- visible: becameVisible,
11626
- pageVersion_sent: pageVersion,
11627
- pageVersion_now: afterVersion,
11628
- ...becameVisible ? {} : {
11629
- 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."
11630
- }
11631
- };
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
+ }
11632
11802
  } catch (verifyErr) {
11633
11803
  save_check = {
11634
11804
  visible: null,
@@ -11773,6 +11943,9 @@ function hexToOklch(hex) {
11773
11943
  if (!c) throw new Error(`not a colour: ${hex}`);
11774
11944
  return rgbToOklch(c);
11775
11945
  }
11946
+ function oklchToHex(o) {
11947
+ return toHex(oklchToRgb(o));
11948
+ }
11776
11949
  function oklchCss(o) {
11777
11950
  return `oklch(${(o.l * 100).toFixed(1)}% ${o.c.toFixed(3)} ${o.h.toFixed(1)})`;
11778
11951
  }
@@ -12182,6 +12355,176 @@ var init_ghl_page = __esm({
12182
12355
  }
12183
12356
  });
12184
12357
 
12358
+ // src/command-os/design/rendered-palette.ts
12359
+ function kindOf(prop) {
12360
+ if (/background|bgcolor/i.test(prop)) return "background";
12361
+ if (/border|outline|shadow/i.test(prop)) return "border";
12362
+ if (prop === "color") return "text";
12363
+ return "other";
12364
+ }
12365
+ function pageVariables(envelope) {
12366
+ const defs = /* @__PURE__ */ new Map();
12367
+ const fromCss = (css2) => {
12368
+ for (const block of css2.matchAll(/:root\s*\{([^}]*)\}/g)) {
12369
+ for (const m of block[1].matchAll(/--([\w-]+)\s*:\s*([^;]+)/g)) if (!defs.has(m[1])) defs.set(m[1], m[2].trim());
12370
+ }
12371
+ };
12372
+ const root = isRecord8(envelope) ? envelope : {};
12373
+ if (typeof root.pageStyles === "string") fromCss(root.pageStyles);
12374
+ const walk = (node) => {
12375
+ if (Array.isArray(node)) return void node.forEach(walk);
12376
+ if (!isRecord8(node)) return;
12377
+ for (const [k, v] of Object.entries(node)) {
12378
+ if (k.startsWith("--") && typeof v === "string" && !defs.has(k.slice(2))) defs.set(k.slice(2), v.trim());
12379
+ else if (typeof v === "string" && v.includes(":root")) fromCss(v);
12380
+ else walk(v);
12381
+ }
12382
+ };
12383
+ walk(root);
12384
+ return defs;
12385
+ }
12386
+ function resolveValue(raw, defs) {
12387
+ let v = raw.trim();
12388
+ for (let i = 0; i < 10; i++) {
12389
+ const m = /^var\(\s*--([\w-]+)\s*(?:,\s*([^)]*))?\)$/.exec(v);
12390
+ if (!m) return v;
12391
+ const next = defs.get(m[1]) ?? m[2];
12392
+ if (next === void 0) return null;
12393
+ v = next.trim();
12394
+ }
12395
+ return null;
12396
+ }
12397
+ function normaliseColour(value) {
12398
+ if (value === null) return null;
12399
+ if (/^transparent$/i.test(value)) return "transparent";
12400
+ const c = parseColor(value);
12401
+ if (!c) return null;
12402
+ if (c.a === 0) return "transparent";
12403
+ if (c.a < 1) return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${Number(c.a.toFixed(3))})`;
12404
+ return toHex(c);
12405
+ }
12406
+ function renderedPalette(envelope) {
12407
+ const defs = pageVariables(envelope);
12408
+ const paints = [];
12409
+ const walk = (node, path40, prop) => {
12410
+ if (Array.isArray(node)) return void node.forEach((x, i) => walk(x, `${path40}[${i}]`, prop));
12411
+ if (isRecord8(node)) {
12412
+ for (const [k, x] of Object.entries(node)) {
12413
+ if (DEFINITION_KEYS.has(k) || k.startsWith("--")) continue;
12414
+ walk(x, `${path40}.${k}`, WRAPPER_KEYS.has(k) ? prop : k);
12415
+ }
12416
+ return;
12417
+ }
12418
+ if (typeof node !== "string") return;
12419
+ const raw = node.trim();
12420
+ if (!/^var\(/.test(raw) && !COLOUR_LITERAL.test(raw)) return;
12421
+ const resolved2 = resolveValue(raw, defs);
12422
+ const colour = normaliseColour(resolved2);
12423
+ if (colour === null && resolved2 !== null) return;
12424
+ paints.push({ path: path40, prop, kind: kindOf(prop), colour });
12425
+ };
12426
+ walk(envelope, "", "");
12427
+ const face = (name, settingsKey) => {
12428
+ const fromVar = resolveValue(`var(--${name})`, defs);
12429
+ if (fromVar) return fromVar.replace(/['"]/g, "").trim() || null;
12430
+ const settings = envelope?.settings?.settings?.typography?.fonts?.[settingsKey];
12431
+ return typeof settings?.value?.text === "string" && settings.value.text.trim() ? settings.value.text.trim() : null;
12432
+ };
12433
+ return { paints, fonts: { headline: face("headlinefont", "headlineFont"), body: face("contentfont", "contentFont") } };
12434
+ }
12435
+ function contrastLosses(before, after) {
12436
+ const afterByPath = new Map(after.paints.map((p) => [p.path, p.colour]));
12437
+ const groups = /* @__PURE__ */ new Map();
12438
+ for (const p of before.paints) {
12439
+ const to = afterByPath.get(p.path);
12440
+ if (p.kind !== "text" && p.kind !== "background" || !p.colour?.startsWith("#") || !to?.startsWith("#")) continue;
12441
+ const g = groups.get(paintGroup(p.path)) ?? { texts: [], backgrounds: [] };
12442
+ (p.kind === "text" ? g.texts : g.backgrounds).push([p.colour, to]);
12443
+ groups.set(paintGroup(p.path), g);
12444
+ }
12445
+ const out = /* @__PURE__ */ new Map();
12446
+ for (const [where, g] of groups) {
12447
+ for (const [t0, t1] of g.texts) for (const [b0, b1] of g.backgrounds) {
12448
+ const was = contrastHex(t0, b0), now = contrastHex(t1, b1);
12449
+ if (was >= 4.5 && now < 4.5 && !out.has(`${t1} ${b1}`)) out.set(`${t1} ${b1}`, { where, text: t1, background: b1, before: Number(was.toFixed(2)), after: Number(now.toFixed(2)) });
12450
+ }
12451
+ }
12452
+ return [...out.values()];
12453
+ }
12454
+ function repaintReport(before, after) {
12455
+ const a = renderedPalette(before), b = renderedPalette(after);
12456
+ const afterByPath = new Map(b.paints.map((p) => [p.path, p]));
12457
+ const changes = /* @__PURE__ */ new Map();
12458
+ const unchanged = /* @__PURE__ */ new Map();
12459
+ const tally = { background: { uses: 0, changed: 0 }, neutral: { uses: 0, changed: 0 }, text: { uses: 0, changed: 0 } };
12460
+ const coloured = (c) => c.startsWith("#") && hexToOklch(c).c >= CARRIES_COLOUR;
12461
+ for (const p of a.paints) {
12462
+ if (!p.colour || p.colour === "transparent") continue;
12463
+ const q2 = afterByPath.get(p.path);
12464
+ const to = q2?.colour ?? null;
12465
+ const differs = to !== null && looksDifferent(p.colour, to);
12466
+ if (p.kind === "background" || p.kind === "text") {
12467
+ const t = p.kind === "text" ? tally.text : coloured(p.colour) ? tally.background : tally.neutral;
12468
+ t.uses++;
12469
+ if (differs) t.changed++;
12470
+ else {
12471
+ const key = `${p.kind} ${p.colour}`;
12472
+ const u = unchanged.get(key) ?? { kind: p.kind, colour: p.colour, uses: 0 };
12473
+ u.uses++;
12474
+ unchanged.set(key, u);
12475
+ }
12476
+ }
12477
+ if (differs) {
12478
+ const key = `${p.kind} ${p.colour} ${to}`;
12479
+ const c = changes.get(key) ?? { kind: p.kind, from: p.colour, to, uses: 0 };
12480
+ c.uses++;
12481
+ changes.set(key, c);
12482
+ }
12483
+ }
12484
+ const byUses = (xs) => [...xs].sort((x, y) => y.uses - x.uses);
12485
+ const changeList = byUses(changes.values());
12486
+ const unchangedList = byUses(unchanged.values());
12487
+ const fonts = { headline: { from: a.fonts.headline, to: b.fonts.headline }, body: { from: a.fonts.body, to: b.fonts.body } };
12488
+ const fontsChanged = fonts.headline.from !== fonts.headline.to || fonts.body.from !== fonts.body.to;
12489
+ const bg = tally.background;
12490
+ const share = bg.uses ? bg.changed / bg.uses : 0;
12491
+ const verdict = changeList.length === 0 && !fontsChanged ? "unchanged" : share < BARELY_SHARE ? "barely" : "repainted";
12492
+ const list2 = (xs, n = 6) => xs.slice(0, n).map((x) => x.colour ? `${x.colour} on ${x.uses}` : `${x.from} to ${x.to} on ${x.uses}`).join(", ");
12493
+ const bgChanges = changeList.filter((c) => c.kind === "background" && coloured(c.from));
12494
+ const bgKept = unchangedList.filter((u) => u.kind === "background" && coloured(u.colour));
12495
+ const faceLine = (role, f) => f.from === f.to ? `${role} still ${f.from ?? "no set face"}` : `${role} ${f.from ?? "no set face"} to ${f.to ?? "no set face"}`;
12496
+ const colourLine = verdict === "unchanged" ? "Nothing the page paints changed: every background, text colour and face is the template's own." : bg.uses === 0 ? "The visible palette barely changed: the template paints no background in a colour, only white, black and greys, so there was little colour to repaint." : verdict === "barely" ? `The visible palette barely changed: ${bg.changed} of ${bg.uses} coloured backgrounds now look different${bgChanges.length ? ` (${list2(bgChanges)})` : ""}.${bgKept.length ? ` Still in the template's own colours: ${list2(bgKept)}.` : ""}` : `Repainted: ${bg.changed} of ${bg.uses} coloured backgrounds now look different (${list2(bgChanges)}).${bgKept.length ? ` Left looking as they were: ${list2(bgKept)}.` : ""}`;
12497
+ const contrast = contrastLosses(a, b);
12498
+ const summary = [
12499
+ ...contrast.length ? [`Harder to read: ${contrast.length} text and background pair(s) that met WCAG AA no longer do (${contrast.slice(0, 4).map((c) => `${c.text} type on ${c.background} is ${c.after}:1, was ${c.before}:1`).join("; ")}).`] : [],
12500
+ colourLine,
12501
+ `White, black and grey backgrounds: ${tally.neutral.changed} of ${tally.neutral.uses} look different.`,
12502
+ `Text colour changed on ${tally.text.changed} of ${tally.text.uses} uses.`,
12503
+ `Faces: ${faceLine("headlines", fonts.headline)}, ${faceLine("body text", fonts.body)}.`
12504
+ ].join(" ");
12505
+ return { verdict, summary, backgrounds: bg, neutralBackgrounds: tally.neutral, text: tally.text, changes: changeList, unchanged: unchangedList, fonts, contrast };
12506
+ }
12507
+ var DEFINITION_KEYS, WRAPPER_KEYS, isRecord8, COLOUR_LITERAL, VISIBLE_CHANGE, BARELY_SHARE, CARRIES_COLOUR, paintGroup, looksDifferent;
12508
+ var init_rendered_palette = __esm({
12509
+ "src/command-os/design/rendered-palette.ts"() {
12510
+ "use strict";
12511
+ init_color();
12512
+ DEFINITION_KEYS = /* @__PURE__ */ new Set(["pageStyles", "general", "rootVars", "sectionStyles", "typography", "fontsToLoad", "fontsToLoadForPreview"]);
12513
+ WRAPPER_KEYS = /* @__PURE__ */ new Set(["value", "styles", "extra", "metaData"]);
12514
+ isRecord8 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
12515
+ COLOUR_LITERAL = /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|transparent)$/i;
12516
+ VISIBLE_CHANGE = 0.05;
12517
+ BARELY_SHARE = 0.5;
12518
+ CARRIES_COLOUR = 0.02;
12519
+ paintGroup = (path40) => /^\.(sections|popups)\[\d+\]/.exec(path40)?.[0]?.slice(1) ?? "page";
12520
+ looksDifferent = (a, b) => {
12521
+ if (a === b) return false;
12522
+ if (a === "transparent" || b === "transparent" || a.startsWith("rgba") || b.startsWith("rgba")) return a !== b;
12523
+ return deltaEHex(a, b) >= VISIBLE_CHANGE;
12524
+ };
12525
+ }
12526
+ });
12527
+
12185
12528
  // src/command-os/design/retheme.ts
12186
12529
  function hexHistogram(envelope) {
12187
12530
  const counts = /* @__PURE__ */ new Map();
@@ -12194,49 +12537,170 @@ function hexHistogram(envelope) {
12194
12537
  function isIconFont(family) {
12195
12538
  return ICON_FONTS.test(family);
12196
12539
  }
12197
- function derivePalette(envelope) {
12198
- const hist = [...hexHistogram(envelope).entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
12540
+ function usageOf(paints) {
12541
+ const u = { background: /* @__PURE__ */ new Map(), text: /* @__PURE__ */ new Map(), all: /* @__PURE__ */ new Map() };
12542
+ const bump = (m, hex) => m.set(hex, (m.get(hex) ?? 0) + 1);
12543
+ for (const p of paints) {
12544
+ if (!p.colour || !p.colour.startsWith("#")) continue;
12545
+ const hex = p.colour.toLowerCase();
12546
+ bump(u.all, hex);
12547
+ if (p.kind === "background") bump(u.background, hex);
12548
+ if (p.kind === "text") bump(u.text, hex);
12549
+ }
12550
+ return u;
12551
+ }
12552
+ function usageOrHexes(envelope) {
12553
+ const u = usageOf(renderedPalette(envelope).paints);
12554
+ if (u.all.size === 0) for (const [hex, n] of hexHistogram(envelope)) u.all.set(hex, n);
12555
+ return u;
12556
+ }
12557
+ function derivePaletteFromUsage(u, fonts) {
12558
+ const sorted = (m) => [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
12559
+ const painted = u.background.size + u.text.size > 0;
12560
+ const merged = new Map(u.background);
12561
+ for (const [hex, n] of u.text) merged.set(hex, (merged.get(hex) ?? 0) + n);
12562
+ const primary = painted ? merged : u.all;
12563
+ const hist = sorted(primary);
12199
12564
  const undecided = [];
12200
- const coloured = hist.filter(([hex]) => !isNeutralHex(hex) && hexToOklch(hex).c >= BRAND_CHROMA);
12201
- const neutrals = hist.filter(([hex]) => isNeutralHex(hex) || hexToOklch(hex).c < BRAND_CHROMA);
12202
- const accent = coloured[0]?.[0] ?? null;
12565
+ const isColoured = (hex) => !isNeutralHex(hex) && hexToOklch(hex).c >= BRAND_CHROMA;
12566
+ const isGreyish = (hex) => isNeutralHex(hex) || hexToOklch(hex).c < BRAND_CHROMA;
12567
+ const firstOf = (pred, ...maps) => {
12568
+ for (const m of maps) {
12569
+ const hit = sorted(m).find(([hex]) => pred(hex));
12570
+ if (hit) return hit[0];
12571
+ }
12572
+ return null;
12573
+ };
12574
+ const accent = painted ? firstOf(isColoured, u.background, u.text) : firstOf(isColoured, primary);
12203
12575
  if (!accent) undecided.push("no strongly-coloured hex appears in this page, so it has no accent to swap \u2014 it is already a greyscale design");
12204
12576
  const SECOND_COLOUR_DISTANCE = 0.12;
12205
- const secondary = accent ? coloured.find(([hex]) => deltaEHex(hex, accent) > SECOND_COLOUR_DISTANCE)?.[0] ?? null : null;
12206
- const lights = neutrals.filter(([hex]) => hexToOklch(hex).l > 0.85);
12207
- const darks = neutrals.filter(([hex]) => hexToOklch(hex).l < 0.4);
12208
- const surface = lights[0]?.[0] ?? null;
12209
- const text2 = darks[0]?.[0] ?? null;
12577
+ const secondary = accent ? hist.find(([hex]) => isColoured(hex) && deltaEHex(hex, accent) > SECOND_COLOUR_DISTANCE)?.[0] ?? null : null;
12578
+ const light = (hex) => isGreyish(hex) && hexToOklch(hex).l > 0.85;
12579
+ const dark = (hex) => isGreyish(hex) && hexToOklch(hex).l < 0.4;
12580
+ const surface = painted ? firstOf(light, u.background, primary) : firstOf(light, primary);
12581
+ const text2 = painted ? firstOf(dark, u.text, primary) : firstOf(dark, primary);
12210
12582
  if (!surface) undecided.push("no light neutral is used often enough to be the page background");
12211
12583
  if (!text2) undecided.push("no dark neutral is used often enough to be the body text colour");
12212
- const varSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
12213
- const serialized = JSON.stringify(envelope);
12214
- const fonts = declaredFonts(envelope).filter((f) => !isIconFont(f)).map((f) => ({ family: f, uses: (serialized.match(new RegExp(`--${varSlug(f)}\\b`, "g")) ?? []).length })).filter((f) => f.uses > 0).sort((a, b) => b.uses - a.uses || a.family.localeCompare(b.family)).map((f) => f.family).slice(0, 2);
12215
- const tint = hist.find(([hex]) => {
12584
+ const isTint = (hex) => {
12216
12585
  if (hex === surface || hex === accent) return false;
12217
12586
  const { l } = hexToOklch(hex);
12218
12587
  return l > 0.7 && l < 0.97;
12219
- })?.[0] ?? null;
12220
- return { accent, surface, text: text2, secondary, tint, fonts, undecided };
12588
+ };
12589
+ const tint = painted ? firstOf(isTint, u.background, primary) : firstOf(isTint, primary);
12590
+ const taken = new Set([accent, surface, text2, secondary, tint].filter((x) => !!x));
12591
+ const tones = painted ? hist.map(([hex]) => hex).filter((hex) => !taken.has(hex) && isGreyish(hex) && hexToOklch(hex).c >= TONE_CHROMA) : [];
12592
+ return { accent, surface, text: text2, secondary, tint, tones, fonts, undecided };
12593
+ }
12594
+ function pageFaces(envelope) {
12595
+ const named = renderedPalette(envelope).fonts;
12596
+ const real = (f) => !!f && !isIconFont(f) && !/^var\(/.test(f);
12597
+ const varSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
12598
+ const serialized = JSON.stringify(envelope);
12599
+ const byUse = declaredFonts(envelope).filter((f) => !isIconFont(f)).map((f) => ({ family: f, uses: (serialized.match(new RegExp(`--${varSlug(f)}\\b`, "g")) ?? []).length })).filter((f) => f.uses > 0).sort((a, b) => b.uses - a.uses || a.family.localeCompare(b.family)).map((f) => f.family);
12600
+ const ordered = [.../* @__PURE__ */ new Set([...real(named.headline) ? [named.headline] : [], ...real(named.body) ? [named.body] : [], ...byUse])];
12601
+ return ordered.slice(0, 2);
12602
+ }
12603
+ function derivePalette(envelope) {
12604
+ return derivePaletteFromUsage(usageOrHexes(envelope), pageFaces(envelope));
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
+ }
12610
+ function sectionPairs(envelope) {
12611
+ const groups = /* @__PURE__ */ new Map();
12612
+ for (const p of renderedPalette(envelope).paints) {
12613
+ if (!p.colour?.startsWith("#") || p.kind !== "background" && p.kind !== "text") continue;
12614
+ const key = /^\.(sections|popups)\[\d+\]/.exec(p.path)?.[0] ?? "page";
12615
+ const g = groups.get(key) ?? { where: key.replace(/^\./, ""), backgrounds: /* @__PURE__ */ new Set(), texts: /* @__PURE__ */ new Set() };
12616
+ (p.kind === "background" ? g.backgrounds : g.texts).add(p.colour.toLowerCase());
12617
+ groups.set(key, g);
12618
+ }
12619
+ return [...groups.values()];
12221
12620
  }
12222
12621
  function rethemeEnvelope(envelope, brand, derived = derivePalette(envelope)) {
12223
12622
  const swaps = [];
12224
12623
  const skipped = [];
12225
12624
  const warnings = [...derived.undecided];
12226
- let text2 = JSON.stringify(envelope);
12227
- const swap = (role, from, to) => {
12625
+ const plan = [];
12626
+ const want = (role, from, to, guarded2 = false) => {
12228
12627
  if (!from) return void skipped.push(`${role}: the template has none to replace`);
12229
12628
  if (from.toLowerCase() === to.toLowerCase()) return void skipped.push(`${role}: the template already uses ${to}`);
12230
- const re = new RegExp(escapeRe(from), "gi");
12231
- const occurrences = (text2.match(re) ?? []).length;
12232
- text2 = text2.replace(re, to);
12233
- swaps.push({ role, from, to, occurrences });
12234
- };
12235
- swap("accent", derived.accent, brand.colors.accent.hex);
12236
- swap("surface", derived.surface, brand.colors.surface.hex);
12237
- swap("body text", derived.text, brand.colors.fg.hex);
12238
- if (derived.secondary) swap("second colour", derived.secondary, brand.colors.muted.hex);
12239
- if (derived.tint) swap("section tint", derived.tint, brand.pageStudioTheme.primarySoft);
12629
+ plan.push({ role, from: from.toLowerCase(), to, guarded: guarded2 });
12630
+ };
12631
+ want("accent", derived.accent, brand.colors.accent.hex);
12632
+ want("surface", derived.surface, brand.colors.surface.hex);
12633
+ want("body text", derived.text, brand.colors.fg.hex);
12634
+ if (derived.secondary) want("second colour", derived.secondary, brand.colors.muted.hex);
12635
+ if (derived.tint) want("section tint", derived.tint, brand.pageStudioTheme.primarySoft, true);
12636
+ const brandHue = hexToOklch(brand.colors.accent.hex);
12637
+ for (const tone of derived.tones ?? []) {
12638
+ if (brandHue.c < NEUTRAL_CHROMA) {
12639
+ skipped.push(`tone ${tone}: the brand's accent is a grey, so it has no hue to carry`);
12640
+ continue;
12641
+ }
12642
+ const o = hexToOklch(tone);
12643
+ want("design tone", tone, oklchToHex({ l: o.l, c: o.c, h: brandHue.h }), true);
12644
+ }
12645
+ const target = (hex) => plan.find((x) => x.from === hex)?.to ?? hex;
12646
+ const pairs = sectionPairs(envelope);
12647
+ const losses = () => {
12648
+ const out = [];
12649
+ for (const g of pairs) for (const bg of g.backgrounds) for (const t of g.texts) {
12650
+ const before = contrastHex(t, bg);
12651
+ if (before < 4.5) continue;
12652
+ const after = contrastHex(target(t), target(bg));
12653
+ if (after >= 4.5) continue;
12654
+ out.push({ where: g.where, t, bg, before, after, involved: plan.filter((x) => x.from === t || x.from === bg) });
12655
+ }
12656
+ return out;
12657
+ };
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.`;
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;
12672
+ }
12673
+ if (!removed) break;
12674
+ }
12675
+ const seen = /* @__PURE__ */ new Set();
12676
+ for (const l of losses()) {
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.`;
12684
+ if (!seen.has(`${target(l.t)} ${target(l.bg)}`)) warnings.push(line3);
12685
+ seen.add(`${target(l.t)} ${target(l.bg)}`);
12686
+ }
12687
+ let text2 = JSON.stringify(envelope);
12688
+ const colourTokens = [];
12689
+ plan.forEach((step, i) => {
12690
+ const token2 = `\0COLOUR${i}\0`;
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;
12700
+ colourTokens.push({ token: token2, to: step.to });
12701
+ swaps.push({ role: step.role, from: step.from, to: step.to, occurrences });
12702
+ });
12703
+ for (const { token: token2, to } of colourTokens) text2 = text2.split(token2).join(to);
12240
12704
  const display = brand.pageStudioTheme.headingFont;
12241
12705
  const body = brand.pageStudioTheme.bodyFont;
12242
12706
  const slug3 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
@@ -12262,28 +12726,32 @@ function rethemeEnvelope(envelope, brand, derived = derivePalette(envelope)) {
12262
12726
  `White text on this brand's accent ${brand.colors.accent.hex} is ${onAccent.toFixed(2)}:1 \u2014 WCAG AA wants 4.5. If the template puts white on its buttons they will fail; ${brand.colors.fg.hex} on that accent is ${contrastHex(brand.colors.fg.hex, brand.colors.accent.hex).toFixed(2)}:1.`
12263
12727
  );
12264
12728
  }
12265
- return { envelope: JSON.parse(text2), swaps, skipped, warnings };
12729
+ const painted = JSON.parse(text2);
12730
+ return { envelope: painted, swaps, skipped, warnings, report: repaintReport(envelope, painted) };
12266
12731
  }
12267
12732
  function derivePaletteAcross(envelopes) {
12268
- const pooled2 = /* @__PURE__ */ new Map();
12733
+ const pooled2 = { background: /* @__PURE__ */ new Map(), text: /* @__PURE__ */ new Map(), all: /* @__PURE__ */ new Map() };
12269
12734
  for (const env2 of envelopes) {
12270
- for (const [hex, n] of hexHistogram(env2)) pooled2.set(hex, (pooled2.get(hex) ?? 0) + n);
12735
+ const u = usageOrHexes(env2);
12736
+ for (const k of ["background", "text", "all"]) for (const [hex, n] of u[k]) pooled2[k].set(hex, (pooled2[k].get(hex) ?? 0) + n);
12271
12737
  }
12272
- const synthetic = [...pooled2.entries()].flatMap(([hex, n]) => Array.from({ length: n }, () => hex));
12273
- const base = derivePalette({ pooled: synthetic });
12274
12738
  const fonts = [];
12275
- for (const env2 of envelopes) for (const f of derivePalette(env2).fonts) if (!fonts.includes(f)) fonts.push(f);
12276
- return { ...base, fonts };
12739
+ for (const env2 of envelopes) for (const f of pageFaces(env2)) if (!fonts.includes(f)) fonts.push(f);
12740
+ return derivePaletteFromUsage(pooled2, fonts.slice(0, 2));
12277
12741
  }
12278
- var ICON_FONTS, BRAND_CHROMA, escapeRe;
12742
+ var ICON_FONTS, BRAND_CHROMA, TONE_CHROMA, escapeRe, COLOUR_LITERAL_RE;
12279
12743
  var init_retheme = __esm({
12280
12744
  "src/command-os/design/retheme.ts"() {
12281
12745
  "use strict";
12282
12746
  init_color();
12283
12747
  init_ghl_page();
12748
+ init_rendered_palette();
12749
+ init_rendered_palette();
12284
12750
  ICON_FONTS = /font\s*awesome|material\s*(icons|symbols)|glyphicons|ionicons|feather|bootstrap-icons|fontello|typicons|dashicons/i;
12285
12751
  BRAND_CHROMA = 0.06;
12752
+ TONE_CHROMA = 0.02;
12286
12753
  escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12754
+ COLOUR_LITERAL_RE = /#[0-9a-f]{3,8}(?![0-9a-f])|rgba?\([^()]*\)|hsla?\([^()]*\)/gi;
12287
12755
  }
12288
12756
  });
12289
12757
 
@@ -16782,7 +17250,8 @@ ${await response.text()}`);
16782
17250
  problems.push(`${step.name}: the branded page saved but GoHighLevel is not serving it`);
16783
17251
  continue;
16784
17252
  }
16785
- const savedText = JSON.stringify(await (await fetch(savedUrl)).json()).toLowerCase();
17253
+ const savedPage = await (await fetch(savedUrl)).json();
17254
+ const savedText = JSON.stringify(savedPage).toLowerCase();
16786
17255
  const stillThere = palette.accent && savedText.includes(palette.accent.toLowerCase());
16787
17256
  if (stillThere) {
16788
17257
  problems.push(`${step.name}: the template's own ${palette.accent} is still in the saved page \u2014 a partial swap, so the original was kept`);
@@ -16793,14 +17262,25 @@ ${await response.text()}`);
16793
17262
  problems.push(`${step.name}: the brand's ${brand.colors.accent.hex} is not in the saved page \u2014 the paint did not land, so the original was kept`);
16794
17263
  continue;
16795
17264
  }
17265
+ const repaint = repaintReport(envelope, savedPage);
17266
+ const pageWarnings = painted.warnings.filter((w) => !palette.undecided.includes(w));
16796
17267
  done.push({
16797
17268
  page: step.name,
16798
17269
  newPageId,
16799
17270
  oldStepId: step.stepId,
16800
- swaps: painted.swaps.map((sw) => `${sw.role} ${sw.from} \u2192 ${sw.to} (${sw.occurrences}\xD7)`),
17271
+ repaint: {
17272
+ verdict: repaint.verdict,
17273
+ summary: repaint.summary,
17274
+ backgrounds: repaint.backgrounds,
17275
+ neutralBackgrounds: repaint.neutralBackgrounds,
17276
+ text: repaint.text,
17277
+ changes: repaint.changes.filter((c) => c.kind === "background" || c.kind === "text"),
17278
+ fonts: repaint.fonts,
17279
+ contrast: repaint.contrast
17280
+ },
16801
17281
  skipped: painted.skipped,
16802
- preview: ghlPreviewUrl(newPageId),
16803
- readBack: `template ${palette.accent ?? "(no accent)"} gone; brand ${brand.colors.accent.hex} present`
17282
+ ...pageWarnings.length ? { warnings: pageWarnings } : {},
17283
+ preview: ghlPreviewUrl(newPageId)
16804
17284
  });
16805
17285
  } catch (e) {
16806
17286
  problems.push(`${step.name}: ${e instanceof Error ? e.message : String(e)}`);
@@ -16827,6 +17307,19 @@ ${await response.text()}`);
16827
17307
  brand: { slug: brand.slug, accent: brand.colors.accent.hex, display: brand.pageStudioTheme.headingFont, body: brand.pageStudioTheme.bodyFont },
16828
17308
  templatePalette: { accent: palette.accent, surface: palette.surface, text: palette.text, secondary: palette.secondary, faces: palette.fonts, undecided: palette.undecided },
16829
17309
  pages: done,
17310
+ ...(() => {
17311
+ const faint = done.filter((d) => d.repaint.verdict !== "repainted").map((d) => String(d.page));
17312
+ const hard = done.filter((d) => d.repaint.contrast.length > 0);
17313
+ const lines = [
17314
+ ...faint.length ? [`On ${faint.join(", ")} the visible palette barely changed. Each page's repaint says what still paints in the template's own colours.`] : [],
17315
+ ...hard.length ? [`On ${hard.map((d) => String(d.page)).join(", ")} some type is now harder to read than WCAG AA allows; each page's repaint.contrast names the colours.`] : []
17316
+ ];
17317
+ const contrast = hard.flatMap((d) => d.repaint.contrast.map((c) => `${String(d.page)}: ${c.text} type on ${c.background} is ${c.after}:1 (was ${c.before}:1).`));
17318
+ return {
17319
+ ...lines.length ? { repaintNote: `${lines.join(" ")} Look at the preview before calling it branded.` } : {},
17320
+ ...contrast.length ? { contrast } : {}
17321
+ };
17322
+ })(),
16830
17323
  originalsRemoved: complete && removeOriginals ? removed : 0,
16831
17324
  ...problems.length > 0 && { problems, kept: "Every original step was LEFT IN PLACE because something failed. Nothing was deleted." },
16832
17325
  ...namedButNotNew && { identification: "GoHighLevel named a funnel that this account ALREADY had before the install. That name was discarded and the account was compared instead, so the funnel above was identified the cautious way and originals are only removed if you asked for it explicitly. Worth reporting: it means the install response no longer means what it did." },
@@ -16895,21 +17388,24 @@ ${await response.text()}`);
16895
17388
  integrations: computeIntegrations({ sections })
16896
17389
  };
16897
17390
  const saved = await autosaveRequest(pre.pageId, { funnelId, pageVersion: savePageVersion, pageData });
16898
- const savedDataUrl = typeof saved.pageDataUrl === "string" ? saved.pageDataUrl : null;
17391
+ const savedDataUrl = typeof saved.pageDataUrl === "string" && saved.pageDataUrl.length > 0 ? saved.pageDataUrl : null;
16899
17392
  let verified = false;
17393
+ let servedUrl = "";
16900
17394
  for (let attempt = 1; attempt <= 8 && !verified; attempt++) {
16901
17395
  await new Promise((r) => setTimeout(r, 4e3));
16902
17396
  try {
16903
17397
  const now = await funnelRequest("GET", `/page/${pre.pageId}?locationId=${client.locationId}`);
16904
- const nowUrl = typeof now.pageDataUrl === "string" ? now.pageDataUrl : "";
16905
- 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;
16906
17400
  } catch {
16907
17401
  }
16908
17402
  }
16909
17403
  return {
16910
17404
  saved,
16911
17405
  verified,
16912
- ...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
+ }
16913
17409
  };
16914
17410
  }
16915
17411
  async function savePageGuarded(input) {
@@ -19516,7 +20012,7 @@ function traceOpportunityContext(node, byId2) {
19516
20012
  }
19517
20013
  return "dangling";
19518
20014
  }
19519
- function isRecord8(v) {
20015
+ function isRecord9(v) {
19520
20016
  return typeof v === "object" && v !== null && !Array.isArray(v);
19521
20017
  }
19522
20018
  function isUuidV4(id) {
@@ -19612,8 +20108,8 @@ function checkSaveBlockers(workflow) {
19612
20108
  });
19613
20109
  }
19614
20110
  {
19615
- const attrs = isRecord8(a.attributes) ? a.attributes : void 0;
19616
- const startAfter = attrs && isRecord8(attrs.startAfter) ? attrs.startAfter : void 0;
20111
+ const attrs = isRecord9(a.attributes) ? a.attributes : void 0;
20112
+ const startAfter = attrs && isRecord9(attrs.startAfter) ? attrs.startAfter : void 0;
19617
20113
  if (startAfter && (startAfter.type === "day" || startAfter.type === "minute")) {
19618
20114
  findings.push({
19619
20115
  severity: "warning",
@@ -19625,8 +20121,8 @@ function checkSaveBlockers(workflow) {
19625
20121
  }
19626
20122
  }
19627
20123
  if (type === "internal_notification") {
19628
- const attrs = isRecord8(a.attributes) ? a.attributes : void 0;
19629
- const notif = attrs && isRecord8(attrs.notification) ? attrs.notification : void 0;
20124
+ const attrs = isRecord9(a.attributes) ? a.attributes : void 0;
20125
+ const notif = attrs && isRecord9(attrs.notification) ? attrs.notification : void 0;
19630
20126
  const selected = notif?.selectedUser;
19631
20127
  const blank = selected === void 0 || selected === null || typeof selected === "string" && selected.trim() === "";
19632
20128
  if (notif?.userType === "user" && blank) {
@@ -20207,6 +20703,10 @@ function registerValidatorTools(server2, client, builderClient) {
20207
20703
  merge_field_warnings_total: 0,
20208
20704
  workflows_with_shape_warnings: 0,
20209
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,
20210
20710
  workflows_unscannable: 0,
20211
20711
  workflows_zero_references: 0,
20212
20712
  // Nothing was scanned, so no reference category was loaded or needed.
@@ -20217,6 +20717,8 @@ function registerValidatorTools(server2, client, builderClient) {
20217
20717
  workflows_with_issues: [],
20218
20718
  merge_field_warnings: [],
20219
20719
  shape_warnings: [],
20720
+ save_blocker_warnings: [],
20721
+ integration_warnings: [],
20220
20722
  unscannable: [],
20221
20723
  notes: catalog2.complete ? [] : [incompleteCatalogNote(catalog2.stopReason, 0, catalog2.reportedTotal)]
20222
20724
  });
@@ -20253,6 +20755,12 @@ function registerValidatorTools(server2, client, builderClient) {
20253
20755
  const warningsTotal = withWarnings.reduce((n, w) => n + w.warnings.length, 0);
20254
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));
20255
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);
20256
20764
  const unverifiedCats = ALL_CATEGORIES.filter((c) => lookups.status[c] !== "loaded");
20257
20765
  const unverifiedRefs = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === "unverified").length, 0);
20258
20766
  return jsonResponse({
@@ -20272,14 +20780,24 @@ function registerValidatorTools(server2, client, builderClient) {
20272
20780
  merge_field_warnings_total: warningsTotal,
20273
20781
  workflows_with_shape_warnings: withShapeWarnings.length,
20274
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,
20275
20787
  workflows_unscannable: unscannable.length,
20276
20788
  workflows_zero_references: zeroRefCount,
20277
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.
20278
20794
  status: errorsTotal > 0 ? "issues_found" : "ok"
20279
20795
  },
20280
20796
  workflows_with_issues: withErrors,
20281
20797
  merge_field_warnings: withWarnings,
20282
20798
  shape_warnings: withShapeWarnings,
20799
+ save_blocker_warnings: withSaveBlockerWarnings,
20800
+ integration_warnings: withIntegrationWarnings,
20283
20801
  unscannable,
20284
20802
  notes: [
20285
20803
  // NEVER advise re-running for "the next batch": GHL's list endpoint ignores
@@ -20290,6 +20808,8 @@ function registerValidatorTools(server2, client, builderClient) {
20290
20808
  ...unverifiedCats.length ? [`Could not fully load: ${unverifiedCats.join(", ")} \u2014 references to those are 'unverified', not 'broken'. Re-run.`] : [],
20291
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.`] : [],
20292
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.`] : [],
20293
20813
  "workflow_goal, goto, and unrecognized condition types are not deeply checked in this version."
20294
20814
  ]
20295
20815
  });
@@ -22362,7 +22882,7 @@ function executablePlanLines(saved) {
22362
22882
  funnel: "funnel"
22363
22883
  };
22364
22884
  const words = (s) => s.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_.-]+/g, " ").trim();
22365
- const kindOf = (s) => kinds[s] || words(s).replace(/s$/, "") || "plan object";
22885
+ const kindOf2 = (s) => kinds[s] || words(s).replace(/s$/, "") || "plan object";
22366
22886
  const names = /* @__PURE__ */ new Map();
22367
22887
  const objects = /* @__PURE__ */ new Map();
22368
22888
  const label = (x, kind) => x.name?.trim() || x.title?.trim() || [x.firstName, x.lastName].filter(Boolean).join(" ") || `Unnamed ${kind}`;
@@ -22384,7 +22904,7 @@ function executablePlanLines(saved) {
22384
22904
  const refs = [...names.keys()].sort((a, b) => b.length - a.length);
22385
22905
  const refPattern = refs.length ? new RegExp(`(?<![\\w./:@])(?:${refs.map(escapeRE).join("|")})(?![\\w]|\\.[\\w])`, "g") : void 0;
22386
22906
  const missingRefPattern = /(?<![\w./:@])(user|pipeline|stage|field|tag|workflow|form|calendar|email_template|sms_template|email|sms|cv|handoff|funnel)\.[a-z0-9_]+(?![\w]|\.[\w])/g;
22387
- const resolve12 = (s) => s.replace(missingRefPattern, (ref) => names.get(ref) || `Unnamed ${kindOf(ref.split(".")[0])}`);
22907
+ const resolve12 = (s) => s.replace(missingRefPattern, (ref) => names.get(ref) || `Unnamed ${kindOf2(ref.split(".")[0])}`);
22388
22908
  const renderRefs = (s) => resolve12(refPattern ? s.replace(refPattern, (ref) => names.get(ref)) : s);
22389
22909
  const mergeLabel = (field) => {
22390
22910
  const [group, ...parts] = field.trim().split(".");
@@ -22403,7 +22923,7 @@ function executablePlanLines(saved) {
22403
22923
  const code = entity[2].toLowerCase() === "x" ? parseInt(entity.slice(3), 16) : parseInt(entity.slice(2), 10);
22404
22924
  return code <= 1114111 ? String.fromCodePoint(code) : entity;
22405
22925
  }).replace(/{{\s*([^{}]+?)\s*}}/g, (_all, field) => `[${mergeLabel(field)}]`)).replace(/\bMCP\b/g, "GHL Command").replace(/Agency OS/g, "Command OS").replace(/\s*—\s*/g, " ").replace(/\s+/g, " ").replace(/\s+([.,;!?])/g, "$1").trim();
22406
- const show = (v, key = "") => typeof v === "string" ? names.get(v) || (/Refs?$/.test(key) ? `Unnamed ${kindOf(key.replace(/Refs?$/, "").replace(/^teamMember$/, "user").replace(/^template$/, "message template"))}` : plain(key === "mergeTags" ? `{{${v}}}` : key === "type" ? words(v) : v)) : Array.isArray(v) ? v.map((value) => show(value, key)).join(", ") : v && typeof v === "object" ? Object.entries(v).filter(([k]) => k !== "ref").map(([k, value]) => `${words(k.replace(/Refs?$/, ""))}: ${show(value, k)}`).join("; ") : String(v ?? "");
22926
+ const show = (v, key = "") => typeof v === "string" ? names.get(v) || (/Refs?$/.test(key) ? `Unnamed ${kindOf2(key.replace(/Refs?$/, "").replace(/^teamMember$/, "user").replace(/^template$/, "message template"))}` : plain(key === "mergeTags" ? `{{${v}}}` : key === "type" ? words(v) : v)) : Array.isArray(v) ? v.map((value) => show(value, key)).join(", ") : v && typeof v === "object" ? Object.entries(v).filter(([k]) => k !== "ref").map(([k, value]) => `${words(k.replace(/Refs?$/, ""))}: ${show(value, k)}`).join("; ") : String(v ?? "");
22407
22927
  const sentence = (s) => {
22408
22928
  const text2 = plain(s);
22409
22929
  return text2 && !/[.!?…]$/.test(text2) ? `${text2}.` : text2;
@@ -22411,7 +22931,7 @@ function executablePlanLines(saved) {
22411
22931
  const messageCopy = (t) => [t.subject ? `Subject: ${sentence(t.subject)}` : "", t.body || t.html ? `Message: ${sentence(t.body || t.html)}` : t.bodyOutline ? `Message outline: ${sentence(t.bodyOutline)}` : ""].filter(Boolean).join(" ");
22412
22932
  for (const section2 of ["users", "pipelines", "customFields", "fields", "tags", "customValues", "calendars", "forms", "funnels"]) for (const x of p?.[section2] ?? []) {
22413
22933
  const existing = preview?.items?.find((i) => i.ref === x.ref)?.status === "existing";
22414
- lines.push(`${existing ? "Reuse" : "Create"} ${kindOf(section2)}: ${label(x, kindOf(section2))}. ${show(Object.fromEntries(Object.entries(x).filter(([k]) => !["ref", "name"].includes(k))))}`);
22934
+ lines.push(`${existing ? "Reuse" : "Create"} ${kindOf2(section2)}: ${label(x, kindOf2(section2))}. ${show(Object.fromEntries(Object.entries(x).filter(([k]) => !["ref", "name"].includes(k))))}`);
22415
22935
  }
22416
22936
  for (const w of p?.workflows ?? []) {
22417
22937
  lines.push(`Workflow: ${label(w, "workflow")}. ${show(w.trigger || w.triggers || "")}`);
@@ -26851,6 +27371,7 @@ ${saved.summary}
26851
27371
  5. ${STAFF_NOTIFICATION_RULE} A notification step whose recipient is empty is listed in issues as waiting for a staff member, never left unmentioned.
26852
27372
  6. ${MISSING_STEP_RULE}
26853
27373
  If the tool reports that no saved plan exists, STOP and finish with RESULT: {"ok":false,"error":"no saved plan on this machine \u2014 run the build stage fresh"}.
27374
+ If apply_build_plan answers phase:"scope" (nothing is selected for this client, or the selection changed after the plan was saved), STOP: build nothing else and finish with RESULT: {"ok":false,"error":"<its error, word for word>"}.
26854
27375
  ${NO_INVENTED_CAUSE_RULE}
26855
27376
  Finish with EXACTLY one final line:
26856
27377
  RESULT: {"ok":true,"summary":"<what was created vs already in place: counts of pipelines/fields/tags/workflows>","issues":["<anything skipped or needing a human>"]}`;
@@ -27189,6 +27710,7 @@ Use these authoritative normalized intake answers when provided: ${JSON.stringif
27189
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.
27190
27711
  9. ${MISSING_STEP_RULE}
27191
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>"}.
27192
27714
  Finish with EXACTLY one final line:
27193
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)
27194
27716
  },
@@ -30060,14 +30582,7 @@ function assessmentFeatureLines(snapshot, purchased = [], availableOnly = true)
30060
30582
  });
30061
30583
  }
30062
30584
  function assessmentPurchasesFor(state, id) {
30063
- const saved = state.assessmentPurchases?.[id], scope = state.scope?.[id];
30064
- if (!scope) return saved;
30065
- return [...new Set([
30066
- ...saved ?? [],
30067
- ...scope.features.map((f) => BUILD_ASSESSMENT_FEATURE[f]),
30068
- "pipeline"
30069
- // Every scoped build includes the pipeline foundation.
30070
- ].filter(Boolean))];
30585
+ return state.assessmentPurchases?.[id];
30071
30586
  }
30072
30587
  function upsellFigure(snapshot, feature) {
30073
30588
  const id = BUILD_ASSESSMENT_FEATURE[feature];
@@ -30707,6 +31222,16 @@ function savePlanRecord(rec, base) {
30707
31222
  fs22.writeFileSync(file2, JSON.stringify({ ...rec, contentHash: contentHash(rec.plan), inputHash: rec.inputHash ?? process.env.GHL_COMMAND_INTAKE_HASH, dependencies: rec.dependencies ?? planDependencies(rec.plan) }, null, 2), { mode: 384 });
30708
31223
  return file2;
30709
31224
  }
31225
+ function savedPlanMatchesScope(saved, current) {
31226
+ if (!saved) return false;
31227
+ if (!current || current.wholePreset) return !saved.scope;
31228
+ if (!saved.scope) return false;
31229
+ const omitted = (saved.scope.omitted ?? []).map((f) => f.id);
31230
+ const offered = /* @__PURE__ */ new Set([...saved.scope.built, ...saved.scope.notBuilt.map((f) => f.id), ...omitted]);
31231
+ const selected = new Set(current.features.filter((f) => offered.has(f)));
31232
+ const planned = /* @__PURE__ */ new Set([...saved.scope.built, ...omitted]);
31233
+ return selected.size === planned.size && [...planned].every((f) => selected.has(f));
31234
+ }
30710
31235
  function readSavedPlan(locationId2, base) {
30711
31236
  try {
30712
31237
  return JSON.parse(fs22.readFileSync(path20.join(plansDir(base), `${locationId2}.json`), "utf8"));
@@ -31230,7 +31755,7 @@ var require_package = __commonJS({
31230
31755
  "package.json"(exports2, module2) {
31231
31756
  module2.exports = {
31232
31757
  name: "@elitedcs/ghl-mcp",
31233
- version: "3.84.0",
31758
+ version: "3.84.2",
31234
31759
  mcpName: "io.github.drjerryrelth/ghl-command",
31235
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.",
31236
31761
  main: "dist/index.js",
@@ -32240,16 +32765,8 @@ async function assessmentAttachmentAction(args, deps) {
32240
32765
  const chosen = args.purchasedFeatureIds;
32241
32766
  if (!Array.isArray(chosen) || chosen.some((id2) => !ASSESSMENT_FEATURES.some((f) => f.id === id2))) throw new Error("Choose features from the list.");
32242
32767
  const state2 = deps.read();
32243
- const scope = state2.scope?.[id];
32244
- const features = FEATURE_IDS.filter((f) => chosen.includes(BUILD_ASSESSMENT_FEATURE[f]));
32245
- const next = {
32246
- ...state2,
32247
- scope: { ...state2.scope, [id]: { ...scope ?? {}, features, setAt: (/* @__PURE__ */ new Date()).toISOString() } },
32248
- assessmentPurchases: { ...state2.assessmentPurchases, [id]: [...new Set(chosen)] }
32249
- };
32250
- next.assessmentPurchases[id] = assessmentPurchasesFor(next, id);
32251
- deps.write(next);
32252
- detail.message = "What they bought is saved for this client document.";
32768
+ deps.write({ ...state2, assessmentPurchases: { ...state2.assessmentPurchases, [id]: [...new Set(chosen)] } });
32769
+ detail.message = state2.scope?.[id] ? "What they bought is saved for this client document. The build still follows the selection confirmed on the Review screen." : "What they bought is saved. Nothing is built until you confirm what to build on the Review screen, which is ticked from this.";
32253
32770
  break;
32254
32771
  }
32255
32772
  default:
@@ -32271,7 +32788,6 @@ var init_attachment_actions = __esm({
32271
32788
  "src/command-os/assessment/attachment-actions.ts"() {
32272
32789
  "use strict";
32273
32790
  init_attachment();
32274
- init_features();
32275
32791
  }
32276
32792
  });
32277
32793
 
@@ -34745,14 +35261,15 @@ function reviewDocumentHtml(review, opts) {
34745
35261
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString(void 0, { year: "numeric", month: "long", day: "numeric" });
34746
35262
  const gap = (g) => `<div class="gap ${g.priority}"><div class="gt">${esc8(g.title)}<span class="pri">${g.priority === "now" ? "Fix first" : g.priority === "soon" ? "Next" : "Later"}</span></div>${g.cost ? `<div class="gc">${esc8(g.cost)}</div>` : ""}${g.fix ? `<div class="gf">What we do: ${esc8(g.fix)}</div>` : ""}</div>`;
34747
35263
  const o = review.offer;
34748
- const purchased = opts.scope || opts.purchasedFeatureIds ? [.../* @__PURE__ */ new Set([
35264
+ const confirmed = opts.scope && !opts.scope.wholePreset && !opts.scope.awaitingConfirmation ? opts.scope : void 0;
35265
+ const purchased = confirmed || opts.purchasedFeatureIds ? [.../* @__PURE__ */ new Set([
34749
35266
  ...opts.purchasedFeatureIds ?? [],
34750
- ...opts.scope ? [...opts.scope.built.map((f) => BUILD_ASSESSMENT_FEATURE[f.id]), "pipeline"] : []
35267
+ ...confirmed ? [...confirmed.built.map((f) => BUILD_ASSESSMENT_FEATURE[f.id]), "pipeline"] : []
34751
35268
  ])] : void 0;
34752
- const unbought = opts.scope?.notBuilt.filter((f) => !purchased?.includes(BUILD_ASSESSMENT_FEATURE[f.id])) ?? [];
34753
- const represented = [...opts.scope?.built ?? [], ...unbought].map((f) => BUILD_ASSESSMENT_FEATURE[f.id]);
35269
+ const unbuilt = opts.scope?.notBuilt ?? [];
35270
+ const represented = [...opts.scope?.built ?? [], ...unbuilt].map((f) => BUILD_ASSESSMENT_FEATURE[f.id]).concat(opts.scope && !opts.scope.awaitingConfirmation ? ["pipeline"] : []);
34754
35271
  const assessmentNext = opts.assessment && purchased ? assessmentFeatureLines(opts.assessment, [...purchased, ...represented]) : [];
34755
- const available = unbought.map((f) => `<div class="build"><b>${esc8(f.name)}</b><span>${esc8(f.what)}${f.figure ? ` <b>${esc8(f.figure)}</b>` : ""}</span></div>`).join("") + assessmentNext.map((line3) => `<p>${esc8(line3)}</p>`).join("");
35272
+ const available = unbuilt.map((f) => `<div class="build"><b>${esc8(f.name)}</b><span>${esc8(f.what)}${f.figure ? ` <b>${esc8(f.figure)}</b>` : ""}</span></div>`).join("") + assessmentNext.map((line3) => `<p>${esc8(line3)}</p>`).join("");
34756
35273
  return `<!doctype html><html><head><meta charset="utf-8"><title>${esc8(opts.clientName)} \u2014 Growth Blueprint</title><style>
34757
35274
  @page{margin:0.6in}
34758
35275
  *{box-sizing:border-box}
@@ -34809,7 +35326,9 @@ function reviewDocumentHtml(review, opts) {
34809
35326
  ${o.recommendedOffer ? `<div class="lead"><b>Run this instead:</b> ${esc8(o.recommendedOffer)}</div>` : ""}` : ""}
34810
35327
  ${review.gaps?.length ? `<div class="head">What's leaking today (${review.gaps.length})</div>${review.gaps.map(gap).join("")}` : ""}
34811
35328
  ${review.build?.length ? `<div class="head">What we're building</div>${review.build.map((b) => `<div class="build"><b>${esc8(b.item)}</b><span>${esc8(b.because)}</span></div>`).join("")}` : ""}
34812
- ${opts.scope ? `<div class="head">What this build includes</div>
35329
+ ${opts.scope?.awaitingConfirmation ? `<div class="head">What this build includes</div>
35330
+ <p>Not confirmed yet. What you bought is recorded, and the build waits until the features to build are confirmed.</p>
35331
+ ` : opts.scope ? `<div class="head">What this build includes</div>
34813
35332
  <p>${opts.scope.package ? `Built to your <b>${esc8(opts.scope.package)}</b> package: ` : ""}${opts.scope.built.length ? esc8(opts.scope.built.map((b) => b.name).join(", ")) : "the foundation only"}. Always included: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing.</p>
34814
35333
  ` : ""}
34815
35334
  ${review.unknowns?.length ? `<div class="head">What we still need from you</div><div class="ask"><ul>${review.unknowns.map((u) => `<li>${esc8(u)}</li>`).join("")}</ul></div>` : ""}
@@ -34923,8 +35442,8 @@ var init_capability_runs = __esm({
34923
35442
  staff: ["get_users", "get_user", "get_calendars"],
34924
35443
  phone: ["list_phone_numbers", "list_number_pools", "get_location"],
34925
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"],
34926
- 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"],
34927
- // 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
34928
35447
  templates: ["list_email_templates", "create_email_template", "update_email_template", "list_message_templates", "create_sms_template", "get_custom_values"],
34929
35448
  broadcasts: ["list_email_templates", "create_email_template", "update_email_template", "list_smart_lists", "get_email_campaigns"],
34930
35449
  // drafts the email as a template; sending or scheduling is the operator's click
@@ -35985,6 +36504,7 @@ __export(dashboard_exports, {
35985
36504
  resolvePlan: () => resolvePlan2,
35986
36505
  runDashboard: () => runDashboard,
35987
36506
  safeDocName: () => safeDocName,
36507
+ scopePrefillFor: () => scopePrefillFor,
35988
36508
  setAccountIndustry: () => setAccountIndustry,
35989
36509
  setAccountType: () => setAccountType,
35990
36510
  setBuildApproval: () => setBuildApproval,
@@ -36046,6 +36566,8 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
36046
36566
  const all = { ...state.scope ?? {} };
36047
36567
  if (!scope) {
36048
36568
  delete all[locationId2];
36569
+ } else if ("wholePreset" in scope) {
36570
+ all[locationId2] = { wholePreset: true, features: [...FEATURE_IDS], setAt: now().toISOString() };
36049
36571
  } else {
36050
36572
  const features = [...new Set(scope.features.filter(isFeatureId))];
36051
36573
  const pkg2 = typeof scope.package === "string" ? scope.package.trim().slice(0, 80) : "";
@@ -36053,19 +36575,18 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
36053
36575
  }
36054
36576
  const next = { ...state, scope: all };
36055
36577
  if (!Object.keys(all).length) delete next.scope;
36056
- const purchases = { ...state.assessmentPurchases };
36057
- if (scope) purchases[locationId2] = assessmentPurchasesFor(next, locationId2);
36058
- return { ...next, assessmentPurchases: purchases };
36578
+ return next;
36059
36579
  }
36060
36580
  function documentScopeFor(state, locationId2) {
36061
36581
  const sc = state.scope?.[locationId2];
36062
- if (!sc) return void 0;
36582
+ if (!sc && Array.isArray(state.assessmentPurchases?.[locationId2])) return { awaitingConfirmation: true, built: [], notBuilt: [] };
36063
36583
  let offered = FEATURE_IDS;
36064
36584
  try {
36065
36585
  const chosen = loadPresetChoice(void 0, presetForIndustry(state.accounts?.[locationId2]?.industry));
36066
36586
  if (chosen.ok) offered = featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
36067
36587
  } catch {
36068
36588
  }
36589
+ if (!sc || sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
36069
36590
  return {
36070
36591
  ...sc.package ? { package: sc.package } : {},
36071
36592
  // Built = the confirmed scope, the one record the build follows. The purchase
@@ -36078,6 +36599,11 @@ function documentScopeFor(state, locationId2) {
36078
36599
  })
36079
36600
  };
36080
36601
  }
36602
+ function scopePrefillFor(state, locationId2) {
36603
+ const bought = state.assessmentPurchases?.[locationId2];
36604
+ if (state.scope?.[locationId2] || !Array.isArray(bought)) return void 0;
36605
+ return FEATURE_IDS.filter((f) => bought.includes(BUILD_ASSESSMENT_FEATURE[f]));
36606
+ }
36081
36607
  function assessmentFiguresFor(state, locationId2) {
36082
36608
  const snap = state.assessmentAttachments?.[locationId2];
36083
36609
  if (!snap) return {};
@@ -36164,9 +36690,11 @@ function startRun(locationId2, stage, onDone, meta = {}) {
36164
36690
  }).map((q2) => ({ key: q2.key, label: q2.label, from: INTAKE_QUESTIONS.find((b) => b.key === q2.key)?.label })),
36165
36691
  removedKeys: INTAKE_QUESTIONS.filter((b) => !composed.some((q2) => q2.key === b.key)).map((b) => b.key),
36166
36692
  // A build re-run reuses the approved plan already saved for this account (finding 10).
36693
+ // ...and only while it still builds the scope the member has confirmed now: a
36694
+ // changed selection composes the plan fresh from the preset (delta round 3, T-M4).
36167
36695
  savedPlan: (() => {
36168
36696
  const sp = stage === 3 ? readSavedPlan(locationId2) : null;
36169
- return sp && sp.inputHash === meta.inputHash ? { planId: sp.planId, savedAt: sp.savedAt, summary: planSummary(sp) } : void 0;
36697
+ return sp && sp.inputHash === meta.inputHash && savedPlanMatchesScope(sp, state.scope?.[locationId2]) ? { planId: sp.planId, savedAt: sp.savedAt, summary: planSummary(sp) } : void 0;
36170
36698
  })()
36171
36699
  };
36172
36700
  if (context.savedPlan) run.lines.push(`Using the build plan saved on ${context.savedPlan.savedAt}.`);
@@ -37090,7 +37618,7 @@ function scopeBlock(id,state){
37090
37618
  const sel=document.createElement("select");sel.style.cssText="padding:.35rem .5rem;border:1px solid var(--line);border-radius:6px;font-size:var(--fs-sm);background:var(--surface);max-width:100%";
37091
37619
  const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"Everything the preset has (no package)":"Everything the preset has (no packages saved yet: add them on Your agency)";sel.appendChild(o0);
37092
37620
  for(const p of pk){const o=document.createElement("option");o.value=p.id;o.textContent=p.name+(p.price?" \xB7 "+p.price:"");sel.appendChild(o)}
37093
- let ticked=new Set(saved?saved.features:Object.keys(FEATS));if(saved&&saved.package)sel.value=saved.package;
37621
+ const prefill=(state.scopePrefill||{})[id];let ticked=new Set(saved&&!saved.wholePreset?saved.features:(prefill||Object.keys(FEATS)));if(saved&&saved.package)sel.value=saved.package;
37094
37622
  const boxes=document.createElement("div");boxes.style.cssText="margin:.5rem 0";
37095
37623
  function drawBoxes(){boxes.innerHTML="";for(const k of Object.keys(FEATS)){const l=document.createElement("label");l.style.cssText="display:flex;gap:.5rem;align-items:flex-start;margin:.3rem 0;font-size:var(--fs-sm);cursor:pointer";
37096
37624
  const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(k);c.style.marginTop=".25rem";c.onchange=()=>{if(c.checked)ticked.add(k);else ticked.delete(k)};l.appendChild(c);
@@ -37102,7 +37630,7 @@ function scopeBlock(id,state){
37102
37630
  const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing. Unticked features go in the client document as available next.";
37103
37631
  const sv=document.createElement("button");sv.className="runbtn";sv.textContent="Save what they bought";
37104
37632
  sv.onclick=async()=>{const all=Object.keys(FEATS).every(k=>ticked.has(k));const body={locationId:id,features:(all&&!sel.value)?null:[...ticked]};if(sel.value)body.package=sel.value;if(await postJSON("/api/scope",body)){delete assessmentCards[id];load()}};
37105
- const status=document.createElement("p");status.className="muted";status.textContent=saved?("Saved: "+saved.features.length+" of "+Object.keys(FEATS).length+" features"+(saved.package?" from the "+saved.package+" package":"")+". The build follows this."):"Nothing saved: the whole preset is built.";
37633
+ const status=document.createElement("p");status.className="muted";status.textContent=saved?(saved.wholePreset?"Saved: the whole preset. The build follows this.":"Saved: "+saved.features.length+" of "+Object.keys(FEATS).length+" features"+(saved.package?" from the "+saved.package+" package":"")+". The build follows this."):prefill?"Not confirmed yet: ticked from what they bought. Nothing is built until you save.":"Nothing saved: the whole preset is built.";
37106
37634
  sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(status);
37107
37635
  return sc}
37108
37636
  async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;render(lastState,lastRun)}
@@ -38351,6 +38879,11 @@ ${opportunityNoteFailure?.detail ?? ""}`);
38351
38879
  }
38352
38880
  const assessmentFigures = {};
38353
38881
  for (const loc of Object.keys(st.assessmentAttachments ?? {})) assessmentFigures[loc] = assessmentFiguresFor(st, loc);
38882
+ const scopePrefill = {};
38883
+ for (const loc of Object.keys(st.assessmentPurchases ?? {})) {
38884
+ const p = scopePrefillFor(st, loc);
38885
+ if (p) scopePrefill[loc] = p;
38886
+ }
38354
38887
  const buildViews = Object.fromEntries(currentRoster().map((c) => {
38355
38888
  const saved = readSavedPlan(c.locationId);
38356
38889
  const approval = st.planApprovals?.[c.locationId];
@@ -38368,7 +38901,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
38368
38901
  }));
38369
38902
  for (const [id, view] of Object.entries(buildViews)) if (!view.planHash || !view.approvedAt || view.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || st.manualTasks?.[id]?.length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
38370
38903
  const outcomes = Object.fromEntries(Object.entries(st.outcomes ?? {}).map(([key, value]) => [key, { ...value, ...key.endsWith(":4") && value.verification ? { plain: verifyPanelOutcome(value) } : { ...plainFallbackOutcome(value), plain: plainFallbackOutcome(value.plain ?? value) }, error: value.error ? "The step did not finish. Check the account before retrying." : void 0 }]));
38371
- res.end(JSON.stringify({ ...st, assessmentFigures, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
38904
+ res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
38372
38905
  const records = st.outcomes?.[`${id}:4`]?.verification;
38373
38906
  const allowed = records ? verifyReadback(records).allowPublishing : false;
38374
38907
  return [id, tasks2.map((t) => ({ ...t, text: verifyPublishingText(plainFallback(t.text, { nextStep: true }), allowed) })).filter((t) => t.text)];
@@ -39325,8 +39858,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
39325
39858
  try {
39326
39859
  const { locationId: locationId2, package: pkg2, features } = JSON.parse(body);
39327
39860
  if (!locationId2 || !currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
39328
- if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to build everything");
39329
- const next = features === null ? null : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) };
39861
+ if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
39862
+ const next = features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) };
39330
39863
  writeCockpitState(setClientScope(readCockpitState(), locationId2, next));
39331
39864
  res.writeHead(200, { "Content-Type": "application/json" });
39332
39865
  res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[locationId2] ?? null }));
@@ -39641,12 +40174,12 @@ function routeFirebaseForCompany(builderClient, registry2, companyId) {
39641
40174
  companyId
39642
40175
  });
39643
40176
  return `
39644
- 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.`;
39645
40178
  }
39646
40179
  builderClient.resetToHomeFirebase();
39647
40180
  const homeCompanyId = builderClient.getCurrentCompanyId();
39648
40181
  if (homeCompanyId === companyId) {
39649
- 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.";
39650
40183
  }
39651
40184
  const registeredCompanies = registry2?.listCompanyFirebases?.() ?? [];
39652
40185
  if (registeredCompanies.length > 0) {
@@ -39877,7 +40410,17 @@ Agency-wide tools now available: list_snapshots, create_snapshot_share_link, and
39877
40410
  isError: true
39878
40411
  };
39879
40412
  }
39880
- 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
+ }
39881
40424
  return {
39882
40425
  content: [{ type: "text", text: `Removed: ${token2.name} (${locationId2}) from token registry.` }]
39883
40426
  };
@@ -40167,11 +40710,13 @@ ${lines.join("\n")}
40167
40710
  try {
40168
40711
  const agencyClient = new GHLClient({ apiKey: agencyKey });
40169
40712
  await agencyClient.delete(`/locations/${location_id}`, { params: { deleteTwilioAccount: delete_twilio ?? false } });
40713
+ const hadKey = Boolean(registry2?.getToken(location_id));
40170
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.` : "";
40171
40716
  return {
40172
40717
  content: [{
40173
40718
  type: "text",
40174
- text: `Sub-account ${location_id} permanently deleted.${wasRegistered ? " Removed from the token registry." : ""}`
40719
+ text: `Sub-account ${location_id} permanently deleted.${keyLine}`
40175
40720
  }]
40176
40721
  };
40177
40722
  } catch (error) {
@@ -41435,6 +41980,28 @@ function buildContactSmartListBody(args) {
41435
41980
  locationId: args.locationId
41436
41981
  };
41437
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
+ }
41438
42005
  function registerSmartListTools(server2, builderClient) {
41439
42006
  if (!builderClient) return;
41440
42007
  const client = builderClient;
@@ -41563,7 +42130,8 @@ ${await res.text()}`);
41563
42130
  try {
41564
42131
  const loc = locationId2 ?? client.locationId;
41565
42132
  const result = await smartListRequest("GET", `/${loc}/${listId}`);
41566
- return jsonResponse(result);
42133
+ const note = contactServiceNote(result);
42134
+ return note ? jsonResponse({ smart_list: result, note }) : jsonResponse(result);
41567
42135
  } catch (error) {
41568
42136
  return errorResponse(error);
41569
42137
  }
@@ -41571,10 +42139,10 @@ ${await res.text()}`);
41571
42139
  );
41572
42140
  server2.tool(
41573
42141
  "create_smart_list",
41574
- "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.',
41575
42143
  {
41576
42144
  name: import_zod59.z.string().describe("Display name for the smart list."),
41577
- 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."),
41578
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."),
41579
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."),
41580
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."),
@@ -41583,6 +42151,11 @@ ${await res.text()}`);
41583
42151
  },
41584
42152
  async ({ name, objectKey, filters, columns, pipelineIds, defaultInPipelines, locationId: locationId2 }) => {
41585
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
+ }
41586
42159
  const loc = locationId2 ?? client.locationId;
41587
42160
  const body = { name, objectKey };
41588
42161
  if (filters !== void 0) body.filters = filters;
@@ -41620,8 +42193,13 @@ ${await res.text()}`);
41620
42193
  if (Object.keys(body).length === 0) {
41621
42194
  throw new Error("Pass at least one of: name, filters, columns, pipelineIds, defaultInPipelines. Nothing to update.");
41622
42195
  }
42196
+ let note;
42197
+ try {
42198
+ note = contactServiceNote(await smartListRequest("GET", `/${loc}/${listId}`));
42199
+ } catch {
42200
+ }
41623
42201
  const result = await smartListRequest("PUT", `/${loc}/${listId}`, body);
41624
- return jsonResponse(result);
42202
+ return note ? jsonResponse({ updated: result, note }) : jsonResponse(result);
41625
42203
  } catch (error) {
41626
42204
  return errorResponse(error);
41627
42205
  }
@@ -41638,8 +42216,13 @@ ${await res.text()}`);
41638
42216
  async ({ listId, locationId: locationId2 }) => {
41639
42217
  try {
41640
42218
  const loc = locationId2 ?? client.locationId;
42219
+ let note;
42220
+ try {
42221
+ note = contactServiceNote(await smartListRequest("GET", `/${loc}/${listId}`));
42222
+ } catch {
42223
+ }
41641
42224
  const result = await smartListRequest("DELETE", `/${loc}/${listId}`);
41642
- return jsonResponse(result);
42225
+ return note ? jsonResponse({ deleted: result, note }) : jsonResponse(result);
41643
42226
  } catch (error) {
41644
42227
  return errorResponse(error);
41645
42228
  }
@@ -48495,6 +49078,140 @@ var init_delivery_audit = __esm({
48495
49078
  }
48496
49079
  });
48497
49080
 
49081
+ // src/command-os/build/verify-findings.ts
49082
+ var verify_findings_exports = {};
49083
+ __export(verify_findings_exports, {
49084
+ CHECKABLE_KINDS: () => CHECKABLE_KINDS,
49085
+ CHECKABLE_KIND_NAMES: () => CHECKABLE_KIND_NAMES,
49086
+ isCheckableKind: () => isCheckableKind,
49087
+ neverLookedAt: () => neverLookedAt,
49088
+ normaliseKind: () => normaliseKind,
49089
+ refuseUnseenFindings: () => refuseUnseenFindings
49090
+ });
49091
+ async function readPaged(read2, spec, locationId2, wanted) {
49092
+ const base = spec.params?.(locationId2) ?? {};
49093
+ const seen = /* @__PURE__ */ new Set();
49094
+ let skip = 0;
49095
+ for (let reads = 0; reads < MAX_READS; reads++) {
49096
+ const raw = await read2(spec.path(locationId2), { ...base, limit: String(PAGE), skip: String(skip) });
49097
+ const ids = spec.ids(raw);
49098
+ const total = totalOf(raw);
49099
+ let added = 0;
49100
+ for (const id of ids) if (!seen.has(id)) {
49101
+ seen.add(id);
49102
+ added++;
49103
+ }
49104
+ if ([...wanted].every((id) => seen.has(id))) return { seen };
49105
+ if (total !== void 0 && seen.size >= total) return { seen };
49106
+ if (ids.length === 0) {
49107
+ return total !== void 0 && seen.size < total ? { seen, incomplete: `the account says it has ${total} but returned ${seen.size}` } : { seen };
49108
+ }
49109
+ if (added === 0) return { seen, incomplete: "the account returned the same entries again when asked for the next page" };
49110
+ skip += ids.length;
49111
+ }
49112
+ return { seen, incomplete: `it was still returning new entries after ${MAX_READS} pages` };
49113
+ }
49114
+ function isCheckableKind(kind) {
49115
+ return Object.hasOwn(CHECKABLE_KINDS, normaliseKind(kind));
49116
+ }
49117
+ function normaliseKind(kind) {
49118
+ const snake = kind.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
49119
+ const singular = snake.endsWith("s") && !snake.endsWith("ss") ? snake.slice(0, -1) : snake;
49120
+ return singular === "stage" ? "pipeline_stage" : singular;
49121
+ }
49122
+ async function refuseUnseenFindings(read2, locationId2, findings) {
49123
+ const refusals = [];
49124
+ const rawByRead = /* @__PURE__ */ new Map();
49125
+ const readByKind = /* @__PURE__ */ new Map();
49126
+ const wantedByKind = /* @__PURE__ */ new Map();
49127
+ for (const f of findings) {
49128
+ const kind = normaliseKind(f.kind);
49129
+ wantedByKind.set(kind, (wantedByKind.get(kind) ?? /* @__PURE__ */ new Set()).add(f.id));
49130
+ }
49131
+ for (const f of findings) {
49132
+ const kind = normaliseKind(f.kind);
49133
+ const spec = Object.hasOwn(CHECKABLE_KINDS, kind) ? CHECKABLE_KINDS[kind] : void 0;
49134
+ if (!spec) {
49135
+ refusals.push({ finding: f, because: `"${f.id}" is a "${f.kind}", which the planner cannot check against the account. Findings it can check: ${CHECKABLE_KIND_NAMES}.` });
49136
+ continue;
49137
+ }
49138
+ let got = readByKind.get(kind);
49139
+ if (got === void 0) {
49140
+ if (spec.paged) {
49141
+ try {
49142
+ got = await readPaged(read2, spec, locationId2, wantedByKind.get(kind) ?? /* @__PURE__ */ new Set());
49143
+ } catch (e) {
49144
+ got = e instanceof Error ? e : new Error(String(e));
49145
+ }
49146
+ } else {
49147
+ const readKey = `${spec.path(locationId2)}?${JSON.stringify(spec.params?.(locationId2) ?? {})}`;
49148
+ let raw = rawByRead.get(readKey);
49149
+ if (raw === void 0) {
49150
+ try {
49151
+ raw = await read2(spec.path(locationId2), spec.params?.(locationId2));
49152
+ } catch (e) {
49153
+ raw = e instanceof Error ? e : new Error(String(e));
49154
+ }
49155
+ rawByRead.set(readKey, raw);
49156
+ }
49157
+ got = raw instanceof Error ? raw : { seen: new Set(spec.ids(raw)) };
49158
+ }
49159
+ readByKind.set(kind, got);
49160
+ }
49161
+ if (got instanceof Error) {
49162
+ const why = got.message.split("\n")[0].slice(0, 160);
49163
+ refusals.push({ finding: f, because: `The account's ${kind.replace(/_/g, " ")} list could not be read (${why}), so "${f.id}" cannot be verified. Nothing is planned against an id that could not be checked.` });
49164
+ continue;
49165
+ }
49166
+ if (got.seen.has(f.id)) continue;
49167
+ refusals.push({
49168
+ finding: f,
49169
+ because: got.incomplete ? `The account's ${kind.replace(/_/g, " ")} list could not be read in full (${got.incomplete}). "${f.id}" was not among the entries it did return, but the list was not read to the end, so it is neither verified nor called missing. Nothing is planned against an id that could not be checked.` : neverLookedAt(f.id)
49170
+ });
49171
+ }
49172
+ return refusals;
49173
+ }
49174
+ var PAGE, MAX_READS, totalOf, rows, idsOf, byLocation, CHECKABLE_KINDS, CHECKABLE_KIND_NAMES, neverLookedAt;
49175
+ var init_verify_findings = __esm({
49176
+ "src/command-os/build/verify-findings.ts"() {
49177
+ "use strict";
49178
+ PAGE = 100;
49179
+ MAX_READS = 50;
49180
+ totalOf = (raw) => {
49181
+ const body = raw;
49182
+ const t = body?.total ?? body?.meta?.total;
49183
+ return typeof t === "number" && Number.isFinite(t) && t >= 0 ? t : void 0;
49184
+ };
49185
+ rows = (raw, key) => {
49186
+ const body = raw;
49187
+ const list2 = Array.isArray(body?.[key]) ? body?.[key] : Array.isArray(raw) ? raw : [];
49188
+ return list2.filter((x) => !!x && typeof x === "object");
49189
+ };
49190
+ idsOf = (raw, key) => rows(raw, key).map((r) => r.id).filter((id) => typeof id === "string" && id.length > 0);
49191
+ byLocation = (locationId2) => ({ locationId: locationId2 });
49192
+ CHECKABLE_KINDS = {
49193
+ pipeline: { path: () => "/opportunities/pipelines", params: byLocation, ids: (raw) => idsOf(raw, "pipelines") },
49194
+ pipeline_stage: {
49195
+ path: () => "/opportunities/pipelines",
49196
+ params: byLocation,
49197
+ ids: (raw) => rows(raw, "pipelines").flatMap((p) => idsOf({ stages: p.stages }, "stages"))
49198
+ },
49199
+ workflow: { path: () => "/workflows/", params: byLocation, ids: (raw) => idsOf(raw, "workflows") },
49200
+ custom_field: { path: (loc) => `/locations/${loc}/customFields`, ids: (raw) => idsOf(raw, "customFields") },
49201
+ custom_value: { path: (loc) => `/locations/${loc}/customValues`, ids: (raw) => idsOf(raw, "customValues") },
49202
+ tag: { path: (loc) => `/locations/${loc}/tags`, ids: (raw) => idsOf(raw, "tags"), paged: true },
49203
+ calendar: { path: () => "/calendars/", params: byLocation, ids: (raw) => idsOf(raw, "calendars") },
49204
+ form: { path: () => "/forms/", params: byLocation, ids: (raw) => idsOf(raw, "forms"), paged: true },
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 }
49209
+ };
49210
+ CHECKABLE_KIND_NAMES = Object.keys(CHECKABLE_KINDS).join(", ");
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.`;
49212
+ }
49213
+ });
49214
+
48498
49215
  // src/command-os/build/automation.ts
48499
49216
  function canonical(v) {
48500
49217
  if (Array.isArray(v)) return v.map(canonical);
@@ -48535,7 +49252,7 @@ function checkPlan(goal, findings, steps, now = /* @__PURE__ */ new Date(), know
48535
49252
  const at = new Date(f.observedAt).getTime();
48536
49253
  if (!Number.isFinite(at)) continue;
48537
49254
  if (at < cutoff) continue;
48538
- explored.set(f.id, f);
49255
+ explored.set(f.id, (explored.get(f.id) ?? /* @__PURE__ */ new Set()).add(normaliseKind(f.kind)));
48539
49256
  }
48540
49257
  const staleIds = new Set(
48541
49258
  findings.filter((f) => {
@@ -48555,7 +49272,13 @@ function checkPlan(goal, findings, steps, now = /* @__PURE__ */ new Date(), know
48555
49272
  const deps = [];
48556
49273
  for (const t of s.targets) {
48557
49274
  if (isExisting(t)) {
48558
- if (explored.has(t.existing)) continue;
49275
+ const kinds = explored.get(t.existing);
49276
+ if (kinds) {
49277
+ if (!kinds.has(normaliseKind(t.kind))) {
49278
+ refusals.push({ step: s.id, because: `"${t.existing}" was looked at as a ${[...kinds].join(" or ").replace(/_/g, " ")}, and this step aims at it as a ${t.kind}. The wrong kind of id silently kills every action after it, so this is refused rather than attempted.` });
49279
+ }
49280
+ continue;
49281
+ }
48559
49282
  if (staleIds.has(t.existing)) {
48560
49283
  refusals.push({ step: s.id, because: `"${t.existing}" was last looked at more than an hour ago. Ids move; look again before planning against it.` });
48561
49284
  } else {
@@ -48727,6 +49450,7 @@ var init_automation = __esm({
48727
49450
  "src/command-os/build/automation.ts"() {
48728
49451
  "use strict";
48729
49452
  import_node_crypto11 = require("node:crypto");
49453
+ init_verify_findings();
48730
49454
  isExisting = (t) => "existing" in t;
48731
49455
  FINDINGS_STALE_AFTER_MINUTES = 60;
48732
49456
  }
@@ -48821,113 +49545,275 @@ var init_store5 = __esm({
48821
49545
  }
48822
49546
  });
48823
49547
 
48824
- // src/command-os/build/verify-findings.ts
48825
- var verify_findings_exports = {};
48826
- __export(verify_findings_exports, {
48827
- CHECKABLE_KINDS: () => CHECKABLE_KINDS,
48828
- CHECKABLE_KIND_NAMES: () => CHECKABLE_KIND_NAMES,
48829
- isCheckableKind: () => isCheckableKind,
48830
- neverLookedAt: () => neverLookedAt,
48831
- normaliseKind: () => normaliseKind,
48832
- refuseUnseenFindings: () => refuseUnseenFindings
48833
- });
48834
- async function readAll(read2, spec, locationId2) {
48835
- const base = spec.params?.(locationId2);
48836
- if (!spec.paged) return new Set(spec.ids(await read2(spec.path(locationId2), base)));
48837
- const seen = /* @__PURE__ */ new Set();
48838
- for (let page = 0; page < MAX_PAGES2; page++) {
48839
- const ids = spec.ids(await read2(spec.path(locationId2), { ...base ?? {}, limit: String(PAGE), skip: String(page * PAGE) }));
48840
- for (const id of ids) seen.add(id);
48841
- if (ids.length < PAGE) return seen;
48842
- }
48843
- throw new Error(`more than ${PAGE * MAX_PAGES2} entries; the list was not read to the end`);
48844
- }
48845
- function isCheckableKind(kind) {
48846
- return Object.hasOwn(CHECKABLE_KINDS, normaliseKind(kind));
48847
- }
48848
- function normaliseKind(kind) {
48849
- const snake = kind.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
48850
- const singular = snake.endsWith("s") && !snake.endsWith("ss") ? snake.slice(0, -1) : snake;
48851
- return singular === "stage" ? "pipeline_stage" : singular;
49548
+ // src/command-os/build/arg-ids.ts
49549
+ function isIdShapedKey(key) {
49550
+ return /(?:Id|ID)s?$|_ids?$/.test(key);
48852
49551
  }
48853
- async function refuseUnseenFindings(read2, locationId2, findings) {
48854
- const refusals = [];
48855
- const rawByRead = /* @__PURE__ */ new Map();
48856
- const seenByKind = /* @__PURE__ */ new Map();
48857
- for (const f of findings) {
48858
- const kind = normaliseKind(f.kind);
48859
- const spec = Object.hasOwn(CHECKABLE_KINDS, kind) ? CHECKABLE_KINDS[kind] : void 0;
48860
- if (!spec) {
48861
- refusals.push({ finding: f, because: `"${f.id}" is a "${f.kind}", which the planner cannot check against the account. Findings it can check: ${CHECKABLE_KIND_NAMES}.` });
48862
- continue;
49552
+ function scanArgs(args, maxNodes = MAX_ARG_NODES) {
49553
+ const out = { ids: [], locations: [], unknown: [], uncheckable: [] };
49554
+ const add = (kind, v, key) => {
49555
+ const list2 = Array.isArray(v) ? v : [v];
49556
+ for (const x of list2) {
49557
+ const id = typeof x === "number" ? String(x) : x;
49558
+ if (typeof id === "string" && id.trim()) out.ids.push({ kind, id, key });
48863
49559
  }
48864
- let seen = seenByKind.get(kind);
48865
- if (seen === void 0) {
48866
- if (spec.paged) {
48867
- try {
48868
- seen = await readAll(read2, spec, locationId2);
48869
- } catch (e) {
48870
- seen = e instanceof Error ? e : new Error(String(e));
48871
- }
48872
- } else {
48873
- const readKey = `${spec.path(locationId2)}?${JSON.stringify(spec.params?.(locationId2) ?? {})}`;
48874
- let raw = rawByRead.get(readKey);
48875
- if (raw === void 0) {
48876
- try {
48877
- raw = await read2(spec.path(locationId2), spec.params?.(locationId2));
48878
- } catch (e) {
48879
- raw = e instanceof Error ? e : new Error(String(e));
48880
- }
48881
- rawByRead.set(readKey, raw);
48882
- }
48883
- seen = raw instanceof Error ? raw : new Set(spec.ids(raw));
48884
- }
48885
- seenByKind.set(kind, seen);
49560
+ };
49561
+ const hasValue = (v) => typeof v === "string" && v.trim().length > 0 || typeof v === "number" || Array.isArray(v) && v.some(hasValue);
49562
+ const stack = [{ v: args, inFields: false }];
49563
+ let visited = 0;
49564
+ while (stack.length) {
49565
+ const { v, inFields, nodeType: parentType } = stack.pop();
49566
+ if (++visited > maxNodes) {
49567
+ out.incomplete = `The step's arguments are too large to check in full (more than ${maxNodes} values), so the ids in them could not all be verified.`;
49568
+ break;
48886
49569
  }
48887
- if (seen instanceof Error) {
48888
- const why = seen.message.split("\n")[0].slice(0, 160);
48889
- refusals.push({ finding: f, because: `The account's ${kind.replace(/_/g, " ")} list could not be read (${why}), so "${f.id}" cannot be verified. Nothing is planned against an id that could not be checked.` });
49570
+ if (Array.isArray(v)) {
49571
+ for (const x of v) stack.push({ v: x, inFields, nodeType: parentType });
48890
49572
  continue;
48891
49573
  }
48892
- if (!seen.has(f.id)) refusals.push({ finding: f, because: neverLookedAt(f.id) });
49574
+ if (!isRecord10(v)) continue;
49575
+ const isNode = typeof v.type === "string" && v.type && (isRecord10(v.attributes) || Array.isArray(v.conditions));
49576
+ const nodeType = isNode ? v.type : parentType;
49577
+ const refs = [];
49578
+ if (typeof v.type === "string" && isRecord10(v.attributes)) extractFromAction(v, refs);
49579
+ if (Array.isArray(v.conditions)) extractFromTrigger(v, refs);
49580
+ for (const r of refs) {
49581
+ if (r.byKey || !r.id.trim()) continue;
49582
+ const kind = AUDIT_KIND[r.kind];
49583
+ if (kind) out.ids.push({ kind, id: r.id, key: r.where });
49584
+ else out.uncheckable.push({ kind: r.kind, id: r.id, key: r.where });
49585
+ }
49586
+ for (const [k, x] of Object.entries(v)) {
49587
+ if (k === "locationId") {
49588
+ if (typeof x === "string" && x.length > 0) out.locations.push(x);
49589
+ } else if (k === "traffic_weightage" && isRecord10(x)) {
49590
+ add("user", Object.keys(x), k);
49591
+ continue;
49592
+ } else if (k === "traffic_index" && Array.isArray(x)) {
49593
+ add("user", x.map((e) => isRecord10(e) ? e.id : void 0), k);
49594
+ } else if (k === "selectedUser") {
49595
+ if (v.userType !== "all") add("user", x, k);
49596
+ } else if (k === "field" && inFields) {
49597
+ if (typeof x === "string") {
49598
+ const standardKey = STANDARD_CONTACT_FIELDS.has(x.toLowerCase());
49599
+ if (!standardKey || isIdShaped(x)) add("custom_field", x, "fields[].field");
49600
+ }
49601
+ } else if (k === "customFields" && Array.isArray(x)) {
49602
+ add("custom_field", x.map((e) => isRecord10(e) ? e.id : void 0), "customFields[].id");
49603
+ } else if (k === "customField" && isRecord10(x)) {
49604
+ add("custom_field", x.id, "customField.id");
49605
+ } else if (Object.hasOwn(ID_ARG_KINDS, k)) {
49606
+ add(ID_ARG_KINDS[k], x, k);
49607
+ } else if (isIdShapedKey(k) && !Object.hasOwn(NOT_CHECKABLE_ID_ARGS, k) && hasValue(x)) {
49608
+ out.unknown.push({ key: k, value: JSON.stringify(x).slice(0, 80), ...nodeType ? { nodeType } : {} });
49609
+ }
49610
+ if ((k === "filterField" || k === "field" && !inFields) && typeof x === "string" && Object.hasOwn(CONDITION_FIELD_KINDS, x)) {
49611
+ add(CONDITION_FIELD_KINDS[x], Object.hasOwn(v, "secondValue") ? v.secondValue : v.value, `${k} ${x}`);
49612
+ }
49613
+ if (x && typeof x === "object") stack.push({ v: x, inFields: k === "fields" && Array.isArray(x), nodeType });
49614
+ }
48893
49615
  }
48894
- return refusals;
49616
+ return out;
48895
49617
  }
48896
- var PAGE, MAX_PAGES2, rows, idsOf, byLocation, CHECKABLE_KINDS, CHECKABLE_KIND_NAMES, neverLookedAt;
48897
- var init_verify_findings = __esm({
48898
- "src/command-os/build/verify-findings.ts"() {
49618
+ var ID_ARG_KINDS, CONDITION_FIELD_KINDS, AUDIT_KIND, NOT_CHECKABLE_ID_ARGS, MAX_ARG_NODES, isRecord10;
49619
+ var init_arg_ids = __esm({
49620
+ "src/command-os/build/arg-ids.ts"() {
48899
49621
  "use strict";
48900
- PAGE = 100;
48901
- MAX_PAGES2 = 20;
48902
- rows = (raw, key) => {
48903
- const body = raw;
48904
- const list2 = Array.isArray(body?.[key]) ? body?.[key] : Array.isArray(raw) ? raw : [];
48905
- return list2.filter((x) => !!x && typeof x === "object");
48906
- };
48907
- idsOf = (raw, key) => rows(raw, key).map((r) => r.id).filter((id) => typeof id === "string" && id.length > 0);
48908
- byLocation = (locationId2) => ({ locationId: locationId2 });
48909
- CHECKABLE_KINDS = {
48910
- pipeline: { path: () => "/opportunities/pipelines", params: byLocation, ids: (raw) => idsOf(raw, "pipelines") },
48911
- pipeline_stage: {
48912
- path: () => "/opportunities/pipelines",
48913
- params: byLocation,
48914
- ids: (raw) => rows(raw, "pipelines").flatMap((p) => idsOf({ stages: p.stages }, "stages"))
48915
- },
48916
- workflow: { path: () => "/workflows/", params: byLocation, ids: (raw) => idsOf(raw, "workflows") },
48917
- custom_field: { path: (loc) => `/locations/${loc}/customFields`, ids: (raw) => idsOf(raw, "customFields") },
48918
- custom_value: { path: (loc) => `/locations/${loc}/customValues`, ids: (raw) => idsOf(raw, "customValues") },
48919
- tag: { path: (loc) => `/locations/${loc}/tags`, ids: (raw) => idsOf(raw, "tags"), paged: true },
48920
- calendar: { path: () => "/calendars/", params: byLocation, ids: (raw) => idsOf(raw, "calendars") },
48921
- form: { path: () => "/forms/", params: byLocation, ids: (raw) => idsOf(raw, "forms"), paged: true },
48922
- user: { path: () => "/users/", params: byLocation, ids: (raw) => idsOf(raw, "users") }
48923
- };
48924
- CHECKABLE_KIND_NAMES = Object.keys(CHECKABLE_KINDS).join(", ");
48925
- 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.`;
49622
+ init_validators();
49623
+ init_id_shape();
49624
+ ID_ARG_KINDS = Object.freeze({
49625
+ pipelineId: "pipeline",
49626
+ pipelineIds: "pipeline",
49627
+ pipeline_id: "pipeline",
49628
+ defaultInPipelines: "pipeline",
49629
+ pipelineStageId: "pipeline_stage",
49630
+ pipeline_stage_id: "pipeline_stage",
49631
+ // the workflow create_opportunity node (validators.ts)
49632
+ stageId: "pipeline_stage",
49633
+ workflowId: "workflow",
49634
+ workflowIds: "workflow",
49635
+ workflow_id: "workflow",
49636
+ sourceWorkflowId: "workflow",
49637
+ callEndWorkflowIds: "workflow",
49638
+ fieldId: "custom_field",
49639
+ customFieldId: "custom_field",
49640
+ consentFieldId: "custom_field",
49641
+ customDateFieldId: "custom_field",
49642
+ valueId: "custom_value",
49643
+ customValueId: "custom_value",
49644
+ // The live DBR workflows: update_custom_value and math_operation
49645
+ // (round-4 delta review, 2026-09-15).
49646
+ custom_value_id: "custom_value",
49647
+ sourceCustomValueId: "custom_value",
49648
+ targetCustomValueId: "custom_value",
49649
+ tagId: "tag",
49650
+ calendarId: "calendar",
49651
+ formId: "form",
49652
+ formIds: "form",
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)
49656
+ userId: "user",
49657
+ userIds: "user",
49658
+ assignedUserId: "user",
49659
+ sentBy: "user",
49660
+ // send_document's sender (release review F2)
49661
+ // Workflow action payloads (CLAUDE.md action schemas). These do not end in
49662
+ // "Id", which is exactly why the guess never saw them.
49663
+ assignedTo: "user",
49664
+ user_list: "user"
49665
+ });
49666
+ CONDITION_FIELD_KINDS = Object.freeze({
49667
+ ...ID_ARG_KINDS,
49668
+ "form.id": "form",
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",
49676
+ "opportunity.pipelineId": "pipeline",
49677
+ "opportunity.pipelineStageId": "pipeline_stage",
49678
+ "opportunity.assignedTo": "user",
49679
+ "contact.assignedTo": "user",
49680
+ "workflow.id": "workflow"
49681
+ });
49682
+ AUDIT_KIND = Object.freeze({
49683
+ pipeline: "pipeline",
49684
+ stage: "pipeline_stage",
49685
+ custom_field: "custom_field",
49686
+ user: "user",
49687
+ workflow: "workflow",
49688
+ form: "form",
49689
+ calendar: "calendar",
49690
+ survey: "survey"
49691
+ });
49692
+ NOT_CHECKABLE_ID_ARGS = Object.freeze({
49693
+ // The account itself, not an object inside it. One account per plan is
49694
+ // enforced separately, and the build is bound to it (agency-build.ts).
49695
+ locationId: "the sub-account itself",
49696
+ location_id: "the sub-account itself",
49697
+ altId: "the sub-account itself",
49698
+ companyId: "the agency itself",
49699
+ company_id: "the agency itself",
49700
+ ghl_location_id: "the sub-account itself",
49701
+ ghl_company_id: "the agency itself",
49702
+ test_location_id: "the sub-account itself",
49703
+ demoLocationId: "a sub-account, not an object inside one",
49704
+ clientLocationId: "a sub-account, not an object inside one",
49705
+ sourceLocationId: "a sub-account, not an object inside one",
49706
+ // Objects that live in the account but have no list the planner reads back.
49707
+ contactId: "contacts are not listed in full by the planner",
49708
+ contactIds: "contacts are not listed in full by the planner",
49709
+ opportunityId: "opportunities are not listed in full by the planner",
49710
+ templateId: "templates have no read-back list here",
49711
+ agentId: "AI agents have no read-back list here",
49712
+ assignedEmployeeId: "a Conversation AI employee; no read-back list here",
49713
+ knowledgeBaseId: "knowledge bases have no read-back list here",
49714
+ knowledgeBaseIds: "knowledge bases have no read-back list here",
49715
+ lastKnowledgeBaseId: "knowledge bases have no read-back list here",
49716
+ faqId: "knowledge base entries have no read-back list here",
49717
+ lastFaqId: "knowledge base entries have no read-back list here",
49718
+ urlIds: "knowledge base entries have no read-back list here",
49719
+ funnelId: "funnels have no read-back list here",
49720
+ pageId: "funnel pages have no read-back list here",
49721
+ stepId: "funnel steps (and this plan's own steps) have no read-back list here",
49722
+ recordId: "custom object records have no read-back list here",
49723
+ targetRecordId: "custom object records have no read-back list here",
49724
+ relationId: "associations have no read-back list here",
49725
+ associationId: "associations have no read-back list here",
49726
+ productId: "products have no read-back list here",
49727
+ productIds: "products have no read-back list here",
49728
+ invoiceId: "invoices have no read-back list here",
49729
+ estimateId: "estimates have no read-back list here",
49730
+ orderId: "orders have no read-back list here",
49731
+ subscriptionId: "subscriptions have no read-back list here",
49732
+ couponId: "coupons have no read-back list here",
49733
+ entityId: "payment records have no read-back list here",
49734
+ actionId: "AI agent actions have no read-back list here",
49735
+ actionIds: "AI agent actions have no read-back list here",
49736
+ listId: "smart lists have no read-back list here",
49737
+ webhookId: "webhooks have no read-back list here",
49738
+ taskId: "contact tasks have no read-back list here",
49739
+ eventId: "appointments have no read-back list here",
49740
+ postId: "social posts have no read-back list here",
49741
+ accountIds: "social accounts have no read-back list here",
49742
+ messageId: "messages have no read-back list here",
49743
+ conversationId: "conversations have no read-back list here",
49744
+ conversationProviderId: "conversation providers have no read-back list here",
49745
+ documentId: "documents have no read-back list here",
49746
+ businessId: "businesses have no read-back list here",
49747
+ numberPoolId: "number pools have no read-back list here",
49748
+ linkId: "trigger links have no read-back list here",
49749
+ blogId: "blogs have no read-back list here",
49750
+ categoryId: "categories have no read-back list here",
49751
+ submissionId: "form submissions have no read-back list here",
49752
+ mediaId: "media files have no read-back list here",
49753
+ photoAssetId: "media files have no read-back list here",
49754
+ beforeAssetId: "media files have no read-back list here",
49755
+ afterAssetId: "media files have no read-back list here",
49756
+ domainId: "domains have no read-back list here",
49757
+ chatWidgetId: "chat widgets have no read-back list here",
49758
+ callId: "calls have no read-back list here",
49759
+ voiceId: "voices have no read-back list here",
49760
+ placeId: "a Google Business place, outside the account",
49761
+ reviewLinkId: "review links have no read-back list here",
49762
+ linkIds: "a link_click goal's trigger links, which have no read-back list here",
49763
+ globalProductIds: "a payment_received goal's products, which have no read-back list here",
49764
+ global_product_ids: "products have no read-back list here",
49765
+ snapshot_id: "snapshots belong to the agency, not the sub-account",
49766
+ parentId: "a folder or parent record with no read-back list here",
49767
+ // Outside GoHighLevel altogether.
49768
+ stripeCustomerId: "a Stripe record, not a GoHighLevel one",
49769
+ google_oauth_client_id: "a Google OAuth client, not a GoHighLevel record",
49770
+ ghl_user_id: "the Firebase login's uid, not an object in the sub-account",
49771
+ device_id: "this machine's id",
49772
+ // This product's own records.
49773
+ buildId: "a Layer 5 build on this machine",
49774
+ runId: "a build run on this machine",
49775
+ planId: "a saved plan on this machine",
49776
+ briefId: "a brief on this machine",
49777
+ operationId: "an operation on this machine",
49778
+ questionId: "an intake question",
49779
+ ruleId: "a design rule",
49780
+ startAfterId: "a paging cursor",
49781
+ purchasedFeatureIds: "feature names, not account objects",
49782
+ // Ids inside a workflow's or a page's own document: the node, branch or
49783
+ // element they belong to, never an object in the account.
49784
+ _id: "an element's own id inside a document",
49785
+ nodeId: "an element's own id inside a page",
49786
+ appId: "an element's own id inside a page",
49787
+ goToId: "an element's own id inside a page",
49788
+ __conditionId: "a condition's own id inside a workflow",
49789
+ __id: "an operator's own id inside a math step",
49790
+ __segmentId: "a segment's own id inside a workflow",
49791
+ ifElseNodeId: "a node's own id inside a workflow",
49792
+ target_node_id: "a node's own id inside a workflow",
49793
+ targetNodeId: "the step a goto jumps to, inside the same workflow",
49794
+ stepIds: "the steps an email_event goal waits on, inside the same workflow",
49795
+ invoiceStepId: "the step an invoice_paid goal waits on, inside the same workflow",
49796
+ sourceId: `GoHighLevel's attribution key on an email edited in its UI, "<workflowId>:<stepId>"; not checked on save (workflow-builder-client.ts)`,
49797
+ origin_id: "a trigger's own id inside a workflow"
49798
+ });
49799
+ MAX_ARG_NODES = 2e5;
49800
+ isRecord10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
48926
49801
  }
48927
49802
  });
48928
49803
 
48929
49804
  // src/tools/agency-build.ts
48930
- function registerAgencyBuildTools(server2, knownTools, verify) {
49805
+ function accountMismatch(a, active) {
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
+ }
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.";
49812
+ }
49813
+ if (active === a.locationId) return null;
49814
+ return active ? `This build's ids were checked against sub-account ${a.locationId}, and the server is now pointed at ${active}. An id checked on one account means nothing on another, so nothing is approved or recorded here. switch_location back to ${a.locationId}, confirm it with get_current_location, then try again.` : `This build's ids were checked against sub-account ${a.locationId}, and the server is not pointed at any sub-account. switch_location to ${a.locationId}, confirm it with get_current_location, then try again.`;
49815
+ }
49816
+ function registerAgencyBuildTools(server2, knownTools, verify, activeLocation) {
48931
49817
  safeTool(
48932
49818
  server2,
48933
49819
  "plan_automation",
@@ -48955,43 +49841,67 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
48955
49841
  const found = findings.map((f) => ({ ...f, observedAt: f.observedAt ?? now.toISOString() }));
48956
49842
  const plan = steps;
48957
49843
  const check = checkPlan(goal, found, plan, now, knownTools?.());
49844
+ let boundAccount;
48958
49845
  if (check.ok) {
48959
- const named = /* @__PURE__ */ new Set();
48960
- const collectLocations = (v, depth) => {
48961
- if (!v || typeof v !== "object" || depth > 4) return;
48962
- for (const [k, x] of Object.entries(v)) {
48963
- if (k === "locationId" && typeof x === "string" && x.length > 0) named.add(x);
48964
- collectLocations(x, depth + 1);
48965
- }
48966
- };
48967
- for (const s of plan) collectLocations(s.args, 0);
49846
+ const scans = plan.map((s) => ({ step: s, scan: scanArgs(s.args) }));
49847
+ const named = new Set(scans.flatMap(({ scan }) => scan.locations));
48968
49848
  if (locationId2) named.add(locationId2);
48969
49849
  if (named.size > 1) {
48970
49850
  return { planned: false, refusals: [{ because: `The steps name ${named.size} different sub-accounts (${[...named].join(", ")}). A plan runs against one account; split it.` }], note: "Nothing was saved." };
48971
49851
  }
48972
- const account2 = [...named][0];
48973
- const declaredRefs = new Set(plan.flatMap((s) => (s.creates ?? []).map((c) => c.ref)));
48974
- const knownIds = new Set(found.map((f) => f.id));
49852
+ const account2 = [...named][0] ?? activeLocation?.();
49853
+ if (!account2) {
49854
+ return { planned: false, refusals: [{ because: "The plan names no sub-account and the server is pointed at none, so there is no sub-account to check its ids against or to run it on. switch_location to the client's account first." }], note: "Nothing was saved." };
49855
+ }
49856
+ const refKinds = new Map(plan.flatMap((s) => (s.creates ?? []).map((c) => [c.ref, normaliseKind(c.kind)])));
49857
+ const seenKeys = new Set(found.map((f) => `${normaliseKind(f.kind)}:${f.id}`));
48975
49858
  const hidden = [];
48976
- const hiddenIds = /* @__PURE__ */ new Set();
48977
- const scan = (v, depth) => {
48978
- if (!v || typeof v !== "object" || depth > 4) return;
48979
- for (const [k, x] of Object.entries(v)) {
48980
- if (/Id$/.test(k) && k !== "locationId" && k !== "companyId" && typeof x === "string" && x.length > 0 && !knownIds.has(x) && !declaredRefs.has(x) && !hiddenIds.has(x) && isCheckableKind(k.replace(/Id$/, ""))) {
48981
- hiddenIds.add(x);
48982
- hidden.push({ kind: k.replace(/Id$/, ""), id: x, observedAt: now.toISOString() });
49859
+ const argRefusals = [];
49860
+ for (const { step: s, scan } of scans) {
49861
+ if (scan.incomplete) argRefusals.push({ because: `Step "${s.id}": ${scan.incomplete}`, finding: { kind: "arguments", id: s.id } });
49862
+ for (const u of scan.unknown) {
49863
+ const inNode = u.nodeType ? ` inside a "${u.nodeType}" node` : "";
49864
+ argRefusals.push({
49865
+ because: `Step "${s.id}" passes "${u.key}"${inNode}: ${u.value}. The planner has no way to check that id against the account, so the plan is refused rather than trusted: a step aimed at an unverified id silently kills every action after it. Two ways on: leave that step out of the plan and do it by hand, or report the node type and key (${u.nodeType ? `"${u.nodeType}" / ` : ""}"${u.key}") so it can be added to the ids this checks.`,
49866
+ finding: { kind: u.key, id: u.value }
49867
+ });
49868
+ }
49869
+ for (const { kind, id } of scan.uncheckable) {
49870
+ const key = `${kind}:${id}`;
49871
+ if (seenKeys.has(key)) continue;
49872
+ seenKeys.add(key);
49873
+ hidden.push({ kind, id, observedAt: now.toISOString() });
49874
+ }
49875
+ for (const { kind, id } of scan.ids) {
49876
+ const refKind = refKinds.get(id);
49877
+ if (refKind !== void 0) {
49878
+ if (refKind !== kind) {
49879
+ argRefusals.push({
49880
+ because: `Step "${s.id}" passes "${id}" where a ${kind.replace(/_/g, " ")} goes, but "${id}" is what an earlier step creates as a ${refKind.replace(/_/g, " ")}. The wrong kind of id silently kills every action after it, so this is refused.`,
49881
+ finding: { kind, id }
49882
+ });
49883
+ }
49884
+ continue;
48983
49885
  }
48984
- scan(x, depth + 1);
49886
+ const key = `${kind}:${id}`;
49887
+ if (seenKeys.has(key)) continue;
49888
+ seenKeys.add(key);
49889
+ hidden.push({ kind, id, observedAt: now.toISOString() });
48985
49890
  }
48986
- };
48987
- for (const s of plan) scan(s.args, 0);
49891
+ }
49892
+ if (argRefusals.length) {
49893
+ return { planned: false, refusals: argRefusals, note: "Nothing was saved. These arguments carry ids that cannot be checked as they stand." };
49894
+ }
48988
49895
  const toVerify = [...found, ...hidden];
49896
+ boundAccount = account2;
48989
49897
  const refusals = toVerify.length === 0 ? [] : verify ? await verify(account2, toVerify) : toVerify.map((f) => ({ finding: f, because: `"${f.id}" could not be checked against any account, so it is refused rather than trusted.` }));
48990
49898
  if (refusals.length) {
48991
49899
  return {
48992
49900
  planned: false,
48993
49901
  refusals: refusals.map((r) => ({ because: r.because, finding: { kind: r.finding.kind, id: r.finding.id } })),
48994
- note: "Nothing was saved. Every finding is read back from the account before a plan is kept, and these were not there."
49902
+ // Not "these were not there": a list that could not be read in full
49903
+ // proves nothing is missing, and each refusal says which it was (G-M2).
49904
+ note: "Nothing was saved. Every finding is read back from the account before a plan is kept, and these could not be verified there. Each reason above says whether the id is missing or the list could not be read."
48995
49905
  };
48996
49906
  }
48997
49907
  }
@@ -49005,6 +49915,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49005
49915
  const a = {
49006
49916
  id: newAutomationId(),
49007
49917
  goal,
49918
+ // check.ok is true here, so the block above set it.
49919
+ locationId: boundAccount,
49008
49920
  createdAt: now.toISOString(),
49009
49921
  planId: planFingerprint(goal, plan),
49010
49922
  findings: found,
@@ -49021,7 +49933,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49021
49933
  const s = plan.find((x) => x.id === id);
49022
49934
  return { step: s.id, does: s.why, tool: s.tool };
49023
49935
  }),
49024
- nextStep: `Read the steps above. Nothing runs until you approve this exact plan: approve_automation with buildId ${a.id} and planId ${a.planId}.`,
49936
+ account: a.locationId,
49937
+ nextStep: `Read the steps above. Nothing runs until you approve this exact plan: approve_automation with buildId ${a.id} and planId ${a.planId}. Its ids were checked against sub-account ${a.locationId}, so it is approved and run only while the server is pointed at that account.`,
49025
49938
  note: "Change any step after this and the plan gets a new fingerprint, which voids the approval. That is on purpose: the plan you read and the plan that runs have to be the same document."
49026
49939
  };
49027
49940
  }
@@ -49038,6 +49951,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49038
49951
  async ({ buildId, planId, approvedBy }) => {
49039
49952
  const a = readAutomation(buildId);
49040
49953
  if (!a) return { approved: false, error: `There is no build "${buildId}" on this machine.` };
49954
+ const wrong = accountMismatch(a, activeLocation?.());
49955
+ if (wrong) return { approved: false, error: wrong };
49041
49956
  const now = /* @__PURE__ */ new Date();
49042
49957
  const r = approve(a, approvedBy, planId, now.toISOString(), now);
49043
49958
  if (!r.ok) return { approved: false, error: r.because };
@@ -49063,6 +49978,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49063
49978
  async ({ buildId, stepId, state, undo, because, result, resolved: resolved2, retry }) => {
49064
49979
  const a = readAutomation(buildId);
49065
49980
  if (!a) return { recorded: false, error: `There is no build "${buildId}" on this machine.` };
49981
+ const wrong = state === "applied" ? accountMismatch(a, activeLocation?.()) : null;
49982
+ if (wrong) return { recorded: false, error: wrong };
49066
49983
  const r = recordStep(a, { stepId, state, at: (/* @__PURE__ */ new Date()).toISOString(), undo, because, result, resolved: resolved2, retryOf: retry });
49067
49984
  if (!r.ok) return { recorded: false, error: r.because };
49068
49985
  saveAutomation(r.automation);
@@ -49111,13 +50028,16 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49111
50028
  const rows2 = ids.map((id) => {
49112
50029
  const a = all[id];
49113
50030
  const p = position(a);
50031
+ const wrongAccount = p.nextStep ? accountMismatch(a, activeLocation?.()) : null;
49114
50032
  return {
49115
50033
  buildId: id,
49116
50034
  goal: a.goal,
50035
+ account: a.locationId ?? null,
49117
50036
  where: p.line,
49118
50037
  phase: p.phase,
49119
50038
  stepsDone: `${p.done} of ${p.total}`,
49120
- nextStep: p.nextStep ? { step: p.nextStep.id, does: p.nextStep.why, tool: p.nextStep.tool } : null,
50039
+ nextStep: p.nextStep && !wrongAccount ? { step: p.nextStep.id, does: p.nextStep.why, tool: p.nextStep.tool } : null,
50040
+ ...wrongAccount ? { wrongAccount } : {},
49121
50041
  stalledOn: p.stalledOn,
49122
50042
  blocked: p.blocked,
49123
50043
  approvedBy: a.approval?.by ?? null,
@@ -49142,6 +50062,7 @@ var init_agency_build = __esm({
49142
50062
  init_automation();
49143
50063
  init_store5();
49144
50064
  init_verify_findings();
50065
+ init_arg_ids();
49145
50066
  targetSchema = import_zod74.z.union([
49146
50067
  import_zod74.z.object({ existing: import_zod74.z.string().describe("A real id, which must appear in `findings`."), kind: import_zod74.z.string() }),
49147
50068
  import_zod74.z.object({ ref: import_zod74.z.string().describe("Something an earlier step in this plan creates."), kind: import_zod74.z.string() })
@@ -49566,7 +50487,7 @@ function dailyBrief(input) {
49566
50487
  );
49567
50488
  }
49568
50489
  }
49569
- 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;
49570
50491
  let headline3;
49571
50492
  if (today2.length === 1) {
49572
50493
  headline3 = `You have one call today \u2014 ${today2[0].who ?? today2[0].client} at ${today2[0].at}.`;
@@ -49597,6 +50518,8 @@ function dailyBrief(input) {
49597
50518
  headline3 = `Nothing needs you in what I could read \u2014 but ${unread[0].client} could not be read this morning.`;
49598
50519
  } else if (unread.length > 1) {
49599
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.";
49600
50523
  } else if (floorUncheckable.length) {
49601
50524
  const c = floorUncheckable[0].client;
49602
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.`;
@@ -50247,6 +51170,45 @@ function translateWorkflowAudit(audit, inputs = {}) {
50247
51170
  evidence: [{ tool: "audit_workflows", detail: `${s.shape_warnings_total ?? shapeCount} steps` }]
50248
51171
  });
50249
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
+ }
50250
51212
  return out;
50251
51213
  }
50252
51214
  function notCheckedFrom(audit) {
@@ -50986,6 +51948,15 @@ function publishNote(r) {
50986
51948
  if (r.publishFailed.length) {
50987
51949
  const lead = r.published.length ? `Auto-published ${r.published.length} workflow(s) live, but ` : "";
50988
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
+ }
50989
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." : ""}`;
50990
51961
  }
50991
51962
  if (r.published.length) {
@@ -50999,6 +51970,8 @@ function publishNote(r) {
50999
51970
  function publishSummaryClause(r) {
51000
51971
  if (r.publishFailed.length) {
51001
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.`;
51002
51975
  return ` ${r.publishFailed.length} workflow(s) BUILT BUT NOT PUBLISHED${also}. They are DRAFT and not running, see publishNote.`;
51003
51976
  }
51004
51977
  if (r.published.length) return ` Published ${r.published.length} workflow(s) live.`;
@@ -52195,8 +53168,14 @@ async function executeBackbone(plan, deps, opts = {}) {
52195
53168
  }
52196
53169
  if (opts.publishWorkflows && !opts.gatedWorkflowRefs?.has(wf.ref)) {
52197
53170
  try {
52198
- await deps.publishWorkflow(wfId);
52199
- 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
+ }
52200
53179
  } catch (e) {
52201
53180
  publishFailed.push({ ref: wf.ref, name: wf.name, error: msg2(e) });
52202
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.` });
@@ -52316,6 +53295,21 @@ async function createWithReadback(create, read2, name, beforeIds, poll, wait) {
52316
53295
  function msg2(e) {
52317
53296
  return accountErrorText(e);
52318
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
+ }
52319
53313
  var norm5, sleep5, PREVIEW_SNIPPET_LENGTH, JournalHookError;
52320
53314
  var init_execute = __esm({
52321
53315
  "src/intake-to-build/execute.ts"() {
@@ -52880,9 +53874,12 @@ function makeExecuteDeps(client, builderClient, locationId2, registry2) {
52880
53874
  deleteWorkflow: async (workflowId) => {
52881
53875
  await builderClient.deleteWorkflow(workflowId);
52882
53876
  },
52883
- publishWorkflow: async (workflowId) => {
52884
- await builderClient.publishWorkflow(workflowId);
52885
- }
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)
52886
53883
  };
52887
53884
  }
52888
53885
  function makeRevertDeps(client, builderClient, locationId2) {
@@ -53085,8 +54082,13 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53085
54082
  });
53086
54083
  }
53087
54084
  if (!briefArg.scope) {
53088
- const saved2 = readBoardState().scope?.[activeLocation];
53089
- if (saved2 && Array.isArray(saved2.features)) {
54085
+ const board = readBoardState();
54086
+ const saved2 = board.scope?.[activeLocation];
54087
+ if (saved2?.wholePreset) {
54088
+ scopeSource = "cockpit";
54089
+ } else if (!saved2 && Array.isArray(board.assessmentPurchases?.[activeLocation])) {
54090
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(activeLocation) });
54091
+ } else if (saved2 && Array.isArray(saved2.features)) {
53090
54092
  const features = saved2.features.filter(isFeatureId);
53091
54093
  if (!features.length) {
53092
54094
  return jsonResponse({
@@ -53143,6 +54145,31 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53143
54145
  if (!gate2.ok) return jsonResponse({ ok: false, phase: "plan", error: gate2.error, savedPlan: saved ? { planId: saved.planId, savedAt: saved.savedAt } : void 0 });
53144
54146
  const plan = gate2.plan;
53145
54147
  const planSource = gate2.source;
54148
+ if (!fromPreset) {
54149
+ const board = readBoardState();
54150
+ const current = board.scope?.[activeLocation];
54151
+ if (!current && Array.isArray(board.assessmentPurchases?.[activeLocation])) {
54152
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(activeLocation) });
54153
+ }
54154
+ if (current && !current.wholePreset && Array.isArray(current.features) && !current.features.some(isFeatureId)) {
54155
+ return jsonResponse({
54156
+ ok: false,
54157
+ phase: "scope",
54158
+ scopeSource: "cockpit",
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."
54160
+ });
54161
+ }
54162
+ if (saved && planSource !== "argument-replaces-saved" && !savedPlanMatchesScope(saved, current)) {
54163
+ const planned = saved?.scope ? [...saved.scope.built, ...(saved.scope.omitted ?? []).map((f) => f.id)].join(", ") || "no features" : "everything in the preset";
54164
+ const now = current?.wholePreset ? "everything in the preset (confirmed)" : current ? current.features.filter(isFeatureId).join(", ") : "everything in the preset (nothing is saved)";
54165
+ return jsonResponse({
54166
+ ok: false,
54167
+ phase: "scope",
54168
+ scopeSource: "cockpit",
54169
+ error: `The features selected for this client changed after this plan was saved (the saved plan builds: ${planned}; the Review screen now says: ${now}), so the saved plan is not what was confirmed and nothing was built. Run the build again without useSavedPlan, composing the plan fresh from the preset (fromPreset), so it follows the current selection.`
54170
+ });
54171
+ }
54172
+ }
53146
54173
  const scopeForValidation = composed?.scope ?? (planSource === "saved" ? saved?.scope : void 0);
53147
54174
  const validation = validateBuildPlan(plan, { nurtureNotBought: Boolean(scopeForValidation && !scopeForValidation.built.includes("nurture")) });
53148
54175
  if (!validation.valid || !validation.plan) {
@@ -53211,7 +54238,8 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53211
54238
  contentHash: contentHash(typedPlan),
53212
54239
  inputHash: process.env.GHL_COMMAND_INTAKE_HASH || saved?.inputHash,
53213
54240
  dependencies: planDependencies(typedPlan),
53214
- preview: result
54241
+ preview: result,
54242
+ scope: scopeForValidation
53215
54243
  });
53216
54244
  const deps = makeExecuteDeps(client, builderClient, activeLocation, registry2);
53217
54245
  const gatedWorkflowRefs = new Set(result.workflows.filter((w) => w.gatedBy.length > 0).map((w) => w.ref));
@@ -53823,7 +54851,7 @@ function intakeFormMatches(expected, actual) {
53823
54851
  const keys = ["id", "tag", "type", "hiddenFieldQueryKey", "required", "label", "options", "picklistOptions"];
53824
54852
  return expected.every((field, i) => keys.every((key) => field[key] === void 0 || JSON.stringify(field[key]) === JSON.stringify(actual[i]?.[key])));
53825
54853
  }
53826
- var import_zod81, customFieldItemSchema, sleep6;
54854
+ var import_zod81, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, notConfirmedAsk;
53827
54855
  var init_intake_to_build = __esm({
53828
54856
  "src/tools/intake-to-build.ts"() {
53829
54857
  "use strict";
@@ -53869,6 +54897,13 @@ var init_intake_to_build = __esm({
53869
54897
  picklistOptions: import_zod81.z.array(import_zod81.z.string()).optional()
53870
54898
  }).passthrough();
53871
54899
  sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
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
+ };
53872
54907
  }
53873
54908
  });
53874
54909
 
@@ -53915,7 +54950,7 @@ function registerAllTools(server2, client, registry2, mcpVersion, env2 = process
53915
54950
  }
53916
54951
  const reader = token2 ? new GHLClient({ apiKey: token2.apiKey, locationId: loc }) : client;
53917
54952
  return refuseUnseenFindings2((path40, params) => reader.get(path40, params ? { params } : void 0), loc, findings);
53918
- });
54953
+ }, () => client.defaultLocationId);
53919
54954
  registerClientEngagementTools(wrap(CLIENT_ENGAGEMENTS_MODULE), registry2);
53920
54955
  registerAuditReportTools(wrap(AUDIT_REPORT_MODULE));
53921
54956
  registerDailyBriefTools(wrap(DAILY_BRIEF_MODULE), registry2);