@elitedcs/ghl-mcp 3.75.0 → 3.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -918,6 +918,129 @@ var init_id_shape = __esm({
918
918
  }
919
919
  });
920
920
 
921
+ // src/parked-guard.ts
922
+ function stable(value) {
923
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
924
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
925
+ const keys = Object.keys(value).sort();
926
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
927
+ }
928
+ function isRecord(v) {
929
+ return typeof v === "object" && v !== null && !Array.isArray(v);
930
+ }
931
+ function referencedStepIds(action) {
932
+ const out = [];
933
+ const attrs = isRecord(action.attributes) ? action.attributes : void 0;
934
+ const extras = attrs && isRecord(attrs.extras) ? attrs.extras : void 0;
935
+ if (!extras) return out;
936
+ const list2 = extras.stepIds;
937
+ if (Array.isArray(list2)) {
938
+ for (const id of list2) if (typeof id === "string" && id) out.push(id);
939
+ }
940
+ const invoice = extras.invoiceStepId;
941
+ if (typeof invoice === "string" && invoice) out.push(invoice);
942
+ return out;
943
+ }
944
+ function branchTargetStepIds(action) {
945
+ const out = [];
946
+ const attrs = isRecord(action.attributes) ? action.attributes : void 0;
947
+ if (!attrs) return out;
948
+ const quick = isRecord(attrs.quickReplies) ? attrs.quickReplies.transitions : void 0;
949
+ for (const table of [attrs.transitions, attrs.nonBranchingTransitions, quick]) {
950
+ if (!Array.isArray(table)) continue;
951
+ for (const entry of table) {
952
+ if (isRecord(entry) && typeof entry.id === "string" && entry.id) out.push(entry.id);
953
+ }
954
+ }
955
+ return out;
956
+ }
957
+ function protectedClosure(parkedIds, byId2) {
958
+ const seen = /* @__PURE__ */ new Set();
959
+ const queue = [...parkedIds];
960
+ while (queue.length > 0) {
961
+ const id = queue.shift();
962
+ if (seen.has(id)) continue;
963
+ seen.add(id);
964
+ const node = byId2.get(id);
965
+ if (!node) continue;
966
+ for (const ref of referencedStepIds(node)) if (!seen.has(ref)) queue.push(ref);
967
+ }
968
+ return seen;
969
+ }
970
+ function disturbedStepIds(current, next) {
971
+ const nextById = new Map(next.filter(hasId).map((a) => [a.id, a]));
972
+ const out = /* @__PURE__ */ new Set();
973
+ for (const before of current.filter(hasId)) {
974
+ const after = nextById.get(before.id);
975
+ if (!after || stable(before) !== stable(after)) out.add(before.id);
976
+ }
977
+ return out;
978
+ }
979
+ function parkedRefusal(current, next, parked) {
980
+ const occupied = parked.filter((p) => p.total > 0 && typeof p.currentStepId === "string" && p.currentStepId);
981
+ if (occupied.length === 0) return null;
982
+ const currentById = new Map(current.filter(hasId).map((a) => [a.id, a]));
983
+ const nextById = new Map(next.filter(hasId).map((a) => [a.id, a]));
984
+ const contactsByStep = new Map(occupied.map((p) => [p.currentStepId, p.total]));
985
+ const guarded = protectedClosure([...contactsByStep.keys()], currentById);
986
+ const deleted = [];
987
+ const altered = [];
988
+ for (const id of guarded) {
989
+ const before = currentById.get(id);
990
+ if (!before) continue;
991
+ const after = nextById.get(id);
992
+ if (!after) {
993
+ deleted.push(id);
994
+ continue;
995
+ }
996
+ if (stable(before) !== stable(after)) altered.push(id);
997
+ }
998
+ for (const id of guarded) {
999
+ const head2 = currentById.get(id);
1000
+ if (!head2) continue;
1001
+ for (const target of branchTargetStepIds(head2)) {
1002
+ if (guarded.has(target) || deleted.includes(target)) continue;
1003
+ if (currentById.has(target) && !nextById.has(target)) deleted.push(target);
1004
+ }
1005
+ }
1006
+ if (deleted.length === 0 && altered.length === 0) return null;
1007
+ const touched = /* @__PURE__ */ new Set([...deleted, ...altered]);
1008
+ let contacts = 0;
1009
+ for (const [stepId, total] of contactsByStep) {
1010
+ const own = protectedClosure([stepId], currentById);
1011
+ let affected = false;
1012
+ for (const id of own) {
1013
+ if (touched.has(id)) {
1014
+ affected = true;
1015
+ break;
1016
+ }
1017
+ const node = currentById.get(id);
1018
+ if (node && branchTargetStepIds(node).some((t) => touched.has(t))) {
1019
+ affected = true;
1020
+ break;
1021
+ }
1022
+ }
1023
+ if (affected) contacts += total;
1024
+ }
1025
+ if (contacts === 0) for (const total of contactsByStep.values()) contacts += total;
1026
+ const parts = [];
1027
+ if (deleted.length > 0) parts.push(`${deleted.length} would be removed (${deleted.join(", ")})`);
1028
+ if (altered.length > 0) parts.push(`${altered.length} would be changed (${altered.join(", ")})`);
1029
+ return {
1030
+ deleted,
1031
+ altered,
1032
+ contacts,
1033
+ message: `Refused: ${contacts} contact(s) are part-way through this workflow, and this change would disturb the step they are waiting on \u2014 ${parts.join(", and ")}. They would be dropped from the sequence with no way to put them back, so nothing was written. Every other step in this workflow can still be edited. Wait until nobody is mid-sequence, or move those contacts on deliberately first.`
1034
+ };
1035
+ }
1036
+ var hasId;
1037
+ var init_parked_guard = __esm({
1038
+ "src/parked-guard.ts"() {
1039
+ "use strict";
1040
+ hasId = (a) => typeof a.id === "string" && a.id.length > 0;
1041
+ }
1042
+ });
1043
+
921
1044
  // src/trigger-schemas.ts
922
1045
  function describe(status, knownFields) {
923
1046
  switch (status) {
@@ -1328,6 +1451,64 @@ var init_trigger_schemas = __esm({
1328
1451
  });
1329
1452
 
1330
1453
  // src/workflow-builder-client.ts
1454
+ function isParkedContactsError(e) {
1455
+ return typeof e === "object" && e !== null && e.code === "PARKED_CONTACTS";
1456
+ }
1457
+ function rewriteActionIdRefs(action, remap) {
1458
+ const a = action;
1459
+ const swap = (v) => typeof v === "string" && remap.has(v) ? remap.get(v) : v;
1460
+ if (typeof a.id === "string" && remap.has(a.id)) a.id = remap.get(a.id);
1461
+ for (const key of ["parent", "parentKey"]) if (typeof a[key] === "string") a[key] = swap(a[key]);
1462
+ if (typeof a.next === "string") a.next = swap(a.next);
1463
+ else if (Array.isArray(a.next)) a.next = a.next.map(swap);
1464
+ if (Array.isArray(a.sibling)) a.sibling = a.sibling.map(swap);
1465
+ const attrs = a.attributes;
1466
+ if (typeof attrs !== "object" || attrs === null || Array.isArray(attrs)) return;
1467
+ const at = attrs;
1468
+ if (Array.isArray(at.branches)) {
1469
+ for (const b of at.branches) {
1470
+ if (!isRecord2(b)) continue;
1471
+ if (typeof b.id === "string") b.id = swap(b.id);
1472
+ if (!Array.isArray(b.segments)) continue;
1473
+ for (const seg of b.segments) {
1474
+ if (!isRecord2(seg) || !Array.isArray(seg.conditions)) continue;
1475
+ for (const cond of seg.conditions) {
1476
+ if (isRecord2(cond) && typeof cond.ifElseNodeId === "string") cond.ifElseNodeId = swap(cond.ifElseNodeId);
1477
+ }
1478
+ }
1479
+ }
1480
+ }
1481
+ if (typeof at.targetNodeId === "string") at.targetNodeId = swap(at.targetNodeId);
1482
+ const quick = isRecord2(at.quickReplies) ? at.quickReplies.transitions : void 0;
1483
+ for (const table of [at.transitions, at.nonBranchingTransitions, quick]) {
1484
+ if (!Array.isArray(table)) continue;
1485
+ for (const entry of table) {
1486
+ if (isRecord2(entry) && typeof entry.id === "string") entry.id = swap(entry.id);
1487
+ }
1488
+ }
1489
+ const extras = at.extras;
1490
+ if (typeof extras === "object" && extras !== null && !Array.isArray(extras)) {
1491
+ const ex = extras;
1492
+ if (Array.isArray(ex.stepIds)) ex.stepIds = ex.stepIds.map(swap);
1493
+ if (typeof ex.invoiceStepId === "string") ex.invoiceStepId = swap(ex.invoiceStepId);
1494
+ }
1495
+ }
1496
+ function coerceLegacyWriteShapes(action) {
1497
+ const attrs = action.attributes;
1498
+ if (typeof attrs !== "object" || attrs === null || Array.isArray(attrs)) return action;
1499
+ const a = attrs;
1500
+ const startAfter = a.startAfter;
1501
+ if (typeof startAfter === "object" && startAfter !== null && !Array.isArray(startAfter)) {
1502
+ const sa = startAfter;
1503
+ if (sa.type === "day") {
1504
+ return { ...action, attributes: { ...a, startAfter: { ...sa, type: "days" } } };
1505
+ }
1506
+ }
1507
+ if (action.type === "internal_notification" && a.type === "inapp") {
1508
+ return { ...action, attributes: { ...a, type: "notification" } };
1509
+ }
1510
+ return action;
1511
+ }
1331
1512
  function normalizeRemoveFromWorkflowAction(action) {
1332
1513
  if (action.type !== "remove_from_workflow") return action;
1333
1514
  const attrs = action.attributes;
@@ -1399,13 +1580,13 @@ function normalizeInternalUpdateOpportunityAction(action) {
1399
1580
  delete attributes.workflowsActionType;
1400
1581
  return { ...action, workflowsActionType: "INTERNAL", attributes };
1401
1582
  }
1402
- function hasId(action) {
1583
+ function hasId2(action) {
1403
1584
  return typeof action.id === "string" && action.id.length > 0;
1404
1585
  }
1405
1586
  function hasTriggerId(trigger) {
1406
1587
  return typeof trigger.id === "string" && trigger.id.length > 0;
1407
1588
  }
1408
- function isRecord(value) {
1589
+ function isRecord2(value) {
1409
1590
  return typeof value === "object" && value !== null && !Array.isArray(value);
1410
1591
  }
1411
1592
  function getStringArray(value) {
@@ -1415,9 +1596,20 @@ function getStringArray(value) {
1415
1596
  return value;
1416
1597
  }
1417
1598
  function validateActionChain(actions, existingIds) {
1418
- const byId2 = new Map(actions.filter(hasId).map((action) => [action.id, action]));
1599
+ const duplicates = /* @__PURE__ */ new Set();
1600
+ const seenIds = /* @__PURE__ */ new Set();
1601
+ for (const action of actions.filter(hasId2)) {
1602
+ if (seenIds.has(action.id)) duplicates.add(action.id);
1603
+ seenIds.add(action.id);
1604
+ }
1605
+ if (duplicates.size > 0) {
1606
+ throw new Error(
1607
+ `Two or more steps share the same id: ${[...duplicates].join(", ")}. Ids identify a step to GoHighLevel and to any contact waiting on it, so they must be unique. Give each step its own id, or omit the id and one will be generated.`
1608
+ );
1609
+ }
1610
+ const byId2 = new Map(actions.filter(hasId2).map((action) => [action.id, action]));
1419
1611
  for (const action of actions) {
1420
- const attr = isRecord(action.attributes) ? action.attributes : void 0;
1612
+ const attr = isRecord2(action.attributes) ? action.attributes : void 0;
1421
1613
  if (!attr) continue;
1422
1614
  switch (action.type) {
1423
1615
  case "sms":
@@ -1438,7 +1630,7 @@ function validateActionChain(actions, existingIds) {
1438
1630
  }
1439
1631
  break;
1440
1632
  case "internal_update_opportunity": {
1441
- const isRoundTripped = hasId(action) && (existingIds ? existingIds.has(action.id) : true);
1633
+ const isRoundTripped = hasId2(action) && (existingIds ? existingIds.has(action.id) : true);
1442
1634
  if (!isRoundTripped) {
1443
1635
  const cif = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : null;
1444
1636
  if (!cif) {
@@ -1459,7 +1651,7 @@ function validateActionChain(actions, existingIds) {
1459
1651
  break;
1460
1652
  }
1461
1653
  case "internal_create_opportunity": {
1462
- const isRoundTripped = hasId(action) && (existingIds ? existingIds.has(action.id) : true);
1654
+ const isRoundTripped = hasId2(action) && (existingIds ? existingIds.has(action.id) : true);
1463
1655
  if (!isRoundTripped) {
1464
1656
  const cif = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : [];
1465
1657
  const listed = (ff) => cif.find((f) => f && f.filterField === ff);
@@ -1505,8 +1697,8 @@ function validateActionChain(actions, existingIds) {
1505
1697
  throw new Error(`Webhook action "${action.name}" needs '${field}' to be an array of {key, value} pairs.`);
1506
1698
  }
1507
1699
  for (const entry of entries) {
1508
- const key = isRecord(entry) ? entry.key : void 0;
1509
- const value = isRecord(entry) ? entry.value : void 0;
1700
+ const key = isRecord2(entry) ? entry.key : void 0;
1701
+ const value = isRecord2(entry) ? entry.value : void 0;
1510
1702
  if (typeof key !== "string" || !key.trim() || value === void 0 || value === null) {
1511
1703
  throw new Error(
1512
1704
  `Webhook action "${action.name}" has an empty '${field}' pair. Every entry needs a non-empty key and a value.`
@@ -1543,7 +1735,7 @@ function validateActionChain(actions, existingIds) {
1543
1735
  }
1544
1736
  for (const action of actions) {
1545
1737
  if (action.type !== "find_opportunity") continue;
1546
- if (existingIds && hasId(action) && existingIds.has(action.id)) continue;
1738
+ if (existingIds && hasId2(action) && existingIds.has(action.id)) continue;
1547
1739
  if (!action.id) throw new Error(`find_opportunity "${action.name}" missing id.`);
1548
1740
  const next = getStringArray(action.next);
1549
1741
  if (!next || next.length !== 2) {
@@ -1567,7 +1759,7 @@ function validateActionChain(actions, existingIds) {
1567
1759
  }
1568
1760
  }
1569
1761
  }
1570
- var fs4, path4, dotenv, import_zod4, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, WorkflowBuilderClient;
1762
+ var fs4, path4, dotenv, import_zod4, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, ParkedContactsError, UUID_V4, WorkflowBuilderClient;
1571
1763
  var init_workflow_builder_client = __esm({
1572
1764
  "src/workflow-builder-client.ts"() {
1573
1765
  "use strict";
@@ -1580,6 +1772,7 @@ var init_workflow_builder_client = __esm({
1580
1772
  init_retry();
1581
1773
  init_firebase_claims();
1582
1774
  init_id_shape();
1775
+ init_parked_guard();
1583
1776
  init_trigger_schemas();
1584
1777
  BACKEND_BASE = "https://backend.leadconnectorhq.com/workflow";
1585
1778
  FIREBASE_TOKEN_URL = "https://securetoken.googleapis.com/v1/token";
@@ -1625,6 +1818,16 @@ var init_workflow_builder_client = __esm({
1625
1818
  version: typeof data.version === "number" ? data.version : 1
1626
1819
  });
1627
1820
  });
1821
+ ParkedContactsError = class extends Error {
1822
+ constructor(message, contacts) {
1823
+ super(message);
1824
+ this.contacts = contacts;
1825
+ this.name = "ParkedContactsError";
1826
+ }
1827
+ contacts;
1828
+ code = "PARKED_CONTACTS";
1829
+ };
1830
+ UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1628
1831
  WorkflowBuilderClient = class _WorkflowBuilderClient {
1629
1832
  // Active Firebase auth — swapped when operating in another company's GHL.
1630
1833
  firebaseApiKey;
@@ -1946,6 +2149,56 @@ var init_workflow_builder_client = __esm({
1946
2149
  Version: "2021-07-28"
1947
2150
  };
1948
2151
  }
2152
+ /**
2153
+ * Who is part-way through this workflow, and which step they are sitting on.
2154
+ *
2155
+ * This is the read the parked-contact guard is built on. It is the endpoint
2156
+ * GoHighLevel's own Automation UI uses for its Enrolment view — found by
2157
+ * reading that module's public JS bundle, because the UI runs in a
2158
+ * cross-origin iframe and neither a network log nor a page-level interceptor
2159
+ * can see its calls (2026-09-01).
2160
+ *
2161
+ * GET {backend}/workflows/status/search/count-per-step
2162
+ * ?workflowId={wf}&locationId={loc}
2163
+ * → [{ "total": 1, "currentStepId": "f065696d-…" }]
2164
+ *
2165
+ * Verified live, read-only, on two accounts. Two behaviours worth keeping:
2166
+ * - `workflowId` is SINGULAR and a string. Passing `workflowIds` returns 422
2167
+ * "property workflowIds should not exist" — which only appears AFTER auth
2168
+ * passes, so it doubles as the quickest auth check.
2169
+ * - `[]` means nobody is MID-SEQUENCE. A workflow can show enrolments in the
2170
+ * UI and still return `[]`; those are finished, and they must not block a
2171
+ * write.
2172
+ *
2173
+ * FAILS CLOSED. If this read cannot be completed we do not know whether anyone
2174
+ * is parked, and a guard that assumes "probably nobody" is not a guard.
2175
+ */
2176
+ async parkedContacts(workflowId) {
2177
+ const url = `https://backend.leadconnectorhq.com/workflows/status/search/count-per-step?workflowId=${encodeURIComponent(workflowId)}&locationId=${encodeURIComponent(this.locationId)}`;
2178
+ const res = await fetch(url, { headers: await this.buildHeaders() });
2179
+ if (!res.ok) {
2180
+ throw new Error(
2181
+ `Could not check whether anyone is part-way through this workflow (HTTP ${res.status}). Nothing was written. This check has to succeed before a step can be changed, because changing a step somebody is waiting on drops them from the sequence permanently.`
2182
+ );
2183
+ }
2184
+ const body = await res.json();
2185
+ if (!Array.isArray(body)) {
2186
+ throw new Error(
2187
+ `The check for contacts part-way through this workflow returned something unexpected, so it is not possible to tell whether anyone would be dropped. Nothing was written.`
2188
+ );
2189
+ }
2190
+ return body.map((row) => {
2191
+ const r = typeof row === "object" && row !== null && !Array.isArray(row) ? row : null;
2192
+ const total = r && typeof r.total === "number" ? r.total : null;
2193
+ const currentStepId = r && typeof r.currentStepId === "string" && r.currentStepId ? r.currentStepId : null;
2194
+ if (total === null || currentStepId === null) {
2195
+ throw new Error(
2196
+ `The check for contacts part-way through this workflow returned a row that could not be read, so it is not possible to tell whether anyone would be dropped. Nothing was written.`
2197
+ );
2198
+ }
2199
+ return { total, currentStepId };
2200
+ });
2201
+ }
1949
2202
  /**
1950
2203
  * Make a request to the internal workflow API.
1951
2204
  * Retries on 429, 5xx, and transient network errors with exponential backoff.
@@ -2027,9 +2280,9 @@ ${errorBody}`
2027
2280
  */
2028
2281
  async getWorkflow(workflowId) {
2029
2282
  const raw = await this.request("GET", `/${this.locationId}/${workflowId}?includeTriggers=true`);
2030
- const flat = isRecord(raw) && isRecord(raw.workflowData) ? {
2283
+ const flat = isRecord2(raw) && isRecord2(raw.workflowData) ? {
2031
2284
  ...raw.workflowData,
2032
- workflowData: isRecord(raw.workflowData.workflowData) ? raw.workflowData.workflowData : { templates: [] },
2285
+ workflowData: isRecord2(raw.workflowData.workflowData) ? raw.workflowData.workflowData : { templates: [] },
2033
2286
  triggers: raw.triggers
2034
2287
  } : raw;
2035
2288
  const parsed = WorkflowFullSchema.parse(flat);
@@ -2065,17 +2318,22 @@ ${errorBody}`
2065
2318
  async updateWorkflow(workflowId, updates) {
2066
2319
  const current = await this.getWorkflow(workflowId);
2067
2320
  const effectiveStatus = updates.status ?? current.status;
2068
- if (updates.triggers !== void 0) {
2069
- await this.syncTriggers(workflowId, current.triggers || [], updates.triggers, effectiveStatus);
2070
- }
2071
2321
  const currentActions = current.workflowData?.templates || [];
2072
2322
  const newActions = updates.actions ?? currentActions;
2073
- const existingActionIds = new Set(currentActions.filter(hasId).map((a) => a.id));
2323
+ const existingActionIds = new Set(currentActions.filter(hasId2).map((a) => a.id));
2074
2324
  const linkedActions = this.buildActionChain(newActions, existingActionIds);
2075
- const currentIds = new Set(currentActions.filter(hasId).map((a) => a.id));
2076
- const newIds = new Set(linkedActions.filter(hasId).map((a) => a.id));
2077
- const createdSteps = linkedActions.filter(hasId).filter((a) => !currentIds.has(a.id)).map((a) => a.id);
2078
- const deletedSteps = currentActions.filter(hasId).filter((a) => !newIds.has(a.id)).map((a) => a.id);
2325
+ if (currentActions.length > 0 && disturbedStepIds(currentActions, linkedActions).size > 0) {
2326
+ const parked = await this.parkedContacts(workflowId);
2327
+ const refusal = parkedRefusal(currentActions, linkedActions, parked);
2328
+ if (refusal) throw new Error(refusal.message);
2329
+ }
2330
+ if (updates.triggers !== void 0) {
2331
+ await this.syncTriggers(workflowId, current.triggers || [], updates.triggers, effectiveStatus);
2332
+ }
2333
+ const currentIds = new Set(currentActions.filter(hasId2).map((a) => a.id));
2334
+ const newIds = new Set(linkedActions.filter(hasId2).map((a) => a.id));
2335
+ const createdSteps = linkedActions.filter(hasId2).filter((a) => !currentIds.has(a.id)).map((a) => a.id);
2336
+ const deletedSteps = currentActions.filter(hasId2).filter((a) => !newIds.has(a.id)).map((a) => a.id);
2079
2337
  const body = {
2080
2338
  name: updates.name ?? current.name,
2081
2339
  isRestoreRequest: true,
@@ -2111,7 +2369,15 @@ ${errorBody}`
2111
2369
  /**
2112
2370
  * Delete a workflow
2113
2371
  */
2114
- async deleteWorkflow(workflowId) {
2372
+ async deleteWorkflow(workflowId, opts) {
2373
+ const parked = await this.parkedContacts(workflowId);
2374
+ const contacts = parked.reduce((sum, p) => sum + p.total, 0);
2375
+ if (contacts > 0 && opts?.acknowledgeContacts !== contacts) {
2376
+ throw new ParkedContactsError(
2377
+ `Refused: ${contacts} contact(s) are part-way through this workflow right now. Deleting it drops every one of them out of the sequence, and there is no way to put them back. Nothing was deleted. If that is what you intend, re-run it acknowledging ${contacts} contact(s).`,
2378
+ contacts
2379
+ );
2380
+ }
2115
2381
  return this.request("DELETE", `/${this.locationId}/${workflowId}`);
2116
2382
  }
2117
2383
  /**
@@ -2142,7 +2408,7 @@ ${errorBody}`
2142
2408
  returned = typeof after.status === "string" ? after.status : null;
2143
2409
  } catch (e) {
2144
2410
  readBackFailed = e instanceof Error ? e.message : String(e);
2145
- returned = isRecord(result) && typeof result.status === "string" ? result.status : null;
2411
+ returned = isRecord2(result) && typeof result.status === "string" ? result.status : null;
2146
2412
  }
2147
2413
  const publish_check = {
2148
2414
  published: returned === "published",
@@ -2160,7 +2426,7 @@ ${errorBody}`
2160
2426
  if (triggers.length === 0) {
2161
2427
  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.";
2162
2428
  }
2163
- return isRecord(result) ? { ...result, publish_check } : { result, publish_check };
2429
+ return isRecord2(result) ? { ...result, publish_check } : { result, publish_check };
2164
2430
  }
2165
2431
  /**
2166
2432
  * Create a workflow trigger. GHL generates the Firestore id and returns it.
@@ -2170,7 +2436,7 @@ ${errorBody}`
2170
2436
  async createTrigger(workflowId, trigger, workflowStatus) {
2171
2437
  const payload = this.buildTriggerPayload(workflowId, trigger, workflowStatus);
2172
2438
  const raw = await this.request("POST", `/${this.locationId}/trigger`, payload);
2173
- if (isRecord(raw) && typeof raw.id === "string") {
2439
+ if (isRecord2(raw) && typeof raw.id === "string") {
2174
2440
  return raw.id;
2175
2441
  }
2176
2442
  throw new Error(`Trigger creation did not return an id. Response: ${JSON.stringify(raw)}`);
@@ -2251,7 +2517,7 @@ ${errorBody}`
2251
2517
  */
2252
2518
  buildActionChain(actions, existingIds) {
2253
2519
  validateActionChain(actions, existingIds);
2254
- const linked = actions.map(normalizeRemoveFromWorkflowAction).map(normalizeInternalUpdateOpportunityAction).map(normalizeInternalCreateOpportunityAction).map((action, i) => {
2520
+ const linked = actions.map(coerceLegacyWriteShapes).map(normalizeRemoveFromWorkflowAction).map(normalizeInternalUpdateOpportunityAction).map(normalizeInternalCreateOpportunityAction).map((action, i) => {
2255
2521
  const copy = { ...action };
2256
2522
  if (!copy.id) {
2257
2523
  copy.id = crypto.randomUUID();
@@ -2261,6 +2527,17 @@ ${errorBody}`
2261
2527
  }
2262
2528
  return copy;
2263
2529
  });
2530
+ const remap = /* @__PURE__ */ new Map();
2531
+ for (const action of linked) {
2532
+ const id = action.id;
2533
+ if (typeof id !== "string" || !id) continue;
2534
+ if (existingIds?.has(id)) continue;
2535
+ if (UUID_V4.test(id)) continue;
2536
+ remap.set(id, crypto.randomUUID());
2537
+ }
2538
+ if (remap.size > 0) {
2539
+ for (const action of linked) rewriteActionIdRefs(action, remap);
2540
+ }
2264
2541
  for (let i = 0; i < linked.length; i++) {
2265
2542
  const action = linked[i];
2266
2543
  if (action.parent || action.nodeType) continue;
@@ -3553,8 +3830,8 @@ function registerContactTools(server2, client) {
3553
3830
  taskId: import_zod9.z.string().min(1).describe("The ID of the task (from get_contact_tasks or create_contact_task)."),
3554
3831
  completed: import_zod9.z.boolean().optional().describe("true (default) marks the task completed; false reopens a completed task.")
3555
3832
  },
3556
- async ({ contactId, taskId, completed }) => {
3557
- return client.put(`/contacts/${contactId}/tasks/${taskId}/completed`, {
3833
+ async ({ contactId, taskId: taskId2, completed }) => {
3834
+ return client.put(`/contacts/${contactId}/tasks/${taskId2}/completed`, {
3558
3835
  body: { completed: completed ?? true }
3559
3836
  });
3560
3837
  }
@@ -3572,7 +3849,7 @@ function registerContactTools(server2, client) {
3572
3849
  completed: import_zod9.z.boolean().optional().describe("Set the completed state of the task."),
3573
3850
  assignedTo: import_zod9.z.string().optional().describe("User ID to reassign the task to.")
3574
3851
  },
3575
- async ({ contactId, taskId, title: title2, body: taskBody, dueDate, completed, assignedTo }) => {
3852
+ async ({ contactId, taskId: taskId2, title: title2, body: taskBody, dueDate, completed, assignedTo }) => {
3576
3853
  if (dueDate !== void 0 && !isDateOnly(dueDate) && !hasTimezoneOffset(dueDate)) {
3577
3854
  throw new Error(
3578
3855
  `dueDate "${dueDate}" carries no timezone offset, so it does not say which 9am it means. GHL reads it as UTC and stores a different local hour \u2014 a task set for 9am in US/Arizona lands at 2am. Add the offset, e.g. "2026-09-15T09:00:00-07:00", or "...Z" for UTC. Nothing was changed.`
@@ -3584,7 +3861,7 @@ function registerContactTools(server2, client) {
3584
3861
  "Nothing to update: pass at least one of title, body, dueDate, completed, assignedTo."
3585
3862
  );
3586
3863
  }
3587
- return client.put(`/contacts/${contactId}/tasks/${taskId}`, { body: reqBody });
3864
+ return client.put(`/contacts/${contactId}/tasks/${taskId2}`, { body: reqBody });
3588
3865
  }
3589
3866
  );
3590
3867
  safeTool(
@@ -3596,8 +3873,8 @@ function registerContactTools(server2, client) {
3596
3873
  taskId: import_zod9.z.string().min(1).describe("The ID of the task to delete (from get_contact_tasks)."),
3597
3874
  confirm: import_zod9.z.literal("DELETE").describe("Must pass 'DELETE' to confirm this destructive action.")
3598
3875
  },
3599
- async ({ contactId, taskId }) => {
3600
- return client.delete(`/contacts/${contactId}/tasks/${taskId}`);
3876
+ async ({ contactId, taskId: taskId2 }) => {
3877
+ return client.delete(`/contacts/${contactId}/tasks/${taskId2}`);
3601
3878
  }
3602
3879
  );
3603
3880
  safeTool(
@@ -3784,7 +4061,7 @@ function registerConversationTools(server2, client) {
3784
4061
  if (emailBcc !== void 0) body.emailBcc = emailBcc;
3785
4062
  if (attachments !== void 0) body.attachments = attachments;
3786
4063
  const result = await client.post("/conversations/messages", { body });
3787
- const messageId = isRecord2(result) && typeof result.messageId === "string" ? result.messageId : null;
4064
+ const messageId = isRecord3(result) && typeof result.messageId === "string" ? result.messageId : null;
3788
4065
  if (!messageId) return result;
3789
4066
  const TERMINAL = /* @__PURE__ */ new Set(["delivered", "sent", "failed", "undelivered", "rejected", "opted_out"]);
3790
4067
  let status = null;
@@ -3794,7 +4071,7 @@ function registerConversationTools(server2, client) {
3794
4071
  if (attempt > 0) await new Promise((r) => setTimeout(r, 1200 * attempt));
3795
4072
  try {
3796
4073
  const raw = await client.get(`/conversations/messages/${messageId}`);
3797
- const msg3 = isRecord2(raw) && isRecord2(raw.message) ? raw.message : isRecord2(raw) ? raw : null;
4074
+ const msg3 = isRecord3(raw) && isRecord3(raw.message) ? raw.message : isRecord3(raw) ? raw : null;
3798
4075
  status = typeof msg3?.status === "string" ? msg3.status : null;
3799
4076
  from = typeof msg3?.from === "string" ? msg3.from : from;
3800
4077
  readError = null;
@@ -3815,7 +4092,7 @@ function registerConversationTools(server2, client) {
3815
4092
  } else if (!delivered) {
3816
4093
  delivery_check.warning = `NOT DELIVERED. GHL reports status "${status ?? "(none)"}" for this message. Do not report it as sent.`;
3817
4094
  }
3818
- return isRecord2(result) ? { ...result, delivery_check } : { result, delivery_check };
4095
+ return isRecord3(result) ? { ...result, delivery_check } : { result, delivery_check };
3819
4096
  }
3820
4097
  );
3821
4098
  safeTool(
@@ -3861,13 +4138,13 @@ function registerConversationTools(server2, client) {
3861
4138
  }
3862
4139
  );
3863
4140
  }
3864
- var import_zod10, isRecord2;
4141
+ var import_zod10, isRecord3;
3865
4142
  var init_conversations = __esm({
3866
4143
  "src/tools/conversations.ts"() {
3867
4144
  "use strict";
3868
4145
  import_zod10 = require("zod");
3869
4146
  init_tool_helpers();
3870
- isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4147
+ isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3871
4148
  }
3872
4149
  });
3873
4150
 
@@ -7577,9 +7854,10 @@ function registerWebhookTools(server2, builderClient) {
7577
7854
  {
7578
7855
  webhookId: import_zod34.z.string().describe("The webhook ID (its workflow ID)."),
7579
7856
  confirm: import_zod34.z.literal("DELETE").describe("Must be 'DELETE' to confirm this destructive action."),
7580
- force: import_zod34.z.boolean().optional().describe("Delete even when the workflow contains other actions that would go with it.")
7857
+ force: import_zod34.z.boolean().optional().describe("Delete even when the workflow contains other actions that would go with it."),
7858
+ acknowledgeContacts: import_zod34.z.number().optional().describe("Only needed if contacts are part-way through this workflow. Deleting drops them permanently and they cannot be put back. The first attempt tells you the number; pass it here to proceed.")
7581
7859
  },
7582
- async ({ webhookId, confirm: confirm2, force }) => {
7860
+ async ({ webhookId, confirm: confirm2, force, acknowledgeContacts }) => {
7583
7861
  try {
7584
7862
  const client = requireClient();
7585
7863
  if (confirm2 !== "DELETE") throw new Error("Pass confirm: 'DELETE' to delete a webhook.");
@@ -7594,7 +7872,7 @@ function registerWebhookTools(server2, builderClient) {
7594
7872
  `Workflow "${summary.name}" does more than fire a webhook \u2014 deleting it would delete those steps too. Pass force: true if that is what you want, or use update_webhook with status 'draft' to just stop it firing.`
7595
7873
  );
7596
7874
  }
7597
- await client.deleteWorkflow(webhookId);
7875
+ await client.deleteWorkflow(webhookId, { acknowledgeContacts });
7598
7876
  return jsonResponse({ deleted: true, webhookId, name: summary.name });
7599
7877
  } catch (error) {
7600
7878
  return errorResponse(error);
@@ -8190,14 +8468,15 @@ function registerWorkflowBuilderTools(server2, client) {
8190
8468
  );
8191
8469
  server2.tool(
8192
8470
  "delete_workflow_full",
8193
- "Permanently delete a workflow. IRREVERSIBLE.",
8471
+ "Permanently delete a workflow. IRREVERSIBLE. If contacts are part-way through it, the first attempt refuses and tells you how many would be dropped \u2014 deleting removes them from the sequence permanently and they cannot be restored.",
8194
8472
  {
8195
8473
  workflowId: import_zod36.z.string().describe("The workflow ID to delete."),
8196
- confirm: import_zod36.z.literal("DELETE").describe("Must pass 'DELETE' to confirm this destructive action.")
8474
+ confirm: import_zod36.z.literal("DELETE").describe("Must pass 'DELETE' to confirm this destructive action."),
8475
+ acknowledgeContacts: import_zod36.z.number().optional().describe("Only needed if contacts are part-way through this workflow. Deleting drops them out of the sequence permanently and they cannot be put back. The first attempt tells you the number; pass it here to proceed.")
8197
8476
  },
8198
- async ({ workflowId }) => {
8477
+ async ({ workflowId, acknowledgeContacts }) => {
8199
8478
  try {
8200
- const result = await client.deleteWorkflow(workflowId);
8479
+ const result = await client.deleteWorkflow(workflowId, { acknowledgeContacts });
8201
8480
  return jsonResponse(result);
8202
8481
  } catch (error) {
8203
8482
  return errorResponse(error);
@@ -10964,7 +11243,31 @@ var init_attestation = __esm({
10964
11243
  });
10965
11244
 
10966
11245
  // src/setup-tool.ts
10967
- function deviceFingerprint2() {
11246
+ function stableFallbackId() {
11247
+ let user;
11248
+ try {
11249
+ user = os2.userInfo().username;
11250
+ } catch {
11251
+ user = process.env.USER || process.env.USERNAME || process.env.LOGNAME || "no-user";
11252
+ }
11253
+ const raw = `${user}:${os2.platform()}:${os2.arch()}`;
11254
+ return crypto4.createHash("sha256").update(raw).digest("hex").slice(0, 16);
11255
+ }
11256
+ function warnNoDurableId() {
11257
+ if (warnedNoDurableId) return;
11258
+ warnedNoDurableId = true;
11259
+ let where = "the config folder";
11260
+ try {
11261
+ const store = (init_credentials_store(), __toCommonJS(credentials_store_exports));
11262
+ where = store.appDataDir();
11263
+ } catch {
11264
+ }
11265
+ process.stderr.write(
11266
+ `[ghl-mcp] Warning: this machine's identity could not be stored in ${where}, so a derived one is being used. Your licence still works and no extra install slot is consumed. If this keeps appearing, that folder is not writable \u2014 set GHL_MCP_CONFIG_DIR to a folder you own.
11267
+ `
11268
+ );
11269
+ }
11270
+ function licenseDeviceId() {
10968
11271
  try {
10969
11272
  const store = (init_credentials_store(), __toCommonJS(credentials_store_exports));
10970
11273
  const creds = store.readCredentials();
@@ -10974,7 +11277,14 @@ function deviceFingerprint2() {
10974
11277
  const att = (init_attestation(), __toCommonJS(attestation_exports));
10975
11278
  adopted = att.parseAttestationPayload(creds.signed_attestation)?.device_fingerprint;
10976
11279
  }
10977
- const id = adopted || telemetryDeviceId() || crypto4.randomBytes(8).toString("hex");
11280
+ let id = adopted;
11281
+ if (!id) {
11282
+ id = telemetryDeviceId() ?? void 0;
11283
+ if (!id) {
11284
+ warnNoDurableId();
11285
+ id = stableFallbackId();
11286
+ }
11287
+ }
10978
11288
  if (creds) {
10979
11289
  try {
10980
11290
  store.writeCredentials({ ...creds, device_id: id });
@@ -10983,8 +11293,7 @@ function deviceFingerprint2() {
10983
11293
  }
10984
11294
  return id;
10985
11295
  } catch {
10986
- const raw = `${os2.userInfo().username}:${os2.platform()}:${os2.arch()}`;
10987
- return crypto4.createHash("sha256").update(raw).digest("hex").slice(0, 16);
11296
+ return stableFallbackId();
10988
11297
  }
10989
11298
  }
10990
11299
  async function validateLicense(email, licenseKey) {
@@ -10996,7 +11305,7 @@ async function validateLicense(email, licenseKey) {
10996
11305
  body: JSON.stringify({
10997
11306
  email: email.trim(),
10998
11307
  license_key: licenseKey.trim(),
10999
- device_fingerprint: deviceFingerprint2()
11308
+ device_fingerprint: licenseDeviceId()
11000
11309
  }),
11001
11310
  signal: AbortSignal.timeout(1e4)
11002
11311
  });
@@ -11553,7 +11862,7 @@ function registerLeadCaptureTool(server2) {
11553
11862
  }
11554
11863
  );
11555
11864
  }
11556
- var os2, crypto4, import_zod43, LICENSE_API, CAPTURE_API, GHL_API, FIREBASE_TOKEN_API, LICENSE_REASON_MAP, setupPkgVersion;
11865
+ var os2, crypto4, import_zod43, LICENSE_API, CAPTURE_API, GHL_API, FIREBASE_TOKEN_API, warnedNoDurableId, LICENSE_REASON_MAP, setupPkgVersion;
11557
11866
  var init_setup_tool = __esm({
11558
11867
  "src/setup-tool.ts"() {
11559
11868
  "use strict";
@@ -11571,6 +11880,7 @@ var init_setup_tool = __esm({
11571
11880
  CAPTURE_API = "https://elitedcs.com/api/capture-lead";
11572
11881
  GHL_API = "https://services.leadconnectorhq.com";
11573
11882
  FIREBASE_TOKEN_API = "https://securetoken.googleapis.com/v1/token";
11883
+ warnedNoDurableId = false;
11574
11884
  LICENSE_REASON_MAP = {
11575
11885
  unreachable: "license_unreachable",
11576
11886
  install_limit: "license_install_limit",
@@ -14587,6 +14897,12 @@ function traceOpportunityContext(node, byId2) {
14587
14897
  }
14588
14898
  return "dangling";
14589
14899
  }
14900
+ function isRecord4(v) {
14901
+ return typeof v === "object" && v !== null && !Array.isArray(v);
14902
+ }
14903
+ function isUuidV4(id) {
14904
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
14905
+ }
14590
14906
  function checkActionShapes(workflow) {
14591
14907
  const findings = [];
14592
14908
  const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
@@ -14659,9 +14975,79 @@ function checkActionShapes(workflow) {
14659
14975
  }
14660
14976
  return findings;
14661
14977
  }
14978
+ function checkSaveBlockers(workflow) {
14979
+ const findings = [];
14980
+ const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
14981
+ for (const a of actions) {
14982
+ const type = typeof a.type === "string" ? a.type : "unknown";
14983
+ const name = typeof a.name === "string" ? a.name : "unnamed action";
14984
+ const id = typeof a.id === "string" ? a.id : name;
14985
+ const where = `action "${name}" (${type})`;
14986
+ if (typeof a.id === "string" && a.id && !isUuidV4(a.id)) {
14987
+ findings.push({
14988
+ severity: "warning",
14989
+ category: "legacy_step_id",
14990
+ id,
14991
+ where,
14992
+ message: `${where} carries a step id GoHighLevel's validator now rejects when the workflow is saved ("${a.id}"). The workflow RUNS normally \u2014 it cannot be EDITED until the id is re-minted. Re-minting drops anyone currently waiting on that step, so it is refused while somebody is part-way through.`
14993
+ });
14994
+ }
14995
+ {
14996
+ const attrs = isRecord4(a.attributes) ? a.attributes : void 0;
14997
+ const startAfter = attrs && isRecord4(attrs.startAfter) ? attrs.startAfter : void 0;
14998
+ if (startAfter && (startAfter.type === "day" || startAfter.type === "minute")) {
14999
+ findings.push({
15000
+ severity: "warning",
15001
+ category: "legacy_wait_unit",
15002
+ id,
15003
+ where,
15004
+ message: `${where} uses the singular wait unit "${String(startAfter.type)}"; GoHighLevel requires the plural on save. It runs fine and blocks saves. This one is corrected automatically on the next write \u2014 no ids change and nobody is ejected.`
15005
+ });
15006
+ }
15007
+ }
15008
+ if (type === "internal_notification") {
15009
+ const attrs = isRecord4(a.attributes) ? a.attributes : void 0;
15010
+ const notif = attrs && isRecord4(attrs.notification) ? attrs.notification : void 0;
15011
+ const selected = notif?.selectedUser;
15012
+ const blank = selected === void 0 || selected === null || typeof selected === "string" && selected.trim() === "";
15013
+ if (notif?.userType === "user" && blank) {
15014
+ findings.push({
15015
+ severity: "warning",
15016
+ category: "missing_required_attribute",
15017
+ id,
15018
+ where,
15019
+ message: `${where} is set to notify a specific person but nobody is selected, so it sends nothing. The workflow saves, publishes and runs \u2014 this step just never notifies anyone. Pick a user, or set it to notify everyone.`
15020
+ });
15021
+ }
15022
+ }
15023
+ }
15024
+ return findings;
15025
+ }
15026
+ function checkMissingIntegrations(workflow, connectedPlatforms) {
15027
+ const findings = [];
15028
+ const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
15029
+ for (const a of actions) {
15030
+ const type = typeof a.type === "string" ? a.type : "";
15031
+ const needs = SOCIAL_STEP_PLATFORMS.get(type);
15032
+ if (!needs) continue;
15033
+ if (needs.some((p) => connectedPlatforms.has(p))) continue;
15034
+ const name = typeof a.name === "string" ? a.name : "unnamed action";
15035
+ const id = typeof a.id === "string" ? a.id : name;
15036
+ const label = needs.length > 1 ? needs.join(" or ") : needs[0];
15037
+ findings.push({
15038
+ severity: "warning",
15039
+ category: "missing_integration",
15040
+ id,
15041
+ where: `action "${name}" (${type})`,
15042
+ message: `action "${name}" needs a connected ${label} account and this location has none. The workflow saves, publishes and runs \u2014 this step just does nothing when it is reached. Connect the account in Settings \u2192 Integrations. (This check only sees a MISSING account: if the account is connected but its messaging permission was never granted, the step fails the same way and nothing here can tell you.)`
15043
+ });
15044
+ }
15045
+ return findings;
15046
+ }
14662
15047
  function summarizeFindings(findings) {
14663
15048
  const issues = findings.filter((f) => f.severity === "error").length;
14664
- const warnings = findings.filter((f) => f.severity === "warning" && (f.category === "custom_field" || f.category === "action_shape")).length;
15049
+ const WARNING_CATEGORIES = /* @__PURE__ */ new Set(["custom_field", "action_shape", "legacy_step_id", "legacy_wait_unit", "missing_required_attribute", "missing_integration"]);
15050
+ const warnings = findings.filter((f) => f.severity === "warning" && WARNING_CATEGORIES.has(f.category)).length;
14665
15051
  return { status: issues > 0 ? "issues_found" : "ok", issues_count: issues, warnings_count: warnings };
14666
15052
  }
14667
15053
  function collectMergeTagKeys(value, out) {
@@ -15089,6 +15475,26 @@ function incompleteCatalogNote(stopReason, enumerated, reportedTotal) {
15089
15475
  return "";
15090
15476
  }
15091
15477
  }
15478
+ async function connectedSocialPlatforms(client, locationId2) {
15479
+ try {
15480
+ const raw = await client.get(`/social-media-posting/${locationId2}/accounts`);
15481
+ const rec = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
15482
+ const root = rec(raw);
15483
+ const results = root && rec(root.results);
15484
+ const list2 = results?.accounts ?? root?.accounts;
15485
+ if (!Array.isArray(list2)) return null;
15486
+ const out = /* @__PURE__ */ new Set();
15487
+ for (const row of list2) {
15488
+ const r = rec(row);
15489
+ if (!r) continue;
15490
+ if (r.isExpired === true || r.deleted === true) continue;
15491
+ if (typeof r.platform === "string") out.add(r.platform.toLowerCase());
15492
+ }
15493
+ return out;
15494
+ } catch {
15495
+ return null;
15496
+ }
15497
+ }
15092
15498
  function registerValidatorTools(server2, client, builderClient) {
15093
15499
  if (!builderClient) {
15094
15500
  const finishInstallStub = (asked) => ({
@@ -15128,7 +15534,12 @@ function registerValidatorTools(server2, client, builderClient) {
15128
15534
  const workflow = await builderClient.getWorkflow(workflowId);
15129
15535
  if (!workflow) return errorResponse(new Error(`Workflow ${workflowId} not found`));
15130
15536
  const refs = [];
15131
- const shape = checkActionShapes(workflow);
15537
+ const socialPlatforms = await connectedSocialPlatforms(client, client.defaultLocationId);
15538
+ const shape = [
15539
+ ...checkActionShapes(workflow),
15540
+ ...checkSaveBlockers(workflow),
15541
+ ...socialPlatforms ? checkMissingIntegrations(workflow, socialPlatforms) : []
15542
+ ];
15132
15543
  const actionsChecked = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates.length : 0;
15133
15544
  for (const t of Array.isArray(workflow.triggers) ? workflow.triggers : []) extractFromTrigger(t, refs);
15134
15545
  for (const a of Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : []) extractFromAction(a, refs);
@@ -15190,6 +15601,7 @@ function registerValidatorTools(server2, client, builderClient) {
15190
15601
  notes: catalog.complete ? [] : [incompleteCatalogNote(catalog.stopReason, 0, catalog.reportedTotal)]
15191
15602
  });
15192
15603
  const lookups = await fetchAndBuildLookups(client, builderClient, locationId2, { ids: catalog.ids, complete: catalog.complete });
15604
+ const auditSocialPlatforms = await connectedSocialPlatforms(client, locationId2);
15193
15605
  const SCAN_CAP = 300;
15194
15606
  const toScan = catalog.rows.slice(0, SCAN_CAP);
15195
15607
  const CONCURRENCY = 6;
@@ -15203,7 +15615,12 @@ function registerValidatorTools(server2, client, builderClient) {
15203
15615
  const wf = await builderClient.getWorkflow(row.id);
15204
15616
  const refs = auditOneWorkflow(wf, row.id, lookups);
15205
15617
  if (refs.length === 0) zeroRefCount++;
15206
- const findings = [...checkActionShapes(wf), ...checkRefs(refs, row.id, lookups)];
15618
+ const findings = [
15619
+ ...checkActionShapes(wf),
15620
+ ...checkSaveBlockers(wf),
15621
+ ...auditSocialPlatforms ? checkMissingIntegrations(wf, auditSocialPlatforms) : [],
15622
+ ...checkRefs(refs, row.id, lookups)
15623
+ ];
15207
15624
  results.push({ id: row.id, name: wf.name ?? row.name, status: wf.status, refs: refs.length, findings });
15208
15625
  } catch (e) {
15209
15626
  unscannable.push({ id: row.id, name: row.name, reason: e instanceof Error ? e.message : String(e) });
@@ -15262,7 +15679,7 @@ function registerValidatorTools(server2, client, builderClient) {
15262
15679
  }
15263
15680
  );
15264
15681
  }
15265
- var import_zod53, ALL_CATEGORIES, OPPORTUNITY_CONTEXT_TRIGGERS, OPPORTUNITY_CREATING_TYPES, KNOWN_UPDATE_OPP_FIELDS, MAX_TRACE_HOPS, STANDARD_CONTACT_FIELDS, MERGE_TAG_RE;
15682
+ var import_zod53, ALL_CATEGORIES, OPPORTUNITY_CONTEXT_TRIGGERS, OPPORTUNITY_CREATING_TYPES, KNOWN_UPDATE_OPP_FIELDS, MAX_TRACE_HOPS, SOCIAL_STEP_PLATFORMS, STANDARD_CONTACT_FIELDS, MERGE_TAG_RE;
15266
15683
  var init_validators = __esm({
15267
15684
  "src/tools/validators.ts"() {
15268
15685
  "use strict";
@@ -15281,6 +15698,12 @@ var init_validators = __esm({
15281
15698
  OPPORTUNITY_CREATING_TYPES = /* @__PURE__ */ new Set(["internal_create_opportunity", "create_opportunity"]);
15282
15699
  KNOWN_UPDATE_OPP_FIELDS = /* @__PURE__ */ new Set(["pipelineId", "pipelineStageId", "name", "status", "source", "monetaryValue"]);
15283
15700
  MAX_TRACE_HOPS = 200;
15701
+ SOCIAL_STEP_PLATFORMS = /* @__PURE__ */ new Map([
15702
+ ["instagram-dm", ["instagram"]],
15703
+ ["fb_interactive_messenger", ["facebook"]],
15704
+ // A comment reply can be on either network; one connected account is enough.
15705
+ ["respond_on_comment", ["facebook", "instagram"]]
15706
+ ]);
15284
15707
  STANDARD_CONTACT_FIELDS = /* @__PURE__ */ new Set([
15285
15708
  "first_name",
15286
15709
  "firstname",
@@ -23802,6 +24225,142 @@ var init_plain_outcome = __esm({
23802
24225
  }
23803
24226
  });
23804
24227
 
24228
+ // src/manual-tasks.ts
24229
+ function taskId(text) {
24230
+ const normalised = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
24231
+ let h = 0;
24232
+ for (let i = 0; i < normalised.length; i++) {
24233
+ h = h * 31 + normalised.charCodeAt(i) | 0;
24234
+ }
24235
+ return `t${(h >>> 0).toString(36)}`;
24236
+ }
24237
+ function findingsFor(outcome) {
24238
+ if (!outcome) return [];
24239
+ const list2 = outcome.plain?.issues?.length ? outcome.plain.issues : outcome.issues;
24240
+ return (list2 ?? []).map((s) => String(s).trim()).filter(Boolean);
24241
+ }
24242
+ function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISOString()) {
24243
+ const before = new Map(previous.map((t) => [t.id, t]));
24244
+ const seen = /* @__PURE__ */ new Set();
24245
+ const out = [];
24246
+ for (const text of findings) {
24247
+ const id = taskId(text);
24248
+ if (seen.has(id)) continue;
24249
+ seen.add(id);
24250
+ const prior = before.get(id);
24251
+ if (prior?.claimed) {
24252
+ out.push({ id, text, reappeared: true });
24253
+ } else {
24254
+ out.push({ id, text, ...prior?.reappeared ? { reappeared: true } : {} });
24255
+ }
24256
+ }
24257
+ void now;
24258
+ return out;
24259
+ }
24260
+ function setTaskClaimed(tasks2, id, claimed, now = (/* @__PURE__ */ new Date()).toISOString()) {
24261
+ return tasks2.map(
24262
+ (t) => t.id !== id ? t : claimed ? { ...t, claimed: true, claimedAt: now, reappeared: false } : { id: t.id, text: t.text, ...t.reappeared ? { reappeared: true } : {} }
24263
+ );
24264
+ }
24265
+ function manualProgress(tasks2, verifyHasRun) {
24266
+ const total = tasks2.length;
24267
+ const claimed = tasks2.filter((t) => t.claimed).length;
24268
+ const reappeared = tasks2.filter((t) => t.reappeared).length;
24269
+ const clear = verifyHasRun && total === 0;
24270
+ let line2;
24271
+ if (!verifyHasRun) {
24272
+ line2 = "Run the check first. It writes this list for you.";
24273
+ } else if (clear) {
24274
+ line2 = "Nothing left to do by hand. The check came back clean.";
24275
+ } else if (reappeared) {
24276
+ line2 = `${reappeared} of these ${reappeared === 1 ? "was" : "were"} ticked off, but the last check still found ${reappeared === 1 ? "it" : "them"} in the account. Worth a second look before signing off.`;
24277
+ } else if (claimed === total) {
24278
+ line2 = "All ticked. Run the check again to confirm the account agrees.";
24279
+ } else {
24280
+ line2 = `${total - claimed} of ${total} still to do. These are the jobs only you can do in the account.`;
24281
+ }
24282
+ return { total, claimed, reappeared, clear, line: line2 };
24283
+ }
24284
+ function manualSignOffRefusal(tasks2, verifyHasRun) {
24285
+ if (!verifyHasRun) {
24286
+ return "Run the check first. It is what writes the list of manual jobs, so there is nothing to sign off yet.";
24287
+ }
24288
+ const p = manualProgress(tasks2, verifyHasRun);
24289
+ if (p.clear) return null;
24290
+ const left = p.total - p.claimed;
24291
+ if (p.reappeared) {
24292
+ return `The last check still found ${p.reappeared} ${p.reappeared === 1 ? "thing" : "things"} that ${p.reappeared === 1 ? "was" : "were"} ticked off. Fix it in the account, then run the check again \u2014 signing off now would record something the account disagrees with.`;
24293
+ }
24294
+ if (left > 0) {
24295
+ return `${left} manual ${left === 1 ? "job is" : "jobs are"} still open. Do them in the account, tick them here, then run the check again.`;
24296
+ }
24297
+ return "Everything is ticked, but the check has not confirmed it yet. Run the check again and this will clear on its own.";
24298
+ }
24299
+ var init_manual_tasks = __esm({
24300
+ "src/manual-tasks.ts"() {
24301
+ "use strict";
24302
+ }
24303
+ });
24304
+
24305
+ // src/go-live.ts
24306
+ function parse3(t) {
24307
+ const n = t ? Date.parse(t) : NaN;
24308
+ return Number.isFinite(n) ? n : NaN;
24309
+ }
24310
+ function goLiveRefusal(e) {
24311
+ if (!e.verify) {
24312
+ return "This account has not been checked yet. Run the check, deal with anything it finds, then sign off.";
24313
+ }
24314
+ const manual = manualSignOffRefusal(e.manualTasks, true);
24315
+ if (manual) return manual;
24316
+ const findings = e.verify.plain?.issues?.length ? e.verify.plain.issues : e.verify.issues;
24317
+ if (findings && findings.length) {
24318
+ return `The last check found ${findings.length} ${findings.length === 1 ? "thing" : "things"} still wrong. Signing off now would tell your client an account works that we have just been told does not.`;
24319
+ }
24320
+ const v = parse3(e.verify.at);
24321
+ const b = parse3(e.build?.at);
24322
+ if (!Number.isFinite(b)) {
24323
+ return "There is no dated record of the last build for this account, so we cannot tell whether the clean check came before or after it. Run the build step and the check again, in that order, then sign off.";
24324
+ }
24325
+ if (!Number.isFinite(v)) {
24326
+ return "The last check has no usable date on it, so it cannot be shown to describe the account as it stands now. Run the check again, then sign off.";
24327
+ }
24328
+ if (b > v) {
24329
+ return "The account was built into again after the last check, so the clean result is out of date. Run the check once more, then sign off on what it says.";
24330
+ }
24331
+ if (!e.by || !e.by.trim()) {
24332
+ return "Put your name to it. A go-live signed by nobody is exactly the word-of-mouth 'done' this replaces.";
24333
+ }
24334
+ return null;
24335
+ }
24336
+ function signOff(e, now = (/* @__PURE__ */ new Date()).toISOString()) {
24337
+ return { by: (e.by ?? "").trim(), at: now, verifiedAt: e.verify?.at ?? "" };
24338
+ }
24339
+ function liveClaim(stageIsDone, sig, lastBuildAt) {
24340
+ if (!stageIsDone) return { live: false };
24341
+ if (!sig || !sig.by || !sig.by.trim() || !Number.isFinite(parse3(sig.at))) {
24342
+ return {
24343
+ live: false,
24344
+ warning: "This client is ticked as live but there is no signed record of it, so it cannot be shown as live. That happens when the board was restored from a backup or edited by hand. Run the check and sign it off properly."
24345
+ };
24346
+ }
24347
+ const built = lastBuildAt ? Date.parse(lastBuildAt) : NaN;
24348
+ const verified = sig.verifiedAt ? Date.parse(sig.verifiedAt) : NaN;
24349
+ if (Number.isFinite(built) && Number.isFinite(verified) && built > verified) {
24350
+ return {
24351
+ live: true,
24352
+ note: "Signed off, but the account has been built into since then, so that sign-off no longer describes what is in there now. Run the check again if you need to stand behind it."
24353
+ };
24354
+ }
24355
+ return { live: true };
24356
+ }
24357
+ var init_go_live = __esm({
24358
+ "src/go-live.ts"() {
24359
+ "use strict";
24360
+ init_manual_tasks();
24361
+ }
24362
+ });
24363
+
23805
24364
  // src/work-queue.ts
23806
24365
  function markKey(locationId2, stageIndex) {
23807
24366
  return `${locationId2}:${stageIndex}`;
@@ -23919,10 +24478,11 @@ function deriveQueue(input) {
23919
24478
  const updatedAt = state.clients[locationId2]?.updated_at || void 0;
23920
24479
  if (stages.every((s) => s === "done")) {
23921
24480
  const proof2 = lastProof(state, locationId2, QUEUE_STEPS.length);
24481
+ const claim = liveClaim(true, state.goLive?.[locationId2]);
23922
24482
  finished.push({
23923
24483
  locationId: locationId2,
23924
24484
  clientName: name,
23925
- title: `${name} is live`,
24485
+ title: claim.live ? `${name} is live` : `${name} is ticked live with no signed record`,
23926
24486
  ...updatedAt ? { finishedAt: updatedAt } : {},
23927
24487
  ...proof2 ? { proof: proof2 } : {}
23928
24488
  });
@@ -24047,6 +24607,7 @@ var init_work_queue = __esm({
24047
24607
  "use strict";
24048
24608
  init_stage_runner();
24049
24609
  init_plain_outcome();
24610
+ init_go_live();
24050
24611
  QUEUE_STEPS = [
24051
24612
  { label: "Intake", todo: "Set up the intake questions for {client}", why: "Command OS puts the questionnaire into their account." },
24052
24613
  { label: "Brief", todo: "Write the build brief for {client}", why: "Command OS turns their answers into the plan." },
@@ -26779,9 +27340,10 @@ async function executeRevert(plan, deps) {
26779
27340
  attempted.push(item);
26780
27341
  } catch (e) {
26781
27342
  const detail = errText(e);
27343
+ const parked = isParkedContactsError(e);
26782
27344
  refused.push({
26783
27345
  object: item.object,
26784
- reason: `GoHighLevel refused to remove ${SINGULAR[item.object.type].toLowerCase()} "${item.object.name}". This is usually because something else in the account still uses it. Remove it by hand in ${WHERE[item.object.type]}, or remove whatever still points at it and undo again.`,
27346
+ reason: parked ? `Left alone: contacts are part-way through workflow "${item.object.name}", and removing it would drop them out of the sequence permanently. Everything else in this undo carried on. If you do want it gone, remove that one with delete_workflow_full \u2014 it will tell you how many people are affected and ask you to confirm that number.` : `GoHighLevel refused to remove ${SINGULAR[item.object.type].toLowerCase()} "${item.object.name}". This is usually because something else in the account still uses it. Remove it by hand in ${WHERE[item.object.type]}, or remove whatever still points at it and undo again.`,
26785
27347
  detail
26786
27348
  });
26787
27349
  }
@@ -26964,6 +27526,7 @@ var BUILD_ORDER, REVERT_ORDER, ORDER_INDEX, PLURAL, SINGULAR, WHERE, BOUND_REASO
26964
27526
  var init_revert = __esm({
26965
27527
  "src/intake-to-build/revert.ts"() {
26966
27528
  "use strict";
27529
+ init_workflow_builder_client();
26967
27530
  BUILD_ORDER = [
26968
27531
  "user",
26969
27532
  "pipeline",
@@ -29097,6 +29660,12 @@ function makeExecuteDeps(client, builderClient, locationId2, registry2) {
29097
29660
  const full = await builderClient.getWorkflow(workflowId);
29098
29661
  return pickCalendarPins(full);
29099
29662
  },
29663
+ // No acknowledgement threaded, deliberately. This is only used to roll back
29664
+ // an UNSAVED workflow shell — one created moments earlier that never had its
29665
+ // actions written. A shell has no steps, so nobody can be parked in it and
29666
+ // the guard returns "nobody" and proceeds. If the parked read itself fails,
29667
+ // the delete fails and `rollbackUnsavedShells` surfaces it in the halt message
29668
+ // rather than swallowing it, which is the behaviour we want anyway.
29100
29669
  deleteWorkflow: async (workflowId) => {
29101
29670
  await builderClient.deleteWorkflow(workflowId);
29102
29671
  },
@@ -29113,6 +29682,17 @@ function makeRevertDeps(client, builderClient, locationId2) {
29113
29682
  return {
29114
29683
  remove: {
29115
29684
  // Reverse build order is enforced by revert.ts; this map is just routes.
29685
+ // No acknowledgement is threaded here, and that is deliberate rather than
29686
+ // an omission (Codex review, 2026-09-01 — my first fix passed
29687
+ // `o.acknowledgeContacts`, which is always undefined because `o` is a
29688
+ // ledger row and never carried one; it looked like a fix and did nothing).
29689
+ //
29690
+ // A revert can remove several workflows at once with different numbers of
29691
+ // contacts parked in each, so a single acknowledgement number cannot
29692
+ // honestly cover them. Rather than invent one, a workflow with somebody
29693
+ // part-way through is REFUSED and reported, the rest of the revert
29694
+ // continues, and the operator removes that one deliberately with
29695
+ // `delete_workflow_full`, which asks for the count for that workflow alone.
29116
29696
  workflow: async (o) => {
29117
29697
  await builderClient.deleteWorkflow(o.ghlId);
29118
29698
  },
@@ -30608,7 +31188,7 @@ var require_package = __commonJS({
30608
31188
  "package.json"(exports2, module2) {
30609
31189
  module2.exports = {
30610
31190
  name: "@elitedcs/ghl-mcp",
30611
- version: "3.75.0",
31191
+ version: "3.76.0",
30612
31192
  mcpName: "io.github.drjerryrelth/ghl-command",
30613
31193
  description: "GoHighLevel MCP Server for Claude. 248 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.",
30614
31194
  main: "dist/index.js",
@@ -32504,6 +33084,13 @@ function startRun(locationId2, stage, onDone, meta = {}) {
32504
33084
  run.lines.push(`Could not write this run to the cost record: ${e instanceof Error ? e.message : String(e)}. The step itself is unaffected.`);
32505
33085
  }
32506
33086
  for (const line2 of costLines(outcome)) run.lines.push(line2);
33087
+ if (stage === 3 && !outcome.summary && !outcome.issues?.length) {
33088
+ try {
33089
+ const st = readCockpitState();
33090
+ writeCockpitState({ ...st, outcomes: { ...st.outcomes ?? {}, [`${locationId2}:3`]: { at: (/* @__PURE__ */ new Date()).toISOString() } } });
33091
+ } catch {
33092
+ }
33093
+ }
32507
33094
  if (outcome.summary || outcome.issues?.length) {
32508
33095
  const at = (/* @__PURE__ */ new Date()).toISOString();
32509
33096
  const save = (plain) => {
@@ -32517,6 +33104,17 @@ function startRun(locationId2, stage, onDone, meta = {}) {
32517
33104
  save(plain);
32518
33105
  }
32519
33106
  }
33107
+ if (stage === 4) {
33108
+ try {
33109
+ const st = readCockpitState();
33110
+ const fresh = findingsFor(st.outcomes?.[`${locationId2}:4`] ?? { issues: outcome.issues, plain: outcome.plain });
33111
+ const merged = mergeTasks(st.manualTasks?.[locationId2] ?? [], fresh);
33112
+ writeCockpitState({ ...st, manualTasks: { ...st.manualTasks ?? {}, [locationId2]: merged } });
33113
+ const back = merged.filter((t) => t.reappeared).length;
33114
+ if (back) run.lines.push(`${back} job${back === 1 ? "" : "s"} you had ticked off came back in this check. Worth a second look.`);
33115
+ } catch {
33116
+ }
33117
+ }
32520
33118
  run.ok = outcome.ok;
32521
33119
  const before = readCockpitState().clients[locationId2]?.stages;
32522
33120
  onDone(outcome.ok);
@@ -33270,6 +33868,47 @@ function card(c,state,run){const st=(state.clients[c.locationId]||{stages:STAGES
33270
33868
  if(!r.ok)alert(r.error);load()};
33271
33869
  ab.appendChild(w);ab.appendChild(btn);ab.appendChild(protectBtn(c))}
33272
33870
  div.appendChild(ab)}
33871
+ // A "live" claim is re-derived from the signature, never trusted from the
33872
+ // stage array: the gate runs on the write path, and this file can be restored
33873
+ // or hand-edited (Codex round 2).
33874
+ // The server decides whether a client is live; this only draws the answer.
33875
+ {const _claim=(lastState.liveClaims||{})[c.locationId]||{live:false};
33876
+ const _sig=(lastState.goLive||{})[c.locationId];
33877
+ if(_claim.warning){const wb=document.createElement("div");
33878
+ wb.style.cssText="margin-top:.5rem;padding:.5rem .6rem;border-radius:8px;font-size:.75rem;background:var(--warn-soft);color:var(--warn)";
33879
+ wb.textContent=_claim.warning;div.appendChild(wb)}
33880
+ else if(_claim.live&&_sig){const lb=document.createElement("div");
33881
+ lb.style.cssText="margin-top:.5rem;font-size:.75rem";
33882
+ lb.textContent="Live. Signed off by "+_sig.by+" on "+String(_sig.at).slice(0,10)+(_sig.verifiedAt?", on a check that came back clean "+String(_sig.verifiedAt).slice(0,10):"")+".";
33883
+ div.appendChild(lb);
33884
+ if(_claim.note){const sb=document.createElement("div");sb.style.cssText="margin-top:.3rem;font-size:.74rem;color:var(--warn)";
33885
+ sb.textContent=_claim.note;div.appendChild(sb)}}}
33886
+ // Stage 5: the jobs only a person can do, written by Verify itself. Shown as
33887
+ // soon as Verify has run, because that is the moment the list exists.
33888
+ {const mt=(lastState.manualTasks||{})[c.locationId]||[];
33889
+ const ranVerify=Boolean((lastState.outcomes||{})[c.locationId+":4"]);
33890
+ if(ranVerify){
33891
+ const box=document.createElement("div");box.style.cssText="margin-top:.6rem;padding:.6rem .7rem;border:1px solid var(--line);border-radius:8px;background:var(--card-soft,transparent)";
33892
+ const h=document.createElement("div");h.style.cssText="font-size:.78rem;font-weight:700;margin-bottom:.35rem";
33893
+ h.textContent=mt.length?"Your jobs in the account ("+mt.filter(t=>!t.claimed).length+" to do)":"Your jobs in the account";
33894
+ box.appendChild(h);
33895
+ const note=document.createElement("div");note.className="muted";note.style.cssText="font-size:.74rem;margin-bottom:.45rem";
33896
+ note.textContent=mt.length===0
33897
+ ?"Nothing left to do by hand. The last check came back clean."
33898
+ :(mt.some(t=>t.reappeared)
33899
+ ?"Some of these were ticked off, but the last check still found them in the account. Worth a second look."
33900
+ :"Ticking a box records that you did it. The check is what proves it, so run the check again when you are done.");
33901
+ box.appendChild(note);
33902
+ mt.forEach(t=>{
33903
+ const row=document.createElement("label");
33904
+ row.style.cssText="display:flex;gap:.5rem;align-items:flex-start;font-size:.76rem;padding:.22rem 0;cursor:pointer"+(t.reappeared?";color:var(--warn)":"");
33905
+ const cb=document.createElement("input");cb.type="checkbox";cb.checked=Boolean(t.claimed);cb.style.marginTop=".15rem";
33906
+ cb.onchange=async(ev)=>{ev.stopPropagation();
33907
+ await postJSON("/api/manual-task",{locationId:c.locationId,id:t.id,claimed:cb.checked});load()};
33908
+ const txt=document.createElement("span");
33909
+ txt.textContent=(t.reappeared?"Came back after you ticked it: ":"")+t.text;
33910
+ row.appendChild(cb);row.appendChild(txt);box.appendChild(row)});
33911
+ div.appendChild(box)}}
33273
33912
  const _intakeIn=(iData[c.locationId]&&iData[c.locationId].status==="received");
33274
33913
  const _writing=reviewBusy(c.locationId,run);
33275
33914
  if(_intakeIn||_writing||st[1]==="done"||st[2]==="done"||(rData[c.locationId]&&rData[c.locationId].ok)){
@@ -33304,7 +33943,15 @@ function whereItStands(c,state,run){
33304
33943
  if(c.pending)return {pill:"Needs connecting",cls:"state warn",text:"One 2-minute step left"};
33305
33944
  if(run&&run.running&&run.locationId===c.locationId)return {pill:"Building now",cls:"state live",text:LABELS[run.stage]||""};
33306
33945
  const st=(state.clients[c.locationId]||{stages:[]}).stages||[];
33307
- if(st.length===STAGES.length&&st.every(s=>s==="done"))return {pill:"Live",cls:"state ok",text:"All 7 steps done"};
33946
+ // "Live" comes from the SIGNATURE, never from the stage array \u2014 the same rule
33947
+ // the card uses, through the same server-computed answer. This row used to say
33948
+ // "Live" from the checkboxes alone, so a restored or hand-edited board showed a
33949
+ // client live in the roster while the card said there was no signed record
33950
+ // (Codex, round 5 \u2014 and I had told it this renderer did not exist).
33951
+ if(st.length===STAGES.length&&st.every(s=>s==="done")){
33952
+ const _cl=(state.liveClaims||{})[c.locationId]||{};
33953
+ if(_cl.live)return {pill:"Live",cls:"state ok",text:_cl.note?"Signed off, but the account changed since":"All 7 steps done"};
33954
+ return {pill:"Needs sign-off",cls:"state warn",text:"Ticked live with no signed record"}}
33308
33955
  if(!st.some(s=>s!=="pending"))return {pill:"Not started",cls:"chip",text:""};
33309
33956
  let i=0;while(i<st.length&&st[i]==="done")i++;
33310
33957
  return {pill:"Step "+Math.min(i+1,STAGES.length)+" of "+STAGES.length,cls:"chip space",text:(LABELS[Math.min(i,STAGES.length-1)]||"")+" is next"}}
@@ -33692,7 +34339,14 @@ async function runDashboard(argv) {
33692
34339
  }
33693
34340
  if (req.method === "GET" && url === "/api/state") {
33694
34341
  res.writeHead(200, { "Content-Type": "application/json" });
33695
- res.end(JSON.stringify({ ...readCockpitState(), protectedAccounts: readAccountRulesSafe().protected }));
34342
+ {
34343
+ const st = readCockpitState();
34344
+ const liveClaims = {};
34345
+ for (const [loc, c] of Object.entries(st.clients ?? {})) {
34346
+ liveClaims[loc] = liveClaim(c.stages?.[6] === "done", st.goLive?.[loc], st.outcomes?.[`${loc}:3`]?.at);
34347
+ }
34348
+ res.end(JSON.stringify({ ...st, protectedAccounts: readAccountRulesSafe().protected, liveClaims }));
34349
+ }
33696
34350
  return;
33697
34351
  }
33698
34352
  if (req.method === "GET" && url === "/api/queue") {
@@ -34004,6 +34658,29 @@ async function runDashboard(argv) {
34004
34658
  });
34005
34659
  return;
34006
34660
  }
34661
+ if (req.method === "POST" && url === "/api/manual-task") {
34662
+ let body = "";
34663
+ req.on("data", (c) => {
34664
+ body += c;
34665
+ if (body.length > 5e3) req.destroy();
34666
+ });
34667
+ req.on("end", () => {
34668
+ try {
34669
+ const { locationId: locationId2, id, claimed } = JSON.parse(body);
34670
+ if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
34671
+ const st = readCockpitState();
34672
+ const tasks2 = st.manualTasks?.[locationId2] ?? [];
34673
+ if (!tasks2.some((t) => t.id === id)) throw new Error("That job is not on this client's list any more \u2014 run the check again.");
34674
+ writeCockpitState({ ...st, manualTasks: { ...st.manualTasks ?? {}, [locationId2]: setTaskClaimed(tasks2, id, Boolean(claimed)) } });
34675
+ res.writeHead(200, { "Content-Type": "application/json" });
34676
+ res.end(JSON.stringify({ ok: true }));
34677
+ } catch (e) {
34678
+ res.writeHead(400, { "Content-Type": "application/json" });
34679
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
34680
+ }
34681
+ });
34682
+ return;
34683
+ }
34007
34684
  if (req.method === "GET" && url === "/api/agency-profile") {
34008
34685
  const prof = readAgencyProfile();
34009
34686
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -34213,8 +34890,27 @@ async function runDashboard(argv) {
34213
34890
  if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
34214
34891
  const refusal = handMarkRefusal(stage, status);
34215
34892
  if (refusal) throw new Error(refusal);
34893
+ if (stage === 6 && status === "done") {
34894
+ const st6 = readCockpitState();
34895
+ const refusal6 = goLiveRefusal({
34896
+ verify: st6.outcomes?.[`${locationId2}:4`],
34897
+ build: st6.outcomes?.[`${locationId2}:3`],
34898
+ manualTasks: st6.manualTasks?.[locationId2] ?? [],
34899
+ by
34900
+ });
34901
+ if (refusal6) throw new Error(refusal6);
34902
+ }
34903
+ if (stage === 5 && status === "done") {
34904
+ const st5 = readCockpitState();
34905
+ const gate3 = manualSignOffRefusal(
34906
+ st5.manualTasks?.[locationId2] ?? [],
34907
+ Boolean(st5.outcomes?.[`${locationId2}:4`])
34908
+ );
34909
+ if (gate3) throw new Error(gate3);
34910
+ }
34216
34911
  const marked = recordHandMark(setStage(readCockpitState(), locationId2, stage, status), locationId2, stage, by, status);
34217
- writeCockpitState(marked);
34912
+ const withSig = stage === 6 && status === "done" ? { ...marked, goLive: { ...marked.goLive ?? {}, [locationId2]: signOff({ verify: marked.outcomes?.[`${locationId2}:4`], manualTasks: marked.manualTasks?.[locationId2] ?? [], by }) } } : stage === 6 ? { ...marked, goLive: Object.fromEntries(Object.entries(marked.goLive ?? {}).filter(([k]) => k !== locationId2)) } : marked;
34913
+ writeCockpitState(withSig);
34218
34914
  pushShared(locationId2);
34219
34915
  res.writeHead(200, { "Content-Type": "application/json" });
34220
34916
  res.end(JSON.stringify({ ok: true }));
@@ -34278,6 +34974,8 @@ var init_dashboard = __esm({
34278
34974
  init_stage_runner();
34279
34975
  init_account_rules();
34280
34976
  init_run_cost();
34977
+ init_manual_tasks();
34978
+ init_go_live();
34281
34979
  init_provisioning();
34282
34980
  init_customization();
34283
34981
  init_question_set();
@@ -34824,7 +35522,7 @@ function confirmSaved(registry2) {
34824
35522
  }
34825
35523
  return true;
34826
35524
  }
34827
- function parse3(argv, options, required) {
35525
+ function parse4(argv, options, required) {
34828
35526
  let parsed;
34829
35527
  try {
34830
35528
  parsed = (0, import_node_util2.parseArgs)({ args: argv, options, strict: true, allowPositionals: false });
@@ -34840,7 +35538,7 @@ function parse3(argv, options, required) {
34840
35538
  return parsed;
34841
35539
  }
34842
35540
  async function cmdRegisterLocation(argv, registry2) {
34843
- const p = parse3(
35541
+ const p = parse4(
34844
35542
  argv,
34845
35543
  {
34846
35544
  "location-id": { type: "string" },
@@ -34890,7 +35588,7 @@ async function cmdRegisterLocation(argv, registry2) {
34890
35588
  return EXIT_OK2;
34891
35589
  }
34892
35590
  async function cmdRegisterCompanyFirebase(argv, registry2) {
34893
- const p = parse3(
35591
+ const p = parse4(
34894
35592
  argv,
34895
35593
  {
34896
35594
  "company-id": { type: "string" },
@@ -34957,7 +35655,7 @@ async function cmdRegisterCompanyFirebase(argv, registry2) {
34957
35655
  return EXIT_OK2;
34958
35656
  }
34959
35657
  async function cmdRegisterAgencyKey(argv, registry2) {
34960
- const p = parse3(
35658
+ const p = parse4(
34961
35659
  argv,
34962
35660
  { "api-key": { type: "string" }, "no-validate": { type: "boolean" } },
34963
35661
  ["api-key"]