@elitedcs/ghl-mcp 3.84.0 → 3.84.1

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
@@ -9483,12 +9483,24 @@ function boundNumbers(agent) {
9483
9483
  }
9484
9484
  return [...out];
9485
9485
  }
9486
+ function hasSingleNumberField(agent) {
9487
+ const a = isRecord7(agent) ? agent : {};
9488
+ return a.inboundNumber !== null && a.inboundNumber !== void 0 && a.inboundNumber !== "";
9489
+ }
9490
+ function routedNumbers(agent) {
9491
+ const a = isRecord7(agent) ? agent : {};
9492
+ if (hasSingleNumberField(a) || !Array.isArray(a.inboundNumbers)) return [];
9493
+ return [...new Set(a.inboundNumbers.filter((n) => typeof n === "string" && n.length > 0))];
9494
+ }
9495
+ function unprovenNumbers(agent) {
9496
+ return hasSingleNumberField(agent) ? boundNumbers(agent) : [];
9497
+ }
9486
9498
  function poolBinding(agent) {
9487
9499
  const a = isRecord7(agent) ? agent : {};
9488
9500
  return typeof a.numberPoolId === "string" && a.numberPoolId.trim() ? a.numberPoolId : null;
9489
9501
  }
9490
9502
  function isLiveBound(agent) {
9491
- return boundNumbers(agent).length > 0 || poolBinding(agent) !== null;
9503
+ return routedNumbers(agent).length > 0 || poolBinding(agent) !== null;
9492
9504
  }
9493
9505
  function inBackupRota(agent) {
9494
9506
  return isRecord7(agent) && agent.isAgentAsBackupDisabled === false;
@@ -9498,7 +9510,7 @@ function canTakeCall(agent) {
9498
9510
  }
9499
9511
  function liveDescription(agent) {
9500
9512
  const parts = [];
9501
- const nums = boundNumbers(agent);
9513
+ const nums = routedNumbers(agent);
9502
9514
  if (nums.length) parts.push(`answers on ${nums.join(", ")}`);
9503
9515
  const pool = poolBinding(agent);
9504
9516
  if (pool) parts.push(`answers through pool ${pool}`);
@@ -9506,7 +9518,7 @@ function liveDescription(agent) {
9506
9518
  return parts.length ? parts.join(", and ") : null;
9507
9519
  }
9508
9520
  function agentWriteDifferences(sent, stored) {
9509
- return Object.keys(sent).filter((k) => k !== "locationId" && k !== "inboundNumber" && k !== "numberPoolId" && !sentSubsetOfStored(sent[k], stored[k])).sort();
9521
+ return Object.keys(sent).filter((k) => k !== "locationId" && k !== "inboundNumber" && k !== "inboundNumbers" && k !== "numberPoolId" && !sentSubsetOfStored(sent[k], stored[k])).sort();
9510
9522
  }
9511
9523
  function poolNumbers(pool) {
9512
9524
  if (!isRecord7(pool)) return [];
@@ -9778,7 +9790,12 @@ function registerVoiceAiTools(server2, shared) {
9778
9790
  const { numbers, pools, poolObjects } = await readNumbers(client, loc);
9779
9791
  const agents = await listAllVoiceAgents(client, loc);
9780
9792
  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" });
9793
+ for (const a of agents) for (const n of routedNumbers(a)) holders.set(n, { agentId: String(a.id), agentName: String(a.agentName), via: "inboundNumber" });
9794
+ const unproven = agents.filter((a) => unprovenNumbers(a).length > 0).map((a) => {
9795
+ const nums = unprovenNumbers(a);
9796
+ 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) };
9797
+ });
9798
+ const heldUnproven = new Set(unproven.flatMap((u) => u.numbers));
9782
9799
  const poolBound = agents.filter((a) => poolBinding(a) !== null).map((a) => {
9783
9800
  const pool = poolBinding(a);
9784
9801
  const obj = poolObjects.find((p) => poolId(p) === pool);
@@ -9788,10 +9805,11 @@ function registerVoiceAiTools(server2, shared) {
9788
9805
  });
9789
9806
  const unresolvedPool = poolBound.some((p) => typeof p.poolNumbers === "string");
9790
9807
  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);
9808
+ const free = unresolvedPool ? [] : numberRows.filter((r) => !r.answeredBy && !heldUnproven.has(r.number)).map((r) => r.number);
9792
9809
  const rota = agents.filter(inBackupRota).map((a) => ({ agentId: String(a.id), agentName: String(a.agentName) }));
9793
9810
  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.`;
9811
+ const unprovenLine = unproven.length ? ` ${unproven.length} agent(s) carry a number binding NOT proven to route (${unproven.map((u) => `"${u.agentName}" on ${u.numbers.join(", ")}`).join("; ")}): see unprovenBindings for the fix.` : "";
9812
+ const verdict = numbers.length === 0 ? "NOT READY: no phone number on this sub-account." : ready ? `READY: ${numberRows.filter((r) => r.answeredBy).length} number(s) bound to a voice agent${poolBound.length ? `, and ${poolBound.length} agent(s) answering through a number pool` : ""}${rota.length ? `; ${rota.length} agent(s) also sit in the backup rota` : ""}.${unprovenLine}` : unproven.length ? `NOT PROVEN TO ROUTE: no number is bound in the shape GoHighLevel routes to an agent.${unprovenLine}${rota.length ? ` ${rota.length} agent(s) sit in the backup rota (${rota.map((r) => r.agentName).join(", ")}) and may be handed unanswered calls.` : ""}` : `NUMBER PRESENT, NO AGENT BOUND BY API: ${numbers.length} number(s), none carried in inboundNumbers or as numberPoolId by any voice agent.${rota.length ? ` ${rota.length} agent(s) sit in the backup rota (${rota.map((r) => r.agentName).join(", ")}) and may be handed unanswered calls.` : ""} Calls may still reach an agent through the number's own routing, which this API cannot read.`;
9795
9813
  return {
9796
9814
  locationId: loc,
9797
9815
  verdict,
@@ -9800,10 +9818,11 @@ function registerVoiceAiTools(server2, shared) {
9800
9818
  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
9819
  pools,
9802
9820
  poolBoundAgents: poolBound,
9821
+ ...unproven.length ? { unprovenBindings: unproven } : {},
9803
9822
  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 })),
9823
+ 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
9824
  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)."
9825
+ 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
9826
  };
9808
9827
  }
9809
9828
  );
@@ -9839,7 +9858,9 @@ function registerVoiceAiTools(server2, shared) {
9839
9858
  const loc = client.resolveLocationId(locationId2);
9840
9859
  const agent = await readVoiceAgent(client, loc, agentId);
9841
9860
  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 };
9861
+ const unproven = unprovenNumbers(agent);
9862
+ const unprovenNote = unproven.length ? `Its number binding is not proven to route: ${UNPROVEN_BINDING_FIX(String(agent.agentName), unproven)}` : "";
9863
+ 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
9864
  }
9844
9865
  );
9845
9866
  safeTool(
@@ -9852,11 +9873,12 @@ function registerVoiceAiTools(server2, shared) {
9852
9873
  businessName: import_zod37.z.string().min(1).describe("The business it answers for, as it should say it."),
9853
9874
  ...(({ businessName: _b, isAgentAsBackupDisabled: _k, ...rest }) => rest)(AGENT_FIELDS),
9854
9875
  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."),
9876
+ 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
9877
  numberPoolId: import_zod37.z.string().nullable().optional().describe("REFUSED here. Creation never binds a pool; use update_voice_agent afterwards."),
9856
9878
  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
9879
  },
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.");
9880
+ async ({ locationId: locationId2, inboundNumber, inboundNumbers, numberPoolId, isAgentAsBackupDisabled, ...fields }) => {
9881
+ 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
9882
  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
9883
  const client = shared.pinned();
9862
9884
  const loc = client.resolveLocationId(locationId2);
@@ -9864,6 +9886,7 @@ function registerVoiceAiTools(server2, shared) {
9864
9886
  resolveResponsiveness(body);
9865
9887
  if (body.maxCallDuration === void 0) body.maxCallDuration = DEFAULT_MAX_CALL_SECONDS;
9866
9888
  delete body.inboundNumber;
9889
+ delete body.inboundNumbers;
9867
9890
  delete body.numberPoolId;
9868
9891
  body.isAgentAsBackupDisabled = true;
9869
9892
  const problem = agentBodyProblem(body);
@@ -9899,13 +9922,13 @@ function registerVoiceAiTools(server2, shared) {
9899
9922
  safeTool(
9900
9923
  server2,
9901
9924
  "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.",
9925
+ "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
9926
  {
9904
9927
  agentId: import_zod37.z.string().describe("Agent id."),
9905
9928
  locationId: LOCATION,
9906
9929
  agentName: import_zod37.z.string().min(1).max(40).optional(),
9907
9930
  ...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."),
9931
+ 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
9932
  numberPoolId: import_zod37.z.string().nullable().optional().describe("Bind a number pool; null unbinds.")
9910
9933
  },
9911
9934
  async ({ agentId, locationId: locationId2, inboundNumber, numberPoolId, ...fields }) => {
@@ -9913,10 +9936,17 @@ function registerVoiceAiTools(server2, shared) {
9913
9936
  const loc = client.resolveLocationId(locationId2);
9914
9937
  const change = dropUndefined({ ...fields });
9915
9938
  resolveResponsiveness(change);
9916
- if (inboundNumber !== void 0) change.inboundNumber = inboundNumber;
9917
9939
  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.");
9940
+ if (Object.keys(change).length === 0 && inboundNumber === void 0) throw new Error("Nothing to update \u2014 pass at least one field to change.");
9919
9941
  const before = await readVoiceAgent(client, loc, agentId);
9942
+ if (typeof inboundNumber === "string") {
9943
+ const current = Array.isArray(before.inboundNumbers) ? before.inboundNumbers.filter((n) => typeof n === "string" && n.length > 0) : [];
9944
+ change.inboundNumbers = [.../* @__PURE__ */ new Set([...current, inboundNumber])];
9945
+ if (hasSingleNumberField(before)) change.inboundNumber = null;
9946
+ } else if (inboundNumber === null) {
9947
+ change.inboundNumber = null;
9948
+ change.inboundNumbers = [];
9949
+ }
9920
9950
  const merged = { ...change };
9921
9951
  if (change.language !== void 0 || change.translation !== void 0) {
9922
9952
  if (merged.language === void 0 && typeof before.language === "string") merged.language = before.language;
@@ -9968,11 +9998,15 @@ function registerVoiceAiTools(server2, shared) {
9968
9998
  if (typeof inboundNumber === "string" && !boundNumbers(after).includes(inboundNumber)) {
9969
9999
  asserted2 = true;
9970
10000
  notes.push(`ASSERTED ONLY: ${inboundNumber} was sent but the read-back does not show it bound.`);
10001
+ } else if (typeof inboundNumber === "string" && !routedNumbers(after).includes(inboundNumber)) {
10002
+ asserted2 = true;
10003
+ 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
10004
  }
9972
10005
  if (inboundNumber === null && boundNumbers(after).length) {
9973
10006
  asserted2 = true;
9974
10007
  notes.push(`ASSERTED ONLY: unbind was sent but the read-back still shows ${boundNumbers(after).join(", ")}.`);
9975
10008
  }
10009
+ 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
10010
  if (typeof numberPoolId === "string" && poolBinding(after) !== numberPoolId) {
9977
10011
  asserted2 = true;
9978
10012
  notes.push(`ASSERTED ONLY: number pool ${numberPoolId} was sent but the read-back shows ${JSON.stringify(after.numberPoolId)}.`);
@@ -9981,7 +10015,7 @@ function registerVoiceAiTools(server2, shared) {
9981
10015
  asserted2 = true;
9982
10016
  notes.push(`ASSERTED ONLY: pool unbind was sent but the read-back still shows pool ${poolBinding(after)}.`);
9983
10017
  }
9984
- const bound = boundNumbers(after);
10018
+ const bound = routedNumbers(after);
9985
10019
  const live = liveDescription(after);
9986
10020
  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
10021
  if (!canTakeCall(after)) notes.push(UNBOUND_NOTE);
@@ -10191,7 +10225,7 @@ function registerVoiceAiTools(server2, shared) {
10191
10225
  }
10192
10226
  );
10193
10227
  }
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;
10228
+ 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
10229
  var init_voice_ai = __esm({
10196
10230
  "src/tools/voice-ai.ts"() {
10197
10231
  "use strict";
@@ -10228,6 +10262,7 @@ var init_voice_ai = __esm({
10228
10262
  "isAgentAsBackupDisabled",
10229
10263
  "translation"
10230
10264
  ];
10265
+ 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
10266
  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
10267
  AGENT_PAGE_SIZE = 50;
10233
10268
  AGENT_PAGE_CAP = 100;
@@ -11773,6 +11808,9 @@ function hexToOklch(hex) {
11773
11808
  if (!c) throw new Error(`not a colour: ${hex}`);
11774
11809
  return rgbToOklch(c);
11775
11810
  }
11811
+ function oklchToHex(o) {
11812
+ return toHex(oklchToRgb(o));
11813
+ }
11776
11814
  function oklchCss(o) {
11777
11815
  return `oklch(${(o.l * 100).toFixed(1)}% ${o.c.toFixed(3)} ${o.h.toFixed(1)})`;
11778
11816
  }
@@ -12182,6 +12220,176 @@ var init_ghl_page = __esm({
12182
12220
  }
12183
12221
  });
12184
12222
 
12223
+ // src/command-os/design/rendered-palette.ts
12224
+ function kindOf(prop) {
12225
+ if (/background|bgcolor/i.test(prop)) return "background";
12226
+ if (/border|outline|shadow/i.test(prop)) return "border";
12227
+ if (prop === "color") return "text";
12228
+ return "other";
12229
+ }
12230
+ function pageVariables(envelope) {
12231
+ const defs = /* @__PURE__ */ new Map();
12232
+ const fromCss = (css2) => {
12233
+ for (const block of css2.matchAll(/:root\s*\{([^}]*)\}/g)) {
12234
+ for (const m of block[1].matchAll(/--([\w-]+)\s*:\s*([^;]+)/g)) if (!defs.has(m[1])) defs.set(m[1], m[2].trim());
12235
+ }
12236
+ };
12237
+ const root = isRecord8(envelope) ? envelope : {};
12238
+ if (typeof root.pageStyles === "string") fromCss(root.pageStyles);
12239
+ const walk = (node) => {
12240
+ if (Array.isArray(node)) return void node.forEach(walk);
12241
+ if (!isRecord8(node)) return;
12242
+ for (const [k, v] of Object.entries(node)) {
12243
+ if (k.startsWith("--") && typeof v === "string" && !defs.has(k.slice(2))) defs.set(k.slice(2), v.trim());
12244
+ else if (typeof v === "string" && v.includes(":root")) fromCss(v);
12245
+ else walk(v);
12246
+ }
12247
+ };
12248
+ walk(root);
12249
+ return defs;
12250
+ }
12251
+ function resolveValue(raw, defs) {
12252
+ let v = raw.trim();
12253
+ for (let i = 0; i < 10; i++) {
12254
+ const m = /^var\(\s*--([\w-]+)\s*(?:,\s*([^)]*))?\)$/.exec(v);
12255
+ if (!m) return v;
12256
+ const next = defs.get(m[1]) ?? m[2];
12257
+ if (next === void 0) return null;
12258
+ v = next.trim();
12259
+ }
12260
+ return null;
12261
+ }
12262
+ function normaliseColour(value) {
12263
+ if (value === null) return null;
12264
+ if (/^transparent$/i.test(value)) return "transparent";
12265
+ const c = parseColor(value);
12266
+ if (!c) return null;
12267
+ if (c.a === 0) return "transparent";
12268
+ if (c.a < 1) return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${Number(c.a.toFixed(3))})`;
12269
+ return toHex(c);
12270
+ }
12271
+ function renderedPalette(envelope) {
12272
+ const defs = pageVariables(envelope);
12273
+ const paints = [];
12274
+ const walk = (node, path40, prop) => {
12275
+ if (Array.isArray(node)) return void node.forEach((x, i) => walk(x, `${path40}[${i}]`, prop));
12276
+ if (isRecord8(node)) {
12277
+ for (const [k, x] of Object.entries(node)) {
12278
+ if (DEFINITION_KEYS.has(k) || k.startsWith("--")) continue;
12279
+ walk(x, `${path40}.${k}`, WRAPPER_KEYS.has(k) ? prop : k);
12280
+ }
12281
+ return;
12282
+ }
12283
+ if (typeof node !== "string") return;
12284
+ const raw = node.trim();
12285
+ if (!/^var\(/.test(raw) && !COLOUR_LITERAL.test(raw)) return;
12286
+ const resolved2 = resolveValue(raw, defs);
12287
+ const colour = normaliseColour(resolved2);
12288
+ if (colour === null && resolved2 !== null) return;
12289
+ paints.push({ path: path40, prop, kind: kindOf(prop), colour });
12290
+ };
12291
+ walk(envelope, "", "");
12292
+ const face = (name, settingsKey) => {
12293
+ const fromVar = resolveValue(`var(--${name})`, defs);
12294
+ if (fromVar) return fromVar.replace(/['"]/g, "").trim() || null;
12295
+ const settings = envelope?.settings?.settings?.typography?.fonts?.[settingsKey];
12296
+ return typeof settings?.value?.text === "string" && settings.value.text.trim() ? settings.value.text.trim() : null;
12297
+ };
12298
+ return { paints, fonts: { headline: face("headlinefont", "headlineFont"), body: face("contentfont", "contentFont") } };
12299
+ }
12300
+ function contrastLosses(before, after) {
12301
+ const afterByPath = new Map(after.paints.map((p) => [p.path, p.colour]));
12302
+ const groups = /* @__PURE__ */ new Map();
12303
+ for (const p of before.paints) {
12304
+ const to = afterByPath.get(p.path);
12305
+ if (p.kind !== "text" && p.kind !== "background" || !p.colour?.startsWith("#") || !to?.startsWith("#")) continue;
12306
+ const g = groups.get(paintGroup(p.path)) ?? { texts: [], backgrounds: [] };
12307
+ (p.kind === "text" ? g.texts : g.backgrounds).push([p.colour, to]);
12308
+ groups.set(paintGroup(p.path), g);
12309
+ }
12310
+ const out = /* @__PURE__ */ new Map();
12311
+ for (const [where, g] of groups) {
12312
+ for (const [t0, t1] of g.texts) for (const [b0, b1] of g.backgrounds) {
12313
+ const was = contrastHex(t0, b0), now = contrastHex(t1, b1);
12314
+ 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)) });
12315
+ }
12316
+ }
12317
+ return [...out.values()];
12318
+ }
12319
+ function repaintReport(before, after) {
12320
+ const a = renderedPalette(before), b = renderedPalette(after);
12321
+ const afterByPath = new Map(b.paints.map((p) => [p.path, p]));
12322
+ const changes = /* @__PURE__ */ new Map();
12323
+ const unchanged = /* @__PURE__ */ new Map();
12324
+ const tally = { background: { uses: 0, changed: 0 }, neutral: { uses: 0, changed: 0 }, text: { uses: 0, changed: 0 } };
12325
+ const coloured = (c) => c.startsWith("#") && hexToOklch(c).c >= CARRIES_COLOUR;
12326
+ for (const p of a.paints) {
12327
+ if (!p.colour || p.colour === "transparent") continue;
12328
+ const q2 = afterByPath.get(p.path);
12329
+ const to = q2?.colour ?? null;
12330
+ const differs = to !== null && looksDifferent(p.colour, to);
12331
+ if (p.kind === "background" || p.kind === "text") {
12332
+ const t = p.kind === "text" ? tally.text : coloured(p.colour) ? tally.background : tally.neutral;
12333
+ t.uses++;
12334
+ if (differs) t.changed++;
12335
+ else {
12336
+ const key = `${p.kind} ${p.colour}`;
12337
+ const u = unchanged.get(key) ?? { kind: p.kind, colour: p.colour, uses: 0 };
12338
+ u.uses++;
12339
+ unchanged.set(key, u);
12340
+ }
12341
+ }
12342
+ if (differs) {
12343
+ const key = `${p.kind} ${p.colour} ${to}`;
12344
+ const c = changes.get(key) ?? { kind: p.kind, from: p.colour, to, uses: 0 };
12345
+ c.uses++;
12346
+ changes.set(key, c);
12347
+ }
12348
+ }
12349
+ const byUses = (xs) => [...xs].sort((x, y) => y.uses - x.uses);
12350
+ const changeList = byUses(changes.values());
12351
+ const unchangedList = byUses(unchanged.values());
12352
+ const fonts = { headline: { from: a.fonts.headline, to: b.fonts.headline }, body: { from: a.fonts.body, to: b.fonts.body } };
12353
+ const fontsChanged = fonts.headline.from !== fonts.headline.to || fonts.body.from !== fonts.body.to;
12354
+ const bg = tally.background;
12355
+ const share = bg.uses ? bg.changed / bg.uses : 0;
12356
+ const verdict = changeList.length === 0 && !fontsChanged ? "unchanged" : share < BARELY_SHARE ? "barely" : "repainted";
12357
+ 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(", ");
12358
+ const bgChanges = changeList.filter((c) => c.kind === "background" && coloured(c.from));
12359
+ const bgKept = unchangedList.filter((u) => u.kind === "background" && coloured(u.colour));
12360
+ 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"}`;
12361
+ 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)}.` : ""}`;
12362
+ const contrast = contrastLosses(a, b);
12363
+ const summary = [
12364
+ ...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("; ")}).`] : [],
12365
+ colourLine,
12366
+ `White, black and grey backgrounds: ${tally.neutral.changed} of ${tally.neutral.uses} look different.`,
12367
+ `Text colour changed on ${tally.text.changed} of ${tally.text.uses} uses.`,
12368
+ `Faces: ${faceLine("headlines", fonts.headline)}, ${faceLine("body text", fonts.body)}.`
12369
+ ].join(" ");
12370
+ return { verdict, summary, backgrounds: bg, neutralBackgrounds: tally.neutral, text: tally.text, changes: changeList, unchanged: unchangedList, fonts, contrast };
12371
+ }
12372
+ var DEFINITION_KEYS, WRAPPER_KEYS, isRecord8, COLOUR_LITERAL, VISIBLE_CHANGE, BARELY_SHARE, CARRIES_COLOUR, paintGroup, looksDifferent;
12373
+ var init_rendered_palette = __esm({
12374
+ "src/command-os/design/rendered-palette.ts"() {
12375
+ "use strict";
12376
+ init_color();
12377
+ DEFINITION_KEYS = /* @__PURE__ */ new Set(["pageStyles", "general", "rootVars", "sectionStyles", "typography", "fontsToLoad", "fontsToLoadForPreview"]);
12378
+ WRAPPER_KEYS = /* @__PURE__ */ new Set(["value", "styles", "extra", "metaData"]);
12379
+ isRecord8 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
12380
+ COLOUR_LITERAL = /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|transparent)$/i;
12381
+ VISIBLE_CHANGE = 0.05;
12382
+ BARELY_SHARE = 0.5;
12383
+ CARRIES_COLOUR = 0.02;
12384
+ paintGroup = (path40) => /^\.(sections|popups)\[\d+\]/.exec(path40)?.[0]?.slice(1) ?? "page";
12385
+ looksDifferent = (a, b) => {
12386
+ if (a === b) return false;
12387
+ if (a === "transparent" || b === "transparent" || a.startsWith("rgba") || b.startsWith("rgba")) return a !== b;
12388
+ return deltaEHex(a, b) >= VISIBLE_CHANGE;
12389
+ };
12390
+ }
12391
+ });
12392
+
12185
12393
  // src/command-os/design/retheme.ts
12186
12394
  function hexHistogram(envelope) {
12187
12395
  const counts = /* @__PURE__ */ new Map();
@@ -12194,49 +12402,147 @@ function hexHistogram(envelope) {
12194
12402
  function isIconFont(family) {
12195
12403
  return ICON_FONTS.test(family);
12196
12404
  }
12197
- function derivePalette(envelope) {
12198
- const hist = [...hexHistogram(envelope).entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
12405
+ function usageOf(paints) {
12406
+ const u = { background: /* @__PURE__ */ new Map(), text: /* @__PURE__ */ new Map(), all: /* @__PURE__ */ new Map() };
12407
+ const bump = (m, hex) => m.set(hex, (m.get(hex) ?? 0) + 1);
12408
+ for (const p of paints) {
12409
+ if (!p.colour || !p.colour.startsWith("#")) continue;
12410
+ const hex = p.colour.toLowerCase();
12411
+ bump(u.all, hex);
12412
+ if (p.kind === "background") bump(u.background, hex);
12413
+ if (p.kind === "text") bump(u.text, hex);
12414
+ }
12415
+ return u;
12416
+ }
12417
+ function usageOrHexes(envelope) {
12418
+ const u = usageOf(renderedPalette(envelope).paints);
12419
+ if (u.all.size === 0) for (const [hex, n] of hexHistogram(envelope)) u.all.set(hex, n);
12420
+ return u;
12421
+ }
12422
+ function derivePaletteFromUsage(u, fonts) {
12423
+ const sorted = (m) => [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
12424
+ const painted = u.background.size + u.text.size > 0;
12425
+ const merged = new Map(u.background);
12426
+ for (const [hex, n] of u.text) merged.set(hex, (merged.get(hex) ?? 0) + n);
12427
+ const primary = painted ? merged : u.all;
12428
+ const hist = sorted(primary);
12199
12429
  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;
12430
+ const isColoured = (hex) => !isNeutralHex(hex) && hexToOklch(hex).c >= BRAND_CHROMA;
12431
+ const isGreyish = (hex) => isNeutralHex(hex) || hexToOklch(hex).c < BRAND_CHROMA;
12432
+ const firstOf = (pred, ...maps) => {
12433
+ for (const m of maps) {
12434
+ const hit = sorted(m).find(([hex]) => pred(hex));
12435
+ if (hit) return hit[0];
12436
+ }
12437
+ return null;
12438
+ };
12439
+ const accent = painted ? firstOf(isColoured, u.background, u.text) : firstOf(isColoured, primary);
12203
12440
  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
12441
  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;
12442
+ const secondary = accent ? hist.find(([hex]) => isColoured(hex) && deltaEHex(hex, accent) > SECOND_COLOUR_DISTANCE)?.[0] ?? null : null;
12443
+ const light = (hex) => isGreyish(hex) && hexToOklch(hex).l > 0.85;
12444
+ const dark = (hex) => isGreyish(hex) && hexToOklch(hex).l < 0.4;
12445
+ const surface = painted ? firstOf(light, u.background, primary) : firstOf(light, primary);
12446
+ const text2 = painted ? firstOf(dark, u.text, primary) : firstOf(dark, primary);
12210
12447
  if (!surface) undecided.push("no light neutral is used often enough to be the page background");
12211
12448
  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]) => {
12449
+ const isTint = (hex) => {
12216
12450
  if (hex === surface || hex === accent) return false;
12217
12451
  const { l } = hexToOklch(hex);
12218
12452
  return l > 0.7 && l < 0.97;
12219
- })?.[0] ?? null;
12220
- return { accent, surface, text: text2, secondary, tint, fonts, undecided };
12453
+ };
12454
+ const tint = painted ? firstOf(isTint, u.background, primary) : firstOf(isTint, primary);
12455
+ const taken = new Set([accent, surface, text2, secondary, tint].filter((x) => !!x));
12456
+ const tones = painted ? hist.map(([hex]) => hex).filter((hex) => !taken.has(hex) && isGreyish(hex) && hexToOklch(hex).c >= TONE_CHROMA) : [];
12457
+ return { accent, surface, text: text2, secondary, tint, tones, fonts, undecided };
12458
+ }
12459
+ function pageFaces(envelope) {
12460
+ const named = renderedPalette(envelope).fonts;
12461
+ const real = (f) => !!f && !isIconFont(f) && !/^var\(/.test(f);
12462
+ const varSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
12463
+ const serialized = JSON.stringify(envelope);
12464
+ 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);
12465
+ const ordered = [.../* @__PURE__ */ new Set([...real(named.headline) ? [named.headline] : [], ...real(named.body) ? [named.body] : [], ...byUse])];
12466
+ return ordered.slice(0, 2);
12467
+ }
12468
+ function derivePalette(envelope) {
12469
+ return derivePaletteFromUsage(usageOrHexes(envelope), pageFaces(envelope));
12470
+ }
12471
+ function sectionPairs(envelope) {
12472
+ const groups = /* @__PURE__ */ new Map();
12473
+ for (const p of renderedPalette(envelope).paints) {
12474
+ if (!p.colour?.startsWith("#") || p.kind !== "background" && p.kind !== "text") continue;
12475
+ const key = /^\.(sections|popups)\[\d+\]/.exec(p.path)?.[0] ?? "page";
12476
+ const g = groups.get(key) ?? { where: key.replace(/^\./, ""), backgrounds: /* @__PURE__ */ new Set(), texts: /* @__PURE__ */ new Set() };
12477
+ (p.kind === "background" ? g.backgrounds : g.texts).add(p.colour.toLowerCase());
12478
+ groups.set(key, g);
12479
+ }
12480
+ return [...groups.values()];
12221
12481
  }
12222
12482
  function rethemeEnvelope(envelope, brand, derived = derivePalette(envelope)) {
12223
12483
  const swaps = [];
12224
12484
  const skipped = [];
12225
12485
  const warnings = [...derived.undecided];
12226
- let text2 = JSON.stringify(envelope);
12227
- const swap = (role, from, to) => {
12486
+ const plan = [];
12487
+ const want = (role, from, to, guarded2 = false) => {
12228
12488
  if (!from) return void skipped.push(`${role}: the template has none to replace`);
12229
12489
  if (from.toLowerCase() === to.toLowerCase()) return void skipped.push(`${role}: the template already uses ${to}`);
12230
- const re = new RegExp(escapeRe(from), "gi");
12490
+ plan.push({ role, from: from.toLowerCase(), to, guarded: guarded2 });
12491
+ };
12492
+ want("accent", derived.accent, brand.colors.accent.hex);
12493
+ want("surface", derived.surface, brand.colors.surface.hex);
12494
+ want("body text", derived.text, brand.colors.fg.hex);
12495
+ if (derived.secondary) want("second colour", derived.secondary, brand.colors.muted.hex);
12496
+ if (derived.tint) want("section tint", derived.tint, brand.pageStudioTheme.primarySoft, true);
12497
+ const brandHue = hexToOklch(brand.colors.accent.hex);
12498
+ for (const tone of derived.tones ?? []) {
12499
+ if (brandHue.c < NEUTRAL_CHROMA) {
12500
+ skipped.push(`tone ${tone}: the brand's accent is a grey, so it has no hue to carry`);
12501
+ continue;
12502
+ }
12503
+ const o = hexToOklch(tone);
12504
+ want("design tone", tone, oklchToHex({ l: o.l, c: o.c, h: brandHue.h }), true);
12505
+ }
12506
+ const target = (hex) => plan.find((x) => x.from === hex)?.to ?? hex;
12507
+ const pairs = sectionPairs(envelope);
12508
+ const losses = () => {
12509
+ const out = [];
12510
+ for (const g of pairs) for (const bg of g.backgrounds) for (const t of g.texts) {
12511
+ const before = contrastHex(t, bg);
12512
+ if (before < 4.5) continue;
12513
+ const after = contrastHex(target(t), target(bg));
12514
+ if (after >= 4.5) continue;
12515
+ out.push({ where: g.where, t, bg, before, after, involved: plan.filter((x) => x.from === t || x.from === bg) });
12516
+ }
12517
+ return out;
12518
+ };
12519
+ const sayLoss = (l) => `${l.where}: ${target(l.t).toUpperCase()} type on ${target(l.bg).toUpperCase()} would be ${l.after.toFixed(2)}:1 (it was ${l.before.toFixed(2)}:1); WCAG AA wants 4.5.`;
12520
+ for (let pass = 0; pass <= plan.length; pass++) {
12521
+ const hit = losses().find((l) => l.involved.some((x) => x.guarded));
12522
+ if (!hit) break;
12523
+ for (const step of hit.involved.filter((x) => x.guarded)) {
12524
+ skipped.push(`${step.role} ${step.from}: left as it was, because ${sayLoss(hit)}`);
12525
+ plan.splice(plan.indexOf(step), 1);
12526
+ }
12527
+ }
12528
+ const seen = /* @__PURE__ */ new Set();
12529
+ for (const l of losses()) {
12530
+ const line3 = `${sayLoss(l)} Kept, because ${l.involved.map((x) => `the ${x.role}`).join(" and ")} ${l.involved.length > 1 ? "are" : "is"} the brand's own colour: check this section before a client sees it.`;
12531
+ if (!seen.has(`${target(l.t)} ${target(l.bg)}`)) warnings.push(line3);
12532
+ seen.add(`${target(l.t)} ${target(l.bg)}`);
12533
+ }
12534
+ let text2 = JSON.stringify(envelope);
12535
+ const colourTokens = [];
12536
+ plan.forEach((step, i) => {
12537
+ const re = new RegExp(escapeRe(step.from), "gi");
12231
12538
  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);
12539
+ if (occurrences === 0) return;
12540
+ const token2 = `\0COLOUR${i}\0`;
12541
+ text2 = text2.replace(re, token2);
12542
+ colourTokens.push({ token: token2, to: step.to });
12543
+ swaps.push({ role: step.role, from: step.from, to: step.to, occurrences });
12544
+ });
12545
+ for (const { token: token2, to } of colourTokens) text2 = text2.split(token2).join(to);
12240
12546
  const display = brand.pageStudioTheme.headingFont;
12241
12547
  const body = brand.pageStudioTheme.bodyFont;
12242
12548
  const slug3 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
@@ -12262,27 +12568,30 @@ function rethemeEnvelope(envelope, brand, derived = derivePalette(envelope)) {
12262
12568
  `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
12569
  );
12264
12570
  }
12265
- return { envelope: JSON.parse(text2), swaps, skipped, warnings };
12571
+ const painted = JSON.parse(text2);
12572
+ return { envelope: painted, swaps, skipped, warnings, report: repaintReport(envelope, painted) };
12266
12573
  }
12267
12574
  function derivePaletteAcross(envelopes) {
12268
- const pooled2 = /* @__PURE__ */ new Map();
12575
+ const pooled2 = { background: /* @__PURE__ */ new Map(), text: /* @__PURE__ */ new Map(), all: /* @__PURE__ */ new Map() };
12269
12576
  for (const env2 of envelopes) {
12270
- for (const [hex, n] of hexHistogram(env2)) pooled2.set(hex, (pooled2.get(hex) ?? 0) + n);
12577
+ const u = usageOrHexes(env2);
12578
+ 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
12579
  }
12272
- const synthetic = [...pooled2.entries()].flatMap(([hex, n]) => Array.from({ length: n }, () => hex));
12273
- const base = derivePalette({ pooled: synthetic });
12274
12580
  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 };
12581
+ for (const env2 of envelopes) for (const f of pageFaces(env2)) if (!fonts.includes(f)) fonts.push(f);
12582
+ return derivePaletteFromUsage(pooled2, fonts.slice(0, 2));
12277
12583
  }
12278
- var ICON_FONTS, BRAND_CHROMA, escapeRe;
12584
+ var ICON_FONTS, BRAND_CHROMA, TONE_CHROMA, escapeRe;
12279
12585
  var init_retheme = __esm({
12280
12586
  "src/command-os/design/retheme.ts"() {
12281
12587
  "use strict";
12282
12588
  init_color();
12283
12589
  init_ghl_page();
12590
+ init_rendered_palette();
12591
+ init_rendered_palette();
12284
12592
  ICON_FONTS = /font\s*awesome|material\s*(icons|symbols)|glyphicons|ionicons|feather|bootstrap-icons|fontello|typicons|dashicons/i;
12285
12593
  BRAND_CHROMA = 0.06;
12594
+ TONE_CHROMA = 0.02;
12286
12595
  escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12287
12596
  }
12288
12597
  });
@@ -16782,7 +17091,8 @@ ${await response.text()}`);
16782
17091
  problems.push(`${step.name}: the branded page saved but GoHighLevel is not serving it`);
16783
17092
  continue;
16784
17093
  }
16785
- const savedText = JSON.stringify(await (await fetch(savedUrl)).json()).toLowerCase();
17094
+ const savedPage = await (await fetch(savedUrl)).json();
17095
+ const savedText = JSON.stringify(savedPage).toLowerCase();
16786
17096
  const stillThere = palette.accent && savedText.includes(palette.accent.toLowerCase());
16787
17097
  if (stillThere) {
16788
17098
  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 +17103,25 @@ ${await response.text()}`);
16793
17103
  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
17104
  continue;
16795
17105
  }
17106
+ const repaint = repaintReport(envelope, savedPage);
17107
+ const pageWarnings = painted.warnings.filter((w) => !palette.undecided.includes(w));
16796
17108
  done.push({
16797
17109
  page: step.name,
16798
17110
  newPageId,
16799
17111
  oldStepId: step.stepId,
16800
- swaps: painted.swaps.map((sw) => `${sw.role} ${sw.from} \u2192 ${sw.to} (${sw.occurrences}\xD7)`),
17112
+ repaint: {
17113
+ verdict: repaint.verdict,
17114
+ summary: repaint.summary,
17115
+ backgrounds: repaint.backgrounds,
17116
+ neutralBackgrounds: repaint.neutralBackgrounds,
17117
+ text: repaint.text,
17118
+ changes: repaint.changes.filter((c) => c.kind === "background" || c.kind === "text"),
17119
+ fonts: repaint.fonts,
17120
+ contrast: repaint.contrast
17121
+ },
16801
17122
  skipped: painted.skipped,
16802
- preview: ghlPreviewUrl(newPageId),
16803
- readBack: `template ${palette.accent ?? "(no accent)"} gone; brand ${brand.colors.accent.hex} present`
17123
+ ...pageWarnings.length ? { warnings: pageWarnings } : {},
17124
+ preview: ghlPreviewUrl(newPageId)
16804
17125
  });
16805
17126
  } catch (e) {
16806
17127
  problems.push(`${step.name}: ${e instanceof Error ? e.message : String(e)}`);
@@ -16827,6 +17148,19 @@ ${await response.text()}`);
16827
17148
  brand: { slug: brand.slug, accent: brand.colors.accent.hex, display: brand.pageStudioTheme.headingFont, body: brand.pageStudioTheme.bodyFont },
16828
17149
  templatePalette: { accent: palette.accent, surface: palette.surface, text: palette.text, secondary: palette.secondary, faces: palette.fonts, undecided: palette.undecided },
16829
17150
  pages: done,
17151
+ ...(() => {
17152
+ const faint = done.filter((d) => d.repaint.verdict !== "repainted").map((d) => String(d.page));
17153
+ const hard = done.filter((d) => d.repaint.contrast.length > 0);
17154
+ const lines = [
17155
+ ...faint.length ? [`On ${faint.join(", ")} the visible palette barely changed. Each page's repaint says what still paints in the template's own colours.`] : [],
17156
+ ...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.`] : []
17157
+ ];
17158
+ 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).`));
17159
+ return {
17160
+ ...lines.length ? { repaintNote: `${lines.join(" ")} Look at the preview before calling it branded.` } : {},
17161
+ ...contrast.length ? { contrast } : {}
17162
+ };
17163
+ })(),
16830
17164
  originalsRemoved: complete && removeOriginals ? removed : 0,
16831
17165
  ...problems.length > 0 && { problems, kept: "Every original step was LEFT IN PLACE because something failed. Nothing was deleted." },
16832
17166
  ...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." },
@@ -19516,7 +19850,7 @@ function traceOpportunityContext(node, byId2) {
19516
19850
  }
19517
19851
  return "dangling";
19518
19852
  }
19519
- function isRecord8(v) {
19853
+ function isRecord9(v) {
19520
19854
  return typeof v === "object" && v !== null && !Array.isArray(v);
19521
19855
  }
19522
19856
  function isUuidV4(id) {
@@ -19612,8 +19946,8 @@ function checkSaveBlockers(workflow) {
19612
19946
  });
19613
19947
  }
19614
19948
  {
19615
- const attrs = isRecord8(a.attributes) ? a.attributes : void 0;
19616
- const startAfter = attrs && isRecord8(attrs.startAfter) ? attrs.startAfter : void 0;
19949
+ const attrs = isRecord9(a.attributes) ? a.attributes : void 0;
19950
+ const startAfter = attrs && isRecord9(attrs.startAfter) ? attrs.startAfter : void 0;
19617
19951
  if (startAfter && (startAfter.type === "day" || startAfter.type === "minute")) {
19618
19952
  findings.push({
19619
19953
  severity: "warning",
@@ -19625,8 +19959,8 @@ function checkSaveBlockers(workflow) {
19625
19959
  }
19626
19960
  }
19627
19961
  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;
19962
+ const attrs = isRecord9(a.attributes) ? a.attributes : void 0;
19963
+ const notif = attrs && isRecord9(attrs.notification) ? attrs.notification : void 0;
19630
19964
  const selected = notif?.selectedUser;
19631
19965
  const blank = selected === void 0 || selected === null || typeof selected === "string" && selected.trim() === "";
19632
19966
  if (notif?.userType === "user" && blank) {
@@ -22362,7 +22696,7 @@ function executablePlanLines(saved) {
22362
22696
  funnel: "funnel"
22363
22697
  };
22364
22698
  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";
22699
+ const kindOf2 = (s) => kinds[s] || words(s).replace(/s$/, "") || "plan object";
22366
22700
  const names = /* @__PURE__ */ new Map();
22367
22701
  const objects = /* @__PURE__ */ new Map();
22368
22702
  const label = (x, kind) => x.name?.trim() || x.title?.trim() || [x.firstName, x.lastName].filter(Boolean).join(" ") || `Unnamed ${kind}`;
@@ -22384,7 +22718,7 @@ function executablePlanLines(saved) {
22384
22718
  const refs = [...names.keys()].sort((a, b) => b.length - a.length);
22385
22719
  const refPattern = refs.length ? new RegExp(`(?<![\\w./:@])(?:${refs.map(escapeRE).join("|")})(?![\\w]|\\.[\\w])`, "g") : void 0;
22386
22720
  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])}`);
22721
+ const resolve12 = (s) => s.replace(missingRefPattern, (ref) => names.get(ref) || `Unnamed ${kindOf2(ref.split(".")[0])}`);
22388
22722
  const renderRefs = (s) => resolve12(refPattern ? s.replace(refPattern, (ref) => names.get(ref)) : s);
22389
22723
  const mergeLabel = (field) => {
22390
22724
  const [group, ...parts] = field.trim().split(".");
@@ -22403,7 +22737,7 @@ function executablePlanLines(saved) {
22403
22737
  const code = entity[2].toLowerCase() === "x" ? parseInt(entity.slice(3), 16) : parseInt(entity.slice(2), 10);
22404
22738
  return code <= 1114111 ? String.fromCodePoint(code) : entity;
22405
22739
  }).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 ?? "");
22740
+ 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
22741
  const sentence = (s) => {
22408
22742
  const text2 = plain(s);
22409
22743
  return text2 && !/[.!?…]$/.test(text2) ? `${text2}.` : text2;
@@ -22411,7 +22745,7 @@ function executablePlanLines(saved) {
22411
22745
  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
22746
  for (const section2 of ["users", "pipelines", "customFields", "fields", "tags", "customValues", "calendars", "forms", "funnels"]) for (const x of p?.[section2] ?? []) {
22413
22747
  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))))}`);
22748
+ lines.push(`${existing ? "Reuse" : "Create"} ${kindOf2(section2)}: ${label(x, kindOf2(section2))}. ${show(Object.fromEntries(Object.entries(x).filter(([k]) => !["ref", "name"].includes(k))))}`);
22415
22749
  }
22416
22750
  for (const w of p?.workflows ?? []) {
22417
22751
  lines.push(`Workflow: ${label(w, "workflow")}. ${show(w.trigger || w.triggers || "")}`);
@@ -26851,6 +27185,7 @@ ${saved.summary}
26851
27185
  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
27186
  6. ${MISSING_STEP_RULE}
26853
27187
  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"}.
27188
+ 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
27189
  ${NO_INVENTED_CAUSE_RULE}
26855
27190
  Finish with EXACTLY one final line:
26856
27191
  RESULT: {"ok":true,"summary":"<what was created vs already in place: counts of pipelines/fields/tags/workflows>","issues":["<anything skipped or needing a human>"]}`;
@@ -30060,14 +30395,7 @@ function assessmentFeatureLines(snapshot, purchased = [], availableOnly = true)
30060
30395
  });
30061
30396
  }
30062
30397
  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))];
30398
+ return state.assessmentPurchases?.[id];
30071
30399
  }
30072
30400
  function upsellFigure(snapshot, feature) {
30073
30401
  const id = BUILD_ASSESSMENT_FEATURE[feature];
@@ -30707,6 +31035,16 @@ function savePlanRecord(rec, base) {
30707
31035
  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
31036
  return file2;
30709
31037
  }
31038
+ function savedPlanMatchesScope(saved, current) {
31039
+ if (!saved) return false;
31040
+ if (!current || current.wholePreset) return !saved.scope;
31041
+ if (!saved.scope) return false;
31042
+ const omitted = (saved.scope.omitted ?? []).map((f) => f.id);
31043
+ const offered = /* @__PURE__ */ new Set([...saved.scope.built, ...saved.scope.notBuilt.map((f) => f.id), ...omitted]);
31044
+ const selected = new Set(current.features.filter((f) => offered.has(f)));
31045
+ const planned = /* @__PURE__ */ new Set([...saved.scope.built, ...omitted]);
31046
+ return selected.size === planned.size && [...planned].every((f) => selected.has(f));
31047
+ }
30710
31048
  function readSavedPlan(locationId2, base) {
30711
31049
  try {
30712
31050
  return JSON.parse(fs22.readFileSync(path20.join(plansDir(base), `${locationId2}.json`), "utf8"));
@@ -31230,7 +31568,7 @@ var require_package = __commonJS({
31230
31568
  "package.json"(exports2, module2) {
31231
31569
  module2.exports = {
31232
31570
  name: "@elitedcs/ghl-mcp",
31233
- version: "3.84.0",
31571
+ version: "3.84.1",
31234
31572
  mcpName: "io.github.drjerryrelth/ghl-command",
31235
31573
  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
31574
  main: "dist/index.js",
@@ -32240,16 +32578,8 @@ async function assessmentAttachmentAction(args, deps) {
32240
32578
  const chosen = args.purchasedFeatureIds;
32241
32579
  if (!Array.isArray(chosen) || chosen.some((id2) => !ASSESSMENT_FEATURES.some((f) => f.id === id2))) throw new Error("Choose features from the list.");
32242
32580
  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.";
32581
+ deps.write({ ...state2, assessmentPurchases: { ...state2.assessmentPurchases, [id]: [...new Set(chosen)] } });
32582
+ 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
32583
  break;
32254
32584
  }
32255
32585
  default:
@@ -32271,7 +32601,6 @@ var init_attachment_actions = __esm({
32271
32601
  "src/command-os/assessment/attachment-actions.ts"() {
32272
32602
  "use strict";
32273
32603
  init_attachment();
32274
- init_features();
32275
32604
  }
32276
32605
  });
32277
32606
 
@@ -34745,14 +35074,15 @@ function reviewDocumentHtml(review, opts) {
34745
35074
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString(void 0, { year: "numeric", month: "long", day: "numeric" });
34746
35075
  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
35076
  const o = review.offer;
34748
- const purchased = opts.scope || opts.purchasedFeatureIds ? [.../* @__PURE__ */ new Set([
35077
+ const confirmed = opts.scope && !opts.scope.wholePreset && !opts.scope.awaitingConfirmation ? opts.scope : void 0;
35078
+ const purchased = confirmed || opts.purchasedFeatureIds ? [.../* @__PURE__ */ new Set([
34749
35079
  ...opts.purchasedFeatureIds ?? [],
34750
- ...opts.scope ? [...opts.scope.built.map((f) => BUILD_ASSESSMENT_FEATURE[f.id]), "pipeline"] : []
35080
+ ...confirmed ? [...confirmed.built.map((f) => BUILD_ASSESSMENT_FEATURE[f.id]), "pipeline"] : []
34751
35081
  ])] : 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]);
35082
+ const unbuilt = opts.scope?.notBuilt ?? [];
35083
+ const represented = [...opts.scope?.built ?? [], ...unbuilt].map((f) => BUILD_ASSESSMENT_FEATURE[f.id]).concat(opts.scope && !opts.scope.awaitingConfirmation ? ["pipeline"] : []);
34754
35084
  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("");
35085
+ 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
35086
  return `<!doctype html><html><head><meta charset="utf-8"><title>${esc8(opts.clientName)} \u2014 Growth Blueprint</title><style>
34757
35087
  @page{margin:0.6in}
34758
35088
  *{box-sizing:border-box}
@@ -34809,7 +35139,9 @@ function reviewDocumentHtml(review, opts) {
34809
35139
  ${o.recommendedOffer ? `<div class="lead"><b>Run this instead:</b> ${esc8(o.recommendedOffer)}</div>` : ""}` : ""}
34810
35140
  ${review.gaps?.length ? `<div class="head">What's leaking today (${review.gaps.length})</div>${review.gaps.map(gap).join("")}` : ""}
34811
35141
  ${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>
35142
+ ${opts.scope?.awaitingConfirmation ? `<div class="head">What this build includes</div>
35143
+ <p>Not confirmed yet. What you bought is recorded, and the build waits until the features to build are confirmed.</p>
35144
+ ` : opts.scope ? `<div class="head">What this build includes</div>
34813
35145
  <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
35146
  ` : ""}
34815
35147
  ${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>` : ""}
@@ -35985,6 +36317,7 @@ __export(dashboard_exports, {
35985
36317
  resolvePlan: () => resolvePlan2,
35986
36318
  runDashboard: () => runDashboard,
35987
36319
  safeDocName: () => safeDocName,
36320
+ scopePrefillFor: () => scopePrefillFor,
35988
36321
  setAccountIndustry: () => setAccountIndustry,
35989
36322
  setAccountType: () => setAccountType,
35990
36323
  setBuildApproval: () => setBuildApproval,
@@ -36046,6 +36379,8 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
36046
36379
  const all = { ...state.scope ?? {} };
36047
36380
  if (!scope) {
36048
36381
  delete all[locationId2];
36382
+ } else if ("wholePreset" in scope) {
36383
+ all[locationId2] = { wholePreset: true, features: [...FEATURE_IDS], setAt: now().toISOString() };
36049
36384
  } else {
36050
36385
  const features = [...new Set(scope.features.filter(isFeatureId))];
36051
36386
  const pkg2 = typeof scope.package === "string" ? scope.package.trim().slice(0, 80) : "";
@@ -36053,19 +36388,18 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
36053
36388
  }
36054
36389
  const next = { ...state, scope: all };
36055
36390
  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 };
36391
+ return next;
36059
36392
  }
36060
36393
  function documentScopeFor(state, locationId2) {
36061
36394
  const sc = state.scope?.[locationId2];
36062
- if (!sc) return void 0;
36395
+ if (!sc && Array.isArray(state.assessmentPurchases?.[locationId2])) return { awaitingConfirmation: true, built: [], notBuilt: [] };
36063
36396
  let offered = FEATURE_IDS;
36064
36397
  try {
36065
36398
  const chosen = loadPresetChoice(void 0, presetForIndustry(state.accounts?.[locationId2]?.industry));
36066
36399
  if (chosen.ok) offered = featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
36067
36400
  } catch {
36068
36401
  }
36402
+ if (!sc || sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
36069
36403
  return {
36070
36404
  ...sc.package ? { package: sc.package } : {},
36071
36405
  // Built = the confirmed scope, the one record the build follows. The purchase
@@ -36078,6 +36412,11 @@ function documentScopeFor(state, locationId2) {
36078
36412
  })
36079
36413
  };
36080
36414
  }
36415
+ function scopePrefillFor(state, locationId2) {
36416
+ const bought = state.assessmentPurchases?.[locationId2];
36417
+ if (state.scope?.[locationId2] || !Array.isArray(bought)) return void 0;
36418
+ return FEATURE_IDS.filter((f) => bought.includes(BUILD_ASSESSMENT_FEATURE[f]));
36419
+ }
36081
36420
  function assessmentFiguresFor(state, locationId2) {
36082
36421
  const snap = state.assessmentAttachments?.[locationId2];
36083
36422
  if (!snap) return {};
@@ -36164,9 +36503,11 @@ function startRun(locationId2, stage, onDone, meta = {}) {
36164
36503
  }).map((q2) => ({ key: q2.key, label: q2.label, from: INTAKE_QUESTIONS.find((b) => b.key === q2.key)?.label })),
36165
36504
  removedKeys: INTAKE_QUESTIONS.filter((b) => !composed.some((q2) => q2.key === b.key)).map((b) => b.key),
36166
36505
  // A build re-run reuses the approved plan already saved for this account (finding 10).
36506
+ // ...and only while it still builds the scope the member has confirmed now: a
36507
+ // changed selection composes the plan fresh from the preset (delta round 3, T-M4).
36167
36508
  savedPlan: (() => {
36168
36509
  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;
36510
+ return sp && sp.inputHash === meta.inputHash && savedPlanMatchesScope(sp, state.scope?.[locationId2]) ? { planId: sp.planId, savedAt: sp.savedAt, summary: planSummary(sp) } : void 0;
36170
36511
  })()
36171
36512
  };
36172
36513
  if (context.savedPlan) run.lines.push(`Using the build plan saved on ${context.savedPlan.savedAt}.`);
@@ -37090,7 +37431,7 @@ function scopeBlock(id,state){
37090
37431
  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
37432
  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
37433
  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;
37434
+ 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
37435
  const boxes=document.createElement("div");boxes.style.cssText="margin:.5rem 0";
37095
37436
  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
37437
  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 +37443,7 @@ function scopeBlock(id,state){
37102
37443
  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
37444
  const sv=document.createElement("button");sv.className="runbtn";sv.textContent="Save what they bought";
37104
37445
  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.";
37446
+ 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
37447
  sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(status);
37107
37448
  return sc}
37108
37449
  async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;render(lastState,lastRun)}
@@ -38351,6 +38692,11 @@ ${opportunityNoteFailure?.detail ?? ""}`);
38351
38692
  }
38352
38693
  const assessmentFigures = {};
38353
38694
  for (const loc of Object.keys(st.assessmentAttachments ?? {})) assessmentFigures[loc] = assessmentFiguresFor(st, loc);
38695
+ const scopePrefill = {};
38696
+ for (const loc of Object.keys(st.assessmentPurchases ?? {})) {
38697
+ const p = scopePrefillFor(st, loc);
38698
+ if (p) scopePrefill[loc] = p;
38699
+ }
38354
38700
  const buildViews = Object.fromEntries(currentRoster().map((c) => {
38355
38701
  const saved = readSavedPlan(c.locationId);
38356
38702
  const approval = st.planApprovals?.[c.locationId];
@@ -38368,7 +38714,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
38368
38714
  }));
38369
38715
  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
38716
  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]) => {
38717
+ res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
38372
38718
  const records = st.outcomes?.[`${id}:4`]?.verification;
38373
38719
  const allowed = records ? verifyReadback(records).allowPublishing : false;
38374
38720
  return [id, tasks2.map((t) => ({ ...t, text: verifyPublishingText(plainFallback(t.text, { nextStep: true }), allowed) })).filter((t) => t.text)];
@@ -39325,8 +39671,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
39325
39671
  try {
39326
39672
  const { locationId: locationId2, package: pkg2, features } = JSON.parse(body);
39327
39673
  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) };
39674
+ if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
39675
+ const next = features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) };
39330
39676
  writeCockpitState(setClientScope(readCockpitState(), locationId2, next));
39331
39677
  res.writeHead(200, { "Content-Type": "application/json" });
39332
39678
  res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[locationId2] ?? null }));
@@ -48495,6 +48841,137 @@ var init_delivery_audit = __esm({
48495
48841
  }
48496
48842
  });
48497
48843
 
48844
+ // src/command-os/build/verify-findings.ts
48845
+ var verify_findings_exports = {};
48846
+ __export(verify_findings_exports, {
48847
+ CHECKABLE_KINDS: () => CHECKABLE_KINDS,
48848
+ CHECKABLE_KIND_NAMES: () => CHECKABLE_KIND_NAMES,
48849
+ isCheckableKind: () => isCheckableKind,
48850
+ neverLookedAt: () => neverLookedAt,
48851
+ normaliseKind: () => normaliseKind,
48852
+ refuseUnseenFindings: () => refuseUnseenFindings
48853
+ });
48854
+ async function readPaged(read2, spec, locationId2, wanted) {
48855
+ const base = spec.params?.(locationId2) ?? {};
48856
+ const seen = /* @__PURE__ */ new Set();
48857
+ let skip = 0;
48858
+ for (let reads = 0; reads < MAX_READS; reads++) {
48859
+ const raw = await read2(spec.path(locationId2), { ...base, limit: String(PAGE), skip: String(skip) });
48860
+ const ids = spec.ids(raw);
48861
+ const total = totalOf(raw);
48862
+ let added = 0;
48863
+ for (const id of ids) if (!seen.has(id)) {
48864
+ seen.add(id);
48865
+ added++;
48866
+ }
48867
+ if ([...wanted].every((id) => seen.has(id))) return { seen };
48868
+ if (total !== void 0 && seen.size >= total) return { seen };
48869
+ if (ids.length === 0) {
48870
+ return total !== void 0 && seen.size < total ? { seen, incomplete: `the account says it has ${total} but returned ${seen.size}` } : { seen };
48871
+ }
48872
+ if (added === 0) return { seen, incomplete: "the account returned the same entries again when asked for the next page" };
48873
+ skip += ids.length;
48874
+ }
48875
+ return { seen, incomplete: `it was still returning new entries after ${MAX_READS} pages` };
48876
+ }
48877
+ function isCheckableKind(kind) {
48878
+ return Object.hasOwn(CHECKABLE_KINDS, normaliseKind(kind));
48879
+ }
48880
+ function normaliseKind(kind) {
48881
+ const snake = kind.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
48882
+ const singular = snake.endsWith("s") && !snake.endsWith("ss") ? snake.slice(0, -1) : snake;
48883
+ return singular === "stage" ? "pipeline_stage" : singular;
48884
+ }
48885
+ async function refuseUnseenFindings(read2, locationId2, findings) {
48886
+ const refusals = [];
48887
+ const rawByRead = /* @__PURE__ */ new Map();
48888
+ const readByKind = /* @__PURE__ */ new Map();
48889
+ const wantedByKind = /* @__PURE__ */ new Map();
48890
+ for (const f of findings) {
48891
+ const kind = normaliseKind(f.kind);
48892
+ wantedByKind.set(kind, (wantedByKind.get(kind) ?? /* @__PURE__ */ new Set()).add(f.id));
48893
+ }
48894
+ for (const f of findings) {
48895
+ const kind = normaliseKind(f.kind);
48896
+ const spec = Object.hasOwn(CHECKABLE_KINDS, kind) ? CHECKABLE_KINDS[kind] : void 0;
48897
+ if (!spec) {
48898
+ 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}.` });
48899
+ continue;
48900
+ }
48901
+ let got = readByKind.get(kind);
48902
+ if (got === void 0) {
48903
+ if (spec.paged) {
48904
+ try {
48905
+ got = await readPaged(read2, spec, locationId2, wantedByKind.get(kind) ?? /* @__PURE__ */ new Set());
48906
+ } catch (e) {
48907
+ got = e instanceof Error ? e : new Error(String(e));
48908
+ }
48909
+ } else {
48910
+ const readKey = `${spec.path(locationId2)}?${JSON.stringify(spec.params?.(locationId2) ?? {})}`;
48911
+ let raw = rawByRead.get(readKey);
48912
+ if (raw === void 0) {
48913
+ try {
48914
+ raw = await read2(spec.path(locationId2), spec.params?.(locationId2));
48915
+ } catch (e) {
48916
+ raw = e instanceof Error ? e : new Error(String(e));
48917
+ }
48918
+ rawByRead.set(readKey, raw);
48919
+ }
48920
+ got = raw instanceof Error ? raw : { seen: new Set(spec.ids(raw)) };
48921
+ }
48922
+ readByKind.set(kind, got);
48923
+ }
48924
+ if (got instanceof Error) {
48925
+ const why = got.message.split("\n")[0].slice(0, 160);
48926
+ 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.` });
48927
+ continue;
48928
+ }
48929
+ if (got.seen.has(f.id)) continue;
48930
+ refusals.push({
48931
+ finding: f,
48932
+ 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)
48933
+ });
48934
+ }
48935
+ return refusals;
48936
+ }
48937
+ var PAGE, MAX_READS, totalOf, rows, idsOf, byLocation, CHECKABLE_KINDS, CHECKABLE_KIND_NAMES, neverLookedAt;
48938
+ var init_verify_findings = __esm({
48939
+ "src/command-os/build/verify-findings.ts"() {
48940
+ "use strict";
48941
+ PAGE = 100;
48942
+ MAX_READS = 50;
48943
+ totalOf = (raw) => {
48944
+ const body = raw;
48945
+ const t = body?.total ?? body?.meta?.total;
48946
+ return typeof t === "number" && Number.isFinite(t) && t >= 0 ? t : void 0;
48947
+ };
48948
+ rows = (raw, key) => {
48949
+ const body = raw;
48950
+ const list2 = Array.isArray(body?.[key]) ? body?.[key] : Array.isArray(raw) ? raw : [];
48951
+ return list2.filter((x) => !!x && typeof x === "object");
48952
+ };
48953
+ idsOf = (raw, key) => rows(raw, key).map((r) => r.id).filter((id) => typeof id === "string" && id.length > 0);
48954
+ byLocation = (locationId2) => ({ locationId: locationId2 });
48955
+ CHECKABLE_KINDS = {
48956
+ pipeline: { path: () => "/opportunities/pipelines", params: byLocation, ids: (raw) => idsOf(raw, "pipelines") },
48957
+ pipeline_stage: {
48958
+ path: () => "/opportunities/pipelines",
48959
+ params: byLocation,
48960
+ ids: (raw) => rows(raw, "pipelines").flatMap((p) => idsOf({ stages: p.stages }, "stages"))
48961
+ },
48962
+ workflow: { path: () => "/workflows/", params: byLocation, ids: (raw) => idsOf(raw, "workflows") },
48963
+ custom_field: { path: (loc) => `/locations/${loc}/customFields`, ids: (raw) => idsOf(raw, "customFields") },
48964
+ custom_value: { path: (loc) => `/locations/${loc}/customValues`, ids: (raw) => idsOf(raw, "customValues") },
48965
+ tag: { path: (loc) => `/locations/${loc}/tags`, ids: (raw) => idsOf(raw, "tags"), paged: true },
48966
+ calendar: { path: () => "/calendars/", params: byLocation, ids: (raw) => idsOf(raw, "calendars") },
48967
+ form: { path: () => "/forms/", params: byLocation, ids: (raw) => idsOf(raw, "forms"), paged: true },
48968
+ user: { path: () => "/users/", params: byLocation, ids: (raw) => idsOf(raw, "users") }
48969
+ };
48970
+ CHECKABLE_KIND_NAMES = Object.keys(CHECKABLE_KINDS).join(", ");
48971
+ 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.`;
48972
+ }
48973
+ });
48974
+
48498
48975
  // src/command-os/build/automation.ts
48499
48976
  function canonical(v) {
48500
48977
  if (Array.isArray(v)) return v.map(canonical);
@@ -48535,7 +49012,7 @@ function checkPlan(goal, findings, steps, now = /* @__PURE__ */ new Date(), know
48535
49012
  const at = new Date(f.observedAt).getTime();
48536
49013
  if (!Number.isFinite(at)) continue;
48537
49014
  if (at < cutoff) continue;
48538
- explored.set(f.id, f);
49015
+ explored.set(f.id, (explored.get(f.id) ?? /* @__PURE__ */ new Set()).add(normaliseKind(f.kind)));
48539
49016
  }
48540
49017
  const staleIds = new Set(
48541
49018
  findings.filter((f) => {
@@ -48555,7 +49032,13 @@ function checkPlan(goal, findings, steps, now = /* @__PURE__ */ new Date(), know
48555
49032
  const deps = [];
48556
49033
  for (const t of s.targets) {
48557
49034
  if (isExisting(t)) {
48558
- if (explored.has(t.existing)) continue;
49035
+ const kinds = explored.get(t.existing);
49036
+ if (kinds) {
49037
+ if (!kinds.has(normaliseKind(t.kind))) {
49038
+ 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.` });
49039
+ }
49040
+ continue;
49041
+ }
48559
49042
  if (staleIds.has(t.existing)) {
48560
49043
  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
49044
  } else {
@@ -48727,6 +49210,7 @@ var init_automation = __esm({
48727
49210
  "src/command-os/build/automation.ts"() {
48728
49211
  "use strict";
48729
49212
  import_node_crypto11 = require("node:crypto");
49213
+ init_verify_findings();
48730
49214
  isExisting = (t) => "existing" in t;
48731
49215
  FINDINGS_STALE_AFTER_MINUTES = 60;
48732
49216
  }
@@ -48821,113 +49305,263 @@ var init_store5 = __esm({
48821
49305
  }
48822
49306
  });
48823
49307
 
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`);
49308
+ // src/command-os/build/arg-ids.ts
49309
+ function isIdShapedKey(key) {
49310
+ return /(?:Id|ID)s?$|_ids?$/.test(key);
48844
49311
  }
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;
48852
- }
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;
49312
+ function scanArgs(args, maxNodes = MAX_ARG_NODES) {
49313
+ const out = { ids: [], locations: [], unknown: [], uncheckable: [] };
49314
+ const add = (kind, v, key) => {
49315
+ const list2 = Array.isArray(v) ? v : [v];
49316
+ for (const x of list2) {
49317
+ const id = typeof x === "number" ? String(x) : x;
49318
+ if (typeof id === "string" && id.trim()) out.ids.push({ kind, id, key });
48863
49319
  }
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);
49320
+ };
49321
+ const hasValue = (v) => typeof v === "string" && v.trim().length > 0 || typeof v === "number" || Array.isArray(v) && v.some(hasValue);
49322
+ const stack = [{ v: args, inFields: false }];
49323
+ let visited = 0;
49324
+ while (stack.length) {
49325
+ const { v, inFields, nodeType: parentType } = stack.pop();
49326
+ if (++visited > maxNodes) {
49327
+ 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.`;
49328
+ break;
48886
49329
  }
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.` });
49330
+ if (Array.isArray(v)) {
49331
+ for (const x of v) stack.push({ v: x, inFields, nodeType: parentType });
48890
49332
  continue;
48891
49333
  }
48892
- if (!seen.has(f.id)) refusals.push({ finding: f, because: neverLookedAt(f.id) });
49334
+ if (!isRecord10(v)) continue;
49335
+ const nodeType = typeof v.type === "string" && v.type ? v.type : parentType;
49336
+ const refs = [];
49337
+ if (typeof v.type === "string" && isRecord10(v.attributes)) extractFromAction(v, refs);
49338
+ if (Array.isArray(v.conditions)) extractFromTrigger(v, refs);
49339
+ for (const r of refs) {
49340
+ if (r.byKey) continue;
49341
+ const kind = AUDIT_KIND[r.kind];
49342
+ if (kind) out.ids.push({ kind, id: r.id, key: r.where });
49343
+ else out.uncheckable.push({ kind: r.kind, id: r.id, key: r.where });
49344
+ }
49345
+ for (const [k, x] of Object.entries(v)) {
49346
+ if (k === "locationId") {
49347
+ if (typeof x === "string" && x.length > 0) out.locations.push(x);
49348
+ } else if (k === "traffic_weightage" && isRecord10(x)) {
49349
+ add("user", Object.keys(x), k);
49350
+ continue;
49351
+ } else if (k === "traffic_index" && Array.isArray(x)) {
49352
+ add("user", x.map((e) => isRecord10(e) ? e.id : void 0), k);
49353
+ } else if (k === "selectedUser") {
49354
+ if (v.userType !== "all") add("user", x, k);
49355
+ } else if (k === "field" && inFields) {
49356
+ if (typeof x === "string") {
49357
+ const standardKey = STANDARD_CONTACT_FIELDS.has(x.toLowerCase());
49358
+ if (!standardKey || isIdShaped(x)) add("custom_field", x, "fields[].field");
49359
+ }
49360
+ } else if (k === "customFields" && Array.isArray(x)) {
49361
+ add("custom_field", x.map((e) => isRecord10(e) ? e.id : void 0), "customFields[].id");
49362
+ } else if (k === "customField" && isRecord10(x)) {
49363
+ add("custom_field", x.id, "customField.id");
49364
+ } else if (Object.hasOwn(ID_ARG_KINDS, k)) {
49365
+ add(ID_ARG_KINDS[k], x, k);
49366
+ } else if (isIdShapedKey(k) && !Object.hasOwn(NOT_CHECKABLE_ID_ARGS, k) && hasValue(x)) {
49367
+ out.unknown.push({ key: k, value: JSON.stringify(x).slice(0, 80), ...nodeType ? { nodeType } : {} });
49368
+ }
49369
+ if ((k === "filterField" || k === "field" && !inFields) && typeof x === "string" && Object.hasOwn(CONDITION_FIELD_KINDS, x)) {
49370
+ add(CONDITION_FIELD_KINDS[x], Object.hasOwn(v, "secondValue") ? v.secondValue : v.value, `${k} ${x}`);
49371
+ }
49372
+ if (x && typeof x === "object") stack.push({ v: x, inFields: k === "fields" && Array.isArray(x), nodeType });
49373
+ }
48893
49374
  }
48894
- return refusals;
49375
+ return out;
48895
49376
  }
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"() {
49377
+ var ID_ARG_KINDS, CONDITION_FIELD_KINDS, AUDIT_KIND, NOT_CHECKABLE_ID_ARGS, MAX_ARG_NODES, isRecord10;
49378
+ var init_arg_ids = __esm({
49379
+ "src/command-os/build/arg-ids.ts"() {
48899
49380
  "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.`;
49381
+ init_validators();
49382
+ init_id_shape();
49383
+ ID_ARG_KINDS = Object.freeze({
49384
+ pipelineId: "pipeline",
49385
+ pipelineIds: "pipeline",
49386
+ pipeline_id: "pipeline",
49387
+ defaultInPipelines: "pipeline",
49388
+ pipelineStageId: "pipeline_stage",
49389
+ pipeline_stage_id: "pipeline_stage",
49390
+ // the workflow create_opportunity node (validators.ts)
49391
+ stageId: "pipeline_stage",
49392
+ workflowId: "workflow",
49393
+ workflowIds: "workflow",
49394
+ workflow_id: "workflow",
49395
+ sourceWorkflowId: "workflow",
49396
+ callEndWorkflowIds: "workflow",
49397
+ fieldId: "custom_field",
49398
+ customFieldId: "custom_field",
49399
+ consentFieldId: "custom_field",
49400
+ customDateFieldId: "custom_field",
49401
+ valueId: "custom_value",
49402
+ customValueId: "custom_value",
49403
+ // The live DBR workflows: update_custom_value and math_operation
49404
+ // (round-4 delta review, 2026-09-15).
49405
+ custom_value_id: "custom_value",
49406
+ sourceCustomValueId: "custom_value",
49407
+ targetCustomValueId: "custom_value",
49408
+ tagId: "tag",
49409
+ calendarId: "calendar",
49410
+ formId: "form",
49411
+ formIds: "form",
49412
+ // a form_submission goal's extras (build_goal_event)
49413
+ userId: "user",
49414
+ userIds: "user",
49415
+ assignedUserId: "user",
49416
+ sentBy: "user",
49417
+ // send_document's sender (release review F2)
49418
+ // Workflow action payloads (CLAUDE.md action schemas). These do not end in
49419
+ // "Id", which is exactly why the guess never saw them.
49420
+ assignedTo: "user",
49421
+ user_list: "user"
49422
+ });
49423
+ CONDITION_FIELD_KINDS = Object.freeze({
49424
+ ...ID_ARG_KINDS,
49425
+ "form.id": "form",
49426
+ "calendar.id": "calendar",
49427
+ "opportunity.pipelineId": "pipeline",
49428
+ "opportunity.pipelineStageId": "pipeline_stage",
49429
+ "opportunity.assignedTo": "user",
49430
+ "contact.assignedTo": "user",
49431
+ "workflow.id": "workflow"
49432
+ });
49433
+ AUDIT_KIND = Object.freeze({
49434
+ pipeline: "pipeline",
49435
+ stage: "pipeline_stage",
49436
+ custom_field: "custom_field",
49437
+ user: "user",
49438
+ workflow: "workflow",
49439
+ form: "form",
49440
+ calendar: "calendar",
49441
+ survey: null
49442
+ });
49443
+ NOT_CHECKABLE_ID_ARGS = Object.freeze({
49444
+ // The account itself, not an object inside it. One account per plan is
49445
+ // enforced separately, and the build is bound to it (agency-build.ts).
49446
+ locationId: "the sub-account itself",
49447
+ location_id: "the sub-account itself",
49448
+ altId: "the sub-account itself",
49449
+ companyId: "the agency itself",
49450
+ company_id: "the agency itself",
49451
+ ghl_location_id: "the sub-account itself",
49452
+ ghl_company_id: "the agency itself",
49453
+ test_location_id: "the sub-account itself",
49454
+ demoLocationId: "a sub-account, not an object inside one",
49455
+ clientLocationId: "a sub-account, not an object inside one",
49456
+ sourceLocationId: "a sub-account, not an object inside one",
49457
+ // Objects that live in the account but have no list the planner reads back.
49458
+ contactId: "contacts are not listed in full by the planner",
49459
+ contactIds: "contacts are not listed in full by the planner",
49460
+ opportunityId: "opportunities are not listed in full by the planner",
49461
+ templateId: "templates have no read-back list here",
49462
+ agentId: "AI agents have no read-back list here",
49463
+ assignedEmployeeId: "a Conversation AI employee; no read-back list here",
49464
+ knowledgeBaseId: "knowledge bases have no read-back list here",
49465
+ knowledgeBaseIds: "knowledge bases have no read-back list here",
49466
+ lastKnowledgeBaseId: "knowledge bases have no read-back list here",
49467
+ faqId: "knowledge base entries have no read-back list here",
49468
+ lastFaqId: "knowledge base entries have no read-back list here",
49469
+ urlIds: "knowledge base entries have no read-back list here",
49470
+ funnelId: "funnels have no read-back list here",
49471
+ pageId: "funnel pages have no read-back list here",
49472
+ stepId: "funnel steps (and this plan's own steps) have no read-back list here",
49473
+ recordId: "custom object records have no read-back list here",
49474
+ targetRecordId: "custom object records have no read-back list here",
49475
+ relationId: "associations have no read-back list here",
49476
+ associationId: "associations have no read-back list here",
49477
+ productId: "products have no read-back list here",
49478
+ productIds: "products have no read-back list here",
49479
+ invoiceId: "invoices have no read-back list here",
49480
+ estimateId: "estimates have no read-back list here",
49481
+ orderId: "orders have no read-back list here",
49482
+ subscriptionId: "subscriptions have no read-back list here",
49483
+ couponId: "coupons have no read-back list here",
49484
+ entityId: "payment records have no read-back list here",
49485
+ actionId: "AI agent actions have no read-back list here",
49486
+ actionIds: "AI agent actions have no read-back list here",
49487
+ listId: "smart lists have no read-back list here",
49488
+ webhookId: "webhooks have no read-back list here",
49489
+ taskId: "contact tasks have no read-back list here",
49490
+ eventId: "appointments have no read-back list here",
49491
+ postId: "social posts have no read-back list here",
49492
+ accountIds: "social accounts have no read-back list here",
49493
+ messageId: "messages have no read-back list here",
49494
+ conversationId: "conversations have no read-back list here",
49495
+ conversationProviderId: "conversation providers have no read-back list here",
49496
+ documentId: "documents have no read-back list here",
49497
+ businessId: "businesses have no read-back list here",
49498
+ numberPoolId: "number pools have no read-back list here",
49499
+ linkId: "trigger links have no read-back list here",
49500
+ blogId: "blogs have no read-back list here",
49501
+ categoryId: "categories have no read-back list here",
49502
+ surveyId: "surveys have no read-back list here",
49503
+ submissionId: "form submissions have no read-back list here",
49504
+ mediaId: "media files have no read-back list here",
49505
+ photoAssetId: "media files have no read-back list here",
49506
+ beforeAssetId: "media files have no read-back list here",
49507
+ afterAssetId: "media files have no read-back list here",
49508
+ domainId: "domains have no read-back list here",
49509
+ chatWidgetId: "chat widgets have no read-back list here",
49510
+ callId: "calls have no read-back list here",
49511
+ voiceId: "voices have no read-back list here",
49512
+ placeId: "a Google Business place, outside the account",
49513
+ reviewLinkId: "review links have no read-back list here",
49514
+ linkIds: "a link_click goal's trigger links, which have no read-back list here",
49515
+ globalProductIds: "a payment_received goal's products, which have no read-back list here",
49516
+ global_product_ids: "products have no read-back list here",
49517
+ snapshot_id: "snapshots belong to the agency, not the sub-account",
49518
+ parentId: "a folder or parent record with no read-back list here",
49519
+ // Outside GoHighLevel altogether.
49520
+ stripeCustomerId: "a Stripe record, not a GoHighLevel one",
49521
+ google_oauth_client_id: "a Google OAuth client, not a GoHighLevel record",
49522
+ ghl_user_id: "the Firebase login's uid, not an object in the sub-account",
49523
+ device_id: "this machine's id",
49524
+ // This product's own records.
49525
+ buildId: "a Layer 5 build on this machine",
49526
+ runId: "a build run on this machine",
49527
+ planId: "a saved plan on this machine",
49528
+ briefId: "a brief on this machine",
49529
+ operationId: "an operation on this machine",
49530
+ questionId: "an intake question",
49531
+ ruleId: "a design rule",
49532
+ startAfterId: "a paging cursor",
49533
+ purchasedFeatureIds: "feature names, not account objects",
49534
+ // Ids inside a workflow's or a page's own document: the node, branch or
49535
+ // element they belong to, never an object in the account.
49536
+ _id: "an element's own id inside a document",
49537
+ nodeId: "an element's own id inside a page",
49538
+ appId: "an element's own id inside a page",
49539
+ goToId: "an element's own id inside a page",
49540
+ __conditionId: "a condition's own id inside a workflow",
49541
+ __id: "an operator's own id inside a math step",
49542
+ __segmentId: "a segment's own id inside a workflow",
49543
+ ifElseNodeId: "a node's own id inside a workflow",
49544
+ target_node_id: "a node's own id inside a workflow",
49545
+ targetNodeId: "the step a goto jumps to, inside the same workflow",
49546
+ stepIds: "the steps an email_event goal waits on, inside the same workflow",
49547
+ invoiceStepId: "the step an invoice_paid goal waits on, inside the same workflow",
49548
+ sourceId: `GoHighLevel's attribution key on an email edited in its UI, "<workflowId>:<stepId>"; not checked on save (workflow-builder-client.ts)`,
49549
+ origin_id: "a trigger's own id inside a workflow"
49550
+ });
49551
+ MAX_ARG_NODES = 2e5;
49552
+ isRecord10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
48926
49553
  }
48927
49554
  });
48928
49555
 
48929
49556
  // src/tools/agency-build.ts
48930
- function registerAgencyBuildTools(server2, knownTools, verify) {
49557
+ function accountMismatch(a, active) {
49558
+ if (!a.locationId) {
49559
+ return "This build was saved before builds recorded the sub-account their ids were checked against, so nothing can say which account they belong to: plan it again (plan_automation) and approve the new plan.";
49560
+ }
49561
+ if (active === a.locationId) return null;
49562
+ 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.`;
49563
+ }
49564
+ function registerAgencyBuildTools(server2, knownTools, verify, activeLocation) {
48931
49565
  safeTool(
48932
49566
  server2,
48933
49567
  "plan_automation",
@@ -48955,43 +49589,67 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
48955
49589
  const found = findings.map((f) => ({ ...f, observedAt: f.observedAt ?? now.toISOString() }));
48956
49590
  const plan = steps;
48957
49591
  const check = checkPlan(goal, found, plan, now, knownTools?.());
49592
+ let boundAccount;
48958
49593
  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);
49594
+ const scans = plan.map((s) => ({ step: s, scan: scanArgs(s.args) }));
49595
+ const named = new Set(scans.flatMap(({ scan }) => scan.locations));
48968
49596
  if (locationId2) named.add(locationId2);
48969
49597
  if (named.size > 1) {
48970
49598
  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
49599
  }
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));
49600
+ const account2 = [...named][0] ?? activeLocation?.();
49601
+ if (!account2) {
49602
+ 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." };
49603
+ }
49604
+ const refKinds = new Map(plan.flatMap((s) => (s.creates ?? []).map((c) => [c.ref, normaliseKind(c.kind)])));
49605
+ const seenKeys = new Set(found.map((f) => `${normaliseKind(f.kind)}:${f.id}`));
48975
49606
  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() });
49607
+ const argRefusals = [];
49608
+ for (const { step: s, scan } of scans) {
49609
+ if (scan.incomplete) argRefusals.push({ because: `Step "${s.id}": ${scan.incomplete}`, finding: { kind: "arguments", id: s.id } });
49610
+ for (const u of scan.unknown) {
49611
+ const inNode = u.nodeType ? ` inside a "${u.nodeType}" node` : "";
49612
+ argRefusals.push({
49613
+ 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.`,
49614
+ finding: { kind: u.key, id: u.value }
49615
+ });
49616
+ }
49617
+ for (const { kind, id } of scan.uncheckable) {
49618
+ const key = `${kind}:${id}`;
49619
+ if (seenKeys.has(key)) continue;
49620
+ seenKeys.add(key);
49621
+ hidden.push({ kind, id, observedAt: now.toISOString() });
49622
+ }
49623
+ for (const { kind, id } of scan.ids) {
49624
+ const refKind = refKinds.get(id);
49625
+ if (refKind !== void 0) {
49626
+ if (refKind !== kind) {
49627
+ argRefusals.push({
49628
+ 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.`,
49629
+ finding: { kind, id }
49630
+ });
49631
+ }
49632
+ continue;
48983
49633
  }
48984
- scan(x, depth + 1);
49634
+ const key = `${kind}:${id}`;
49635
+ if (seenKeys.has(key)) continue;
49636
+ seenKeys.add(key);
49637
+ hidden.push({ kind, id, observedAt: now.toISOString() });
48985
49638
  }
48986
- };
48987
- for (const s of plan) scan(s.args, 0);
49639
+ }
49640
+ if (argRefusals.length) {
49641
+ return { planned: false, refusals: argRefusals, note: "Nothing was saved. These arguments carry ids that cannot be checked as they stand." };
49642
+ }
48988
49643
  const toVerify = [...found, ...hidden];
49644
+ boundAccount = account2;
48989
49645
  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
49646
  if (refusals.length) {
48991
49647
  return {
48992
49648
  planned: false,
48993
49649
  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."
49650
+ // Not "these were not there": a list that could not be read in full
49651
+ // proves nothing is missing, and each refusal says which it was (G-M2).
49652
+ 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
49653
  };
48996
49654
  }
48997
49655
  }
@@ -49005,6 +49663,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49005
49663
  const a = {
49006
49664
  id: newAutomationId(),
49007
49665
  goal,
49666
+ // check.ok is true here, so the block above set it.
49667
+ locationId: boundAccount,
49008
49668
  createdAt: now.toISOString(),
49009
49669
  planId: planFingerprint(goal, plan),
49010
49670
  findings: found,
@@ -49021,7 +49681,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49021
49681
  const s = plan.find((x) => x.id === id);
49022
49682
  return { step: s.id, does: s.why, tool: s.tool };
49023
49683
  }),
49024
- nextStep: `Read the steps above. Nothing runs until you approve this exact plan: approve_automation with buildId ${a.id} and planId ${a.planId}.`,
49684
+ account: a.locationId,
49685
+ 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
49686
  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
49687
  };
49027
49688
  }
@@ -49038,6 +49699,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49038
49699
  async ({ buildId, planId, approvedBy }) => {
49039
49700
  const a = readAutomation(buildId);
49040
49701
  if (!a) return { approved: false, error: `There is no build "${buildId}" on this machine.` };
49702
+ const wrong = accountMismatch(a, activeLocation?.());
49703
+ if (wrong) return { approved: false, error: wrong };
49041
49704
  const now = /* @__PURE__ */ new Date();
49042
49705
  const r = approve(a, approvedBy, planId, now.toISOString(), now);
49043
49706
  if (!r.ok) return { approved: false, error: r.because };
@@ -49063,6 +49726,8 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49063
49726
  async ({ buildId, stepId, state, undo, because, result, resolved: resolved2, retry }) => {
49064
49727
  const a = readAutomation(buildId);
49065
49728
  if (!a) return { recorded: false, error: `There is no build "${buildId}" on this machine.` };
49729
+ const wrong = state === "applied" ? accountMismatch(a, activeLocation?.()) : null;
49730
+ if (wrong) return { recorded: false, error: wrong };
49066
49731
  const r = recordStep(a, { stepId, state, at: (/* @__PURE__ */ new Date()).toISOString(), undo, because, result, resolved: resolved2, retryOf: retry });
49067
49732
  if (!r.ok) return { recorded: false, error: r.because };
49068
49733
  saveAutomation(r.automation);
@@ -49111,13 +49776,16 @@ function registerAgencyBuildTools(server2, knownTools, verify) {
49111
49776
  const rows2 = ids.map((id) => {
49112
49777
  const a = all[id];
49113
49778
  const p = position(a);
49779
+ const wrongAccount = p.nextStep ? accountMismatch(a, activeLocation?.()) : null;
49114
49780
  return {
49115
49781
  buildId: id,
49116
49782
  goal: a.goal,
49783
+ account: a.locationId ?? null,
49117
49784
  where: p.line,
49118
49785
  phase: p.phase,
49119
49786
  stepsDone: `${p.done} of ${p.total}`,
49120
- nextStep: p.nextStep ? { step: p.nextStep.id, does: p.nextStep.why, tool: p.nextStep.tool } : null,
49787
+ nextStep: p.nextStep && !wrongAccount ? { step: p.nextStep.id, does: p.nextStep.why, tool: p.nextStep.tool } : null,
49788
+ ...wrongAccount ? { wrongAccount } : {},
49121
49789
  stalledOn: p.stalledOn,
49122
49790
  blocked: p.blocked,
49123
49791
  approvedBy: a.approval?.by ?? null,
@@ -49142,6 +49810,7 @@ var init_agency_build = __esm({
49142
49810
  init_automation();
49143
49811
  init_store5();
49144
49812
  init_verify_findings();
49813
+ init_arg_ids();
49145
49814
  targetSchema = import_zod74.z.union([
49146
49815
  import_zod74.z.object({ existing: import_zod74.z.string().describe("A real id, which must appear in `findings`."), kind: import_zod74.z.string() }),
49147
49816
  import_zod74.z.object({ ref: import_zod74.z.string().describe("Something an earlier step in this plan creates."), kind: import_zod74.z.string() })
@@ -53085,8 +53754,13 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53085
53754
  });
53086
53755
  }
53087
53756
  if (!briefArg.scope) {
53088
- const saved2 = readBoardState().scope?.[activeLocation];
53089
- if (saved2 && Array.isArray(saved2.features)) {
53757
+ const board = readBoardState();
53758
+ const saved2 = board.scope?.[activeLocation];
53759
+ if (saved2?.wholePreset) {
53760
+ scopeSource = "cockpit";
53761
+ } else if (!saved2 && Array.isArray(board.assessmentPurchases?.[activeLocation])) {
53762
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: NOT_CONFIRMED_SCOPE });
53763
+ } else if (saved2 && Array.isArray(saved2.features)) {
53090
53764
  const features = saved2.features.filter(isFeatureId);
53091
53765
  if (!features.length) {
53092
53766
  return jsonResponse({
@@ -53143,6 +53817,31 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53143
53817
  if (!gate2.ok) return jsonResponse({ ok: false, phase: "plan", error: gate2.error, savedPlan: saved ? { planId: saved.planId, savedAt: saved.savedAt } : void 0 });
53144
53818
  const plan = gate2.plan;
53145
53819
  const planSource = gate2.source;
53820
+ if (planSource === "saved") {
53821
+ const board = readBoardState();
53822
+ const current = board.scope?.[activeLocation];
53823
+ if (!current && Array.isArray(board.assessmentPurchases?.[activeLocation])) {
53824
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: NOT_CONFIRMED_SCOPE });
53825
+ }
53826
+ if (current && !current.wholePreset && Array.isArray(current.features) && !current.features.some(isFeatureId)) {
53827
+ return jsonResponse({
53828
+ ok: false,
53829
+ phase: "scope",
53830
+ scopeSource: "cockpit",
53831
+ error: "No features are selected for this client, so nothing was built, not even from the saved plan. Open the client's Review screen, tick the features to build, confirm, and run the build again."
53832
+ });
53833
+ }
53834
+ if (!savedPlanMatchesScope(saved, current)) {
53835
+ const planned = saved?.scope ? [...saved.scope.built, ...(saved.scope.omitted ?? []).map((f) => f.id)].join(", ") || "no features" : "everything in the preset";
53836
+ const now = current?.wholePreset ? "everything in the preset (confirmed)" : current ? current.features.filter(isFeatureId).join(", ") : "everything in the preset (nothing is saved)";
53837
+ return jsonResponse({
53838
+ ok: false,
53839
+ phase: "scope",
53840
+ scopeSource: "cockpit",
53841
+ 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.`
53842
+ });
53843
+ }
53844
+ }
53146
53845
  const scopeForValidation = composed?.scope ?? (planSource === "saved" ? saved?.scope : void 0);
53147
53846
  const validation = validateBuildPlan(plan, { nurtureNotBought: Boolean(scopeForValidation && !scopeForValidation.built.includes("nurture")) });
53148
53847
  if (!validation.valid || !validation.plan) {
@@ -53211,7 +53910,8 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
53211
53910
  contentHash: contentHash(typedPlan),
53212
53911
  inputHash: process.env.GHL_COMMAND_INTAKE_HASH || saved?.inputHash,
53213
53912
  dependencies: planDependencies(typedPlan),
53214
- preview: result
53913
+ preview: result,
53914
+ scope: scopeForValidation
53215
53915
  });
53216
53916
  const deps = makeExecuteDeps(client, builderClient, activeLocation, registry2);
53217
53917
  const gatedWorkflowRefs = new Set(result.workflows.filter((w) => w.gatedBy.length > 0).map((w) => w.ref));
@@ -53823,7 +54523,7 @@ function intakeFormMatches(expected, actual) {
53823
54523
  const keys = ["id", "tag", "type", "hiddenFieldQueryKey", "required", "label", "options", "picklistOptions"];
53824
54524
  return expected.every((field, i) => keys.every((key) => field[key] === void 0 || JSON.stringify(field[key]) === JSON.stringify(actual[i]?.[key])));
53825
54525
  }
53826
- var import_zod81, customFieldItemSchema, sleep6;
54526
+ var import_zod81, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE;
53827
54527
  var init_intake_to_build = __esm({
53828
54528
  "src/tools/intake-to-build.ts"() {
53829
54529
  "use strict";
@@ -53869,6 +54569,7 @@ var init_intake_to_build = __esm({
53869
54569
  picklistOptions: import_zod81.z.array(import_zod81.z.string()).optional()
53870
54570
  }).passthrough();
53871
54571
  sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
54572
+ 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.";
53872
54573
  }
53873
54574
  });
53874
54575
 
@@ -53915,7 +54616,7 @@ function registerAllTools(server2, client, registry2, mcpVersion, env2 = process
53915
54616
  }
53916
54617
  const reader = token2 ? new GHLClient({ apiKey: token2.apiKey, locationId: loc }) : client;
53917
54618
  return refuseUnseenFindings2((path40, params) => reader.get(path40, params ? { params } : void 0), loc, findings);
53918
- });
54619
+ }, () => client.defaultLocationId);
53919
54620
  registerClientEngagementTools(wrap(CLIENT_ENGAGEMENTS_MODULE), registry2);
53920
54621
  registerAuditReportTools(wrap(AUDIT_REPORT_MODULE));
53921
54622
  registerDailyBriefTools(wrap(DAILY_BRIEF_MODULE), registry2);