@elitedcs/ghl-mcp 3.93.1 → 3.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24340,6 +24340,8 @@ var init_features = __esm({
24340
24340
  BY_WORKFLOW_REF = {
24341
24341
  "workflow.speed_to_lead": "speed_to_lead",
24342
24342
  "workflow.missed_call": "missed_call",
24343
+ // F6 (2026-10-29): a missed caller with no card gets one. Goes with the text-back it hangs off.
24344
+ "workflow.missed_call_card": "missed_call",
24343
24345
  "workflow.lead_nurture": "nurture",
24344
24346
  "workflow.win_back": "winback",
24345
24347
  "workflow.appointment_booked": "booking",
@@ -26084,10 +26086,14 @@ var init_registry = __esm({
26084
26086
  whatItIs: "Google sign-in gives this machine permission to read your calendar without pasting a password here.",
26085
26087
  checkedOn: null,
26086
26088
  gives: "Adds your own day beside your clients' appointments as a separate part of the brief.",
26087
- needs: ["A Google account with a calendar you can read.", "Google sign-in configured for this installation; Connect will tell you if it is unavailable."],
26088
- before: "Once Google sign-in is set up on this computer:",
26089
+ // ba's Hub audit (2026-10-29): the guide read as if this connects today, while CHANGELOG 3.92.2 and the card say
26090
+ // CALENDAR_SETUP_MESSAGE. It does not connect until GHL Command ships its own Google sign-in, so the guide says so
26091
+ // first and the steps below are what it will be.
26092
+ needs: ["A Google account with a calendar you can read.", "Google sign-in for GHL Command, which is not available yet."],
26093
+ before: `${CALENDAR_SETUP_MESSAGE} Choosing Connect says so and saves nothing. When Google sign-in arrives in an update, these are the steps:`,
26089
26094
  steps: ["On the Google Calendar card on the Connections screen, choose Connect to open Google sign-in.", "Select your Google account and allow read-only calendar access.", "Return to the Google Calendar card and choose Finish. Your sign-in is saved only after your calendars can be read."],
26090
- say: ["Connect my own Google Calendar.", "Finish connecting my Google Calendar.", "Read my calendar for today."],
26095
+ say: [],
26096
+ // none until Google sign-in ships: each one would fail (ba audit 2026-10-29)
26091
26097
  revoke: "Choose Disconnect on this system's Connections card to remove the saved sign-in and request revocation with Google. You can also remove the app in your Google Account's third-party connections at myaccount.google.com/connections.",
26092
26098
  tools: { connect: "connect_google_calendar", disconnect: "connect_google_calendar", read: "read_my_calendar" }
26093
26099
  },
@@ -43533,6 +43539,7 @@ function goLiveTopic(text3) {
43533
43539
  function jobTiming(text3) {
43534
43540
  const t = String(text3).trim();
43535
43541
  if (t === HANDOFF_NO_HOME_JOB) return "later";
43542
+ if (HELD_JOB.test(t)) return "later";
43536
43543
  if (LEAD_MAGNET_PAGES_JOB.test(t)) return "later";
43537
43544
  if (SWITCH_ON_JOB.test(t) || STILL_OFF_NOTE.test(t)) return "at-go-live";
43538
43545
  if (LATER_JOB.test(t)) return "later";
@@ -43649,11 +43656,12 @@ function manualSignOffRefusal(tasks2, verifyHasRun) {
43649
43656
  }
43650
43657
  return "Everything is ticked, but the check has not confirmed it yet. Run the check again and this will clear on its own.";
43651
43658
  }
43652
- var SELF_REPORTED, SWITCH_ON_JOB, LATER_JOB, LEAD_MAGNET_PAGES_JOB, HANDOFF_NO_HOME_JOB, GO_LIVE_TOPICS, NAMED_LATER, STILL_OFF_NOTE, keepsTick;
43659
+ var SELF_REPORTED, HELD_JOB, SWITCH_ON_JOB, LATER_JOB, LEAD_MAGNET_PAGES_JOB, HANDOFF_NO_HOME_JOB, GO_LIVE_TOPICS, NAMED_LATER, STILL_OFF_NOTE, keepsTick;
43653
43660
  var init_manual_tasks = __esm({
43654
43661
  "src/manual-tasks.ts"() {
43655
43662
  "use strict";
43656
43663
  SELF_REPORTED = /^[^:]+: Bought, but not built automatically yet\./;
43664
+ HELD_JOB = /^(?:Waiting for your client's answer|Not built, your client said not yet|Not built yet, chosen and coming in a later update): /;
43657
43665
  SWITCH_ON_JOB = /^[^\n]+: This workflow is built and waiting for you to switch it on in GoHighLevel under Automation > Workflows\.$/;
43658
43666
  LATER_JOB = /^Manual action: (?:[^:\n]*\b(?:after (?:a|the|each) (?:trial )?visit|before any bulk)\b[^:\n]*|How [^:\n]{1,80} starts): /;
43659
43667
  LEAD_MAGNET_PAGES_JOB = /^[^:\n]+: Bought, but not built automatically yet\. The form is built\. The pages are separate work\.$/;
@@ -43668,6 +43676,7 @@ var init_manual_tasks = __esm({
43668
43676
  { key: "price", title: "Check the trial price, deposit and balance", re: /trial price|deposit amount|balance amount|trial_price/i }
43669
43677
  ];
43670
43678
  NAMED_LATER = [
43679
+ HELD_JOB,
43671
43680
  /^[^:\n]+: Bought, but not built automatically yet\. (?:Set up rebooking and recall|Payment setup is done|Set up referral requests|The form is built|It is not in the plan)/,
43672
43681
  /^Manual action: Set up the trial balance link\b/,
43673
43682
  /^Manual action: Limit the Trial Balance Paid workflow\b/,
@@ -45666,7 +45675,13 @@ var init_client_capabilities = __esm({
45666
45675
  what: "One sentence about a prospect's business becomes a bot they can text, on your own demo account, and one command puts it back. It messages a real prospect, so it runs from your own Claude with you watching, never from a button.",
45667
45676
  says: "Prepare an AI employee demo for {client} from their website and arm it so they can text it.",
45668
45677
  hub: "modules/11-your-ai-employee/lesson-04-the-five-minute-demo.html",
45669
- reads: []
45678
+ // command-center 2026-10-29: read live, the same two lists the demo tools use (the demo bot, and one knowledge
45679
+ // base per prepared prospect, named "Demo: ..."). Whether a demo is armed is the bot's mode, which only
45680
+ // show_ai_employee_demo reads; this page does not claim it.
45681
+ reads: [
45682
+ N("bots", "/conversation-ai/agents/search", { limit: "50" }, "agents", "name", AI2),
45683
+ W(N("knowledge bases", "/knowledge-bases/", { locationId: "{loc}", limit: "50" }, "knowledgeBases", "name", AI2), { one: "knowledge base" })
45684
+ ]
45670
45685
  },
45671
45686
  // ── Money and paperwork ─────────────────────────────────────────────────
45672
45687
  {
@@ -45809,7 +45824,7 @@ var require_package = __commonJS({
45809
45824
  "package.json"(exports2, module2) {
45810
45825
  module2.exports = {
45811
45826
  name: "@elitedcs/ghl-mcp",
45812
- version: "3.93.1",
45827
+ version: "3.94.0",
45813
45828
  mcpName: "io.github.drjerryrelth/ghl-command",
45814
45829
  description: "GoHighLevel MCP Server for Claude. 250 tools for CRM, automation and marketing, an account-wide workflow audit, live funnel-capture verification and a workflow builder, across client accounts.",
45815
45830
  main: "dist/index.js",
@@ -53038,7 +53053,7 @@ function compute(ans, skip, asm) {
53038
53053
  function() {
53039
53054
  return raw("calls_missed") * share("missed_never_return") * c * v;
53040
53055
  },
53041
- "Call answering, day and night"
53056
+ "Call answering"
53042
53057
  );
53043
53058
  add2(
53044
53059
  "waited",
@@ -53721,7 +53736,12 @@ function programBuiltLabel(name2) {
53721
53736
  }
53722
53737
  return `Built: Program follow-up${win ? ` (texts over the ${win} after the visit)` : ""}`;
53723
53738
  }
53724
- function boughtItemsFor(offered, wording = {}) {
53739
+ function statusLabel(label2, feature, status) {
53740
+ const what = label2.replace(/^Built: /, "");
53741
+ if (!status?.made) return `Will build: ${what}`;
53742
+ return status.made.has(feature) ? `Built: ${what}` : `Not built yet: ${what}`;
53743
+ }
53744
+ function boughtItemsFor(offered, wording = {}, status) {
53725
53745
  return ASSESSMENT_FEATURES.map((f) => {
53726
53746
  const id2 = f.id;
53727
53747
  const base = { id: id2, name: f.name, means: BOUGHT_MEANS[id2] };
@@ -53731,11 +53751,12 @@ function boughtItemsFor(offered, wording = {}) {
53731
53751
  if (!offered.includes(feature)) {
53732
53752
  return { ...base, kind: "not-in-plan", feature, ...next, delivery: `Not built for this client: ${FEATURES[feature].name.toLowerCase()} is not in the plan for this industry.` };
53733
53753
  }
53734
- const delivery = id2 === "quotes" && wording.programFollowUp ? programBuiltLabel(wording.programFollowUp) : BUILT_LABEL[id2];
53754
+ const label2 = id2 === "quotes" && wording.programFollowUp ? programBuiltLabel(wording.programFollowUp) : BUILT_LABEL[id2];
53755
+ const delivery = status?.held?.[id2] ?? statusLabel(label2, feature, status);
53735
53756
  return { ...base, kind: "built", feature, ...next, delivery };
53736
53757
  }
53737
53758
  const other = OTHER_DELIVERY[id2];
53738
- return { ...base, kind: other.kind, delivery: other.text };
53759
+ return { ...base, kind: other.kind, delivery: status?.held?.[id2] ?? other.text };
53739
53760
  });
53740
53761
  }
53741
53762
  function featuresFromBought(ticks, offered) {
@@ -53781,13 +53802,19 @@ function assessmentGateRefusal(state2, rk, clientKey, name2) {
53781
53802
  function approvalBindingHash(scopeHash2, state2, rk) {
53782
53803
  const saved = state2.bought?.[rk];
53783
53804
  const answer2 = assessmentAnswerOf(state2, rk);
53784
- if (!Array.isArray(saved) && !answer2) return scopeHash2;
53805
+ const asks = asksBound(state2, rk);
53806
+ if (!Array.isArray(saved) && !answer2 && !asks) return scopeHash2;
53785
53807
  const snapshot = state2.assessmentAttachments?.[rk];
53786
- return contentHash2({ scope: scopeHash2, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null, assessment: answer2, attached: snapshot ? contentHash2(snapshot) : null });
53808
+ return contentHash2({ scope: scopeHash2, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null, assessment: answer2, attached: snapshot ? contentHash2(snapshot) : null, ...asks ? { asks } : {} });
53787
53809
  }
53788
53810
  function boughtBindingHash(scopeHash2, state2, rk) {
53789
53811
  const saved = state2.bought?.[rk];
53790
- return contentHash2({ scope: scopeHash2, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null });
53812
+ const asks = asksBound(state2, rk);
53813
+ return contentHash2({ scope: scopeHash2, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null, ...asks ? { asks } : {} });
53814
+ }
53815
+ function asksBound(state2, rk) {
53816
+ const a = state2.asks?.[rk];
53817
+ return a && Object.keys(a).length ? a : void 0;
53791
53818
  }
53792
53819
  function intakeChannelAnswers(answers) {
53793
53820
  const get2 = (key2, label2) => {
@@ -53807,6 +53834,12 @@ function boughtJobs(ticks, offered, channels = {}) {
53807
53834
  reason: item.kind === "not-in-plan" ? "Bought, but not built automatically yet. It is not in the plan for this client's industry." : `Bought, but not built automatically yet. ${OTHER_DELIVERY[item.id].job}`
53808
53835
  }));
53809
53836
  }
53837
+ function heldJobs(held2) {
53838
+ return held2.map((h) => {
53839
+ const at = h.line.indexOf(": ");
53840
+ return { ref: `bought:held-${h.id}`, type: BOUGHT_JOB_TYPE, name: h.line.slice(0, at), reason: h.line.slice(at + 2) };
53841
+ });
53842
+ }
53810
53843
  function boughtButExcludedJobs(ticks, offered, scopeFeatures) {
53811
53844
  if (scopeFeatures === "all") return [];
53812
53845
  return boughtItemsFor(offered).filter((item) => ticks.includes(item.id) && item.kind === "built" && item.feature && !scopeFeatures.includes(item.feature)).map((item) => ({
@@ -53829,10 +53862,10 @@ function assessmentChoiceLine(answer2, attached = answer2 === "attached") {
53829
53862
  if (answer2 === "run-first") return "The Assessment will be run first.";
53830
53863
  return "Not answered yet: has the Assessment been run for this client?";
53831
53864
  }
53832
- function boughtViewFor(state2, rk, offered, wording = {}) {
53865
+ function boughtViewFor(state2, rk, offered, wording = {}, status) {
53833
53866
  const { ticks, source } = ticksFor(state2, rk);
53834
53867
  return {
53835
- items: boughtItemsFor(offered, wording),
53868
+ items: boughtItemsFor(offered, wording, status),
53836
53869
  ticks,
53837
53870
  source,
53838
53871
  answer: assessmentAnswerOf(state2, rk),
@@ -53841,17 +53874,20 @@ function boughtViewFor(state2, rk, offered, wording = {}) {
53841
53874
  suggestions: source === "saved" ? claudeSuggestions(ticks) : []
53842
53875
  };
53843
53876
  }
53844
- function boughtDocumentFor(state2, rk, offered, wording = {}) {
53877
+ function boughtDocumentFor(state2, rk, offered, wording = {}, status) {
53845
53878
  const { ticks, source } = ticksFor(state2, rk);
53846
- const items = boughtItemsFor(offered, wording);
53879
+ const items = boughtItemsFor(offered, wording, status);
53847
53880
  const confirmed = source === "saved" || source === "scope";
53848
53881
  const ticked = items.filter((i) => ticks.includes(i.id));
53849
53882
  return {
53850
53883
  confirmed,
53851
53884
  assessment: assessmentChoiceLine(assessmentAnswerOf(state2, rk), Boolean(state2.assessmentAttachments?.[rk])),
53852
- bought: ticked.map((i) => ({ name: i.name, means: i.means, delivery: clientDelivery(i) })),
53885
+ // "No call goes unanswered" is a promise only an AI answering live keeps, and this version builds none: the client
53886
+ // reads what the build did for calls (promise-vs-build F4 wording, 2026-10-29).
53887
+ bought: ticked.map((i) => ({ name: i.name, means: i.id === "missed" ? status?.made?.has("missed_call") ? "Missed calls get a text back" : "How calls are answered" : i.means, delivery: clientDelivery(i) })),
53853
53888
  // The client reads this: never the agency's job text, and never "with Claude" (review of ea3526f).
53854
53889
  stillToDo: ticked.flatMap((i) => {
53890
+ if (HELD_LINE.test(i.delivery)) return [`${i.name}: ${clientDelivery(i).toLowerCase()}.`];
53855
53891
  if (i.kind === "go-live") return [`${i.name}: being set up in your account.`];
53856
53892
  if (i.kind === "separate") return [`${i.name}: the form is ready. The pages are a separate piece of work.`];
53857
53893
  if (i.kind === "not-in-plan") return [`${i.name}: being set up separately from this build.`];
@@ -53861,13 +53897,15 @@ function boughtDocumentFor(state2, rk, offered, wording = {}) {
53861
53897
  };
53862
53898
  }
53863
53899
  function clientDelivery(i) {
53900
+ const held2 = HELD_LINE.exec(i.delivery);
53901
+ if (held2) return held2[1] === "Waiting for your client's answer" ? "Waiting on your answer" : held2[1] === "Not built, your client said not yet" ? "Not set up, as you said not yet" : "Chosen, and coming in a later update";
53864
53902
  if (i.kind === "go-live") return "Being set up in your account";
53865
53903
  if (i.kind === "separate") return "The form is built; the pages are separate work";
53866
53904
  if (i.kind === "not-in-plan") return "Not part of this build";
53867
53905
  if (i.kind === "claude") return "A weekly report on what is working";
53868
53906
  return i.delivery;
53869
53907
  }
53870
- var BOUGHT_IDS, BOUGHT_MEANS, FEATURE_FOR_BOUGHT, BUILT_LABEL, OTHER_DELIVERY, CALL_ANSWERING_NEXT, ASSESSMENT_CHOICES, ANSWER_THE_ASSESSMENT_QUESTION, ATTACH_THE_ASSESSMENT, RUN_THE_ASSESSMENT_FIRST, BOUGHT_JOB_TYPE;
53908
+ var BOUGHT_IDS, BOUGHT_MEANS, FEATURE_FOR_BOUGHT, BUILT_LABEL, OTHER_DELIVERY, CALL_ANSWERING_NEXT, ASSESSMENT_CHOICES, ANSWER_THE_ASSESSMENT_QUESTION, ATTACH_THE_ASSESSMENT, RUN_THE_ASSESSMENT_FIRST, BOUGHT_JOB_TYPE, HELD_LINE;
53871
53909
  var init_bought_list = __esm({
53872
53910
  "src/bought-list.ts"() {
53873
53911
  "use strict";
@@ -53876,11 +53914,13 @@ var init_bought_list = __esm({
53876
53914
  init_build_contract();
53877
53915
  BOUGHT_IDS = ASSESSMENT_FEATURES.map((f) => f.id);
53878
53916
  BOUGHT_MEANS = {
53879
- missed: "No call goes unanswered",
53917
+ // rc.1 MED-1: true whatever the client chooses; nothing answers live until a voice agent is built.
53918
+ missed: "Callers get the answer your client chooses",
53880
53919
  waited: "Every new lead hears back in seconds",
53881
53920
  quotes: "No quote goes cold",
53882
53921
  noshow: "Bookings confirmed, reminded and rescued",
53883
- reactivation: "Old leads and past clients brought back",
53922
+ // rc.1 MED-2: what Win-back reaches (leads who go cold from now on); an old list only once a campaign for it is built.
53923
+ reactivation: "Leads who go quiet are brought back",
53884
53924
  nurture: "Leads kept warm until they are ready",
53885
53925
  recall: "Clients come back on schedule",
53886
53926
  deposits: "Fewer no-shows, cash up front",
@@ -53918,6 +53958,7 @@ var init_bought_list = __esm({
53918
53958
  ATTACH_THE_ASSESSMENT = "Attach the Assessment in step 4, or choose Build without an Assessment there.";
53919
53959
  RUN_THE_ASSESSMENT_FIRST = "You chose to run the Assessment first. Attach it in step 4 when it is done, or choose Build without an Assessment there.";
53920
53960
  BOUGHT_JOB_TYPE = "bought-not-built";
53961
+ HELD_LINE = /^(Waiting for your client's answer|Not built, your client said not yet|Not built yet, chosen and coming in a later update): /;
53921
53962
  }
53922
53963
  });
53923
53964
 
@@ -53975,6 +54016,23 @@ var init_offer_normalize = __esm({
53975
54016
  }
53976
54017
  });
53977
54018
 
54019
+ // src/intake-to-build/texting.ts
54020
+ function textingAnswer(brief) {
54021
+ const channels = brief && typeof brief === "object" ? brief.channels : void 0;
54022
+ const sms = channels && typeof channels === "object" ? channels.sms : void 0;
54023
+ if (sms === true) return "yes";
54024
+ if (sms === false) return "not_yet";
54025
+ return "unanswered";
54026
+ }
54027
+ function smsOn(brief) {
54028
+ return textingAnswer(brief) === "yes";
54029
+ }
54030
+ var init_texting = __esm({
54031
+ "src/intake-to-build/texting.ts"() {
54032
+ "use strict";
54033
+ }
54034
+ });
54035
+
53978
54036
  // src/intake-to-build/brief.ts
53979
54037
  function norm4(s) {
53980
54038
  return s.trim().toLowerCase();
@@ -54749,7 +54807,7 @@ function deriveFlags(brief) {
54749
54807
  const channels = brief.channels ?? {};
54750
54808
  const goal = brief.goal ?? {};
54751
54809
  const a2p = typeof channels.a2pStatus === "string" ? channels.a2pStatus.toLowerCase() : "";
54752
- if (channels.sms !== false && a2p !== "approved" && a2p !== "not needed") {
54810
+ if (smsOn(brief) && a2p !== "approved" && a2p !== "not needed") {
54753
54811
  flags.push("needs_a2p");
54754
54812
  }
54755
54813
  if (channels.payment === "Stripe not connected" || channels.payment === void 0) flags.push("stripe_not_connected");
@@ -54757,7 +54815,7 @@ function deriveFlags(brief) {
54757
54815
  flags.push("calendar_oauth_needed");
54758
54816
  }
54759
54817
  if (channels.email === false) flags.push("email_not_ready");
54760
- if (channels.sms !== false && channels.hasPhoneNumber !== "yes") {
54818
+ if (smsOn(brief) && channels.hasPhoneNumber !== "yes") {
54761
54819
  flags.push("phone_number_needed");
54762
54820
  }
54763
54821
  return flags;
@@ -54928,6 +54986,7 @@ var init_normalizer = __esm({
54928
54986
  "src/intake-to-build/normalizer.ts"() {
54929
54987
  "use strict";
54930
54988
  init_offer_normalize();
54989
+ init_texting();
54931
54990
  init_question_set();
54932
54991
  init_brief2();
54933
54992
  EMAIL_RE3 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -55508,7 +55567,8 @@ function context(src) {
55508
55567
  const notify2 = clean3(src.roster?.notifyName) || intakeAnswer2(src.intake, "notify_name") || void 0;
55509
55568
  const calls = clean3(src.roster?.callsName) || intakeAnswer2(src.intake, "calls_name") || void 0;
55510
55569
  const notifyShown = notify2 ? teamNames(src).find((n) => sameName(n, notify2)) : void 0;
55511
- return { features, live: workflowsConfirmed(plan, src.lines), calendars, notify: notify2, ...notifyShown && { notifyShown }, calls, items, ticked: confirmedTicks ? ticks : [], confirmedTicks, programFollowUp: isProgramFollowUp(plan), review: reviewTimingOf(plan), noShowRescue: hasWorkflow(plan, "workflow.no_show"), reply: replyChannels(plan) };
55570
+ const callAnswering = src.recordKey && src.boughtState?.asks?.[src.recordKey]?.callAnswering || [];
55571
+ return { features, live: workflowsConfirmed(plan, src.lines), calendars, notify: notify2, ...notifyShown && { notifyShown }, calls, items, ticked: confirmedTicks ? ticks : [], callAnswering, confirmedTicks, programFollowUp: isProgramFollowUp(plan), review: reviewTimingOf(plan), noShowRescue: hasWorkflow(plan, "workflow.no_show"), reply: replyChannels(plan) };
55512
55572
  }
55513
55573
  function printableCalendars(ctx) {
55514
55574
  return [...new Set(ctx.calendars.map((c) => c.name))].filter((n) => n !== "booking calendar");
@@ -55570,7 +55630,9 @@ function summaryOf2(ctx, plan) {
55570
55630
  }
55571
55631
  const extras = [
55572
55632
  f("missed_call") ? "missed calls get a text back" : "",
55573
- f("winback") ? "old leads get a win-back message" : "",
55633
+ // Win-back starts from a tag Lead Nurture or No-Show Rescue adds, so it reaches leads who go cold from now on,
55634
+ // not an old list (promise-vs-build F5, 2026-10-29).
55635
+ f("winback") ? "leads who go cold get a win-back message" : "",
55574
55636
  f("quote_followup") ? ctx.programFollowUp ? PROGRAM_WORDS.quotes.summary : "every quote is followed up" : "",
55575
55637
  f("review_requests") ? "happy clients are asked for a review" : ""
55576
55638
  ].filter(Boolean);
@@ -55687,7 +55749,9 @@ function boughtOf(ctx) {
55687
55749
  if (built) working.push(boughtLabel(item, true, ctx));
55688
55750
  else if (item.kind !== "built") next.push(boughtLabel(item, false, ctx));
55689
55751
  else next.push(item.name);
55690
- if (item.id === "missed" && built && ctx.ticked.includes("missed")) next.push("Live call answering");
55752
+ if (item.id === "missed" && ctx.ticked.includes("missed")) {
55753
+ for (const c of ctx.callAnswering) if (c !== "missed_text") next.push(c === "live_voice" ? "An AI that answers the phone live (chosen, coming in a later update)" : "An AI that replies by text or chat and books (chosen, coming in a later update)");
55754
+ }
55691
55755
  }
55692
55756
  return { workingLabel: ctx.live ? "Working now" : "Built, ready to switch on", working, next };
55693
55757
  }
@@ -62762,6 +62826,7 @@ function evalCondition(cond, ctx) {
62762
62826
  const [, pathExpr, op, rawRhs] = cmp;
62763
62827
  let lhs = getPath(ctx.brief, pathExpr);
62764
62828
  if (pathExpr === "goal.bookingNeeded") lhs = ctx.bookingWanted;
62829
+ if (pathExpr === "channels.sms") lhs = smsOn(ctx.brief);
62765
62830
  const rhs = rawRhs.trim().replace(/^'(.*)'$/, "$1").replace(/^"(.*)"$/, "$1");
62766
62831
  let equal;
62767
62832
  if (rhs === "true" || rhs === "false") equal = lhs === (rhs === "true");
@@ -63631,7 +63696,7 @@ function planFromPreset(input) {
63631
63696
  const skTemplates = sk.templates ?? {};
63632
63697
  const emailTemplates = keptByCondition(skTemplates.emails, ctx).map((t) => fillNode(t, ctx));
63633
63698
  const gsm7 = typeof p.conventions?.gsm7 === "string";
63634
- const smsTemplates = keptByCondition(skTemplates.sms, ctx).map((t) => {
63699
+ const smsTemplates = (smsOn(input.brief) ? keptByCondition(skTemplates.sms, ctx) : []).map((t) => {
63635
63700
  const filled2 = fillNode(t, ctx);
63636
63701
  if (gsm7 && typeof filled2.body === "string") filled2.body = toGsm7(filled2.body);
63637
63702
  return filled2;
@@ -64043,6 +64108,7 @@ var init_plan_from_preset = __esm({
64043
64108
  init_sms_rules();
64044
64109
  init_business_hours();
64045
64110
  init_plan_guide();
64111
+ init_texting();
64046
64112
  init_claims_guard();
64047
64113
  PRESET_TOKEN_ROOTS = /* @__PURE__ */ new Set(["business", "offer", "audience", "voice", "goal", "proof"]);
64048
64114
  GSM7_REPLACEMENTS = [
@@ -66869,7 +66935,7 @@ function compute(ans, skip, asm) {
66869
66935
  add("missed", "Calls you never get back to",
66870
66936
  [{ id: "calls_missed" }, { id: "missed_never_return", share: true }], null,
66871
66937
  function () { return raw("calls_missed") * share("missed_never_return") * c * v; },
66872
- "Call answering, day and night");
66938
+ "Call answering");
66873
66939
 
66874
66940
  add("waited", "Enquiries that waited",
66875
66941
  [{ id: "waited_month" }], "response_penalty",
@@ -69652,6 +69718,14 @@ function reviewPrompt(input) {
69652
69718
  "For each gap, state the cost using their own numbers when they gave any, and what we build to close it:",
69653
69719
  ...GAP_CHECKLIST.map((g) => ` \u2022 ${g}`),
69654
69720
  "",
69721
+ ...input.promises ? [
69722
+ "WHAT THIS BUILD MAKES. You do not list what we build or how each gap is fixed: the document takes that from this list itself. You write the diagnosis only. Your recommended offer and biggest lever may name a system or automation ONLY if it is on this list:",
69723
+ ...input.promises.willBuild.length ? input.promises.willBuild.map((w) => ` \u2022 ${w}`) : [" \u2022 (nothing yet)"],
69724
+ ...input.promises.held.length ? ["WAITING ON THE CLIENT'S ANSWER (never say we build these):", ...input.promises.held.map((h) => ` \u2022 ${h}`)] : [],
69725
+ 'Never promise that calls are answered live, "day and night", "24/7" or "every call": nothing in this build answers the phone.',
69726
+ ...input.promises.textingOn ? [] : ["This client is not texting: never promise a text, an SMS or a text-back anywhere."],
69727
+ ""
69728
+ ] : [],
69655
69729
  "THE CLIENT'S INTAKE ANSWERS:",
69656
69730
  answers || "(no answers provided)",
69657
69731
  "",
@@ -69663,7 +69737,7 @@ function reviewPrompt(input) {
69663
69737
  ] : [],
69664
69738
  'A gap about deposits or prepayment is never priority "now": deposits are set up later and never hold up sign-off.',
69665
69739
  "Reply with ONE line of JSON and nothing else:",
69666
- 'RESULT: {"headline":"<one sentence a busy owner would nod at>","ica":{"who":"","painPoints":[""],"objections":[""],"buyingTrigger":"","whereTheyAre":""},"offer":{"score":0,"dreamOutcome":"","believability":"","speed":"","effort":"","biggestLever":"","recommendedOffer":""},"gaps":[{"title":"","cost":"","fix":"","priority":"now"}],"build":[{"item":"","because":""}],"unknowns":[""]}'
69740
+ 'RESULT: {"headline":"<one sentence a busy owner would nod at>","ica":{"who":"","painPoints":[""],"objections":[""],"buyingTrigger":"","whereTheyAre":""},"offer":{"score":0,"dreamOutcome":"","believability":"","speed":"","effort":"","biggestLever":"","recommendedOffer":""},"gaps":[{"title":"","cost":"","priority":"now"}],"unknowns":[""]}'
69667
69741
  ].join("\n");
69668
69742
  }
69669
69743
  function accountPrefixes(clientName) {
@@ -69719,6 +69793,97 @@ function strictRetryPrompt(input) {
69719
69793
  "No preamble, no explanation, no markdown fences, no closing remarks."
69720
69794
  ].join("\n");
69721
69795
  }
69796
+ function renderedLines(r) {
69797
+ const out = [];
69798
+ const add2 = (where, text3) => {
69799
+ if (typeof text3 === "string" && text3.trim()) out.push({ where, text: text3 });
69800
+ };
69801
+ add2("headline", r.headline);
69802
+ if (r.ica) {
69803
+ add2("ica.who", r.ica.who);
69804
+ add2("ica.buyingTrigger", r.ica.buyingTrigger);
69805
+ add2("ica.whereTheyAre", r.ica.whereTheyAre);
69806
+ (r.ica.painPoints ?? []).forEach((t, i) => add2(`ica.painPoints.${i}`, t));
69807
+ (r.ica.objections ?? []).forEach((t, i) => add2(`ica.objections.${i}`, t));
69808
+ }
69809
+ if (r.offer) for (const k of ["dreamOutcome", "believability", "speed", "effort", "biggestLever", "recommendedOffer"]) add2(`offer.${k}`, r.offer[k]);
69810
+ (r.gaps ?? []).forEach((g, i) => {
69811
+ add2(`gaps.${i}.title`, g.title);
69812
+ add2(`gaps.${i}.cost`, g.cost);
69813
+ });
69814
+ (r.unknowns ?? []).forEach((t, i) => add2(`unknowns.${i}`, t));
69815
+ return out;
69816
+ }
69817
+ function overPromises(r, p) {
69818
+ if (!p || !r.ok) return [];
69819
+ const made = p.willBuild.join(" | ");
69820
+ return renderedLines(r).filter((l) => LIVE_CALLS.test(l.text) || (!p.textingOn || p.held.some((h) => /texting/i.test(h))) && TEXTS.test(l.text) || TOPICS.some((t) => t.re.test(l.text) && !t.built.test(made))).map((l) => l.where);
69821
+ }
69822
+ function graderPrompt(r, p) {
69823
+ return [
69824
+ "You check a client document for promises. A line promises something if it says or implies that we will build, set up, run or deliver it for the client.",
69825
+ "WHAT THIS BUILD MAKES (the only things a line may promise):",
69826
+ ...p.willBuild.length ? p.willBuild.map((w) => `- ${w}`) : ["- (nothing)"],
69827
+ ...p.held.length ? ["NOT BUILT, WAITING ON THE CLIENT'S ANSWER (a line promising these is a violation):", ...p.held.map((h) => `- ${h}`)] : [],
69828
+ `Texting is ${p.textingOn ? "on" : "OFF: any promise of a text, SMS or text-back is a violation"}. Nothing in this build answers the phone live: any promise of answered calls is a violation.`,
69829
+ "Diagnosis, observations about the client and questions are not promises. Advice clearly marked as for later is not a promise.",
69830
+ "THE LINES (id: text):",
69831
+ ...renderedLines(r).map((l) => `${l.where}: ${l.text}`),
69832
+ 'Reply with ONE line of JSON and nothing else: RESULT: {"violations":["<id>", ...]}'
69833
+ ].join("\n");
69834
+ }
69835
+ function parseGrader(text3, r) {
69836
+ const json3 = extractResultJson(text3);
69837
+ if (json3 === void 0) return null;
69838
+ try {
69839
+ const v = JSON.parse(json3).violations;
69840
+ if (!Array.isArray(v)) return null;
69841
+ const known = new Set(renderedLines(r).map((l) => l.where));
69842
+ return v.map(String).filter((x) => known.has(x));
69843
+ } catch {
69844
+ return null;
69845
+ }
69846
+ }
69847
+ function trimLines(r, bad) {
69848
+ if (!bad.length) return r;
69849
+ const set = new Set(bad);
69850
+ const trimmed = renderedLines(r).filter((l) => set.has(l.where)).map((l) => l.text);
69851
+ const keep = (where) => !set.has(where);
69852
+ const ica = r.ica && {
69853
+ ...r.ica,
69854
+ who: keep("ica.who") ? r.ica.who : "",
69855
+ buyingTrigger: keep("ica.buyingTrigger") ? r.ica.buyingTrigger : "",
69856
+ whereTheyAre: keep("ica.whereTheyAre") ? r.ica.whereTheyAre : "",
69857
+ painPoints: (r.ica.painPoints ?? []).filter((_, i) => keep(`ica.painPoints.${i}`)),
69858
+ objections: (r.ica.objections ?? []).filter((_, i) => keep(`ica.objections.${i}`))
69859
+ };
69860
+ const offer = r.offer && Object.fromEntries(Object.entries(r.offer).map(([k, v]) => [k, typeof v === "string" && !keep(`offer.${k}`) ? "" : v]));
69861
+ return {
69862
+ ...r,
69863
+ headline: keep("headline") ? r.headline : void 0,
69864
+ ...ica ? { ica } : {},
69865
+ ...offer ? { offer } : {},
69866
+ gaps: (r.gaps ?? []).filter((_, i) => keep(`gaps.${i}.title`) && keep(`gaps.${i}.cost`)),
69867
+ unknowns: (r.unknowns ?? []).filter((_, i) => keep(`unknowns.${i}`)),
69868
+ trimmed: [...r.trimmed ?? [], ...trimmed]
69869
+ };
69870
+ }
69871
+ async function violations(spawnClaude, r, p) {
69872
+ if (!p || !r.ok) return { ids: [], graded: true };
69873
+ const fast = overPromises(r, p);
69874
+ let found = null;
69875
+ for (let attempt2 = 0; attempt2 < 2 && found === null; attempt2++) {
69876
+ try {
69877
+ found = parseGrader(await spawnClaude(graderPrompt(r, p)), r);
69878
+ } catch {
69879
+ found = null;
69880
+ }
69881
+ }
69882
+ return { ids: [.../* @__PURE__ */ new Set([...fast, ...found ?? []])], graded: found !== null };
69883
+ }
69884
+ function structural(r, p) {
69885
+ return { ...r, build: [], gaps: (r.gaps ?? []).map((g) => ({ ...g, fix: "" })), ...p ? { promises: p } : {} };
69886
+ }
69722
69887
  async function generateReview(spawnClaude, input) {
69723
69888
  const answered = Object.values(input.answers).filter((v) => v && String(v).trim()).length;
69724
69889
  if (answered < 5) {
@@ -69731,7 +69896,21 @@ async function generateReview(spawnClaude, input) {
69731
69896
  lastRaw = raw;
69732
69897
  const names = [input.clientName, ...Object.entries(input.answers).filter(([k]) => /business[ _]?name/i.test(k)).map(([, v]) => v)].join(" ");
69733
69898
  const parsed2 = parseReview(raw, names);
69734
- if (parsed2.ok) return parsed2;
69899
+ if (parsed2.ok) {
69900
+ const first2 = structural(parsed2, input.promises);
69901
+ const over2 = await violations(spawnClaude, first2, input.promises);
69902
+ if (!over2.ids.length) return { ...first2, graded: over2.graded };
69903
+ try {
69904
+ const again = parseReview(await spawnClaude([reviewPrompt(input), "", `IMPORTANT: your previous answer promised what this build does not make (${over2.ids.join(", ")}). Promise only what is listed under WHAT THIS BUILD MAKES.`].join("\n")), names);
69905
+ if (again.ok) {
69906
+ const second = structural(again, input.promises);
69907
+ const v = await violations(spawnClaude, second, input.promises);
69908
+ return { ...trimLines(second, v.ids), graded: v.graded };
69909
+ }
69910
+ } catch {
69911
+ }
69912
+ return { ...trimLines(first2, over2.ids), graded: over2.graded };
69913
+ }
69735
69914
  } catch (e) {
69736
69915
  lastRaw = e instanceof Error ? e.message : String(e);
69737
69916
  }
@@ -69742,12 +69921,15 @@ async function generateReview(spawnClaude, input) {
69742
69921
  raw: lastRaw.slice(-600)
69743
69922
  };
69744
69923
  }
69924
+ function clientHeldLine(line4) {
69925
+ return line4.replace(/^Waiting for your client's answer: /, "Waiting on your answer: ").replace(/^Not built, your client said not yet: /, "Not set up, as you said not yet: ").replace(/^Not built yet, chosen and coming in a later update: /, "Chosen, and coming in a later update: ");
69926
+ }
69745
69927
  function reviewDocumentHtml(review, opts) {
69746
69928
  const accent = opts.accent && /^#[0-9a-fA-F]{6}$/.test(opts.accent) ? opts.accent : "#B8934A";
69747
69929
  const agency = opts.agencyName?.trim() || "";
69748
69930
  const esc18 = (v) => String(v ?? "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
69749
69931
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString(void 0, { year: "numeric", month: "long", day: "numeric" });
69750
- const gap = (g) => `<div class="gap ${g.priority}"><div class="gt">${esc18(g.title)}<span class="pri">${g.priority === "now" ? "Fix first" : g.priority === "soon" ? "Next" : "Later"}</span></div>${g.cost ? `<div class="gc">${esc18(g.cost)}</div>` : ""}${g.fix ? `<div class="gf">What we do: ${esc18(g.fix)}</div>` : ""}</div>`;
69932
+ const gap = (g) => `<div class="gap ${g.priority}"><div class="gt">${esc18(g.title)}<span class="pri">${g.priority === "now" ? "Fix first" : g.priority === "soon" ? "Next" : "Later"}</span></div>${g.cost ? `<div class="gc">${esc18(g.cost)}</div>` : ""}</div>`;
69751
69933
  const o = review.offer;
69752
69934
  const confirmed = opts.scope && !opts.scope.wholePreset && !opts.scope.awaitingConfirmation ? opts.scope : void 0;
69753
69935
  const purchased = confirmed || opts.purchasedFeatureIds ? [.../* @__PURE__ */ new Set([
@@ -69758,7 +69940,7 @@ function reviewDocumentHtml(review, opts) {
69758
69940
  const represented = [...opts.scope?.built ?? [], ...unbuilt].map((f) => BUILD_ASSESSMENT_FEATURE[f.id]).concat(opts.scope && !opts.scope.awaitingConfirmation ? ["pipeline"] : []);
69759
69941
  const assessmentNext = opts.assessment && purchased ? assessmentFeatureLines(opts.assessment, [...purchased, ...represented]) : [];
69760
69942
  const available = unbuilt.map((f) => `<div class="build"><b>${esc18(f.name)}</b><span>${esc18(f.what)}${f.figure ? ` <b>${esc18(f.figure)}</b>` : ""}</span></div>`).join("") + assessmentNext.map((line4) => `<p>${esc18(line4)}</p>`).join("");
69761
- return `<!doctype html><html><head><meta charset="utf-8"><title>${esc18(opts.clientName)} \u2014 Growth Blueprint</title><style>
69943
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${esc18(opts.clientName)}: Growth Blueprint</title><style>
69762
69944
  @page{margin:0.6in}
69763
69945
  *{box-sizing:border-box}
69764
69946
  body{font-family:-apple-system,Segoe UI,Helvetica,sans-serif;color:#1f2430;margin:0;line-height:1.55}
@@ -69813,7 +69995,7 @@ function reviewDocumentHtml(review, opts) {
69813
69995
  ${o.biggestLever ? `<p class="kv"><b>Biggest lever:</b> ${esc18(o.biggestLever)}</p>` : ""}
69814
69996
  ${o.recommendedOffer ? `<div class="lead"><b>Run this instead:</b> ${esc18(o.recommendedOffer)}</div>` : ""}` : ""}
69815
69997
  ${review.gaps?.length ? `<div class="head">What's leaking today (${review.gaps.length})</div>${review.gaps.map(gap).join("")}` : ""}
69816
- ${review.build?.length ? `<div class="head">What we're building</div>${review.build.map((b) => `<div class="build"><b>${esc18(b.item)}</b><span>${esc18(b.because)}</span></div>`).join("")}` : ""}
69998
+ ${review.promises && (review.promises.willBuild.length || review.promises.held.length) ? `<div class="head">What we're building</div>${review.promises.willBuild.map((w) => `<div class="build"><b>${esc18(w)}</b></div>`).join("")}${review.promises.held.map((h) => `<div class="build"><b>${esc18(clientHeldLine(h))}</b></div>`).join("")}` : ""}
69817
69999
  ${opts.scope?.awaitingConfirmation ? `<div class="head">What this build includes</div>
69818
70000
  <p>Not confirmed yet. The build waits until the features to build are confirmed.</p>
69819
70001
  ` : opts.scope ? `<div class="head">What this build includes</div>
@@ -69829,7 +70011,7 @@ function reviewDocumentHtml(review, opts) {
69829
70011
  <footer><span>${agency ? esc18(agency) : "Growth Blueprint"}</span><span>${esc18(opts.clientName)} \xB7 ${date}</span></footer>
69830
70012
  </div></body></html>`;
69831
70013
  }
69832
- var GAP_CHECKLIST;
70014
+ var GAP_CHECKLIST, LIVE_CALLS, TEXTS, TOPICS;
69833
70015
  var init_blueprint_review = __esm({
69834
70016
  "src/blueprint-review.ts"() {
69835
70017
  "use strict";
@@ -69849,6 +70031,12 @@ var init_blueprint_review = __esm({
69849
70031
  "SMS capability (A2P registered) if they want to text",
69850
70032
  "Reporting they will actually look at"
69851
70033
  ];
70034
+ LIVE_CALLS = /(?:\bday\s*(?:and|&)\s*night\b|\b24\s*[\/\-\u2013]\s*7\b|\b24 hours\b|\b(?:a)?round[- ]the[- ]clock\b|\bafter[- ]hours?\b|\bevery (?:single )?(?:call|caller|enquiry|inquiry|ring)s?\b|\banswer(?:s|ed|ing)?\b[^.]{0,40}\b(?:phones?|calls?|rings?)\b|\b(?:phones?|calls?)\b[^.]{0,20}\banswer(?:s|ed|ing)?\b|\bcalls? (?:are |get )?answered\b|\bpick(?:s|ing)? up\b|\bsomeone rings\b|\bphones? (?:are |is )?covered\b|\b(?:call|phone) coverage\b|\bcoverage on (?:your |the )?phones?\b|\bno missed calls\b|\bnever miss(?:es)?\b[^.]{0,40}\b(?:calls?|leads?|phone)\b|\bno (?:call|caller) (?:goes|is) (?:unanswered|missed)\b|\blive (?:call |phone )?answering\b|\banswering 24\b|\b(?:ai|virtual) (?:receptionist|voice|phone)\b|\breceptionist\b)/i;
70035
+ TEXTS = /\b(?:texts?|texted|texting|sms|text-?backs?|whatsapp|by message on (?:their|your) (?:mobile|phone)|on their mobile|mobile messages?)\b/i;
70036
+ TOPICS = [
70037
+ { re: /\b(?:missed[- ]calls?|text[- ]?backs?|callers?)\b/i, built: /missed-call text-back/i },
70038
+ { re: /\b(?:reactivat\w*|win[- ]?backs?|past (?:clients|customers|patients)|old (?:leads|lists?|clients|customers|patients)|dormant|lapsed|bring (?:them|back)\b|brought back)\b/i, built: /win-back/i }
70039
+ ];
69852
70040
  }
69853
70041
  });
69854
70042
 
@@ -71782,6 +71970,116 @@ var init_clinic_prefix_registry = __esm({
71782
71970
  }
71783
71971
  });
71784
71972
 
71973
+ // src/client-asks.ts
71974
+ function parseAsks(raw) {
71975
+ if (raw === void 0 || raw === null) return { ok: true, asks: {} };
71976
+ if (typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "The answers could not be read. Choose them again, then save." };
71977
+ const r = raw;
71978
+ const out = {};
71979
+ if (r.texting !== void 0) {
71980
+ if (r.texting !== "yes" && r.texting !== "not_yet") return { ok: false, error: "Texting is Yes or Not yet." };
71981
+ out.texting = r.texting;
71982
+ }
71983
+ if (r.callAnswering !== void 0) {
71984
+ if (!Array.isArray(r.callAnswering) || !r.callAnswering.every((c) => CALL_ANSWERING_CHOICES.includes(c))) {
71985
+ return { ok: false, error: "Call answering choices could not be read. Choose them again, then save." };
71986
+ }
71987
+ const chosen = [...new Set(r.callAnswering)];
71988
+ if (chosen.length) out.callAnswering = CALL_ANSWERING_CHOICES.filter((c) => chosen.includes(c));
71989
+ }
71990
+ if (r.reactivation !== void 0) {
71991
+ const x = r.reactivation;
71992
+ if (!x || typeof x !== "object") return { ok: false, error: "Reactivation answers could not be read. Choose them again, then save." };
71993
+ if (!REACTIVATION_LISTS.includes(x.list)) return { ok: false, error: "Choose who reactivation should reach." };
71994
+ const offer = x.offer;
71995
+ const text3 = typeof offer?.text === "string" ? offer.text.trim() : "";
71996
+ if (!offer || offer.from !== "brief" && offer.from !== "typed" || !text3) return { ok: false, error: "Choose the offer that brings them back, or type one." };
71997
+ if (text3.length > MAX_OFFER) return { ok: false, error: `Keep the offer under ${MAX_OFFER} characters.` };
71998
+ if (x.channel !== "email" && x.channel !== "email_and_text") return { ok: false, error: "Choose Email, or Email and text." };
71999
+ const a = { list: x.list, offer: { from: offer.from, text: text3 }, channel: x.channel };
72000
+ if (a.list === "in_account_not_booked") {
72001
+ const d = Number(x.notBookedDays);
72002
+ if (!Number.isInteger(d) || d < 1 || d > 3650) return { ok: false, error: "Not booked in how many days? Use a whole number from 1 to 3650." };
72003
+ a.notBookedDays = d;
72004
+ }
72005
+ if (x.textConsentConfirmed === true) a.textConsentConfirmed = true;
72006
+ out.reactivation = a;
72007
+ }
72008
+ return { ok: true, asks: out };
72009
+ }
72010
+ function effectiveTexting(asks, intakeSms) {
72011
+ if (asks?.texting === "yes") return "yes";
72012
+ if (asks?.texting === "not_yet") return "not_yet";
72013
+ if (intakeSms === true) return "yes";
72014
+ if (intakeSms === false) return "not_yet";
72015
+ return "unanswered";
72016
+ }
72017
+ function asksContradiction(asks, texting) {
72018
+ if (texting !== "yes" && asks.callAnswering?.includes("missed_text")) return "A text back after a missed call needs Texting: Yes.";
72019
+ const re = asks.reactivation;
72020
+ if (re?.channel === "email_and_text") {
72021
+ if (texting !== "yes") return "Email and text needs Texting: Yes.";
72022
+ if (!re.textConsentConfirmed) return "Email and text needs your client to confirm these people agreed to get texts.";
72023
+ }
72024
+ return null;
72025
+ }
72026
+ function heldItems(ticks, asks, texting) {
72027
+ const out = [];
72028
+ const wait = (id2, name2) => ({ id: id2, name: name2, reason: "unanswered", line: `Waiting for your client's answer: ${name2}` });
72029
+ const textsMatter = ticks.includes("sms") || ticks.includes("missed") || ticks.includes("reactivation");
72030
+ if (textsMatter && texting === "unanswered") out.push(wait("texting", "Texting"));
72031
+ if (ticks.includes("sms") && texting === "not_yet") out.push({ id: "texting", name: "Texting", reason: "not_yet", line: "Not built, your client said not yet: Texting" });
72032
+ if (ticks.includes("missed")) {
72033
+ const c = asks?.callAnswering ?? [];
72034
+ if (!c.length) out.push(wait("call_answering", "Call answering"));
72035
+ else {
72036
+ const later = c.filter((x) => x === "live_voice" || x === "chat_bot");
72037
+ if (later.length) out.push({ id: "call_answering", name: "Call answering", reason: "later_update", line: `Not built yet, chosen and coming in a later update: ${later.map((x) => x === "live_voice" ? "an AI that answers the phone live" : "an AI that replies by text or chat and books").join(" and ")}` });
72038
+ if (c.includes("missed_text") && texting !== "yes") out.push(wait("texting", "Texting (the text back after a missed call needs it)"));
72039
+ }
72040
+ }
72041
+ if (ticks.includes("reactivation")) {
72042
+ const re = asks?.reactivation;
72043
+ if (!re) out.push(wait("reactivation", "Reactivation (who it reaches, which offer)"));
72044
+ else if (re.list !== "cold_from_now") out.push({ id: "reactivation", name: "Reactivation", reason: "later_update", line: `Not built yet, chosen and coming in a later update: reactivation for ${re.list === "import" ? "a list your client will give you" : `contacts here with no booking in ${re.notBookedDays} days`}` });
72045
+ }
72046
+ return out.filter((h, i) => out.findIndex((o) => o.id === h.id && o.reason === h.reason) === i);
72047
+ }
72048
+ function featuresAllowedByAsks(features, ticks, asks) {
72049
+ return features.filter((f) => {
72050
+ if (f === "missed_call" && ticks.includes("missed")) return asks?.callAnswering?.includes("missed_text") === true;
72051
+ if (f === "winback" && ticks.includes("reactivation")) return asks?.reactivation?.list === "cold_from_now";
72052
+ return true;
72053
+ });
72054
+ }
72055
+ function briefWithAsks(brief, asks, ticks, presetWorkflows) {
72056
+ const ch = brief.channels && typeof brief.channels === "object" ? brief.channels : {};
72057
+ const texting = effectiveTexting(asks, typeof ch.sms === "boolean" ? ch.sms : void 0);
72058
+ const { sms: _was, ...rest } = ch;
72059
+ let out = { ...brief, channels: { ...rest, ...texting === "unanswered" ? {} : { sms: texting === "yes" } } };
72060
+ if (!ticks) return out;
72061
+ const scope = out.scope;
72062
+ const offered = [...new Set(presetWorkflows.map((w) => featureOfWorkflow(String(w.ref ?? ""), w.feature)).filter((f) => f !== null))];
72063
+ const current = scope ? Array.isArray(scope.features) ? scope.features.map(String) : [] : offered;
72064
+ const allowed = featuresAllowedByAsks(current, ticks, asks);
72065
+ if (allowed.length !== current.length) out = { ...out, scope: { ...scope?.package ? { package: scope.package } : {}, features: allowed } };
72066
+ return out;
72067
+ }
72068
+ function asksBindingHash(asks, ticks) {
72069
+ return contentHash2({ asks: asks && Object.keys(asks).length ? asks : null, ticks: ticks ? [...new Set(ticks)].sort() : null });
72070
+ }
72071
+ var CALL_ANSWERING_CHOICES, REACTIVATION_LISTS, MAX_OFFER;
72072
+ var init_client_asks = __esm({
72073
+ "src/client-asks.ts"() {
72074
+ "use strict";
72075
+ init_features();
72076
+ init_build_contract();
72077
+ CALL_ANSWERING_CHOICES = ["live_voice", "chat_bot", "missed_text"];
72078
+ REACTIVATION_LISTS = ["import", "in_account_not_booked", "cold_from_now"];
72079
+ MAX_OFFER = 300;
72080
+ }
72081
+ });
72082
+
71785
72083
  // src/dashboard.ts
71786
72084
  var dashboard_exports = {};
71787
72085
  __export(dashboard_exports, {
@@ -71796,6 +72094,7 @@ __export(dashboard_exports, {
71796
72094
  ASSESSMENT_CHANGED_SINCE_APPROVAL: () => ASSESSMENT_CHANGED_SINCE_APPROVAL,
71797
72095
  ASSESSMENT_PIPELINE_MISSING: () => ASSESSMENT_PIPELINE_MISSING,
71798
72096
  BRIEF_NEEDS_INTAKE: () => BRIEF_NEEDS_INTAKE2,
72097
+ BUILD_WHAT_WAS_WAITING: () => BUILD_WHAT_WAS_WAITING,
71799
72098
  CHOOSE_BEFORE_BUILD: () => CHOOSE_BEFORE_BUILD,
71800
72099
  CHOOSE_CLIENT: () => CHOOSE_CLIENT,
71801
72100
  CHOOSE_WHAT_THEY_BOUGHT: () => CHOOSE_WHAT_THEY_BOUGHT,
@@ -71852,6 +72151,7 @@ __export(dashboard_exports, {
71852
72151
  brandPagesForPro: () => brandPagesForPro,
71853
72152
  briefReach: () => briefReach,
71854
72153
  buildPreviewsFor: () => buildPreviewsFor,
72154
+ buildStatusFor: () => buildStatusFor,
71855
72155
  callClaudeRecorded: () => callClaudeRecorded,
71856
72156
  canonicalIndustry: () => canonicalIndustry,
71857
72157
  capabilityJobs: () => capabilityJobs,
@@ -71893,6 +72193,7 @@ __export(dashboard_exports, {
71893
72193
  guarded: () => guarded,
71894
72194
  hasChosenScope: () => hasChosenScope,
71895
72195
  insideDir: () => insideDir,
72196
+ intakeAskFacts: () => intakeAskFacts,
71896
72197
  intakeErrorDetail: () => intakeErrorDetail,
71897
72198
  intakeInput: () => intakeInput,
71898
72199
  intakeMissingMessage: () => intakeMissingMessage,
@@ -71948,15 +72249,20 @@ __export(dashboard_exports, {
71948
72249
  rereadQueueIntakes: () => rereadQueueIntakes,
71949
72250
  resetCockpitStateRecovery: () => resetCockpitStateRecovery,
71950
72251
  resolvePlan: () => resolvePlan2,
72252
+ reviewIsStale: () => reviewIsStale,
72253
+ reviewPromisesFor: () => reviewPromisesFor,
72254
+ reviewPromisesHash: () => reviewPromisesHash,
71951
72255
  routeGate: () => routeGate,
71952
72256
  runDashboard: () => runDashboard,
71953
72257
  runForTests: () => runForTests,
71954
72258
  runRefusal: () => runRefusal,
71955
72259
  runStopReason: () => runStopReason,
71956
72260
  safeDocName: () => safeDocName,
72261
+ savedPlanFitsAnswers: () => savedPlanFitsAnswers,
71957
72262
  scanGate: () => scanGate,
71958
72263
  scopeHashOf: () => scopeHashOf,
71959
72264
  scopePrefillFor: () => scopePrefillFor,
72265
+ scopeWithAsks: () => scopeWithAsks,
71960
72266
  secondClientInputHash: () => secondClientInputHash,
71961
72267
  setAccountIndustry: () => setAccountIndustry,
71962
72268
  setAccountType: () => setAccountType,
@@ -72147,7 +72453,7 @@ function withdrawIfStaffChanged(state2, locationId2) {
72147
72453
  return { state: { ...setBuildApproval(state2, locationId2, null), planApprovals: approvals }, withdrawn: true, message: ap.staffHash === void 0 ? APPROVAL_WITHDRAWN_NO_TEAM : APPROVAL_WITHDRAWN_FOR_STAFF };
72148
72454
  }
72149
72455
  function withBoughtJobs(tasks2, lines) {
72150
- const kept = tasks2.filter((t) => !SELF_REPORTED.test(t.text));
72456
+ const kept = tasks2.filter((t) => !SELF_REPORTED.test(t.text) && !HELD_JOB.test(t.text));
72151
72457
  const prior = new Map(tasks2.map((t) => [t.id, t]));
72152
72458
  return [...kept, ...lines.map((line4) => {
72153
72459
  if (typeof line4 === "string") return prior.get(taskId(line4)) ?? { id: taskId(line4), text: line4 };
@@ -72247,6 +72553,71 @@ function presetWorkflowsFor(state2, recordKey) {
72247
72553
  }
72248
72554
  return null;
72249
72555
  }
72556
+ function scopeWithAsks(state2, rk) {
72557
+ const current = state2.scope?.[rk];
72558
+ const ticks = state2.bought?.[rk];
72559
+ if (!current || !Array.isArray(ticks)) return current;
72560
+ const base = current.wholePreset ? offeredFeaturesFor(state2, rk) : current.features;
72561
+ const allowed = featuresAllowedByAsks(base, ticks, state2.asks?.[rk]);
72562
+ return allowed.length === base.length ? current : { features: allowed, setAt: current.setAt };
72563
+ }
72564
+ function intakeAskFacts(answers) {
72565
+ const get2 = (key2, label2) => {
72566
+ const v = answers?.[label2] ?? answers?.[key2];
72567
+ return typeof v === "string" && v.trim() ? v.trim() : void 0;
72568
+ };
72569
+ const { sms } = intakeChannelAnswers(answers);
72570
+ const phone = get2("has_phone_number", "Do you already have a phone number in GoHighLevel?")?.toLowerCase();
72571
+ const offer = get2("core_offer", "Core Offer");
72572
+ return { ...sms !== void 0 ? { sms } : {}, ...phone === "yes" || phone === "no" || phone === "unsure" ? { phone } : {}, ...offer ? { offer: offer.slice(0, 300) } : {} };
72573
+ }
72574
+ function buildStatusFor(st, rk, locationId2) {
72575
+ const ticks = st.bought?.[rk] ?? [];
72576
+ const intake = intakeChannelAnswers(st.intakeCompleted?.[locationId2]?.answers ?? st.intakeInputs?.[locationId2]?.answers);
72577
+ const held2 = {};
72578
+ const item = { texting: "sms", call_answering: "missed", reactivation: "reactivation" };
72579
+ for (const h of heldItems(ticks, st.asks?.[rk], effectiveTexting(st.asks?.[rk], intake.sms))) held2[item[h.id]] ??= h.line;
72580
+ const sp = readSavedPlan(rk);
72581
+ const ran = st.outcomes?.[`${rk}:3`]?.ok === true || (sp?.built?.length ?? 0) > 0;
72582
+ if (!ran) return { held: held2 };
72583
+ const featureOf = (ref) => {
72584
+ try {
72585
+ return featureOfWorkflow(ref);
72586
+ } catch {
72587
+ return null;
72588
+ }
72589
+ };
72590
+ const made = /* @__PURE__ */ new Set();
72591
+ for (const o of sp?.planId ? madeByBuild(locationId2, sp.planId) : []) if (o.type === "workflow") {
72592
+ const f = featureOf(o.ref);
72593
+ if (f) made.add(f);
72594
+ }
72595
+ for (const b of sp?.built ?? []) if (b.type === "workflow" && b.realId) {
72596
+ const f = featureOf(String(b.ref));
72597
+ if (f) made.add(f);
72598
+ }
72599
+ return { made, held: held2 };
72600
+ }
72601
+ function reviewPromisesFor(st, rk, locationId2) {
72602
+ const ticks = new Set(st.bought?.[rk] ?? []);
72603
+ const items = boughtItemsFor(offeredFeaturesFor(st, rk), boughtWordingFor(st, rk), buildStatusFor(st, rk, locationId2)).filter((i) => ticks.has(i.id));
72604
+ const willBuild = items.map((i) => /^(?:Will build|Built|Always built): (.+)$/.exec(i.delivery)?.[1]).filter((x) => Boolean(x));
72605
+ const held2 = items.map((i) => i.delivery).filter((d) => HELD_JOB.test(d));
72606
+ const intake = intakeChannelAnswers(st.intakeCompleted?.[locationId2]?.answers ?? st.intakeInputs?.[locationId2]?.answers);
72607
+ return { willBuild, held: held2, textingOn: effectiveTexting(st.asks?.[rk], intake.sms) === "yes" };
72608
+ }
72609
+ function reviewPromisesHash(st, rk, locationId2 = rk) {
72610
+ return contentHash2(reviewPromisesFor(st, rk, locationId2));
72611
+ }
72612
+ function reviewIsStale(st, rk, review) {
72613
+ return Boolean(review?.ok) && review.promisesHash !== reviewPromisesHash(st, rk);
72614
+ }
72615
+ function savedPlanFitsAnswers(sp, state2, rk) {
72616
+ if (!sp || !savedPlanMatchesScope(sp, scopeWithAsks(state2, rk))) return false;
72617
+ const asks = state2.asks?.[rk];
72618
+ if (!sp.asksHash) return !(asks && Object.keys(asks).length);
72619
+ return sp.asksHash === asksBindingHash(asks, state2.bought?.[rk]);
72620
+ }
72250
72621
  function offeredFeaturesFor(state2, recordKey) {
72251
72622
  const workflows = presetWorkflowsFor(state2, recordKey);
72252
72623
  return workflows ? featuresInPreset(workflows) : FEATURE_IDS;
@@ -72456,7 +72827,7 @@ function startRun(locationId2, stage, onDone, meta = {}) {
72456
72827
  // or before Your team decided) is made again, never built with someone the member was not shown.
72457
72828
  savedPlan: (() => {
72458
72829
  const sp = stage === 3 ? readSavedPlan(rk) : null;
72459
- return sp && sp.inputHash === meta.inputHash && savedPlanMatchesScope(sp, state2.scope?.[rk]) && savedPlanTeamIsCurrent(sp, meta.normalizedAnswers, composed) ? { planId: sp.planId, savedAt: sp.savedAt, summary: planSummary(sp) } : void 0;
72830
+ return sp && sp.inputHash === meta.inputHash && savedPlanFitsAnswers(sp, state2, rk) && savedPlanTeamIsCurrent(sp, meta.normalizedAnswers, composed) ? { planId: sp.planId, savedAt: sp.savedAt, summary: planSummary(sp) } : void 0;
72460
72831
  })(),
72461
72832
  textSnippets: Boolean(state2.textSnippets?.[rk])
72462
72833
  };
@@ -74203,6 +74574,60 @@ async function profLoad(){if(profLoading)return;profLoading=true;try{profCache=(
74203
74574
  waits for the Assessment question above the list. */
74204
74575
  /* The ticks and package chosen but not yet saved, kept outside the block so a redraw keeps them (review of c09e946). */
74205
74576
  let scopeDraft={},boughtNotice={};
74577
+ /* Ask first (Jerry 2026-10-29): texting, call answering and reactivation are asked of the client before Build. The
74578
+ answers save with "Save what they bought" (server: parseAsks, asksContradiction). Nothing is pre-selected: the
74579
+ client's own form answer shows with a Change link; a phone number is only a hint. Blank = held, never built. */
74580
+ let asksDraft={};
74581
+ function asksClean(a){const o={};if(a.texting)o.texting=a.texting;if(a.callAnswering&&a.callAnswering.length)o.callAnswering=[...a.callAnswering];
74582
+ /* W44: typed text counts whenever no other offer was chosen, whatever was clicked first. */
74583
+ const r=a.reactivation;if(r&&r.list){const off=r.offer&&r.offer.text?r.offer:(r._typed?{from:"typed",text:r._typed}:{});const x={list:r.list,offer:off,channel:r.channel};if(r.list==="in_account_not_booked")x.notBookedDays=Number(r.notBookedDays);if(r.textConsentConfirmed)x.textConsentConfirmed=true;o.reactivation=x}return o}
74584
+ function asksBox(id,v,getTicked,keep){
74585
+ const box=mk("div","cds-stack ask-first");box.id="asks-"+id;const ia=v.intakeAsks||{};const savedJson=JSON.stringify(v.asks||{});
74586
+ if(!asksDraft[id]||asksDraft[id].base!==savedJson)asksDraft[id]={base:savedJson,a:JSON.parse(savedJson),change:false};
74587
+ const d=asksDraft[id],a=d.a;
74588
+ const radio=(name,val,lab,on,dis,why,act)=>{const l=mk("label","cds-check");const r=document.createElement("input");r.type="radio";r.name=name;r.checked=on;r.disabled=!!dis;r.onchange=act;l.appendChild(r);const s=mk("span","");s.appendChild(mk("b","",lab));if(why)s.appendChild(mk("small","muted",why));l.appendChild(s);return l};
74589
+ function draw(){box.innerHTML="";const t=getTicked();
74590
+ if(!(t.has("sms")||t.has("missed")||t.has("reactivation"))){box.hidden=true;return}box.hidden=false;
74591
+ box.appendChild(mk("h4","cds-panel-label","Confirm with your client before Build"));
74592
+ box.appendChild(mk("p","","Ask your client these, then choose. Nothing is built for a question left blank; it waits for your client's answer."));
74593
+ box.appendChild(mk("p","muted","The answers save with Save what they bought. Changing one after the plan is approved means approving it again."));
74594
+ const texting=a.texting||(ia.sms===true?"yes":ia.sms===false?"not_yet":"");
74595
+ const tx=mk("div","cds-stack");tx.appendChild(mk("b","","Texting"));tx.appendChild(mk("p","","Can this business text leads?"));
74596
+ if(!a.texting&&ia.sms!==undefined&&!d.change){const p=mk("p","","Your client's form said: "+(ia.sms?"Yes":"Not yet")+" ");const ch=mk("button","cds-link","Change");ch.type="button";ch.onclick=()=>{d.change=true;draw()};p.appendChild(ch);tx.appendChild(p)}
74597
+ else{const row=mk("div","cds-row");row.appendChild(radio("tx-"+id,"yes","Yes",a.texting==="yes",false,"",()=>{a.texting="yes";keep();draw()}));row.appendChild(radio("tx-"+id,"not_yet","Not yet",a.texting==="not_yet",false,"",()=>{a.texting="not_yet";keep();draw()}));tx.appendChild(row)}
74598
+ if(ia.phone==="yes")tx.appendChild(mk("small","muted","Hint: your client's form said they have a phone number in GoHighLevel."));
74599
+ box.appendChild(tx);
74600
+ if(t.has("missed")){const ca=mk("div","cds-stack");ca.appendChild(mk("b","","Call answering"));ca.appendChild(mk("p","","What did your client pay for? Choose all that apply."));
74601
+ const cur=new Set(a.callAnswering||[]);
74602
+ const opt=(val,lab,sub,why,greyed)=>{const l=mk("label","cds-check");const c=document.createElement("input");c.type="checkbox";c.checked=cur.has(val);c.disabled=greyed&&!cur.has(val);c.onchange=()=>{if(c.checked)cur.add(val);else cur.delete(val);a.callAnswering=[...cur];keep();draw()};l.appendChild(c);const s=mk("span","");s.appendChild(mk("b","",lab));if(sub)s.appendChild(mk("small","",sub));if(why)s.appendChild(mk("small","muted",why));l.appendChild(s);ca.appendChild(l)};
74603
+ opt("live_voice","An AI answers the phone live","Your client connects their number to it afterwards. Once it is connected, GoHighLevel charges for every minute it talks, usually from your agency's wallet.","",false);
74604
+ opt("chat_bot","An AI replies by text or chat and books the visit","",texting==="yes"?"":"Texting replies need Texting: Yes.",false);
74605
+ opt("missed_text","A text back after a missed call","",texting==="yes"?"":"Needs texting: Yes",texting!=="yes");
74606
+ box.appendChild(ca)}
74607
+ if(t.has("reactivation")){const re=a.reactivation||(a.reactivation={});const rv=mk("div","cds-stack");rv.appendChild(mk("b","","Reactivation"));
74608
+ rv.appendChild(mk("p","","Who should it reach?"));
74609
+ rv.appendChild(radio("rl-"+id,"import","A list your client will give you to import",re.list==="import",false,"",()=>{re.list="import";keep();draw()}));
74610
+ /* rc.3 c6 hole 4: the number is in the sentence as text, and its box sits on its own labelled line. */
74611
+ const nDays=re.notBookedDays||90;
74612
+ rv.appendChild(radio("rl-"+id,"in_account_not_booked","Contacts already here who have not booked in "+nDays+" days",re.list==="in_account_not_booked",false,"",()=>{re.list="in_account_not_booked";if(!re.notBookedDays)re.notBookedDays=90;keep();draw()}));
74613
+ if(re.list==="in_account_not_booked"){const row=mk("label","cds-row");row.appendChild(mk("span","","Days with no booking: "));const days=document.createElement("input");days.type="number";days.min="1";days.max="3650";days.className="cds-input";days.style.width="6em";days.value=String(nDays);days.onchange=()=>{const n=Math.round(Number(days.value));if(n>=1&&n<=3650){re.notBookedDays=n;keep();draw()}else days.value=String(nDays)};row.appendChild(days);rv.appendChild(row);
74614
+ rv.appendChild(mk("small","muted","You will see how many people this reaches and approve it before anyone gets a message."))}
74615
+ rv.appendChild(radio("rl-"+id,"cold_from_now","Only leads who go cold from now on",re.list==="cold_from_now",false,"",()=>{re.list="cold_from_now";keep();draw()}));
74616
+ rv.appendChild(mk("p","","What offer brings them back?"));
74617
+ /* W44: with no offer in the brief, typing one is the only choice, so it starts chosen. */
74618
+ if(!ia.offer&&!re.offer)re.offer={from:"typed",text:""};
74619
+ if(ia.offer)rv.appendChild(radio("ro-"+id,"brief","From the brief: \\u201c"+ia.offer+"\\u201d",re.offer&&re.offer.from==="brief",false,"",()=>{re.offer={from:"brief",text:ia.offer};keep();draw()}));
74620
+ const typed=radio("ro-"+id,"typed","Type one:",re.offer&&re.offer.from==="typed",false,"",()=>{re.offer={from:"typed",text:ot.value.trim()};keep();draw()});
74621
+ /* W45 (Jerry, Safari): inside the label the box drew as a tiny oval. It is its own full-width field under its choice. */
74622
+ const ot=document.createElement("textarea");ot.rows=2;ot.maxLength=300;ot.className="cds-input ask-offer";ot.style.width="100%";ot.style.boxSizing="border-box";ot.placeholder="e.g. a free skin check";ot.value=re.offer&&re.offer.from==="typed"?re.offer.text:"";ot.setAttribute("aria-label","The offer, in your client's words");ot.oninput=()=>{re._typed=ot.value.trim();re.offer={from:"typed",text:re._typed};keep();typed.querySelector("input").checked=true};
74623
+ rv.appendChild(typed);rv.appendChild(ot);
74624
+ rv.appendChild(mk("p","","How?"));
74625
+ const canText=texting==="yes"&&re.textConsentConfirmed===true;
74626
+ rv.appendChild(radio("rc-"+id,"email","Email",re.channel==="email",false,"",()=>{re.channel="email";keep();draw()}));
74627
+ rv.appendChild(radio("rc-"+id,"email_and_text","Email and text",re.channel==="email_and_text",!canText&&re.channel!=="email_and_text",texting!=="yes"?"Needs texting: Yes":re.textConsentConfirmed?"":"Needs your client to confirm these people agreed to get texts",()=>{re.channel="email_and_text";keep();draw()}));
74628
+ const cl=mk("label","cds-check");const cc=document.createElement("input");cc.type="checkbox";cc.checked=re.textConsentConfirmed===true;cc.onchange=()=>{if(cc.checked)re.textConsentConfirmed=true;else{delete re.textConsentConfirmed;if(re.channel==="email_and_text")re.channel="email"}keep();draw()};cl.appendChild(cc);cl.appendChild(mk("span","","My client confirmed these people agreed to get texts"));rv.appendChild(cl);
74629
+ box.appendChild(rv)}}
74630
+ draw();box.redraw=draw;return box}
74206
74631
  const BOUGHT_ITEMS=${scriptJson(boughtItemsFor(FEATURE_IDS))};
74207
74632
  function scopeChosen(state,id){const s=(state.scope||{})[id];const v=(state.boughtViews||{})[id];return Boolean(s&&(s.wholePreset||(Array.isArray(s.features)&&s.features.length)))&&!(v&&v.ready===false)}
74208
74633
  function openWhatTheyBought(id){rOpen[id]=true;rSeen[id]=true;const go=()=>{render(lastState,lastRun);requestAnimationFrame(()=>{const t=document.getElementById("wtb-"+id);if(t){t.scrollIntoView({behavior:"smooth",block:"start"});const f=t.querySelector("button,input,select");if(f)f.focus({preventScroll:true})}})};if(!rData[id])rLoad(id).then(go,go);else go()}
@@ -74225,7 +74650,7 @@ function scopeBlock(id,state){
74225
74650
  const v=boughtView(state,id);
74226
74651
  if(!bsHideAssess)sc.appendChild(assessmentQuestion(id,v));
74227
74652
  sc.appendChild(mk("h4","cds-panel-label","What they bought"));
74228
- sc.appendChild(mk("p","",v.source==="all"?"Everything starts ticked. Untick what this client did not buy.":v.source==="assessment"?"Ticked from what the Assessment recorded as bought. Untick anything they did not buy.":"Tick what the client bought. Only the items marked Built get built."));
74653
+ sc.appendChild(mk("p","",v.source==="all"?"Everything starts ticked. Untick what this client did not buy.":v.source==="assessment"?"Ticked from what the Assessment recorded as bought. Untick anything they did not buy.":"Tick what the client bought. Only the items marked Will build get built."));
74229
74654
  if(!profCache){profLoad();const p=document.createElement("p");p.className="muted";p.textContent="Loading your packages\u2026";sc.appendChild(p);return sc}
74230
74655
  const saved=(state.scope||{})[id]||null;const pk=(profCache.packages||[]);
74231
74656
  const sel=document.createElement("select");sel.className="cds-select";sel.setAttribute("aria-label","Package they bought");
@@ -74236,17 +74661,17 @@ function scopeBlock(id,state){
74236
74661
  const keepScope=()=>{scopeDraft[id]={ticked:[...ticked],pkg:sel.value,base:base};unsaved.hidden=false};
74237
74662
  const everything=document.createElement("button");everything.className="cds-btn ghost";everything.textContent="Tick everything";
74238
74663
  everything.title="Ticks all fourteen. Nothing is saved until you press Save what they bought.";
74239
- everything.onclick=()=>{ticked=new Set(v.items.map(i=>i.id));keepScope();drawBoxes();drawFigs()};
74664
+ everything.onclick=()=>{ticked=new Set(v.items.map(i=>i.id));keepScope();drawBoxes();drawFigs();if(askEl)askEl.redraw()};
74240
74665
  sc.appendChild(everything);
74241
74666
  const boxes=document.createElement("div");boxes.className="cds-stack";
74242
74667
  function drawBoxes(){boxes.innerHTML="";for(const it of v.items){const l=document.createElement("label");l.className="cds-check";
74243
- l.dataset.k=it.id;const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(it.id);c.onchange=()=>{if(c.checked)ticked.add(it.id);else ticked.delete(it.id);keepScope();drawFigs()};l.appendChild(c);
74668
+ l.dataset.k=it.id;const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(it.id);c.onchange=()=>{if(c.checked)ticked.add(it.id);else ticked.delete(it.id);keepScope();drawFigs();if(askEl)askEl.redraw()};l.appendChild(c);
74244
74669
  const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=it.name;t.appendChild(tb);t.appendChild(mk("small","",it.means));
74245
- t.appendChild(mk("small","bought-delivery",it.delivery));if(it.next)t.appendChild(mk("small","bought-next",it.next));l.appendChild(t);boxes.appendChild(l)}}
74670
+ t.appendChild(mk("small","bought-delivery",it.delivery));if(it.next&&!(((asksDraft[id]&&asksDraft[id].a.callAnswering)||(v.asks||{}).callAnswering||[]).includes("live_voice")))t.appendChild(mk("small","bought-next",it.next));l.appendChild(t);boxes.appendChild(l)}}
74246
74671
  const figs=((state.assessmentFigures||{})[id])||{};
74247
74672
  function drawFigs(){for(const l of boxes.querySelectorAll("label")){const it=v.items.find(i=>i.id===l.dataset.k);const old=l.querySelector("em");if(old)old.remove();if(it&&it.feature&&figs[it.feature]){const e=document.createElement("em");e.className="cds-chip"+(ticked.has(it.id)?"":" warn");e.textContent="Assessment: "+figs[it.feature]+(ticked.has(it.id)?".":". Not ticked.");l.querySelector("span").appendChild(e)}}}
74248
74673
  /* A package names build features: it ticks their items and unticks the other built ones; the rest stay as they are. */
74249
- sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);if(p)for(const it of v.items){if(!it.feature)continue;if((p.features||[]).includes(it.feature))ticked.add(it.id);else ticked.delete(it.id)}keepScope();drawBoxes();drawFigs()};drawBoxes();drawFigs();
74674
+ sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);if(p)for(const it of v.items){if(!it.feature)continue;if((p.features||[]).includes(it.feature))ticked.add(it.id);else ticked.delete(it.id)}keepScope();drawBoxes();drawFigs();if(askEl)askEl.redraw()};let askEl=null;drawBoxes();drawFigs();askEl=asksBox(id,v,()=>ticked,keepScope);
74250
74675
  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. A ticked item that is not built automatically becomes one of your jobs in the account.";
74251
74676
  const sv=document.createElement("button");sv.className="cds-btn ghost";sv.textContent="Save what they bought";
74252
74677
  const status=document.createElement("p");status.className="muted";status.setAttribute("role","status");
@@ -74254,14 +74679,15 @@ function scopeBlock(id,state){
74254
74679
  if(!v.items.some(it=>it.kind==="built"&&ticked.has(it.id))){status.textContent="None of the ticked items is built for this client's industry. Tick at least one item marked Built.";return}
74255
74680
  /* Run 8 finding 9: about 5 seconds with nothing showing it was working. */
74256
74681
  sv.disabled=true;const was=sv.textContent;sv.textContent="Saving\u2026";status.textContent="Saving what they bought\u2026";
74257
- const body={locationId:id,bought:[...ticked]};if(sel.value)body.package=sel.value;const r=await postJSON("/api/scope",body);
74258
- if(r){unsaved.hidden=true;if(r.approvalWithdrawn)boughtNotice[id]=r.message;else delete boughtNotice[id];delete scopeDraft[id];delete assessmentCards[id];status.textContent="Saved.";load()}
74682
+ const body={locationId:id,bought:[...ticked]};if(sel.value)body.package=sel.value;if(asksDraft[id])body.asks=asksClean(asksDraft[id].a);const r=await postJSON("/api/scope",body);
74683
+ if(r){unsaved.hidden=true;delete asksDraft[id];if(r.approvalWithdrawn)boughtNotice[id]=r.message;else delete boughtNotice[id];delete scopeDraft[id];delete assessmentCards[id];status.textContent="Saved.";load()}
74259
74684
  if(sv.isConnected){sv.disabled=false;sv.textContent=was}};
74260
74685
  /* Audit 2026-10-28 #9: the always-built pipeline counts as built ("2 bought, 1 built" while it is always built). */
74261
- const nBought=v.ticks.length,nBuilt=v.items.filter(it=>(it.kind==="built"||it.kind==="always")&&v.ticks.includes(it.id)).length;
74262
- status.textContent=saved&&v.source==="saved"?"Saved: "+nBought+" of "+v.items.length+" bought, "+nBuilt+" of them built automatically"+(saved.package?(pk.find(x=>x.id===saved.package)?", from the "+pk.find(x=>x.id===saved.package).name+" package":", from a package no longer on Your agency"):"")+". The build follows this.":saved?(saved.wholePreset?"Saved: everything in the plan. The build follows this.":"Saved before this list: "+saved.features.length+" features. Save again to record what they bought."):"Not saved yet. Save what they bought before approving.";
74686
+ /* rc.3 c6 hole 2: the count follows the labels: what will be built (or was) and what waits on your client's answer. */
74687
+ const tickedItems=v.items.filter(it=>v.ticks.includes(it.id));const nBought=v.ticks.length,nBuilt=tickedItems.filter(it=>/^(Will build|Built|Always built): /.test(it.delivery)).length,nHeld=tickedItems.filter(it=>/^(Waiting for your client's answer|Not built, your client said not yet|Not built yet, chosen and coming in a later update): /.test(it.delivery)).length;
74688
+ status.textContent=saved&&v.source==="saved"?"Saved: "+nBought+" of "+v.items.length+" bought. "+nBuilt+(nBuilt===1?" is":" are")+" built automatically"+(nHeld?", "+nHeld+(nHeld===1?" waits":" wait")+" for your client's answer":"")+(saved.package?(pk.find(x=>x.id===saved.package)?", from the "+pk.find(x=>x.id===saved.package).name+" package":", from a package no longer on Your agency"):"")+". The build follows this.":saved?(saved.wholePreset?"Saved: everything in the plan. The build follows this.":"Saved before this list: "+saved.features.length+" features. Save again to record what they bought."):"Not saved yet. Save what they bought before approving.";
74263
74689
  const unsaved=mk("span","cds-chip warn","Not saved yet");unsaved.hidden=!scopeDraft[id];
74264
- sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(unsaved);sc.appendChild(status);
74690
+ sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(askEl);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(unsaved);sc.appendChild(status);
74265
74691
  if(boughtNotice[id]){const n=mk("p","cds-callout warn",boughtNotice[id]);n.setAttribute("role","alert");sc.appendChild(n)}
74266
74692
  return sc}
74267
74693
  async function rLoad(id){const tok=clientToken();const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;renderFor(tok)}
@@ -74290,6 +74716,10 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
74290
74716
  rBusy[id]=false;load()};
74291
74717
  if(busy){const p=document.createElement("p");p.className="muted";
74292
74718
  p.textContent="Reading their answers and writing the review, usually 1 to 2 minutes. This panel updates itself when it is ready.";d.appendChild(p)}
74719
+ /* rc.3 c6 hole 1: a review written before the answers changed may promise what the build will not make. */
74720
+ if(r&&r.ok&&r.stale&&!busy){const w=mk("p","cds-callout warn","This review was written before your client's answers or what they bought changed, so it may promise what this build will not make. Press Regenerate before you share it; the client document waits for the new one.");w.setAttribute("role","alert");d.appendChild(w)}
74721
+ if(r&&r.ok&&!r.stale&&r.graded!==true&&!busy){const w=mk("p","cds-callout warn","Not checked for over-promises. Regenerate.");w.setAttribute("role","alert");d.appendChild(w)}
74722
+ if(r&&r.ok&&Array.isArray(r.trimmed)&&r.trimmed.length&&!busy)d.appendChild(mk("p","muted","Taken out because they promised more than this build makes: "+r.trimmed.join("; ")+"."));
74293
74723
  if(!r||!r.ok){if(!busy){const p=document.createElement("p");if(r&&r.error&&r.error!=="not generated yet"){p.className="cds-callout warn";p.setAttribute("role","alert")}else p.className="muted";
74294
74724
  p.textContent=r&&r.error?r.error:"Turn their intake answers into strategy: who they really sell to, whether the offer holds up, what the account is missing, and what to build first.";
74295
74725
  d.appendChild(p)}
@@ -74316,15 +74746,16 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
74316
74746
  if((r.gaps||[]).length){const s3=document.createElement("div");s3.className="cds-stack";const gh=document.createElement("h4");gh.className="cds-panel-label";gh.textContent="What's missing ("+r.gaps.length+")";s3.appendChild(gh);
74317
74747
  const gl=mk("ul","cds-list gaps");
74318
74748
  for(const g of r.gaps){const gg=mk("li","gap "+(g.priority||"soon"));
74319
- const pri=g.priority||"soon";const gt=mk("span","ttl",String(g.title||""));gt.appendChild(mk("span","sub",moneyText(joinSentences(g.cost,g.fix))));gg.appendChild(gt);
74749
+ const pri=g.priority||"soon";const gt=mk("span","ttl",String(g.title||""));gt.appendChild(mk("span","sub",moneyText(String(g.cost||""))));/* rc.5: the fix is never the model's words; what we build is listed below, from the build */gg.appendChild(gt);
74320
74750
  const ge=mk("span","end");ge.appendChild(chip(pri==="now"?"warn":"",pri==="now"?"Fix now":pri==="later"?"Later":"Soon"));gg.appendChild(ge);gl.appendChild(gg)}
74321
74751
  s3.appendChild(gl);
74322
74752
  d.appendChild(s3)}
74323
- if((r.build||[]).length){const s4=document.createElement("div");s4.className="cds-stack";s4.innerHTML="<h4 class='cds-panel-label'>What we build</h4>";
74753
+ /* rc.5 (c6 ruling): What we build is the build's own will-build list and held lines, never the model's prose. */
74754
+ {const pr=r.promises;if(pr&&((pr.willBuild||[]).length||(pr.held||[]).length)){const s4=document.createElement("div");s4.className="cds-stack";s4.innerHTML="<h4 class='cds-panel-label'>What we build</h4>";
74324
74755
  const bl=document.createElement("ul");bl.className="cds-list";
74325
- for(const b of r.build){const bb=document.createElement("li");bb.className="buildline";
74326
- const bt2=mk("span","ttl",String(b.item||""));bt2.appendChild(mk("span","sub",moneyText(b.because||"")));bb.appendChild(bt2);bl.appendChild(bb)}
74327
- s4.appendChild(bl);d.appendChild(s4)}
74756
+ for(const w of (pr.willBuild||[])){const bb=document.createElement("li");bb.className="buildline";bb.appendChild(mk("span","ttl",String(w)));bl.appendChild(bb)}
74757
+ for(const h of (pr.held||[])){const bb=document.createElement("li");bb.className="buildline";bb.appendChild(mk("span","ttl muted",String(h)));bl.appendChild(bb)}
74758
+ s4.appendChild(bl);d.appendChild(s4)}}
74328
74759
  if((r.unknowns||[]).length){const s5=document.createElement("div");s5.className="cds-stack";s5.innerHTML="<h4 class='cds-panel-label'>Ask them next</h4>";
74329
74760
  const p=document.createElement("p");p.textContent=r.unknowns.join(" \xB7 ");s5.appendChild(p);d.appendChild(s5)}
74330
74761
  d.appendChild(scopeBlock(id,state));
@@ -75407,7 +75838,9 @@ function renderCapability(m,state,run){
75407
75838
  if(CAP.key==="intake"){const go=mk("a","cds-btn cap-build-account","Build this client's account");end.appendChild(asClientLink(go,"/new",c.locationId))}
75408
75839
  m.appendChild(screenHead(CAP.label,CAP.what,CAP.groupLabel,end));
75409
75840
  const sc=document.getElementById("side-client");if(sc)sc.textContent=c.name;
75410
- if(CAP.key==="intake"){renderIntakeCapability(m,c);return}
75841
+ /* W43 (Jerry's watched walk, 2026-10-29): he saw no Build button on the page Your clients leads to. The header carries
75842
+ one; this puts the next step at the top of the page itself, where nobody can miss it. */
75843
+ if(CAP.key==="intake"){const nx=mk("div","cds-callout cap-next-build");nx.appendChild(mk("span","ttl","Next step: build this client's account"));nx.appendChild(mk("span","","Answers, approval and the build itself are on the build page."));const go=mk("a","cds-btn cap-build-account-main","Build this client's account");nx.appendChild(asClientLink(go,"/new",c.locationId));m.appendChild(nx);renderIntakeCapability(m,c);return}
75411
75844
  const built=capBuiltItems(view,CAP.key);
75412
75845
  const toCheck=built.filter(x=>x.status==="uncertain");
75413
75846
  const health=HEALTH_PAGES.includes(CAP.key);
@@ -75448,7 +75881,25 @@ function renderCapability(m,state,run){
75448
75881
  if(health)m.appendChild(capOwedPanel(owed,(state.jobCounts||{})[c.locationId],((view||{}).handoff||{}).lines))}
75449
75882
  /* What this page reads live: one list for one read. With several reads, a read of 3 or fewer shows its rows under its own label,
75450
75883
  and a longer one folds under a closed "See the N \u2026". A count read is one row: the number. */
75884
+ /* Sign-off and handoff (command-center 2026-10-29): read from this client's own build record, the same one the build
75885
+ steps show (F2): what is Built, what waits on the client (the held lines), and the jobs still open. Never GoHighLevel's
75886
+ words for it, and never a claim the record does not hold. */
75887
+ const HELD_RE=/^(Waiting for your client's answer|Not built, your client said not yet|Not built yet, chosen and coming in a later update): /;
75888
+ function capHandoffRecord(card,c){
75889
+ const v=((lastState&&lastState.boughtViews)||{})[c.locationId];
75890
+ if(!v){card.appendChild(mk("p","muted","Nothing is on record for this client yet."));return}
75891
+ const ticked=new Set(v.ticks||[]);const items=(v.items||[]).filter(i=>ticked.has(i.id));
75892
+ const built=items.filter(i=>/^Built: /.test(i.delivery)).map(i=>i.delivery.replace(/^Built: /,""));
75893
+ const waiting=items.filter(i=>HELD_RE.test(i.delivery)).map(i=>i.delivery);
75894
+ const open=(((lastState&&lastState.manualTasks)||{})[c.locationId]||[]).filter(t=>!t.claimed&&!HELD_RE.test(String(t.text))).map(t=>String(t.text));
75895
+ const sec=(title,list,none)=>{const b=mk("div","cap-read");b.appendChild(mk("b","",title));if(!list.length)b.appendChild(mk("p","muted",none));else{const u=mk("ul","");for(const x of list.slice(0,8))u.appendChild(mk("li","",x));if(list.length>8)u.appendChild(mk("li","muted","and "+(list.length-8)+" more"));b.appendChild(u)}card.appendChild(b)};
75896
+ card.appendChild(mk("p","muted","From this client's build record on this computer, the same record the build steps show."));
75897
+ sec("Built",built,"Nothing is recorded as built yet.");
75898
+ sec("Waiting on your client",waiting,"Nothing is waiting on your client.");
75899
+ sec("Still open",open,"No open jobs.");
75900
+ }
75451
75901
  function capReadsInto(card,c,hasBuild,foldAll){
75902
+ if(CAP.key==="handoff"){capHandoffRecord(card,c);return}
75452
75903
  if(!CAP.reads.length){card.appendChild(capEmpty("This page does not read the account yet","Say the words below to Claude, and it reads what this account has for this job, live."));return}
75453
75904
  if(capReadsFor!==c.locationId){card.appendChild(mk("p","muted","Reading this account\u2026"));loadCapReads(c.locationId);return}
75454
75905
  if(!capReads){card.appendChild(mk("p","muted","Reading this account\u2026"));return}
@@ -76995,10 +77446,11 @@ async function runDashboard(argv) {
76995
77446
  const hit = await pickIntakeContact(c, locationId2, byId2, snap.contactId, INTAKE_FORM_NAME2);
76996
77447
  const completed = st0.intakeCompleted?.[locationId2]?.answers;
76997
77448
  const answers = Object.fromEntries(Object.entries(completed ?? normalizedIntake({ answers: hit?.answers ?? {}, answeredAt: snap.answeredAt }, st0.intakeDrafts?.[locationId2])).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v]));
76998
- return generateReview(
77449
+ const review = await generateReview(
76999
77450
  (prompt) => callClaudeRecorded(claudeBin(), ["-p", prompt, "--allowedTools", "", "--disallowedTools", `Bash,Write,Edit,WebFetch,WebSearch,Task,Read,Glob,Grep,${MCP_DENY}`, "--max-turns", "6", ...NO_MCP_ARGS], { locationId: locationId2, stage: 1, kind: "review" }),
77000
- { clientName, industry: st0.accounts?.[locationId2]?.industry, answers, ...st0.assessmentAttachments?.[locationId2] ? { assessmentLines: findingLines(st0.assessmentAttachments[locationId2]) } : {} }
77451
+ { clientName, industry: st0.accounts?.[locationId2]?.industry, answers, ...st0.assessmentAttachments?.[locationId2] ? { assessmentLines: findingLines(st0.assessmentAttachments[locationId2]) } : {}, promises: reviewPromisesFor(st0, locationId2, locationId2) }
77001
77452
  );
77453
+ return review.ok ? { ...review, promisesHash: contentHash2(review.promises ?? reviewPromisesFor(st0, locationId2, locationId2)) } : review;
77002
77454
  };
77003
77455
  const currentRoster = () => {
77004
77456
  const registered = registry2.listLocations();
@@ -77652,7 +78104,12 @@ ${opportunityNoteFailure?.detail ?? ""}`);
77652
78104
  const assessmentFigures = {};
77653
78105
  for (const loc of Object.keys(st.assessmentAttachments ?? {})) assessmentFigures[loc] = assessmentFiguresFor(st, loc);
77654
78106
  const scopeOffered = Object.fromEntries(rosterNow.map((c) => [c.locationId, offeredFeaturesFor(st, c.locationId)]));
77655
- const boughtViews = Object.fromEntries(rosterNow.map((c) => [c.locationId, boughtViewFor(st, c.locationId, scopeOffered[c.locationId], boughtWordingFor(st, c.locationId))]));
78107
+ const boughtViews = Object.fromEntries(rosterNow.map((c) => [c.locationId, {
78108
+ ...boughtViewFor(st, c.locationId, scopeOffered[c.locationId], boughtWordingFor(st, c.locationId), buildStatusFor(st, c.locationId, c.locationId)),
78109
+ // Ask first (2026-10-29): the saved answers and what the client's own form said, for the questions box.
78110
+ asks: st.asks?.[c.locationId] ?? {},
78111
+ intakeAsks: intakeAskFacts(st.intakeCompleted?.[c.locationId]?.answers ?? st.intakeInputs?.[c.locationId]?.answers)
78112
+ }]));
77656
78113
  const buildViews = Object.fromEntries(rosterNow.map((c) => {
77657
78114
  const saved = readSavedPlan(c.locationId);
77658
78115
  const approval = st.planApprovals?.[c.locationId];
@@ -77926,7 +78383,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
77926
78383
  if (stage === 3) {
77927
78384
  assertStaffUnchanged(st0.planApprovals?.[rk], st0.staffRoster?.[locationId2]);
77928
78385
  const sp = readSavedPlan(rk);
77929
- if (!sp || !savedPlanMatchesScope(sp, st0.scope?.[rk])) throw new Error("The features selected for this client changed after its plan was prepared. Prepare the plan again, approve it, then build.");
78386
+ if (!sp || !savedPlanFitsAnswers(sp, st0, rk)) throw new Error("The features selected for this client, or the answers about them, changed after its plan was prepared. Prepare the plan again, approve it, then build.");
77930
78387
  }
77931
78388
  if (stage === 4) {
77932
78389
  const build = st0.outcomes?.[`${rk}:3`];
@@ -78651,9 +79108,19 @@ ${opportunityNoteFailure?.detail ?? ""}`);
78651
79108
  res.end("No review generated for this client yet.");
78652
79109
  return;
78653
79110
  }
79111
+ if (reviewIsStale(st, locationId2, review)) {
79112
+ res.writeHead(409, { "Content-Type": "text/plain; charset=utf-8" });
79113
+ res.end("This client's Blueprint Review was written before their answers or what they bought changed. Press Regenerate on the review, then open the client document again.");
79114
+ return;
79115
+ }
79116
+ if (review.graded !== true) {
79117
+ res.writeHead(409, { "Content-Type": "text/plain; charset=utf-8" });
79118
+ res.end("This client's Blueprint Review was not checked for over-promises. Press Regenerate on the review, then open the client document again.");
79119
+ return;
79120
+ }
78654
79121
  const name2 = clientDisplayName(st, locationId2, currentRoster().find((r) => r.locationId === locationId2)?.name || locationId2);
78655
79122
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
78656
- res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2, readSavedPlan(locationId2)), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), bought: boughtDocumentFor(st, locationId2, offeredFeaturesFor(st, locationId2), boughtWordingFor(st, locationId2)), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name2, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
79123
+ res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2, readSavedPlan(locationId2)), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), bought: boughtDocumentFor(st, locationId2, offeredFeaturesFor(st, locationId2), boughtWordingFor(st, locationId2), buildStatusFor(st, locationId2, locationId2)), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name2, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
78657
79124
  return;
78658
79125
  }
78659
79126
  if (req.method === "GET" && url === "/api/review") {
@@ -78661,7 +79128,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
78661
79128
  res.writeHead(200, { "Content-Type": "application/json" });
78662
79129
  const stR = readCockpitState();
78663
79130
  const businessName = clientDisplayName(stR, locationId2, currentRoster().find((r) => r.locationId === locationId2)?.name ?? "");
78664
- res.end(JSON.stringify({ ...stR.reviews?.[locationId2] ?? { ok: false, error: "not generated yet" }, ...businessName ? { businessName } : {} }));
79131
+ const savedReview = stR.reviews?.[locationId2];
79132
+ res.end(JSON.stringify({ ...savedReview ?? { ok: false, error: "not generated yet" }, ...businessName ? { businessName } : {}, ...reviewIsStale(stR, locationId2, savedReview) ? { stale: true } : {} }));
78665
79133
  return;
78666
79134
  }
78667
79135
  if (req.method === "POST" && url === "/api/shared-adopt") {
@@ -79205,7 +79673,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
79205
79673
  if (req.method === "POST" && url === "/api/scope") {
79206
79674
  readBody(req, res, 1e4, (body) => {
79207
79675
  try {
79208
- const { locationId: locationId2, clientKey, package: pkg2, features, bought } = JSON.parse(body);
79676
+ const { locationId: locationId2, clientKey, package: pkg2, features, bought, asks: asksIn } = JSON.parse(body);
79209
79677
  const who2 = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
79210
79678
  let ticks;
79211
79679
  let derived;
@@ -79229,21 +79697,36 @@ ${opportunityNoteFailure?.detail ?? ""}`);
79229
79697
  else ticks = savedTicks[rk];
79230
79698
  nextState = { ...nextState, bought: savedTicks };
79231
79699
  if (!Object.keys(savedTicks).length) delete nextState.bought;
79700
+ const intakeSms = intakeChannelAnswers(nextState.intakeCompleted?.[who2.locationId]?.answers ?? nextState.intakeInputs?.[who2.locationId]?.answers).sms;
79701
+ if (asksIn !== void 0) {
79702
+ const parsed2 = parseAsks(asksIn);
79703
+ if (!parsed2.ok) throw new Error(parsed2.error);
79704
+ const clash = asksContradiction(parsed2.asks, effectiveTexting(parsed2.asks, intakeSms));
79705
+ if (clash) throw new Error(clash);
79706
+ const all = { ...nextState.asks ?? {} };
79707
+ if (Object.keys(parsed2.asks).length) all[rk] = parsed2.asks;
79708
+ else delete all[rk];
79709
+ nextState = { ...nextState, asks: all };
79710
+ if (!Object.keys(all).length) delete nextState.asks;
79711
+ }
79232
79712
  const { state: bound, withdrawn } = withdrawIfBindingChanged(nextState, rk);
79233
79713
  nextState = bound;
79234
79714
  {
79235
79715
  const offered = offeredFeaturesFor(nextState, rk);
79236
79716
  const channels = intakeChannelAnswers(nextState.intakeCompleted?.[who2.locationId]?.answers ?? nextState.intakeInputs?.[who2.locationId]?.answers);
79717
+ const texting = effectiveTexting(nextState.asks?.[rk], channels.sms);
79237
79718
  const scopeNow = nextState.scope?.[rk];
79238
79719
  const excluded = ticks ? boughtButExcludedJobs(ticks, offered, !scopeNow || scopeNow.wholePreset ? "all" : scopeNow.features ?? []) : [];
79239
- const boughtList = ticks ? [...boughtJobs(ticks, offered, channels), ...excluded] : [];
79720
+ const held2 = ticks ? heldJobs(heldItems(ticks, nextState.asks?.[rk], texting)) : [];
79721
+ const boughtList = ticks ? [...boughtJobs(ticks, offered, { ...channels, sms: texting === "yes" }), ...excluded, ...held2] : [];
79240
79722
  replaceBuildObligationsOfType(rk, BOUGHT_JOB_TYPE, boughtList);
79241
79723
  const tasks2 = withBoughtJobs(nextState.manualTasks?.[rk] ?? [], ticks ? catalogJobs({ handoffs: [], obligations: boughtList }) : []);
79242
79724
  if (tasks2.length || nextState.manualTasks?.[rk]) nextState = { ...nextState, manualTasks: { ...nextState.manualTasks, [rk]: tasks2 } };
79243
79725
  }
79244
79726
  writeCockpitState(nextState);
79245
79727
  res.writeHead(200, { "Content-Type": "application/json" });
79246
- res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[rk] ?? null, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_SCOPE } : {} }));
79728
+ const builtBefore = nextState.outcomes?.[`${rk}:3`]?.ok === true;
79729
+ res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[rk] ?? null, ...withdrawn ? { approvalWithdrawn: true, message: builtBefore && asksIn !== void 0 ? BUILD_WHAT_WAS_WAITING : APPROVAL_WITHDRAWN_FOR_SCOPE } : {} }));
79247
79730
  } catch (e) {
79248
79731
  res.writeHead(400, { "Content-Type": "application/json" });
79249
79732
  res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
@@ -79434,7 +79917,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
79434
79917
  process.on("SIGTERM", stop3);
79435
79918
  });
79436
79919
  }
79437
- var import_zod94, import_node_util5, fs53, path53, http2, import_child_process4, import_child_process5, osmod, VIEWER_COCKPIT_MSG, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, stateRecovery, stateSaveFs, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, TEXT_SNIPPETS_LOCKED, SCOPE_REFUSAL, APPROVAL_WITHDRAWN_FOR_STAFF, APPROVAL_WITHDRAWN_NO_TEAM, HANDOFF_WORKFLOW, NOTHING_BOUGHT, NOTHING_BUILT_FROM_TICKS, ASSESSMENT_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_ASSESSMENT, APPROVE_NEEDS_INTAKE, APPROVE_NEEDS_BRIEF, INDUSTRY_FIRST, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, INACTIVE, OFF_LICENSE, LICENSE_RECHECK_MS, activeRun2, runStarting, buildOneTime, oneTimeView, runForTests, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, queueScanWatch, queueScanOnce, ASSESSMENT_PIPELINE_MISSING, GATE_BODY, GATE_BODY_MAX, DOC_NOT_SAVED, NO_KEY_ON_FILE, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_READ_TRANSIENT, INTAKE_REFRESH_RETRYING, ACCOUNT_CHANGED_DURING_READ, commitWithoutRead, SNAPSHOTS_CHANGED_DURING_READ, IntakeColdError, INTAKE_ANSWERS_MISSING, INTAKE_READ_FAILED, RUN_IN_PROGRESS, BRIEF_NEEDS_INTAKE2, BUSINESS_TYPE_FOR_INDUSTRY, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, NO_SUCH_PAGE, CHOOSE_CLIENT, ONE_CLIENT_ONLY, anthropicKeyWrites, SEAT_TOKEN_HEADER, notSignedInMsg, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, COCKPIT_AFTER_CANCEL_PATHS, licenceRefusedHooks, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
79920
+ var import_zod94, import_node_util5, fs53, path53, http2, import_child_process4, import_child_process5, osmod, VIEWER_COCKPIT_MSG, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, stateRecovery, stateSaveFs, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, TEXT_SNIPPETS_LOCKED, SCOPE_REFUSAL, APPROVAL_WITHDRAWN_FOR_STAFF, APPROVAL_WITHDRAWN_NO_TEAM, HANDOFF_WORKFLOW, NOTHING_BOUGHT, NOTHING_BUILT_FROM_TICKS, ASSESSMENT_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_ASSESSMENT, APPROVE_NEEDS_INTAKE, APPROVE_NEEDS_BRIEF, INDUSTRY_FIRST, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, BUILD_WHAT_WAS_WAITING, INACTIVE, OFF_LICENSE, LICENSE_RECHECK_MS, activeRun2, runStarting, buildOneTime, oneTimeView, runForTests, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, queueScanWatch, queueScanOnce, ASSESSMENT_PIPELINE_MISSING, GATE_BODY, GATE_BODY_MAX, DOC_NOT_SAVED, NO_KEY_ON_FILE, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_READ_TRANSIENT, INTAKE_REFRESH_RETRYING, ACCOUNT_CHANGED_DURING_READ, commitWithoutRead, SNAPSHOTS_CHANGED_DURING_READ, IntakeColdError, INTAKE_ANSWERS_MISSING, INTAKE_READ_FAILED, RUN_IN_PROGRESS, BRIEF_NEEDS_INTAKE2, BUSINESS_TYPE_FOR_INDUSTRY, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, NO_SUCH_PAGE, CHOOSE_CLIENT, ONE_CLIENT_ONLY, anthropicKeyWrites, SEAT_TOKEN_HEADER, notSignedInMsg, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, COCKPIT_AFTER_CANCEL_PATHS, licenceRefusedHooks, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
79438
79921
  var init_dashboard = __esm({
79439
79922
  "src/dashboard.ts"() {
79440
79923
  "use strict";
@@ -79574,6 +80057,7 @@ var init_dashboard = __esm({
79574
80057
  init_daily_brief2();
79575
80058
  init_daily_brief_collect();
79576
80059
  init_brief_deadline();
80060
+ init_client_asks();
79577
80061
  VIEWER_COCKPIT_MSG = "Your team login on this license is view only, and Command OS runs work that changes accounts, so it did not open. Ask the license owner to make you a team member, then open Command OS again.";
79578
80062
  STAGES2 = [
79579
80063
  "Intake",
@@ -79608,6 +80092,7 @@ var init_dashboard = __esm({
79608
80092
  NOTHING_TICKED_IN_PLAN = "None of the ticked features is in the plan for this client's industry, so nothing was saved. Tick features from the list, or choose Everything in the plan.";
79609
80093
  SCOPE_CHANGED_SINCE_APPROVAL = "What they bought changed after the build was approved, so Build did not start. Approve this plan again, then build.";
79610
80094
  APPROVAL_WITHDRAWN_FOR_SCOPE = "What they bought changed, so the build approval was withdrawn. Approve this plan again before building.";
80095
+ BUILD_WHAT_WAS_WAITING = "Your client's answers changed what gets built, so the build approval was withdrawn. Approve the updated plan, then run Build: it adds only what was waiting and makes nothing twice.";
79611
80096
  INACTIVE = "License is not active. Contact support@ghlcommand.com.";
79612
80097
  OFF_LICENSE = "This computer is no longer on your license. Free a device on your license, then restart Command OS.";
79613
80098
  LICENSE_RECHECK_MS = 4 * 60 * 60 * 1e3;
@@ -91514,18 +91999,22 @@ function claimBuildLock(locationId2, base) {
91514
91999
  }
91515
92000
  return null;
91516
92001
  }
91517
- function takeOver(lock, seen) {
92002
+ function takeOver(lock, seen, retried = false) {
91518
92003
  const mutex = `${lock}.takeover`;
91519
92004
  let fd;
91520
92005
  try {
91521
92006
  fd = fs63.openSync(mutex, "wx", 384);
91522
92007
  } catch (e) {
91523
92008
  if (e.code !== "EEXIST") throw e;
92009
+ let aged = false;
91524
92010
  try {
91525
- if (Date.now() - fs63.statSync(mutex).mtimeMs > TAKEOVER_STALE_MS) fs63.unlinkSync(mutex);
92011
+ if (Date.now() - fs63.statSync(mutex).mtimeMs > TAKEOVER_STALE_MS) {
92012
+ fs63.unlinkSync(mutex);
92013
+ aged = true;
92014
+ }
91526
92015
  } catch {
91527
92016
  }
91528
- return false;
92017
+ return aged && !retried ? takeOver(lock, seen, true) : false;
91529
92018
  }
91530
92019
  try {
91531
92020
  try {
@@ -94598,6 +95087,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
94598
95087
  const recordKey = clientRecordKey(activeLocation, clientKey);
94599
95088
  let planArg = planArgIn;
94600
95089
  let composed;
95090
+ let asksHash;
94601
95091
  if (fromPreset) {
94602
95092
  if (planArg) {
94603
95093
  return jsonResponse({ ok: false, phase: "compose", error: "fromPreset composes the plan itself \u2014 pass brief (and optionally preset / copy), not a plan. To run a hand-authored plan, drop fromPreset." });
@@ -94655,6 +95145,12 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
94655
95145
  if (!chosen.ok) {
94656
95146
  return jsonResponse({ ok: false, phase: "compose", error: chosen.error });
94657
95147
  }
95148
+ {
95149
+ const board = readBoardState();
95150
+ const ticks = board.bought?.[recordKey];
95151
+ briefForCompose = briefWithAsks(briefForCompose, board.asks?.[recordKey], Array.isArray(ticks) ? ticks : void 0, chosen.preset.skeleton?.workflows ?? []);
95152
+ asksHash = asksBindingHash(board.asks?.[recordKey], Array.isArray(ticks) ? ticks : void 0);
95153
+ }
94658
95154
  const composition = planFromPreset({ preset: chosen.preset, brief: briefForCompose, locationId: activeLocation, copy: copyArg });
94659
95155
  if (!composition.ok) {
94660
95156
  return jsonResponse({
@@ -94785,7 +95281,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
94785
95281
  const { name: locationName, timeZone: locationTimeZone } = await readLocationBasics(client, activeLocation);
94786
95282
  const { assets, warnings: scanWarnings } = await scanExistingAssets(client, activeLocation, registry2);
94787
95283
  const result = resolvePlan(typedPlan, assets, { metHandoffs });
94788
- const clientFields = { ...clientKey ? { clientKey } : {}, ...clinicPrefix ? { clinicPrefix } : {}, ...locationTimeZone ? { locationTimeZone } : {} };
95284
+ const clientFields = { ...clientKey ? { clientKey } : {}, ...asksHash ? { asksHash } : {}, ...clinicPrefix ? { clinicPrefix } : {}, ...locationTimeZone ? { locationTimeZone } : {} };
94789
95285
  if (clinicPrefix && clinicScoping) {
94790
95286
  let checked = false;
94791
95287
  let findings = [];
@@ -95749,6 +96245,7 @@ var init_intake_to_build = __esm({
95749
96245
  init_claims_guard();
95750
96246
  init_plan_from_preset();
95751
96247
  init_board_roster();
96248
+ init_client_asks();
95752
96249
  init_features();
95753
96250
  init_executor();
95754
96251
  init_build_confirm();