@elitedcs/ghl-mcp 3.89.0-beta.2 → 3.89.0-beta.3

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
@@ -25006,7 +25006,7 @@ var init_question_set = __esm({
25006
25006
  field: { kind: "custom", dataType: "LARGE_TEXT" },
25007
25007
  placeholder: "The problems they come to you with (one per line)",
25008
25008
  briefPath: "audience.painPoints",
25009
- briefKind: "list"
25009
+ briefKind: "textList"
25010
25010
  },
25011
25011
  {
25012
25012
  key: "objections",
@@ -25017,7 +25017,7 @@ var init_question_set = __esm({
25017
25017
  field: { kind: "custom", dataType: "LARGE_TEXT" },
25018
25018
  placeholder: "Why prospects hesitate (one per line)",
25019
25019
  briefPath: "audience.objections",
25020
- briefKind: "list"
25020
+ briefKind: "textList"
25021
25021
  },
25022
25022
  // ── Section D: Goal and sales process ──
25023
25023
  {
@@ -30420,6 +30420,9 @@ function findingsFor(outcome) {
30420
30420
  const list4 = outcome.plain?.issues?.length ? outcome.plain.issues : outcome.issues;
30421
30421
  return (list4 ?? []).map((s) => String(s).trim()).filter(Boolean);
30422
30422
  }
30423
+ function openTasks(tasks2) {
30424
+ return tasks2.filter((t) => !(t.claimed === true && typeof t.text === "string" && SELF_REPORTED.test(t.text)));
30425
+ }
30423
30426
  function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISOString()) {
30424
30427
  const before = new Map(previous.map((t) => [t.id, t]));
30425
30428
  const seen = /* @__PURE__ */ new Set();
@@ -30429,7 +30432,9 @@ function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISO
30429
30432
  if (seen.has(id)) continue;
30430
30433
  seen.add(id);
30431
30434
  const prior = before.get(id);
30432
- if (prior?.claimed) {
30435
+ if (prior?.claimed && SELF_REPORTED.test(text2)) {
30436
+ out.push(prior);
30437
+ } else if (prior?.claimed) {
30433
30438
  out.push({ id, text: text2, reappeared: true });
30434
30439
  } else {
30435
30440
  out.push({ id, text: text2, ...prior?.reappeared ? { reappeared: true } : {} });
@@ -30447,7 +30452,7 @@ function manualProgress(tasks2, verifyHasRun) {
30447
30452
  const total = tasks2.length;
30448
30453
  const claimed = tasks2.filter((t) => t.claimed).length;
30449
30454
  const reappeared = tasks2.filter((t) => t.reappeared).length;
30450
- const clear = verifyHasRun && total === 0;
30455
+ const clear = verifyHasRun && openTasks(tasks2).length === 0;
30451
30456
  let line3;
30452
30457
  if (!verifyHasRun) {
30453
30458
  line3 = "Run the check first. It writes this list for you.";
@@ -30481,9 +30486,11 @@ function manualSignOffRefusal(tasks2, verifyHasRun) {
30481
30486
  }
30482
30487
  return "Everything is ticked, but the check has not confirmed it yet. Run the check again and this will clear on its own.";
30483
30488
  }
30489
+ var SELF_REPORTED;
30484
30490
  var init_manual_tasks = __esm({
30485
30491
  "src/manual-tasks.ts"() {
30486
30492
  "use strict";
30493
+ SELF_REPORTED = /^[^:]+: Bought, but not built automatically yet\./;
30487
30494
  }
30488
30495
  });
30489
30496
 
@@ -30665,7 +30672,7 @@ function deriveQueue(input) {
30665
30672
  if (stages.every((s) => s === "done")) {
30666
30673
  const proof2 = lastProof(state, locationId2, QUEUE_STEPS.length);
30667
30674
  const claim = liveClaim(true, state.goLive?.[locationId2], state.outcomes?.[`${locationId2}:3`]?.at);
30668
- const signedLive = claim.live && state.outcomes?.[`${locationId2}:4`]?.ok === true && !state.outcomes?.[`${locationId2}:4`]?.issues?.length && !state.manualTasks?.[locationId2]?.length && !state.staleClients?.includes(locationId2);
30675
+ const signedLive = claim.live && state.outcomes?.[`${locationId2}:4`]?.ok === true && !state.outcomes?.[`${locationId2}:4`]?.issues?.length && !openTasks(state.manualTasks?.[locationId2] ?? []).length && !state.staleClients?.includes(locationId2);
30669
30676
  finished.push({
30670
30677
  locationId: locationId2,
30671
30678
  clientName: name,
@@ -30799,6 +30806,7 @@ var init_work_queue = __esm({
30799
30806
  "use strict";
30800
30807
  init_stage_runner();
30801
30808
  init_plain_outcome();
30809
+ init_manual_tasks();
30802
30810
  init_go_live();
30803
30811
  QUEUE_STEPS = [
30804
30812
  { label: "Intake", todo: "Set up the intake questions for {client}", why: "Command OS puts the questionnaire into their account." },
@@ -32494,7 +32502,7 @@ var require_package = __commonJS({
32494
32502
  "package.json"(exports2, module2) {
32495
32503
  module2.exports = {
32496
32504
  name: "@elitedcs/ghl-mcp",
32497
- version: "3.89.0-beta.2",
32505
+ version: "3.89.0-beta.3",
32498
32506
  mcpName: "io.github.drjerryrelth/ghl-command",
32499
32507
  description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
32500
32508
  main: "dist/index.js",
@@ -38538,6 +38546,13 @@ function saveBuildObligations(id, items) {
38538
38546
  fs30.writeFileSync(file2 + ".tmp", JSON.stringify([...merged.values()], null, 2), { mode: 384 });
38539
38547
  fs30.renameSync(file2 + ".tmp", file2);
38540
38548
  }
38549
+ function replaceBuildObligationsOfType(id, type, items) {
38550
+ const kept = readBuildObligations(id).filter((m) => m.type !== type);
38551
+ const file2 = fileFor(id);
38552
+ fs30.mkdirSync(path28.dirname(file2), { recursive: true });
38553
+ fs30.writeFileSync(file2 + ".tmp", JSON.stringify([...kept, ...items.map((i) => ({ ...i, type }))], null, 2), { mode: 384 });
38554
+ fs30.renameSync(file2 + ".tmp", file2);
38555
+ }
38541
38556
  function planObjectRefs(plan) {
38542
38557
  const refs2 = /* @__PURE__ */ new Set();
38543
38558
  const visit = (value) => {
@@ -38556,7 +38571,7 @@ function planObjectRefs(plan) {
38556
38571
  function reconcileBuildObligations(id, checks, currentPlan) {
38557
38572
  const refs2 = currentPlan && typeof currentPlan === "object" ? planObjectRefs(currentPlan) : void 0;
38558
38573
  const workflows = currentPlan?.workflows;
38559
- const stillPlanned = (item) => !refs2 || refs2.has(item.ref) || // The producer keys each manual job by workflow name and local job index.
38574
+ const stillPlanned = (item) => !refs2 || refs2.has(item.ref) || NOT_FROM_PLAN.has(item.type) || // The producer keys each manual job by workflow name and local job index.
38560
38575
  // Its index can change or disappear from a later preview without completion.
38561
38576
  item.type === "workflow-manual" && Array.isArray(workflows) && workflows.some((workflow) => item.ref.startsWith(`${workflow.name}:`) && /^\d+$/.test(item.ref.slice(workflow.name.length + 1)));
38562
38577
  const remaining = readBuildObligations(id).filter((item) => stillPlanned(item) && !checks.some((c) => c.ref === item.ref && c.type === item.type && c.ok === true && typeof c.evidence === "string" && c.evidence.trim()));
@@ -38565,13 +38580,14 @@ function reconcileBuildObligations(id, checks, currentPlan) {
38565
38580
  fs30.writeFileSync(file2 + ".tmp", JSON.stringify(remaining, null, 2), { mode: 384 });
38566
38581
  fs30.renameSync(file2 + ".tmp", file2);
38567
38582
  }
38568
- var fs30, path28;
38583
+ var fs30, path28, NOT_FROM_PLAN;
38569
38584
  var init_build_obligations = __esm({
38570
38585
  "src/build-obligations.ts"() {
38571
38586
  "use strict";
38572
38587
  fs30 = __toESM(require("node:fs"));
38573
38588
  path28 = __toESM(require("node:path"));
38574
38589
  init_credentials_store();
38590
+ NOT_FROM_PLAN = /* @__PURE__ */ new Set(["bought-not-built"]);
38575
38591
  }
38576
38592
  });
38577
38593
 
@@ -39674,8 +39690,12 @@ function firstString(v) {
39674
39690
  }
39675
39691
  return void 0;
39676
39692
  }
39677
- function splitList(text2) {
39678
- return text2.split(/\r?\n|;|•/).flatMap((line3) => line3.includes(",") && !/\d,\d/.test(line3) ? line3.split(",") : [line3]).map((s) => s.replace(/^[\s\-*\d.)]+/, "").trim()).filter(Boolean);
39693
+ function splitList(text2, opts = {}) {
39694
+ let lines = text2.split(/\r?\n/).filter((l) => l.trim());
39695
+ if (lines.length > 1 && /:\s*$/.test(lines[0])) lines = lines.slice(1);
39696
+ const oneLine = (line3) => line3.split(/;|•/).flatMap((part) => opts.commas !== false && part.includes(",") && !/\d,\d/.test(part) ? part.split(",") : [part]);
39697
+ const items = lines.length > 1 ? lines : lines.flatMap(oneLine);
39698
+ return items.map((s) => s.replace(/^[\s\-*•\d.)]+/, "").replace(/[\s,;]+$/, "").trim()).filter(Boolean);
39679
39699
  }
39680
39700
  function parsePricePoints(text2) {
39681
39701
  const out = [];
@@ -39688,10 +39708,13 @@ function parsePricePoints(text2) {
39688
39708
  const t = segment.trim().replace(/\.$/, "");
39689
39709
  if (!t) continue;
39690
39710
  const m = t.match(ENTRY);
39711
+ const withTerms = m ? null : t.match(/^(.*?)\s*[-–—:|]?\s*\$\s*([\d,]+(?:\.\d+)?)\s*(.+)$/);
39691
39712
  if (m && m[2]) {
39692
39713
  const name = clean3(m[1]);
39693
39714
  const price = (m[3] ? `${num3(m[2])}-${num3(m[3])}` : num3(m[2])) + recur(m[4]);
39694
39715
  out.push({ name: name || t, price });
39716
+ } else if (withTerms && clean3(withTerms[1])) {
39717
+ out.push({ name: clean3(withTerms[1]), price: num3(withTerms[2]) + (withTerms[3].trim().startsWith("/") ? "" : " ") + withTerms[3].trim() });
39695
39718
  } else {
39696
39719
  out.push({ name: t, price: "" });
39697
39720
  }
@@ -39707,7 +39730,16 @@ function cleanLines(text2) {
39707
39730
  function parseStaffLines(text2) {
39708
39731
  const staff = [];
39709
39732
  const warnings = [];
39710
- cleanLines(text2).forEach((line3, i) => {
39733
+ const emailsIn = (t) => splitFields(t).filter((f) => EMAIL_RE2.test(f.replace(/^e-?mail\s*[:=]\s*/i, ""))).length;
39734
+ const lines = cleanLines(text2).flatMap((line3) => {
39735
+ if (!line3.includes(";") || emailsIn(line3) < 2) return [line3];
39736
+ const pieces = line3.split(";").map((p) => p.trim()).filter(Boolean);
39737
+ return pieces.every((p) => emailsIn(p) === 1) ? pieces : [line3];
39738
+ });
39739
+ lines.forEach((line3, i) => {
39740
+ if (emailsIn(line3) > 1) {
39741
+ warnings.push(`Staff line ${i + 1} ("${line3}") lists more than one email address, so only the first person was read. Put one person per line.`);
39742
+ }
39711
39743
  const fields = splitFields(line3);
39712
39744
  let email;
39713
39745
  let mobile;
@@ -39845,12 +39877,29 @@ function cleanPersonCandidate(s) {
39845
39877
  if (durationMinutesFromWords(t) !== void 0) return void 0;
39846
39878
  return t;
39847
39879
  }
39848
- function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames) {
39849
- const fields = splitFields(entry);
39880
+ function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames, sharedPeople = []) {
39881
+ let bracketMinutes;
39882
+ const bracketNotes = [];
39883
+ const bracketPeople = [];
39884
+ const staffIn = (part) => knownStaffNames.length ? matchesStaffName(part.replace(/^(?:with|by)\s+/i, ""), knownStaffNames) : void 0;
39885
+ const unbracketed = entry.replace(/\s*\(([^()]*)\)/g, (whole, inner) => {
39886
+ const hasLength = countDurations(inner) > 0;
39887
+ if (!hasLength && !/[,$;]/.test(inner) && !staffIn(inner)) return whole;
39888
+ const parts = inner.split(/[,;]/).map((x) => x.trim()).filter(Boolean);
39889
+ for (const part of parts) {
39890
+ const d = durationInProse(part);
39891
+ const person = staffIn(part);
39892
+ if (d && bracketMinutes === void 0 && !d.rest.replace(/[\s.,;:-]+/g, "")) bracketMinutes = d.minutes;
39893
+ else if (person) bracketPeople.push(person);
39894
+ else bracketNotes.push(part);
39895
+ }
39896
+ return " ";
39897
+ }).replace(/\s{2,}/g, " ").trim();
39898
+ const fields = splitFields(unbracketed);
39850
39899
  let nameField = fields.shift() ?? "";
39851
39900
  let type;
39852
- let durationMinutes;
39853
- const candidates = [];
39901
+ let durationMinutes = bracketMinutes;
39902
+ const candidates = [...sharedPeople, ...bracketPeople];
39854
39903
  let proseName = false;
39855
39904
  const inName = durationInProse(nameField);
39856
39905
  if (inName) {
@@ -39911,6 +39960,9 @@ function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames)
39911
39960
  warnings.push(`Calendar line ${index + 1} ("${entry}") was skipped: no calendar name found.`);
39912
39961
  return;
39913
39962
  }
39963
+ if (bracketNotes.length) {
39964
+ warnings.push(`Calendar "${name}": Review the details "${bracketNotes.join(", ")}". They were kept as a note, not as calendar settings.`);
39965
+ }
39914
39966
  const people = [];
39915
39967
  for (const c of candidates) {
39916
39968
  const cleaned = cleanPersonCandidate(c);
@@ -39953,10 +40005,18 @@ function parseCalendarLines(text2, knownStaffNames = []) {
39953
40005
  const calendars = [];
39954
40006
  const warnings = [];
39955
40007
  const entries = [];
40008
+ const sharedPeople = [];
40009
+ const SHARED = /(?:^|[.;]\s*)(?:both|all|each)(?:\s+(?:of\s+them|calendars?|are|is))*\s+(?:with|by|for)\s+([^.;\n]+)[.;]?\s*$/i;
39956
40010
  for (const rawLine of text2.split(/\r?\n/)) {
39957
40011
  let line3 = rawLine.replace(/^[\s\-*•]+/, "").trim();
39958
40012
  if (!line3) continue;
39959
- line3 = line3.replace(LEADING_COUNT_WITH_NOUN, "").replace(LEADING_COUNT_BEFORE_MARKER, "");
40013
+ const shared = SHARED.exec(line3);
40014
+ if (shared) {
40015
+ sharedPeople.push(...splitNames(shared[1]));
40016
+ line3 = line3.slice(0, shared.index).trim();
40017
+ if (!line3) continue;
40018
+ }
40019
+ line3 = line3.replace(LEADING_COUNT_WITH_NOUN, "").replace(LEADING_COUNT_BEFORE_MARKER, "").replace(LEADING_BARE_COUNT, "");
39960
40020
  for (const marked3 of splitOnMarkers(line3)) {
39961
40021
  for (const clause of splitOnAnd(marked3)) {
39962
40022
  const cleaned = clause.replace(/^\(?\d{1,2}[).]\s+/, "").replace(/^[\s\-*•]+/, "").trim();
@@ -39964,7 +40024,7 @@ function parseCalendarLines(text2, knownStaffNames = []) {
39964
40024
  }
39965
40025
  }
39966
40026
  }
39967
- entries.forEach((entry, i) => parseCalendarEntry(entry, i, calendars, warnings, knownStaffNames));
40027
+ entries.forEach((entry, i) => parseCalendarEntry(entry, i, calendars, warnings, knownStaffNames, sharedPeople));
39968
40028
  return { calendars, warnings };
39969
40029
  }
39970
40030
  function coerceTriState(v) {
@@ -39992,6 +40052,10 @@ function coerceValue(raw, kind, warnings) {
39992
40052
  const s = firstString(raw);
39993
40053
  return s ? splitList(s) : void 0;
39994
40054
  }
40055
+ case "textList": {
40056
+ const s = firstString(raw);
40057
+ return s ? splitList(s, { commas: false }) : void 0;
40058
+ }
39995
40059
  case "pricePoints": {
39996
40060
  const s = firstString(raw);
39997
40061
  return s ? parsePricePoints(s) : void 0;
@@ -40179,7 +40243,7 @@ function normalizeSubmissionToBrief(opts) {
40179
40243
  brief.flags = deriveFlags(brief);
40180
40244
  return brief;
40181
40245
  }
40182
- var EMAIL_RE2, PHONE_RE, CALENDAR_TYPE_WORDS, DURATION_IN_PROSE, COUNT_WORD, LEADING_COUNT_BEFORE_MARKER, LEADING_COUNT_WITH_NOUN, ASSIGNMENT_SLOTS;
40246
+ var EMAIL_RE2, PHONE_RE, CALENDAR_TYPE_WORDS, DURATION_IN_PROSE, COUNT_WORD, LEADING_COUNT_BEFORE_MARKER, LEADING_COUNT_WITH_NOUN, LEADING_BARE_COUNT, ASSIGNMENT_SLOTS;
40183
40247
  var init_normalizer = __esm({
40184
40248
  "src/intake-to-build/normalizer.ts"() {
40185
40249
  "use strict";
@@ -40198,6 +40262,7 @@ var init_normalizer = __esm({
40198
40262
  `^\\s*(?:(?:we|i)(?:'d| would| will)?\\s*(?:need|want|like|use)\\s+)?${COUNT_WORD}\\s+(?:booking\\s+)?calendars?\\s*[.:;,\u2013\u2014-]+\\s*`,
40199
40263
  "i"
40200
40264
  );
40265
+ LEADING_BARE_COUNT = new RegExp(`^\\s*${COUNT_WORD}\\s*:\\s+`, "i");
40201
40266
  ASSIGNMENT_SLOTS = [
40202
40267
  { key: "notifyName", label: "who should be notified about new leads" },
40203
40268
  { key: "callsName", label: "who takes booking and follow-up calls" }
@@ -41884,6 +41949,193 @@ var init_attachment = __esm({
41884
41949
  }
41885
41950
  });
41886
41951
 
41952
+ // src/bought-list.ts
41953
+ function isBoughtId(x) {
41954
+ return typeof x === "string" && BOUGHT_IDS.includes(x);
41955
+ }
41956
+ function boughtItemsFor(offered) {
41957
+ return ASSESSMENT_FEATURES.map((f) => {
41958
+ const id = f.id;
41959
+ const base = { id, name: f.name, means: BOUGHT_MEANS[id] };
41960
+ const feature = FEATURE_FOR_BOUGHT[id];
41961
+ if (feature) {
41962
+ const next = id === "missed" ? { next: CALL_ANSWERING_NEXT } : {};
41963
+ if (!offered.includes(feature)) {
41964
+ 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.` };
41965
+ }
41966
+ return { ...base, kind: "built", feature, ...next, delivery: BUILT_LABEL[id] };
41967
+ }
41968
+ const other = OTHER_DELIVERY[id];
41969
+ return { ...base, kind: other.kind, delivery: other.text };
41970
+ });
41971
+ }
41972
+ function featuresFromBought(ticks, offered) {
41973
+ return FEATURE_IDS.filter((f) => ticks.includes(BUILD_ASSESSMENT_FEATURE[f]) && offered.includes(f));
41974
+ }
41975
+ function scopeFromBought(ticks, offered, pkg2) {
41976
+ const features = featuresFromBought(ticks, offered);
41977
+ if (!pkg2 && offered.length && offered.every((f) => features.includes(f))) return { wholePreset: true };
41978
+ return { ...pkg2 ? { package: pkg2 } : {}, features };
41979
+ }
41980
+ function isAssessmentChoice(x) {
41981
+ return typeof x === "string" && ASSESSMENT_CHOICES.includes(x);
41982
+ }
41983
+ function ticksFor(state, rk) {
41984
+ const saved = state.bought?.[rk];
41985
+ if (Array.isArray(saved)) return { ticks: BOUGHT_IDS.filter((id) => saved.includes(id)), source: "saved" };
41986
+ const sc = state.scope?.[rk];
41987
+ if (sc && !sc.wholePreset && Array.isArray(sc.features)) {
41988
+ const chosen = sc.features.filter(isFeatureId).map((f) => BUILD_ASSESSMENT_FEATURE[f]);
41989
+ return { ticks: BOUGHT_IDS.filter((id) => !FEATURE_FOR_BOUGHT[id] || chosen.includes(id)), source: "scope" };
41990
+ }
41991
+ if (sc) return { ticks: [...BOUGHT_IDS], source: "scope" };
41992
+ const purchases = state.assessmentPurchases?.[rk];
41993
+ if (Array.isArray(purchases)) return { ticks: BOUGHT_IDS.filter((id) => purchases.includes(id)), source: "assessment" };
41994
+ return { ticks: [...BOUGHT_IDS], source: "all" };
41995
+ }
41996
+ function assessmentAnswerOf(state, rk) {
41997
+ if (state.assessmentAttachments?.[rk]) return "attached";
41998
+ const c = state.assessmentChoice?.[rk]?.choice;
41999
+ return isAssessmentChoice(c) ? c : null;
42000
+ }
42001
+ function secondClinicAssessmentMessage(clientKey, name) {
42002
+ return `Nobody has answered the Assessment question for ${name ? name : `the second clinic "${clientKey}"`}, so nothing was approved or built. For a second clinic in a sub-account the only answer available for now is Build without an Assessment. Send the cockpit POST /api/assessment-choice with this sub-account's locationId, clientKey "${clientKey}" and choice "without". Then approve and build again.`;
42003
+ }
42004
+ function assessmentGateRefusal(state, rk, clientKey, name) {
42005
+ const answer2 = assessmentAnswerOf(state, rk);
42006
+ if (state.assessmentAttachments?.[rk] || answer2 === "without") return null;
42007
+ if (clientKey) return secondClinicAssessmentMessage(clientKey, name);
42008
+ if (answer2 === "attached") return ATTACH_THE_ASSESSMENT;
42009
+ if (answer2 === "run-first") return RUN_THE_ASSESSMENT_FIRST;
42010
+ return ANSWER_THE_ASSESSMENT_QUESTION;
42011
+ }
42012
+ function approvalBindingHash(scopeHash, state, rk) {
42013
+ const saved = state.bought?.[rk];
42014
+ const answer2 = assessmentAnswerOf(state, rk);
42015
+ if (!Array.isArray(saved) && !answer2) return scopeHash;
42016
+ const snapshot = state.assessmentAttachments?.[rk];
42017
+ return contentHash({ scope: scopeHash, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null, assessment: answer2, attached: snapshot ? contentHash(snapshot) : null });
42018
+ }
42019
+ function boughtBindingHash(scopeHash, state, rk) {
42020
+ const saved = state.bought?.[rk];
42021
+ return contentHash({ scope: scopeHash, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null });
42022
+ }
42023
+ function boughtJobs(ticks, offered) {
42024
+ return boughtItemsFor(offered).filter((item) => ticks.includes(item.id) && (item.kind === "go-live" || item.kind === "separate" || item.kind === "not-in-plan")).map((item) => ({
42025
+ ref: `bought:${item.id}`,
42026
+ type: BOUGHT_JOB_TYPE,
42027
+ name: item.name,
42028
+ 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}`
42029
+ }));
42030
+ }
42031
+ function boughtJobLines(ticks, offered) {
42032
+ return boughtJobs(ticks, offered).map((j) => `${j.name}: ${j.reason}`);
42033
+ }
42034
+ function claudeSuggestions(ticks) {
42035
+ const out = [];
42036
+ if (ticks.includes("missed")) out.push("Call answering: next step with Claude, an AI voice agent to answer live.");
42037
+ if (ticks.includes("reporting")) out.push("Reporting: from Claude, a weekly client report. No account setup.");
42038
+ return out;
42039
+ }
42040
+ function assessmentChoiceLine(answer2, attached = answer2 === "attached") {
42041
+ if (attached) return "The Assessment was run and is attached.";
42042
+ if (answer2 === "attached") return "The Assessment was run. It is not attached yet.";
42043
+ if (answer2 === "without") return "No Assessment was run for this client.";
42044
+ if (answer2 === "run-first") return "The Assessment will be run first.";
42045
+ return "Not answered yet: has the Assessment been run for this client?";
42046
+ }
42047
+ function boughtViewFor(state, rk, offered) {
42048
+ const { ticks, source } = ticksFor(state, rk);
42049
+ return {
42050
+ items: boughtItemsFor(offered),
42051
+ ticks,
42052
+ source,
42053
+ answer: assessmentAnswerOf(state, rk),
42054
+ attached: Boolean(state.assessmentAttachments?.[rk]),
42055
+ ready: assessmentGateRefusal(state, rk) === null,
42056
+ suggestions: source === "saved" ? claudeSuggestions(ticks) : []
42057
+ };
42058
+ }
42059
+ function boughtDocumentFor(state, rk, offered) {
42060
+ const { ticks, source } = ticksFor(state, rk);
42061
+ const items = boughtItemsFor(offered);
42062
+ const confirmed = source === "saved" || source === "scope";
42063
+ const ticked = items.filter((i) => ticks.includes(i.id));
42064
+ return {
42065
+ confirmed,
42066
+ assessment: assessmentChoiceLine(assessmentAnswerOf(state, rk), Boolean(state.assessmentAttachments?.[rk])),
42067
+ bought: ticked.map((i) => ({ name: i.name, means: i.means, delivery: clientDelivery(i) })),
42068
+ // The client reads this: never the agency's job text, and never "with Claude" (review of ea3526f).
42069
+ stillToDo: ticked.flatMap((i) => {
42070
+ if (i.kind === "go-live") return [`${i.name}: being set up in your account.`];
42071
+ if (i.kind === "separate") return [`${i.name}: the form is ready. The pages are a separate piece of work.`];
42072
+ if (i.kind === "not-in-plan") return [`${i.name}: being set up separately from this build.`];
42073
+ if (i.kind === "claude") return [`${i.name}: a weekly report on what is working, sent to you.`];
42074
+ return [];
42075
+ }).concat(ticks.includes("missed") ? ["Call answering: live call answering by an AI voice agent is a possible next step."] : [])
42076
+ };
42077
+ }
42078
+ function clientDelivery(i) {
42079
+ if (i.kind === "go-live") return "Being set up in your account";
42080
+ if (i.kind === "separate") return "The form is built; the pages are separate work";
42081
+ if (i.kind === "not-in-plan") return "Not part of this build";
42082
+ if (i.kind === "claude") return "A weekly report on what is working";
42083
+ return i.delivery;
42084
+ }
42085
+ 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;
42086
+ var init_bought_list = __esm({
42087
+ "src/bought-list.ts"() {
42088
+ "use strict";
42089
+ init_attachment();
42090
+ init_features();
42091
+ init_build_contract();
42092
+ BOUGHT_IDS = ASSESSMENT_FEATURES.map((f) => f.id);
42093
+ BOUGHT_MEANS = {
42094
+ missed: "No call goes unanswered",
42095
+ waited: "Every new lead hears back in seconds",
42096
+ quotes: "No quote goes cold",
42097
+ noshow: "Bookings confirmed, reminded and rescued",
42098
+ reactivation: "Old leads and past clients brought back",
42099
+ nurture: "Leads kept warm until they are ready",
42100
+ recall: "Clients come back on schedule",
42101
+ deposits: "Fewer no-shows, cash up front",
42102
+ reviews: "More five-star reviews",
42103
+ referrals: "Happy clients send friends",
42104
+ lead_magnet: "A reason to give you their details",
42105
+ sms: "Texts that actually get delivered",
42106
+ pipeline: "Every lead tracked to a sale",
42107
+ reporting: "The owner sees what is working"
42108
+ };
42109
+ FEATURE_FOR_BOUGHT = Object.fromEntries(
42110
+ FEATURE_IDS.map((f) => [BUILD_ASSESSMENT_FEATURE[f], f])
42111
+ );
42112
+ BUILT_LABEL = {
42113
+ missed: "Built: missed-call text-back",
42114
+ waited: "Built: Speed to Lead",
42115
+ quotes: "Built: Quote follow-up",
42116
+ noshow: "Built: booking, reminders and no-show rescue",
42117
+ reactivation: "Built: Win-back",
42118
+ nurture: "Built: 30-day lead nurture",
42119
+ reviews: "Built: Review requests"
42120
+ };
42121
+ OTHER_DELIVERY = {
42122
+ pipeline: { kind: "always", text: "Always built: Sales pipeline" },
42123
+ recall: { kind: "go-live", text: "Go-live job for now: Rebooking and recall", job: "Set up rebooking and recall in the account." },
42124
+ referrals: { kind: "go-live", text: "Go-live job for now: Referral requests", job: "Set up referral requests in the account." },
42125
+ deposits: { kind: "go-live", text: "Go-live job: Deposits and prepayment (payment setup is done in the account)", job: "Payment setup is done in the account." },
42126
+ sms: { kind: "go-live", text: "Go-live job: SMS capability (phone number and text registration)", job: "Get the phone number and finish text registration." },
42127
+ lead_magnet: { kind: "separate", text: "Separate job: Lead magnet (the form is built; pages are separate work)", job: "The form is built. The pages are separate work." },
42128
+ reporting: { kind: "claude", text: "From Claude: Reporting (weekly client report, no account setup)" }
42129
+ };
42130
+ CALL_ANSWERING_NEXT = "Next step with Claude: an AI voice agent to answer live";
42131
+ ASSESSMENT_CHOICES = ["attached", "run-first", "without"];
42132
+ ANSWER_THE_ASSESSMENT_QUESTION = "Answer the Assessment question before approving: Blueprint Review, then Have you run the Assessment for this client.";
42133
+ ATTACH_THE_ASSESSMENT = "Attach the Assessment before approving, or choose Build without an Assessment.";
42134
+ RUN_THE_ASSESSMENT_FIRST = "You chose to run the Assessment first. Attach it when it is done, or choose Build without an Assessment.";
42135
+ BOUGHT_JOB_TYPE = "bought-not-built";
42136
+ }
42137
+ });
42138
+
41887
42139
  // src/intake-contact.ts
41888
42140
  async function pickIntakeContact(client, locationId2, fieldIdToLabel, knownContactId, formName) {
41889
42141
  const ids = /* @__PURE__ */ new Set();
@@ -42282,6 +42534,21 @@ function prefillFormUrl(formUrl, queryKeys, answers, contact = {}, altQueryKeys
42282
42534
  const qs = p.toString();
42283
42535
  return { url: qs ? `${formUrl}?${qs}` : formUrl, leftovers };
42284
42536
  }
42537
+ function isAuthFailure(error) {
42538
+ const text2 = error instanceof Error ? error.message : String(error ?? "");
42539
+ return /GHL API Error (?:401|403)\b/.test(text2);
42540
+ }
42541
+ function isRequestFailure(error) {
42542
+ if (isGHLRequestError(error)) return true;
42543
+ const text2 = error instanceof Error ? error.message : String(error ?? "");
42544
+ return /GHL API Error \d{3}|Request timeout|timed? ?out|fetch failed|ECONN|ETIMEDOUT|socket hang up|network/i.test(text2);
42545
+ }
42546
+ function unavailable(empty, reason3) {
42547
+ return { ...empty, status: "unavailable", unavailableReason: reason3 };
42548
+ }
42549
+ function unavailableFrom(empty, error) {
42550
+ return unavailable(empty, isAuthFailure(error) ? "auth" : isRequestFailure(error) ? "transient" : "error");
42551
+ }
42285
42552
  async function readIntakeSnapshot(client, locationId2, sentMarker) {
42286
42553
  const empty = { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
42287
42554
  try {
@@ -42333,16 +42600,16 @@ async function readIntakeSnapshot(client, locationId2, sentMarker) {
42333
42600
  contactId = hit2.id;
42334
42601
  channel = isFormSubmission(hit2) ? "form" : "cockpit";
42335
42602
  }
42336
- } catch {
42337
- return { ...empty, status: "unavailable" };
42603
+ } catch (e) {
42604
+ return unavailableFrom(empty, e);
42338
42605
  }
42339
42606
  }
42340
42607
  const hit = await pickIntakeContact(client, locationId2, Object.fromEntries(Object.entries(fields).map(([label2, id]) => [id, label2])), contactId, INTAKE_FORM_NAME2);
42341
- if (submissions && !hit) return { ...empty, status: "unavailable" };
42608
+ if (submissions && !hit) return unavailable(empty, "missing");
42342
42609
  const status = submissions > 0 ? "received" : sentMarker ? "sent" : "installed";
42343
42610
  return { status, answers: hit?.answers ?? {}, formId: form.id, formUrl: publicFormUrl(form.id), fields, queryKeys, altQueryKeys, multiFields, submissions, answeredAt, channel, contactId };
42344
- } catch {
42345
- return { ...empty, status: "unavailable" };
42611
+ } catch (e) {
42612
+ return unavailableFrom(empty, e);
42346
42613
  }
42347
42614
  }
42348
42615
  function recorderAllowPatterns(mcpListOutput) {
@@ -42472,6 +42739,7 @@ var init_client_intake = __esm({
42472
42739
  "src/client-intake.ts"() {
42473
42740
  "use strict";
42474
42741
  init_intake_contact();
42742
+ init_ghl_client();
42475
42743
  init_question_set();
42476
42744
  init_form_template();
42477
42745
  init_transcript();
@@ -43338,23 +43606,17 @@ async function assessmentRequest(id,action,extra={}){
43338
43606
  const response=await fetch('/api/client-assessment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientLocationId:id,action,...extra})});
43339
43607
  const result=await response.json();if(!response.ok||!result.ok)throw new Error(result.error||'The assessment action did not finish.');
43340
43608
  if(action==='list'){p.list=result.assessments;p.listed=true;p.selected='';if(!p.list.length)p.notice='There are no assessments in this account’s Assessments pipeline.'}
43341
- else{p.data=result;p.choices=result.purchasedFeatureIds||[];p.notice=result.message||'';if(action==='fill'){p.fill=result;delete iData[id];if(typeof iOpen!=='undefined'&&iOpen[id])await iLoad(id)}}
43342
- if(action==='purchases')await load();
43609
+ else{p.data=result;p.notice=result.message||'';if(action==='fill'){p.fill=result;delete iData[id];if(typeof iOpen!=='undefined'&&iOpen[id])await iLoad(id)}}
43610
+ if(action==='purchases'||action==='attach'||action==='detach')await load();
43343
43611
  }catch(e){p.notice=e.message||'The assessment could not be read. Try again.'}
43344
43612
  p.busy=false;render(lastState,lastRun);
43345
43613
  }
43346
43614
  function assessmentPanel(id){
43347
- const fresh=!assessmentCards[id];const p=assessmentCards[id]||(assessmentCards[id]={busy:false,data:null,list:[],source:'',selected:'',choices:[]});
43615
+ const fresh=!assessmentCards[id];const p=assessmentCards[id]||(assessmentCards[id]={busy:false,data:null,list:[],source:'',selected:''});
43348
43616
  const box=document.createElement('section');box.className='cds-stack';
43349
43617
  const text=(tag,value)=>{const el=document.createElement(tag);if(tag==='h4')el.className='cds-panel-label';el.textContent=value;box.appendChild(el);return el};
43350
43618
  const button=(label,run,disabled=false)=>{const b=text('button',label);b.className='cds-btn ghost';b.disabled=p.busy||disabled;b.onclick=run;return b};
43351
- text('h4','Bought in the Assessment');
43352
- text('p','Record what they bought in your sales conversation. The What they bought list further down is ticked from this, and only that list decides what gets built.');
43353
- if(p.data){
43354
- for(const feature of p.data.features){const label=text('label','');label.className='cds-check';const cb=document.createElement('input');cb.type='checkbox';cb.checked=p.choices.includes(feature.id);cb.disabled=p.busy;cb.onchange=()=>{p.choices=cb.checked?[...p.choices,feature.id]:p.choices.filter(v=>v!==feature.id)};label.appendChild(cb);const span=document.createElement('span');span.textContent=feature.name;label.appendChild(span)}
43355
- button('Save the Assessment record',()=>assessmentRequest(id,'purchases',{purchasedFeatureIds:p.choices}));
43356
- if(p.data.purchasedFeatureIds===null)text('p','Nothing is recorded from the Assessment yet.');
43357
- }
43619
+ /* What they bought is ticked on the one list below this panel (2026-09-29); this panel attaches the Assessment. */
43358
43620
  text('h4','Assessment');
43359
43621
  text('p','The numbers from your sales conversation follow the client into the intake, the Review and their Blueprint.');
43360
43622
  const select=text('select','');select.className='cds-select';select.setAttribute('aria-label','Your account where the assessment was saved');select.disabled=p.busy;
@@ -46201,6 +46463,10 @@ function reviewDocumentHtml(review, opts) {
46201
46463
  ` : opts.scope ? `<div class="head">What this build includes</div>
46202
46464
  <p>${opts.scope.package ? `Built to your <b>${esc14(opts.scope.package)}</b> package: ` : ""}${opts.scope.built.length ? esc14(opts.scope.built.map((b) => b.name).join(", ")) : "the foundation only"}. Always included: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing.</p>
46203
46465
  ` : ""}
46466
+ ${opts.bought ? `<div class="head">What you bought</div>
46467
+ <p>${esc14(opts.bought.assessment)}</p>
46468
+ ${opts.bought.confirmed ? opts.bought.bought.map((b) => `<div class="build"><b>${esc14(b.name)}</b><span>${esc14(b.means)}. ${esc14(b.delivery)}.</span></div>`).join("") : "<p>Not confirmed yet. The build waits until what you bought is saved.</p>"}
46469
+ ${opts.bought.confirmed && opts.bought.stillToDo.length ? `<div class="head">Still to do</div><div class="ask"><ul>${opts.bought.stillToDo.map((line3) => `<li>${esc14(line3)}</li>`).join("")}</ul></div>` : ""}` : ""}
46204
46470
  ${review.unknowns?.length ? `<div class="head">What we still need from you</div><div class="ask"><ul>${review.unknowns.map((u) => `<li>${esc14(u)}</li>`).join("")}</ul></div>` : ""}
46205
46471
  ${opts.assessment ? `<div class="head">Assessment</div><p>Snapshot attached on ${esc14(opts.assessment.attachedAt.slice(0, 10))}.</p>${assessmentFeatureLines(opts.assessment, [], false).filter((line3) => line3.includes("by your own numbers")).map((line3) => `<p>${esc14(line3)}</p>`).join("")}` : ""}
46206
46472
  ${available ? `<div class="head">Available next</div>${available}` : opts.assessment ? `<div class="head">Available next</div><p>${purchased ? "Every listed feature is included in what you bought." : "What you bought has not been recorded yet."}</p>` : ""}
@@ -47779,14 +48045,22 @@ var init_clinic_prefix_registry = __esm({
47779
48045
  var dashboard_exports = {};
47780
48046
  __export(dashboard_exports, {
47781
48047
  ACCOUNT_TYPES: () => ACCOUNT_TYPES,
48048
+ APPROVAL_WITHDRAWN_FOR_ASSESSMENT: () => APPROVAL_WITHDRAWN_FOR_ASSESSMENT,
47782
48049
  APPROVAL_WITHDRAWN_FOR_SCOPE: () => APPROVAL_WITHDRAWN_FOR_SCOPE,
48050
+ ASSESSMENT_CHANGED_SINCE_APPROVAL: () => ASSESSMENT_CHANGED_SINCE_APPROVAL,
47783
48051
  CHOOSE_BEFORE_BUILD: () => CHOOSE_BEFORE_BUILD,
47784
48052
  CHOOSE_WHAT_THEY_BOUGHT: () => CHOOSE_WHAT_THEY_BOUGHT,
47785
48053
  CLAUDE_REPAIR_MESSAGES: () => CLAUDE_REPAIR_MESSAGES,
48054
+ INTAKE_ANSWERS_MISSING: () => INTAKE_ANSWERS_MISSING,
47786
48055
  INTAKE_CONNECT_REQUIRED: () => INTAKE_CONNECT_REQUIRED,
47787
48056
  INTAKE_FORM_REQUIRED: () => INTAKE_FORM_REQUIRED,
47788
48057
  INTAKE_OWN_MESSAGES: () => INTAKE_OWN_MESSAGES,
48058
+ INTAKE_READ_FAILED: () => INTAKE_READ_FAILED,
48059
+ INTAKE_READ_TRANSIENT: () => INTAKE_READ_TRANSIENT,
48060
+ INTAKE_REFRESH_RETRYING: () => INTAKE_REFRESH_RETRYING,
47789
48061
  INTAKE_UNREADABLE: () => INTAKE_UNREADABLE,
48062
+ NOTHING_BOUGHT: () => NOTHING_BOUGHT,
48063
+ NOTHING_BUILT_FROM_TICKS: () => NOTHING_BUILT_FROM_TICKS,
47790
48064
  NOTHING_TICKED_IN_PLAN: () => NOTHING_TICKED_IN_PLAN,
47791
48065
  NOT_SIGNED_IN_MSG: () => NOT_SIGNED_IN_MSG,
47792
48066
  PARKED_NOT_BUILT: () => PARKED_NOT_BUILT,
@@ -47800,8 +48074,11 @@ __export(dashboard_exports, {
47800
48074
  STAGES: () => STAGES2,
47801
48075
  accountTypeOf: () => accountTypeOf,
47802
48076
  agencyConnectionReady: () => agencyConnectionReady,
48077
+ approvalBoughtHash: () => approvalBoughtHash,
48078
+ approvalScopeHash: () => approvalScopeHash,
47803
48079
  assessmentAgencyTag: () => assessmentAgencyTag,
47804
48080
  assessmentFiguresFor: () => assessmentFiguresFor,
48081
+ boughtUnchangedSinceApproval: () => boughtUnchangedSinceApproval,
47805
48082
  callClaudeRecorded: () => callClaudeRecorded,
47806
48083
  canonicalIndustry: () => canonicalIndustry,
47807
48084
  checkMutationGuard: () => checkMutationGuard,
@@ -47823,6 +48100,7 @@ __export(dashboard_exports, {
47823
48100
  insideDir: () => insideDir,
47824
48101
  intakeInput: () => intakeInput,
47825
48102
  intakeRequestError: () => intakeRequestError,
48103
+ intakeUnavailableError: () => intakeUnavailableError,
47826
48104
  isAllowedCockpitHost: () => isAllowedCockpitHost,
47827
48105
  isLoopback: () => isLoopback,
47828
48106
  isUpstreamFailure: () => isUpstreamFailure,
@@ -47865,7 +48143,9 @@ __export(dashboard_exports, {
47865
48143
  uniqueDocName: () => uniqueDocName,
47866
48144
  unknownCapabilityHtml: () => unknownCapabilityHtml,
47867
48145
  verifyPanelOutcome: () => verifyPanelOutcome,
48146
+ withBoughtJobs: () => withBoughtJobs,
47868
48147
  withCockpitShell: () => withCockpitShell,
48148
+ withdrawIfBindingChanged: () => withdrawIfBindingChanged,
47869
48149
  writeCockpitState: () => writeCockpitState,
47870
48150
  writeDocAtomically: () => writeDocAtomically,
47871
48151
  writeOverlay: () => writeOverlay
@@ -47940,6 +48220,28 @@ function scopeHashOf(sel) {
47940
48220
  if (sel.wholePreset) return contentHash({ wholePreset: true });
47941
48221
  return contentHash({ package: sel.package ?? "", features: [...new Set((sel.features ?? []).filter(isFeatureId))].sort() });
47942
48222
  }
48223
+ function approvalScopeHash(state, recordKey) {
48224
+ return approvalBindingHash(scopeHashOf(state.scope?.[recordKey]), state, recordKey);
48225
+ }
48226
+ function withdrawIfBindingChanged(state, recordKey) {
48227
+ const ap = state.planApprovals?.[recordKey];
48228
+ if (!ap || ap.scopeHash === approvalScopeHash(state, recordKey)) return { state, withdrawn: false };
48229
+ const approvals = { ...state.planApprovals };
48230
+ delete approvals[recordKey];
48231
+ return { state: { ...setBuildApproval(state, recordKey, null), planApprovals: approvals }, withdrawn: true };
48232
+ }
48233
+ function withBoughtJobs(tasks2, lines) {
48234
+ const kept = tasks2.filter((t) => !SELF_REPORTED.test(t.text));
48235
+ const prior = new Map(tasks2.map((t) => [t.id, t]));
48236
+ return [...kept, ...lines.map((text2) => prior.get(taskId(text2)) ?? { id: taskId(text2), text: text2 })];
48237
+ }
48238
+ function approvalBoughtHash(state, recordKey) {
48239
+ return boughtBindingHash(scopeHashOf(state.scope?.[recordKey]), state, recordKey);
48240
+ }
48241
+ function boughtUnchangedSinceApproval(ap, state, recordKey) {
48242
+ if (ap.boughtHash) return ap.boughtHash === approvalBoughtHash(state, recordKey);
48243
+ return ap.scopeHash === scopeHashOf(state.scope?.[recordKey]) && !Array.isArray(state.bought?.[recordKey]);
48244
+ }
47943
48245
  function hasChosenScope(state, recordKey) {
47944
48246
  const sel = state.scope?.[recordKey];
47945
48247
  return Boolean(sel && (sel.wholePreset || Array.isArray(sel.features) && sel.features.some(isFeatureId)));
@@ -48076,7 +48378,10 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
48076
48378
  normalizedAnswers: meta2.normalizedAnswers,
48077
48379
  dependencies: verificationPlan ? verificationPlan.dependencies ?? planDependencies(verificationPlan.plan) : readSavedPlan(rk)?.dependencies,
48078
48380
  industry,
48079
- manualObligations: stage === 4 ? readBuildObligations(rk) : void 0,
48381
+ // Bought-but-not-built jobs are the member's to confirm: no check can read them, so
48382
+ // Verify never reports them as findings (review of ea3526f). They reach the card's
48383
+ // jobs from the obligations file directly.
48384
+ manualObligations: stage === 4 ? readBuildObligations(rk).filter((o) => o.type !== BOUGHT_JOB_TYPE) : void 0,
48080
48385
  intakeSummary: describeComposition({ industry, agency }),
48081
48386
  customLabels: composed.filter((q3) => {
48082
48387
  const base = INTAKE_QUESTIONS.find((b) => b.key === q3.key);
@@ -48523,6 +48828,10 @@ function readableAccount(locationId2, roster = currentRosterRef(), state = readC
48523
48828
  const t = state.accounts?.[locationId2]?.type;
48524
48829
  return t !== "hidden" && t !== "template";
48525
48830
  }
48831
+ function intakeUnavailableError(snap) {
48832
+ const reason3 = snap.unavailableReason;
48833
+ return new Error(reason3 === "auth" ? INTAKE_UNREADABLE : reason3 === "missing" ? INTAKE_ANSWERS_MISSING : reason3 === "error" ? INTAKE_READ_FAILED : INTAKE_READ_TRANSIENT);
48834
+ }
48526
48835
  function intakeRequestError(e) {
48527
48836
  const text2 = e instanceof Error ? e.message : "";
48528
48837
  return INTAKE_OWN_MESSAGES.includes(text2) ? text2 : "The intake request did not finish. Retry this step.";
@@ -48563,6 +48872,9 @@ function html(roster, seatToken, screen = "board", cap) {
48563
48872
  /* Board: four doors in one row (DoorTile), then the two-column grid */
48564
48873
  .board{display:grid;gap:var(--space-6)}
48565
48874
  .txt > .cds-chip{justify-self:start}
48875
+ /* A class that sets display (.cds-chip is inline-flex) beats the browser's own [hidden] rule, so
48876
+ "Not saved yet" stayed on screen after a save (rehearsal 2, finding 12). Hidden means hidden. */
48877
+ [hidden]{display:none!important}
48566
48878
  .cds-door > .cds-callout{display:grid;gap:var(--space-2)}
48567
48879
 
48568
48880
  /* Toolbars and rows of controls */
@@ -48663,7 +48975,6 @@ const SCREEN=document.body.getAttribute("data-screen")||"board";
48663
48975
  const CAP=${scriptJson(capForPage)};const HUB=${scriptJson(HUB_URL)};const HEADS=${scriptJson(SCREEN_HEADS)};
48664
48976
  const INDUSTRIES=${scriptJson([...INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })), { slug: "other", label: "Other" }])};
48665
48977
  const INDUSTRIES2=${scriptJson(INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })))};
48666
- const FEATS=${scriptJson(FEATURES)};
48667
48978
  /* One account at a time (Jerry, 2026-08-26: "way too busy\u2026 tempting to work on multiple
48668
48979
  accounts at once"). One card is ever expanded; every other account is a one-line row. */
48669
48980
  let newClientId=new URLSearchParams(location.hash.slice(1)).get("client"),existingClientsOpen=false;
@@ -48672,7 +48983,7 @@ let registerNotice="",registerFailed=false,registerBusy=false;
48672
48983
  let activeId=null;try{activeId=localStorage.getItem("cmdos-active")||null}catch(e){}
48673
48984
  {const linkedClient=new URLSearchParams(location.hash.slice(1)).get("client");if(linkedClient)rememberClient(linkedClient)}
48674
48985
  function rememberClient(id){activeId=id;try{localStorage.setItem("cmdos-active",id)}catch(e){};document.querySelectorAll(".cds-side a[href]").forEach(a=>{const href=a.getAttribute("href");if(href&&href.startsWith("/")&&!href.startsWith("/api/")&&!href.includes("?")){a.href=href.split("#")[0]+"#client="+encodeURIComponent(id)}})}
48675
- function setActive(id){rememberClient(id);render(lastState,lastRun)}
48986
+ function setActive(id){if(typeof iRetryStopExcept==="function")iRetryStopExcept(id);rememberClient(id);render(lastState,lastRun)}
48676
48987
  function initials(name){const p=String(name||"").trim().split(/\\s+/);return ((p[0]||"?")[0]+(p[1]?p[1][0]:"")).toUpperCase()}
48677
48988
  /* Command Design System pieces, built as text (never markup from data). */
48678
48989
  function mk(tag,cls,text){const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}
@@ -48726,11 +49037,14 @@ let lastStatusOkAt=0,redrawPending=false,pointerIsDown=false,flushTimer=0;
48726
49037
  then would re-enable its button and drop its message, so it waits like a focused field. */
48727
49038
  let iBusy={};
48728
49039
  function intakeWorkBusy(){try{return Object.values(iBusy).some(n=>n>0)}catch(e){return false}}
49040
+ /* Inline confirm panels (see icPanel): one waiting on the server holds the redraw like intake work. */
49041
+ let icOpen={};
49042
+ function icBusy(){try{return Object.values(icOpen).some(x=>x&&x.busy)}catch(e){return false}}
48729
49043
  /* A counter, so two requests for one client overlapping never clear each other. */
48730
49044
  async function iWork(id,fn){iBusy[id]=(iBusy[id]||0)+1;try{return await fn()}finally{iBusy[id]=Math.max(0,(iBusy[id]||0)-1);if(redrawPending)scheduleFlush()}}
48731
49045
  function inputFocused(){const a=document.activeElement;return Boolean(a&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))}
48732
49046
  /* Holds back the full redraw, never the status fetch. */
48733
- function userIsBusy(){if(SCREEN==="new")return true;return inputFocused()||intakeWorkBusy()}
49047
+ function userIsBusy(){if(SCREEN==="new")return true;return inputFocused()||intakeWorkBusy()||icBusy()}
48734
49048
  function intakeIsOpen(){try{return Object.values(iOpen||{}).some(Boolean)}catch(e){return false}}
48735
49049
  async function fetchRunStatus(){const rr=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();return rr}
48736
49050
  function statusIsStale(){return !lastStatusOkAt||Date.now()-lastStatusOkAt>15000}
@@ -48861,7 +49175,33 @@ function costLabel(kind){return ({stage:"Stage",review:"Client review","plain re
48861
49175
  function iContact(id,questions){const out={};const keys={first_name:"firstName",last_name:"lastName",email:"email",phone:"phone"};for(const q of questions){if(keys[q.contactTag])out[keys[q.contactTag]]=String((iAnswers[id]||{})[q.label]||"")}return out}
48862
49176
  function iEdit(id,label,value){(iAnswers[id]||(iAnswers[id]={}))[label]=value;(iDirty[id]||(iDirty[id]=new Set())).add(label)}
48863
49177
  function iApplyProposals(id,proposals){const applied=[];const answers=iAnswers[id]||(iAnswers[id]={});for(const [label,value] of Object.entries(proposals||{})){if(answers[label]&&JSON.stringify(answers[label])!==JSON.stringify(value)){if(!confirm(label+String.fromCharCode(10)+"Current: "+answers[label]+String.fromCharCode(10)+"Proposed: "+value+String.fromCharCode(10)+"Replace this answer?"))continue}iEdit(id,label,value);applied.push(label)}return applied}
48864
- async function iLoad(id){rememberClient(id);const r=await(await clientPageRequest("/api/client-intake?locationId="+id)).json();iData[id]=r;
49178
+ /* Rehearsal 2 (2026-09-29): a read that failed for a passing reason (a timeout, a rate limit) is not a broken
49179
+ key. The panel keeps what it last read, says so quietly, and retries; the next good read clears the note.
49180
+ Only a real refusal of the key (401/403) still says to paste it again. */
49181
+ /* Review of d407f04: the retry is a refresh only. It never changes the client on screen (iLoad does, for a
49182
+ click), it stops when the member moves to another client or closes the intake, a throw schedules the next
49183
+ try instead of ending them, and after I_RETRY_MAX tries it stops and says so. */
49184
+ let iRetry={};const I_RETRY_MAX=10;
49185
+ const I_RETRY_NOTE="Could not refresh just now; retrying.",I_RETRY_GAVE_UP="Could not refresh. Refresh status to try again.";
49186
+ function iRetryNote(id){const n=iRetry[id];if(!n)return null;const p=mk("p","muted iretry",n.gaveUp?I_RETRY_GAVE_UP:(n.note||I_RETRY_NOTE));p.setAttribute("role","status");
49187
+ if(n.gaveUp){const b=mk("button","cds-link","Refresh status");b.type="button";b.onclick=()=>iLoad(id);p.appendChild(document.createTextNode(" "));p.appendChild(b)}return p}
49188
+ function iRetryStop(id){const n=iRetry[id];if(n){if(n.t)clearTimeout(n.t);delete iRetry[id]}}
49189
+ function iRetryStopExcept(id){for(const k of Object.keys(iRetry))if(k!==id)iRetryStop(k)}
49190
+ /* Still worth refreshing: the client is the one on screen. */
49191
+ function iShown(id){return activeId===id}
49192
+ function iScheduleRetry(id,note){const n=((iRetry[id]&&iRetry[id].n)||0)+1;if(iRetry[id]&&iRetry[id].t)clearTimeout(iRetry[id].t);
49193
+ if(n>I_RETRY_MAX){iRetry[id]={n:n,gaveUp:true,t:0};return}
49194
+ iRetry[id]={n:n,note:note||I_RETRY_NOTE,t:setTimeout(()=>{const cur=iRetry[id];if(!cur||cur.gaveUp)return;cur.t=0;if(!iShown(id)){iRetryStop(id);return}iRefresh(id)},Math.min(30000,3000*n))}}
49195
+ /* The background read: no rememberClient, no change of client. */
49196
+ async function iRefresh(id){try{const res=await fetch("/api/client-intake?locationId="+encodeURIComponent(id));const r=await res.json();if(!res.ok&&r&&r.ok!==false)r.ok=false;iApply(id,r)}
49197
+ catch(e){if(!iShown(id)){iRetryStop(id);return}iScheduleRetry(id,I_RETRY_NOTE);try{render(lastState,lastRun)}catch(_){}}}
49198
+ async function iLoad(id){rememberClient(id);iRetryStop(id);const r=await(await clientPageRequest("/api/client-intake?locationId="+id)).json();iApply(id,r)}
49199
+ function iApply(id,r){
49200
+ if(r&&r.ok===false&&r.transient){iScheduleRetry(id,r.error);
49201
+ if(!iData[id]||!iData[id].ok)iData[id]={ok:false,transient:true,error:r.error};
49202
+ render(lastState,lastRun);return}
49203
+ iRetryStop(id);
49204
+ iData[id]=r;
48865
49205
  // Only unsaved edits override the newest server submission, including edits made before Connect.
48866
49206
  if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits};if(r.status==="received")delete iPrepared[id]}render(lastState,lastRun)}
48867
49207
  let iFieldN=0;
@@ -48888,8 +49228,10 @@ function iField(id,q,val,prefilled){const w=document.createElement("div");w.clas
48888
49228
  w.appendChild(inp);return w}
48889
49229
  function intakeWorkspace(id,name){const d=iData[id];const box=document.createElement("div");box.className="cds-panel ipanel";
48890
49230
  if(!d){box.textContent="Loading intake\u2026";return box}
49231
+ if(!d.ok&&d.transient){box.innerHTML="<h3 class='cds-panel-title'>Intake</h3>";box.appendChild(iRetryNote(id)||mk("p","muted",d.error||"Could not refresh just now; retrying."));return box}
48891
49232
  if(!d.ok){box.innerHTML="<h3 class='cds-panel-title'>Intake</h3><p role='alert'>"+escT(clientPageError(d.error))+"</p>";const a=document.createElement("a");a.className="cds-link";a.href="/new";a.textContent="Reconnect this client";box.appendChild(a);return box}
48892
49233
  box.innerHTML="<h3 class='cds-panel-title'>Intake for "+escT(name)+"</h3>";
49234
+ {const rn=iRetryNote(id);if(rn)box.appendChild(rn)}
48893
49235
  if(!d.industry){const label=document.createElement("p");label.textContent="Choose this client\u2019s industry before preparing the intake.";box.appendChild(label);const select=document.createElement("select");select.className="cds-select";select.setAttribute("aria-label","Client industry");for(const item of [{slug:"",label:"Choose the client industry"},...INDUSTRIES]){const o=document.createElement("option");o.value=item.slug;o.textContent=item.label;select.appendChild(o)}box.appendChild(select);const save=document.createElement("button");save.className="cds-btn";save.textContent="Confirm industry";save.onclick=async()=>{if(!select.value)return;if(await postJSON("/api/industry",{locationId:id,industry:select.value}))await iLoad(id)};box.appendChild(save);return box}
48894
49236
  if(d.status==="received"&&d.savedAt){const note=document.createElement("p");note.textContent="Submitted answers are loaded. A newer saved draft may include changes that have not been submitted.";box.appendChild(note)}
48895
49237
  const sub=document.createElement("p");sub.className="muted";
@@ -49052,46 +49394,67 @@ let rOpen={},rData={},rBusy={},rSeen={};
49052
49394
  let profCache=null,profLoading=false;
49053
49395
  async function profLoad(){if(profLoading)return;profLoading=true;try{profCache=((await(await fetch("/api/agency-profile")).json()).profile)||{}}catch(e){profCache={}}profLoading=false;render(lastState,lastRun)}
49054
49396
  /* What they bought (2026-09-06). Foundation is always built; the ticks decide the features.
49055
- Jerry, 2026-09-29: nothing saved no longer means the whole preset. Approve and Build wait for a save,
49056
- and nothing is pre-ticked unless the Assessment recorded purchases. */
49397
+ One list since 2026-09-29 (Jerry's approved design): the fourteen Assessment items, each with
49398
+ what it means for the client and what ticking it does for this client's preset. The server
49399
+ derives the build scope from the ticks, so the build reads what it always read. Approve also
49400
+ waits for the Assessment question above the list. */
49057
49401
  /* The ticks and package chosen but not yet saved, kept outside the block so a redraw keeps them (review of c09e946). */
49058
- let scopeDraft={};
49059
- function scopeChosen(state,id){const s=(state.scope||{})[id];return Boolean(s&&(s.wholePreset||(Array.isArray(s.features)&&s.features.length)))}
49402
+ let scopeDraft={},boughtNotice={};
49403
+ const BOUGHT_ITEMS=${scriptJson(boughtItemsFor(FEATURE_IDS))};
49404
+ 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)}
49060
49405
  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()}
49406
+ function boughtView(state,id){return (state.boughtViews||{})[id]||{items:BOUGHT_ITEMS,ticks:BOUGHT_ITEMS.map(i=>i.id),source:"all",answer:null,attached:false,ready:false,suggestions:[]}}
49407
+ /* "Have you run the Assessment for <client>?" One click records the answer; Yes opens the attach flow. */
49408
+ function assessmentQuestion(id,v){
49409
+ const q=mk("div","cds-stack");const who=((typeof ROSTER!=="undefined"&&ROSTER.find(r=>r.locationId===id))||{}).name||"this client";
49410
+ q.appendChild(mk("h4","cds-panel-label","Have you run the Assessment for "+who+"?"));
49411
+ const row=mk("div","cds-row");
49412
+ const opt=(choice,label)=>{const on=v.answer===choice;const b=mk("button","cds-btn"+(on?"":" ghost"),label);b.setAttribute("aria-pressed",String(on));
49413
+ b.onclick=async()=>{b.disabled=true;const r=await postJSON("/api/assessment-choice",{locationId:id,choice});if(r){if(r.approvalWithdrawn)boughtNotice[id]=r.message;load()}else b.disabled=false};row.appendChild(b)};
49414
+ opt("attached","Yes, attach it");opt("run-first","Run it first (recommended)");opt("without","Build without an Assessment");q.appendChild(row);
49415
+ if(v.attached||v.answer==="attached")q.appendChild(assessmentPanel(id));
49416
+ else if(v.answer==="run-first"){const p=mk("p","");const a=document.createElement("a");a.className="cds-link";a.href="/assessment";a.target="_blank";a.rel="noopener";a.textContent="Run the Assessment";p.appendChild(a);p.appendChild(document.createTextNode(". When it is saved, come back and attach it here."));q.appendChild(p)}
49417
+ else if(v.answer==="without")q.appendChild(mk("p","muted","Recorded: no Assessment for this client. The client document says so."));
49418
+ else q.appendChild(mk("p","muted","Approve waits for an answer."));
49419
+ return q}
49061
49420
  function scopeBlock(id,state){
49062
49421
  const sc=document.createElement("div");sc.className="cds-stack";sc.id="wtb-"+id;
49063
- sc.innerHTML="<h4 class='cds-panel-label'>What they bought</h4>";
49064
- const lead=document.createElement("p");lead.textContent="Tick what the client bought. Only these get built.";sc.appendChild(lead);
49422
+ const v=boughtView(state,id);
49423
+ sc.appendChild(assessmentQuestion(id,v));
49424
+ sc.appendChild(mk("h4","cds-panel-label","What they bought"));
49425
+ 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."));
49065
49426
  if(!profCache){profLoad();const p=document.createElement("p");p.className="muted";p.textContent="Loading your packages\u2026";sc.appendChild(p);return sc}
49066
49427
  const saved=(state.scope||{})[id]||null;const pk=(profCache.packages||[]);
49067
49428
  const sel=document.createElement("select");sel.className="cds-select";sel.setAttribute("aria-label","Package they bought");
49068
- const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"No package: tick the features below":"No packages saved yet (add them on Your agency): tick the features below";sel.appendChild(o0);
49429
+ const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"No package: tick the items below":"No packages saved yet (add them on Your agency): tick the items below";sel.appendChild(o0);
49069
49430
  for(const p of pk){const o=document.createElement("option");o.value=p.id;o.textContent=p.name+(p.price?" \xB7 "+moneyText(p.price):"");sel.appendChild(o)}
49070
- /* Only the features this client's plan has are offered (review of 89acc7d); a tick saved earlier outside it stays visible so it can be removed. */
49071
- const offered=((state.scopeOffered||{})[id])||Object.keys(FEATS);
49072
- const prefill=(state.scopePrefill||{})[id];let ticked=new Set(saved?(saved.wholePreset?offered:saved.features):(prefill||[]));if(saved&&saved.package)sel.value=saved.package;
49073
- const base=JSON.stringify(saved);if(scopeDraft[id]&&scopeDraft[id].base!==base)delete scopeDraft[id];const dr=scopeDraft[id];if(dr){ticked=new Set(dr.ticked);sel.value=dr.pkg}
49431
+ let ticked=new Set(v.ticks);if(saved&&saved.package)sel.value=saved.package;
49432
+ const base=JSON.stringify([saved,v.ticks]);if(scopeDraft[id]&&scopeDraft[id].base!==base)delete scopeDraft[id];const dr=scopeDraft[id];if(dr){ticked=new Set(dr.ticked);sel.value=dr.pkg}
49074
49433
  const keepScope=()=>{scopeDraft[id]={ticked:[...ticked],pkg:sel.value,base:base};unsaved.hidden=false};
49075
- const everything=document.createElement("button");everything.className="cds-btn"+(saved&&saved.wholePreset?" ghost":"");everything.textContent="Everything in the plan";
49076
- everything.title="Builds every feature in the plan for this client's industry.";
49077
- everything.onclick=async()=>{everything.disabled=true;const r=await postJSON("/api/scope",{locationId:id,features:null});if(r){if(r.approvalWithdrawn)alert(r.message);delete scopeDraft[id];delete assessmentCards[id];load()}else everything.disabled=false};
49434
+ const everything=document.createElement("button");everything.className="cds-btn ghost";everything.textContent="Tick everything";
49435
+ everything.title="Ticks all fourteen. Nothing is saved until you press Save what they bought.";
49436
+ everything.onclick=()=>{ticked=new Set(v.items.map(i=>i.id));keepScope();drawBoxes();drawFigs()};
49078
49437
  sc.appendChild(everything);
49079
49438
  const boxes=document.createElement("div");boxes.className="cds-stack";
49080
- function drawBoxes(){boxes.innerHTML="";for(const k of Object.keys(FEATS).filter(k=>offered.includes(k)||ticked.has(k))){const l=document.createElement("label");l.className="cds-check";
49081
- l.dataset.k=k;const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(k);c.onchange=()=>{if(c.checked)ticked.add(k);else ticked.delete(k);keepScope();drawFigs()};l.appendChild(c);
49082
- const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=FEATS[k].name;t.appendChild(tb);t.appendChild(mk("small","",FEATS[k].what));l.appendChild(t);boxes.appendChild(l)}}
49439
+ function drawBoxes(){boxes.innerHTML="";for(const it of v.items){const l=document.createElement("label");l.className="cds-check";
49440
+ 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);
49441
+ const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=it.name;t.appendChild(tb);t.appendChild(mk("small","",it.means));
49442
+ 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)}}
49083
49443
  const figs=((state.assessmentFigures||{})[id])||{};
49084
- function drawFigs(){for(const l of boxes.querySelectorAll("label")){const k=l.dataset.k;const old=l.querySelector("em");if(old)old.remove();if(figs[k]&&!ticked.has(k)){const e=document.createElement("em");e.className="cds-chip warn";e.textContent="Not bought: "+figs[k]+".";l.querySelector("span").appendChild(e)}}}
49085
- sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);if(p)ticked=new Set(p.features);keepScope();drawBoxes();drawFigs()};drawBoxes();drawFigs();
49086
- const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing. Unticked features go in the client document as available next.";
49444
+ 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)}}}
49445
+ /* A package names build features: it ticks their items and unticks the other built ones; the rest stay as they are. */
49446
+ 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();
49447
+ 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.";
49087
49448
  const sv=document.createElement("button");sv.className="cds-btn ghost";sv.textContent="Save what they bought";
49088
49449
  const status=document.createElement("p");status.className="muted";status.setAttribute("role","status");
49089
- sv.onclick=async()=>{if(!ticked.size){status.textContent="Tick at least one feature, or choose Everything in the plan.";return}
49090
- if(![...ticked].some(k=>offered.includes(k))){status.textContent="None of the ticked features is in this client's plan. Tick features from the list, or choose Everything in the plan.";return}
49091
- const all=offered.every(k=>ticked.has(k));const body={locationId:id,features:(all&&!sel.value)?null:[...ticked]};if(sel.value)body.package=sel.value;const r=await postJSON("/api/scope",body);if(r){if(r.approvalWithdrawn)alert(r.message);delete scopeDraft[id];delete assessmentCards[id];load()}};
49092
- status.textContent=saved?(saved.wholePreset?"Saved: everything in the plan. The build follows this.":saved.features.length?"Saved: "+saved.features.filter(k=>offered.includes(k)).length+" of "+offered.length+" features"+(saved.package?" from the "+saved.package+" package":"")+". The build follows this.":"Saved with nothing ticked, so nothing can be built. Tick what they bought, or choose Everything in the plan, and save."):prefill?"Not confirmed yet: ticked from what they bought. Nothing is built until you save.":"Nothing saved yet. Choose and save before approving.";
49450
+ sv.onclick=async()=>{if(!ticked.size){status.textContent="Tick at least one item they bought, then save.";return}
49451
+ 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}
49452
+ const body={locationId:id,bought:[...ticked]};if(sel.value)body.package=sel.value;const r=await postJSON("/api/scope",body);if(r){unsaved.hidden=true;if(r.approvalWithdrawn)boughtNotice[id]=r.message;else delete boughtNotice[id];delete scopeDraft[id];delete assessmentCards[id];load()}};
49453
+ const nBought=v.ticks.length,nBuilt=v.items.filter(it=>it.kind==="built"&&v.ticks.includes(it.id)).length;
49454
+ status.textContent=saved&&v.source==="saved"?"Saved: "+nBought+" of "+v.items.length+" bought, "+nBuilt+" of them built automatically"+(saved.package?", from the "+saved.package+" package":"")+". 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.";
49093
49455
  const unsaved=mk("span","cds-chip warn","Not saved yet");unsaved.hidden=!scopeDraft[id];
49094
49456
  sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(unsaved);sc.appendChild(status);
49457
+ if(boughtNotice[id]){const n=mk("p","cds-callout warn",boughtNotice[id]);n.setAttribute("role","alert");sc.appendChild(n)}
49095
49458
  return sc}
49096
49459
  async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;render(lastState,lastRun)}
49097
49460
  // Finding 20 (2026-08-26): writing the review takes 30\u201390 s and the page re-renders every few seconds meanwhile,
@@ -49109,7 +49472,6 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
49109
49472
  {const sc=(state.staffChecks||{})[id];if(!sc||sc.status!=="ready"){const w=document.createElement("p");w.className="cds-callout warn";w.setAttribute("role","alert");w.textContent=sc?STAFF_REVIEW_WARNING:STAFF_REVIEW_UNCHECKED;d.appendChild(w)}}
49110
49473
  /* Codex F1 (2026-09-06): the review is the model's output and the names are other businesses' records; both are TEXT. */
49111
49474
  const bt=(el,label,text)=>{const b=document.createElement("b");b.textContent=label;el.appendChild(b);el.appendChild(document.createTextNode(moneyText(text)))};
49112
- d.appendChild(assessmentPanel(id));
49113
49475
  const busy=reviewBusy(id,run);
49114
49476
  const gen=document.createElement("button");gen.className="cds-btn ghost";
49115
49477
  gen.textContent=busy?"Thinking it through\u2026":(r&&r.ok)?"Regenerate":"Generate the review";gen.disabled=busy;
@@ -49164,17 +49526,13 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
49164
49526
  const a=document.createElement("a");a.className="cds-link";a.href="/blueprint?locationId="+encodeURIComponent(id);a.target="_blank";a.rel="noopener";a.textContent="Open the prepared client document";brand.appendChild(a)};
49165
49527
  brand.appendChild(an);brand.appendChild(ac);brand.appendChild(dl);d.appendChild(brand);
49166
49528
  const row=document.createElement("div");row.className="cds-row";
49167
- const ap=document.createElement("button");ap.className="cds-btn ghost";ap.textContent="Approve: unlock the build";
49168
-
49169
- const stg=(state.clients[id]||{stages:[]}).stages||[];
49170
- if(stg[2]==="done"){ap.disabled=true;ap.textContent="Approved"}
49171
- /* Finding 35: this button used to POST to /api/stage on its own, with no operator name and
49172
- without reading the reply. When the work queue made a hand mark record WHO made it, this
49173
- button started failing the check and saying nothing at all . it just looked dead. Every
49174
- hand mark goes through markStage(), which asks for the name once, keeps it, and shows any
49175
- refusal instead of swallowing it. */
49176
- ap.onclick=async()=>{await markStage(id,2,"done")};
49177
- row.appendChild(ap);row.appendChild(gen);d.appendChild(row);
49529
+ /* Rehearsal 2, finding 15: this was a third approve button (labelled unlock the build) that only
49530
+ ticked the step, so Build then refused with "Approve building before running Build." It is now the
49531
+ same "Approve this plan" as the next step, and it appears only when approving can go through:
49532
+ once what they bought is saved, and not after approval. */
49533
+ if((state.buildApproved||{})[id]){const ap=document.createElement("button");ap.className="cds-btn ghost";ap.textContent="Approved";ap.disabled=true;row.appendChild(ap)}
49534
+ else if(scopeChosen(state,id)&&!["template","hidden"].includes(typeOf(state,id))&&!PROTECTED[id])row.appendChild(approveButton(id,name,state,"review","cds-btn"));
49535
+ row.appendChild(gen);d.appendChild(row);
49178
49536
  return d}
49179
49537
  function clientPageError(raw,next){
49180
49538
  const sentence=value=>{const t=String(value||"").trim().replace(/\\s*\\u2014\\s*/g,". ").replace(/[.!?;:]+$/,"");return t?t.charAt(0).toUpperCase()+t.slice(1)+".":""};
@@ -49355,11 +49713,6 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
49355
49713
  else if(!st.some(s=>s==="done"))div.appendChild(unnest(preflightCard(c)));
49356
49714
  /* Kept reachable after a finished Build while a re-run is held for staff (the gate saves its check). */
49357
49715
  {const sc=(state.staffChecks||{})[c.locationId];if(!parkedType&&((st[3]||"pending")!=="done"||(sc&&sc.status!=="ready")))div.appendChild(unnest(staffCard(c,state)))}
49358
- const approveBuilding=async()=>{const typed=prompt('Approve the Blueprint Review for "'+c.name+'" and let Command OS build in this account.'+String.fromCharCode(10,10)+'Type the client name exactly to confirm:');if(typed===null)return;
49359
- try{const response=await fetch("/api/build-approval",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,confirmName:typed})});const r=await response.json();
49360
- if(!response.ok||r.ok!==true){r.ok=false;r.error=r.error||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
49361
- if(!r.ok)alert(r.error);else load()
49362
- }catch(e){alert("Command OS could not confirm the result. Refresh this client to read its saved state before retrying.")}};
49363
49716
  // The steps, laid out as the owner approved on 2026-09-25: a progress bar, the next step in
49364
49717
  // full, then one row per step. Only the layout changed. Every button below keeps the condition
49365
49718
  // and the handler it had in the seven-column layout; the next-step panel draws the SAME
@@ -49456,13 +49809,23 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
49456
49809
  is "Choose what the client bought", which opens the Blueprint Review at that block. */
49457
49810
  const mustChoose=i===2&&!scopeChosen(state,c.locationId);
49458
49811
  const gateLabel=mustChoose?"Choose what the client bought":({2:"Approve this plan",5:"Confirm these tasks are finished",6:"Sign this client live"})[i];
49459
- const mkGate=cls=>{const action=document.createElement("button");action.className=cls;action.textContent=gateLabel;action.onclick=()=>{if(mustChoose)return openWhatTheyBought(c.locationId);if(i===2)return approveBuilding();return markStage(c.locationId,i,"done")};return action};
49812
+ /* Approve opens the one approval panel; the other gates open a hand-mark panel with the name on it (rehearsal 2). */
49813
+ const mkGate=cls=>{if(i===2&&!mustChoose)return approveButton(c.locationId,c.name,state,"gate",cls);
49814
+ const action=document.createElement("button");action.className=cls;action.textContent=gateLabel;const key="mark:"+c.locationId+":"+i;
49815
+ action.onclick=()=>{if(mustChoose)return openWhatTheyBought(c.locationId);icShow(action,key,markSpec(c.locationId,i,gateLabel))};return icAfter(action,key)};
49460
49816
  const canBuild=!parkedType&&(i<=1||sandbox||Boolean((state.buildApproved||{})[c.locationId]));
49461
- const runIt=async(ev)=>{ev.stopPropagation();if(i===0&&!confirm("In "+c.name+", Intake writes custom fields, one form, and one published notification workflow."+String.fromCharCode(10,10)+INTAKE_NOTIFICATION_REVIEW.join(String.fromCharCode(10))+String.fromCharCode(10,10)+"Create or repair these intake items?"))return;if(i===4&&PAGE_PLANS.includes(c.locationId)&&!confirm("Verify submits one test lead to the pages "+c.name+"\u2019s older build made, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once. Run the check?"))return;const res=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,stage:i,intakeConfirmed:i===0})});const j=await res.json();if(!j.ok)alert(j.error);load()};
49817
+ /* Starts the run; the answer is an error sentence, or "" when it started. */
49818
+ const startIt=async()=>{let j=null;try{const res=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,stage:i,intakeConfirmed:i===0})});j=await res.json()}catch(e){}
49819
+ return (j&&j.ok)?"":((j&&j.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.")};
49820
+ /* Intake, and Verify on an older page build, ask first: in a panel under the button, not a confirm() (rehearsal 2). */
49821
+ const runAsk=i===0?{title:"Create or repair the intake in "+c.name,text:["Intake writes custom fields, one form, and one published notification workflow."].concat(INTAKE_NOTIFICATION_REVIEW),ok:"Create or repair these intake items",busyLabel:"Starting\u2026",run:startIt}
49822
+ :(i===4&&PAGE_PLANS.includes(c.locationId))?{title:"Run the check on "+c.name,text:["Verify submits one test lead to the pages "+c.name+"\u2019s older build made, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once."],ok:"Run the check",busyLabel:"Starting\u2026",run:startIt}:null;
49823
+ const runKeyOf="run:"+c.locationId+":"+i;
49824
+ const runIt=async(ev,btn)=>{ev.stopPropagation();if(runAsk)return icShow(btn,runKeyOf,runAsk);const err=await startIt();if(err)alert(err);load()};
49462
49825
  const mkRun=(cls,label)=>{const b=document.createElement("button");b.className=cls;
49463
49826
  const busy=run&&run.running;b.textContent=busy&&run.locationId===c.locationId&&run.stage===i?"Running\u2026":label;
49464
49827
  b.disabled=Boolean(busy)||(!st.some(s=>s==="done")&&!preflights[c.locationId]?.ok);
49465
- b.onclick=runIt;return b};
49828
+ b.onclick=ev=>runIt(ev,b);return runAsk?icAfter(b,runKeyOf):b};
49466
49829
  if(!live&&nIss){const si=document.createElement("button");si.className="cds-link sactbtn";si.textContent=nIss>1?"See issues":"See issue";si.onclick=toggleIssues;act.appendChild(si)}
49467
49830
  /* One way to act on a step: the current step's buttons live in the next-step block only; other rows keep their links.
49468
49831
  A gate the owner has already answered offers nothing. */
@@ -49481,7 +49844,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
49481
49844
  if(canBuild&&POWERED.includes(i))btns.appendChild(mkRun("cds-btn runbtn sbig","Run "+LABELS[i]));
49482
49845
  if(!parkedType&&canBuild&&POWERED.includes(i)&&run&&run.running&&run.locationId!==c.locationId){const why=document.createElement("p");why.className="muted swhy";why.textContent="A step is running for another client. This one can start when it finishes.";btns.appendChild(why)}
49483
49846
  if(!btns.children.length&&!live&&!parkedType){const why=document.createElement("p");why.className="muted swhy";
49484
- why.textContent=anyBusy?"Another step is running for this client. This one opens when it finishes.":POWERED.includes(i)?"Approve building for this client below, and this step can run.":"";
49847
+ why.textContent=anyBusy?"Another step is running for this client. This one opens when it finishes.":POWERED.includes(i)?"Approve this plan in step 3, and this step can run.":"";
49485
49848
  if(why.textContent)btns.appendChild(why)}
49486
49849
  nextBox.title=el.title||"";nextBox.appendChild(main);nextBox.appendChild(btns)}
49487
49850
  row.appendChild(el)});
@@ -49508,9 +49871,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
49508
49871
  if(view.stale.length){const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const stale of view.stale){const p=document.createElement("p");p.textContent=LABELS[stale.stage]+": stale. "+stale.reason;d.appendChild(p)}div.appendChild(d)}
49509
49872
  if(view.approvedAt){const p=document.createElement("p");p.textContent="Building approved on "+niceDate(view.approvedAt,true)+".";div.appendChild(p)}
49510
49873
  for(const repair of view.repairs){const p=document.createElement("p");p.textContent="Live workflow repair: "+repair.name+". "+repair.changes.join(" ");div.appendChild(p)}
49511
- /* No "Approve building\u2026" while nothing is chosen (review of 89acc7d): it would ask for a name and then refuse. */
49874
+ /* Rehearsal 2, finding 15: the second building-approval button that was here is gone. The one "Approve this plan"
49875
+ (step 3) approves the listed live workflow repairs too, and says so in its panel. */
49512
49876
  if(view.planHash&&!parkedType&&!scopeChosen(state,c.locationId)){const ch=document.createElement("button");ch.className="cds-btn ghost";ch.textContent="Choose what the client bought";ch.onclick=()=>openWhatTheyBought(c.locationId);div.appendChild(ch)}
49513
- else if(view.planHash&&!parkedType){const approve=document.createElement("button");approve.className="cds-btn ghost";approve.textContent="Approve building\u2026";approve.onclick=async()=>{const typed=sandbox?c.name:prompt("Approve building and the listed live workflow repairs for "+c.name+". Type the client name to confirm.");if(typed===null)return;if(await postJSON("/api/build-approval",{locationId:c.locationId,confirmName:typed,repairHashes:view.repairs.map(r=>r.hash)}))load()};div.appendChild(approve)}
49514
49877
  }else{const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));const p=document.createElement("p");p.textContent="The executable plan is prepared when Build runs.";d.appendChild(p);div.appendChild(d)}
49515
49878
  if(view?.handoff)div.appendChild(unnest(handoffPanel(c,view)));
49516
49879
  const _prot=PROTECTED[c.locationId];
@@ -49528,11 +49891,11 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
49528
49891
  rv.onclick=async()=>{await postJSON("/api/build-approval",{locationId:c.locationId,revoke:true});load()};
49529
49892
  ab.appendChild(s1);ab.appendChild(rv);ab.appendChild(protectBtn(c))}
49530
49893
  else if(parkedType){ab.appendChild(protectBtn(c))}
49531
- else{const w=document.createElement("span");w.className="muted";w.textContent="Automated building is off for this client. ";
49532
- const btn=document.createElement("button");btn.className="cds-btn ghost";
49533
- if(scopeChosen(lastState,c.locationId)){btn.textContent="Approve building\u2026";btn.onclick=approveBuilding}
49534
- else{btn.textContent="Choose what the client bought";btn.onclick=()=>openWhatTheyBought(c.locationId)}
49535
- ab.appendChild(w);ab.appendChild(btn);ab.appendChild(protectBtn(c))}
49894
+ else{/* No second approve button here (rehearsal 2, finding 15): step 3's "Approve this plan" is the one. */
49895
+ const w=document.createElement("span");w.className="muted";w.textContent="Automated building is off for this client until you approve this plan in step 3. ";
49896
+ ab.appendChild(w);
49897
+ if(!scopeChosen(lastState,c.locationId)){const btn=document.createElement("button");btn.className="cds-btn ghost";btn.textContent="Choose what the client bought";btn.onclick=()=>openWhatTheyBought(c.locationId);ab.appendChild(btn)}
49898
+ ab.appendChild(protectBtn(c))}
49536
49899
  div.appendChild(ab)}
49537
49900
  // A "live" claim is re-derived from the signature, never trusted from the
49538
49901
  // stage array: the gate runs on the write path, and this file can be restored
@@ -49574,6 +49937,13 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
49574
49937
  txt.textContent=t.text;if(t.reappeared){txt.textContent="";txt.appendChild(chip("warn","Came back after you ticked it"));txt.appendChild(document.createTextNode(" "+t.text))}
49575
49938
  row.appendChild(cb);row.appendChild(txt);box.appendChild(row)});
49576
49939
  div.appendChild(box)}}
49940
+ // Suggested next steps with Claude (2026-09-29): from what they bought, shown beside the
49941
+ // jobs once Verify has run, never a job to tick and never something go-live waits on.
49942
+ {const sg=((lastState.boughtViews||{})[c.locationId]||{}).suggestions||[];
49943
+ if(sg.length&&(Boolean((lastState.outcomes||{})[c.locationId+":4"])||((lastState.manualTasks||{})[c.locationId]||[]).length)){
49944
+ const box=mk("div","cds-stack");box.appendChild(mk("h3","cds-panel-title","Suggested next steps with Claude"));
49945
+ box.appendChild(mk("p","muted","From what they bought. These are not jobs in the account, and nothing waits on them."));
49946
+ const ul=mk("ul","cds-list");for(const s of sg)ul.appendChild(mk("li","",s));box.appendChild(ul);div.appendChild(box)}}
49577
49947
  const _intakeIn=(iData[c.locationId]&&iData[c.locationId].status==="received");
49578
49948
  const _writing=reviewBusy(c.locationId,run);
49579
49949
  { // Review is accessible before intake so an assessment can fill it.
@@ -49585,7 +49955,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
49585
49955
  if(rOpen[c.locationId])div.appendChild(unnest(reviewPanel(c.locationId,c.name,state,run)))}
49586
49956
  if(st[0]==="done"){const ir=document.createElement("div");ir.className="cds-row";
49587
49957
  const ib2=document.createElement("button");ib2.className="cds-btn ghost";ib2.textContent=iOpen[c.locationId]?"Close the intake":"Complete the intake";ib2.setAttribute("aria-expanded",String(Boolean(iOpen[c.locationId])));
49588
- ib2.onclick=async()=>{iOpen[c.locationId]=!iOpen[c.locationId];if(iOpen[c.locationId]&&!iData[c.locationId])await iLoad(c.locationId);else render(lastState,lastRun)};
49958
+ ib2.onclick=async()=>{iOpen[c.locationId]=!iOpen[c.locationId];if(!iOpen[c.locationId])iRetryStop(c.locationId);if(iOpen[c.locationId]&&!iData[c.locationId])await iLoad(c.locationId);else render(lastState,lastRun)};
49589
49959
  ir.appendChild(ib2);
49590
49960
  const dd=iData[c.locationId];
49591
49961
  if(dd&&dd.ok){const ic=document.createElement("span");ic.className="cds-chip istatus "+(dd.status==="received"?"ok":dd.status==="sent"?"warn":"");
@@ -49636,10 +50006,77 @@ function acctRow(c,state,run){const row=document.createElement("li");row.classNa
49636
50006
  Derived server-side from the same board the cards show (/api/queue): no second
49637
50007
  list to fall out of step with. Clicking a row opens that account's card. \u2500\u2500 */
49638
50008
  let queueData=null,showQueueRest=false,showLive=false;
49639
- function opName(force){let n="";try{n=localStorage.getItem("cmdos-operator")||""}catch(e){}
49640
- if(!n||force){const t=prompt("Your name. Command OS records who marked a step done by hand, so nobody has to take it on trust:",n||"");
49641
- if(t===null)return n||null;n=t.trim();if(!n)return null;try{localStorage.setItem("cmdos-operator",n)}catch(e){}}
49642
- return n}
50009
+ /* \u2500\u2500 Inline confirm (rehearsal 2, 2026-09-29) \u2500\u2500
50010
+ A panel under the button instead of prompt(), confirm() or alert(): one stray keystroke dismissed a
50011
+ prompt, and an alert sent the member off the page to read why. The panel keeps its fields, says what
50012
+ went wrong in place, and is drawn again after a redraw with what was typed (its state lives here). */
50013
+ function icPanel(key){const st=icOpen[key],sp=st.spec;const p=document.createElement("div");p.className="cds-callout info cds-stack ic-panel";p.dataset.icp=key;p.setAttribute("role","group");
50014
+ if(sp.title)p.setAttribute("aria-label",sp.title);
50015
+ /* Clicks and keys stay in the panel: a queue row behind it opens a client on click. */
50016
+ p.onclick=e=>e.stopPropagation();
50017
+ p.onkeydown=e=>{e.stopPropagation();if(e.key==="Escape"&&!st.busy){delete icOpen[key];p.remove()}};
50018
+ if(sp.title)p.appendChild(mk("b","",sp.title));
50019
+ for(const line of [].concat(sp.text||[]))p.appendChild(mk("p","",line));
50020
+ const inputs={};let ok;
50021
+ for(const f of sp.fields||[]){const l=document.createElement("label");l.className="cds-field";l.appendChild(mk("span","",f.label));
50022
+ const inp=document.createElement("input");inp.className="cds-input";inp.autocomplete="off";inp.name="ic:"+key+":"+f.id;inp.value=st.values[f.id]!=null?st.values[f.id]:(f.value||"");
50023
+ inp.oninput=()=>{st.values[f.id]=inp.value};inp.onkeydown=e=>{if(e.key==="Enter"){e.preventDefault();ok.click()}};
50024
+ l.appendChild(inp);p.appendChild(l);inputs[f.id]=inp}
50025
+ const err=mk("p","cds-callout warn",st.error||"");err.setAttribute("role","alert");err.hidden=!st.error;p.appendChild(err);
50026
+ const row=document.createElement("div");row.className="cds-row";
50027
+ ok=document.createElement("button");ok.type="button";ok.className="cds-btn";ok.textContent=st.busy?(sp.busyLabel||"Working\u2026"):sp.ok;ok.disabled=Boolean(st.busy);
50028
+ const no=document.createElement("button");no.type="button";no.className="cds-btn ghost";no.textContent="Cancel";no.disabled=Boolean(st.busy);
50029
+ no.onclick=()=>{delete icOpen[key];p.remove()};
50030
+ ok.onclick=async()=>{if(st.busy)return;const vals={};for(const f of sp.fields||[])vals[f.id]=String(inputs[f.id].value||"").trim();
50031
+ st.busy=true;st.error="";err.hidden=true;ok.disabled=true;no.disabled=true;ok.textContent=sp.busyLabel||"Working\u2026";
50032
+ let msg="";try{msg=(await sp.run(vals))||""}catch(e){msg="Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
50033
+ st.busy=false;
50034
+ /* A redraw during the request drew a new panel from the state (showing "Approving\u2026"); this one may
50035
+ be detached. Every copy of the panel is replaced with a fresh one, or removed on success (review of d407f04). */
50036
+ const copies=new Set([p,...icLive(key)]);
50037
+ if(msg){st.error=msg;for(const x of copies)if(x.parentNode)x.replaceWith(icPanel(key));if(redrawPending)scheduleFlush();return}
50038
+ delete icOpen[key];for(const x of copies)x.remove();if(sp.after)await sp.after();else load()};
50039
+ row.appendChild(ok);row.appendChild(no);p.appendChild(row);return p}
50040
+ /* Opens the panel under the button (or focuses it when it is already open). */
50041
+ function icShow(btn,key,spec){const nx=btn.nextElementSibling;if(icOpen[key]&&nx&&nx.dataset&&nx.dataset.icp===key){const f=nx.querySelector("input")||nx.querySelector("button");if(f)f.focus();return}
50042
+ icOpen[key]={spec:spec,values:{},error:"",busy:false};const p=icPanel(key);btn.insertAdjacentElement("afterend",p);
50043
+ const f=p.querySelector("input")||p.querySelector("button");if(f)f.focus()}
50044
+ /* Marks a button as the owner of a panel, so a redraw puts an open panel back under it. */
50045
+ /* Put back synchronously by render() before it restores focus, so a field typed in keeps the cursor;
50046
+ the microtask covers a card drawn outside render(). */
50047
+ let icQueue=[];
50048
+ function icAfter(btn,key){if(icOpen[key]){icQueue.push([btn,key]);queueMicrotask(icFlush)}return btn}
50049
+ function icFlush(){const q=icQueue;icQueue=[];for(const [btn,key] of q){const nx=btn.nextElementSibling;if(btn.parentNode&&icOpen[key]&&!(nx&&nx.dataset&&nx.dataset.icp===key))btn.insertAdjacentElement("afterend",icPanel(key))}}
50050
+ /* Every panel for this key on the page now. */
50051
+ function icLive(key){return [...document.querySelectorAll(".ic-panel")].filter(x=>x.dataset.icp===key)}
50052
+ /* A panel whose question the state has answered elsewhere is dropped, so a later revoke or reset never
50053
+ reopens it with old typing (review of d407f04). */
50054
+ function icPrune(state){for(const k of Object.keys(icOpen)){const st=icOpen[k];if(!st||st.busy)continue;const [kind,id,step]=k.split(":");
50055
+ const stages=((state.clients||{})[id]||{}).stages||[];
50056
+ if(kind==="approve"&&(state.buildApproved||{})[id])delete icOpen[k];
50057
+ else if((kind==="mark"||kind==="qmark")&&stages[Number(step)]==="done")delete icOpen[k]}}
50058
+ /* The name Command OS records on a hand mark, as last saved on this computer. */
50059
+ function opName(){let n="";try{n=localStorage.getItem("cmdos-operator")||""}catch(e){}return n}
50060
+ function saveOpName(n){try{localStorage.setItem("cmdos-operator",n)}catch(e){}}
50061
+ const NAME_FIELD=()=>({id:"who",label:"Your name",value:opName()});
50062
+ function needName(v){if(!v.who)return "Type your name. Command OS records who did this, so nobody has to take it on trust.";saveOpName(v.who);return ""}
50063
+ /* The ONE approval (rehearsal 2, finding 15). The next-step button and the Blueprint Review both open this,
50064
+ with the same label and the same request; nothing else approves, and nothing refuses and points elsewhere. */
50065
+ function approveSpec(id,name,state){const sandbox=typeOf(state,id)==="sandbox";
50066
+ const view=(state.buildViews||{})[id];const repairs=(view&&view.planHash&&view.repairs)||[];
50067
+ return {title:"Approve this plan for "+name,
50068
+ text:["Command OS then builds in this account what the Blueprint Review lists"+(repairs.length?", and makes the "+repairs.length+" live workflow "+plural(repairs.length,"repair")+" listed with the plan":"")+"."],
50069
+ fields:[NAME_FIELD()].concat(sandbox?[]:[{id:"client",label:"Type the client name to confirm: "+name,value:""}]),
50070
+ ok:"Approve",busyLabel:"Approving\u2026",
50071
+ run:async v=>{const bad=needName(v);if(bad)return bad;
50072
+ if(!sandbox&&v.client.toLowerCase()!==String(name).trim().toLowerCase())return 'Type the client name exactly as shown: "'+name+'".';
50073
+ let res,j=null;try{res=await fetch("/api/build-approval",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,confirmName:sandbox?name:v.client,by:v.who,repairHashes:repairs.map(r=>r.hash)})})}
50074
+ catch(e){return "Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
50075
+ try{j=await res.json()}catch(e){}
50076
+ if(!res.ok||!j||j.ok!==true)return (j&&j.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.";
50077
+ return ""}}}
50078
+ function approveButton(id,name,state,place,cls){const b=document.createElement("button");b.className=cls;b.textContent="Approve this plan";
50079
+ const key="approve:"+id+":"+place;b.onclick=()=>icShow(b,key,approveSpec(id,name,lastState||state));return icAfter(b,key)}
49643
50080
  /* Finding 35: a write whose reply nobody reads is a button that can start failing in
49644
50081
  silence. Every cockpit write goes through here, so a refusal is shown rather than
49645
50082
  swallowed. */
@@ -49652,13 +50089,14 @@ async function postJSON(url,body){
49652
50089
  return j||true}
49653
50090
  /* Nothing hand-markable that Command OS can check for itself. Steps it runs are marked
49654
50091
  by the run; steps that need a person are marked by that person, on the record. */
49655
- async function markStage(id,i,status){
49656
- if(POWERED.includes(i)&&status!=="pending"){
49657
- alert(LABELS[i]+" is a step Command OS runs and checks for you, so it cannot be ticked off by hand. Use the Run this step button on the card. If it went wrong, set it back to not started and run it again.");return}
49658
- const who=opName(false);if(!who)return;
49659
- const r=await(await fetch("/api/stage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,stage:i,status:status,by:who})})).json();
49660
- if(!r.ok){alert(r.error);return}
49661
- await loadQueue();load()}
50092
+ async function markStage(id,i,status,who){
50093
+ if(POWERED.includes(i)&&status!=="pending")return LABELS[i]+" is a step Command OS runs and checks for you, so it cannot be ticked off by hand. Use the Run this step button on the card. If it went wrong, set it back to not started and run it again.";
50094
+ let r=null;try{r=await(await fetch("/api/stage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,stage:i,status:status,by:who})})).json()}catch(e){}
50095
+ if(!r||!r.ok)return (r&&r.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.";
50096
+ return ""}
50097
+ /* A hand mark opens a panel with the name that goes on it (prefilled from last time) instead of a prompt. */
50098
+ function markSpec(id,i,label,text){return {title:label,text:[].concat(text||[],"Marking this done puts your name and the time on it."),fields:[NAME_FIELD()],ok:label,busyLabel:"Saving\u2026",
50099
+ run:async v=>{const bad=needName(v);if(bad)return bad;return markStage(id,i,"done",v.who)},after:async()=>{await loadQueue();load()}}}
49662
50100
  /* cheap: a background refresh uses GET /api/queue, which derives the queue from the saved board without re-reading
49663
50101
  every client's intake from GoHighLevel; the POST (which does) stays for the page's own loads. */
49664
50102
  /* Three ways to read the queue:
@@ -49718,10 +50156,9 @@ function queueRow(it){const row=document.createElement("li");row.className="q-ro
49718
50156
  /* State in words: running is info; anything unfinished is warn. */
49719
50157
  side.appendChild(it.running?chip("info","Running now"):it.needs==="you"?chip("warn","Your call"):(it.proof&&it.proof.issues&&it.proof.issues.length)?chip("warn","To sort out"):chip("warn","Not run yet"));
49720
50158
  if(it.action==="mark"){const mb=document.createElement("button");mb.className="cds-btn ghost";mb.textContent="Mark done";
49721
- mb.onclick=async(e)=>{e.stopPropagation();
49722
- if(!confirm(it.title+String.fromCharCode(10,10)+"Marking this done puts your name and the time on it."))return;
49723
- await markStage(it.locationId,it.stageIndex,"done")};
49724
- side.appendChild(mb)}
50159
+ const mkey="qmark:"+it.locationId+":"+it.stageIndex;
50160
+ mb.onclick=(e)=>{e.stopPropagation();icShow(mb,mkey,markSpec(it.locationId,it.stageIndex,"Mark done",it.title))};
50161
+ side.appendChild(icAfter(mb,mkey))}
49725
50162
  else side.appendChild(mk("span","cds-link acct-open","Open"));
49726
50163
  row.appendChild(side);
49727
50164
  row.onclick=()=>{if(it.href){rememberClient(it.locationId);location.href=it.href}else queueOpen(it.locationId)};
@@ -49745,7 +50182,9 @@ function operatorLine(){const who=document.createElement("span");who.className="
49745
50182
  let nm="";try{nm=localStorage.getItem("cmdos-operator")||""}catch(e){}
49746
50183
  who.appendChild(document.createTextNode(nm?("Marking as "+nm):"Nobody set to mark steps"));
49747
50184
  const ch=document.createElement("button");ch.className="cds-link";ch.textContent=nm?"Change":"Set your name";
49748
- ch.onclick=()=>{opName(true);render(lastState,lastRun)};who.appendChild(ch);return who}
50185
+ /* A panel with the name field, not a prompt (rehearsal 2). */
50186
+ ch.onclick=()=>icShow(ch,"opname",{title:"Who marks steps done",fields:[NAME_FIELD()],ok:"Save",busyLabel:"Saving\u2026",run:async v=>needName(v),after:async()=>render(lastState,lastRun)});
50187
+ who.appendChild(icAfter(ch,"opname"));return who}
49749
50188
  function queueBlock(match){const q=queueData;if(!q||!q.ok)return queueReadError();
49750
50189
  const frag=document.createDocumentFragment();
49751
50190
  const items=(q.items||[]).filter(i=>match({name:i.clientName,locationId:i.locationId}));
@@ -49939,7 +50378,8 @@ const KEY_FACTS=[/website/i,/primary goal/i,/primary services|services you sell/
49939
50378
  function capAnswer(v){return Array.isArray(v)?v.join(", "):String(v==null?"":v).trim()}
49940
50379
  function renderIntakeCapability(m,c){const id=c.locationId;const data=iData[id];
49941
50380
  if(!data){iLoad(id);const p=panel("Intake","The client\u2019s answers");p.appendChild(mk("p","muted","Reading the intake\u2026"));m.appendChild(p);return}
49942
- if(data.ok===false){const w=mk("div","cds-callout warn");w.setAttribute("role","alert");w.appendChild(mk("span","ttl","Could not read the intake"));w.appendChild(mk("span","",data.error||"Refresh the page to try again."));m.appendChild(w)}
50381
+ if(data.ok===false&&data.transient){const p=mk("p","muted",data.error||"Could not refresh just now; retrying.");p.setAttribute("role","status");m.appendChild(p)}
50382
+ else if(data.ok===false){const w=mk("div","cds-callout warn");w.setAttribute("role","alert");w.appendChild(mk("span","ttl","Could not read the intake"));w.appendChild(mk("span","",data.error||"Refresh the page to try again."));m.appendChild(w)}
49943
50383
  const answers=data.answers||{};const questions=data.questions||[];
49944
50384
  const answerOf=q=>capAnswer(answers[q.label]!=null?answers[q.label]:answers[q.key]);
49945
50385
  const answered=questions.length?questions.filter(q=>answerOf(q)).map(q=>[q.label,answerOf(q)]):Object.entries(answers).map(([k,v])=>[k,capAnswer(v)]).filter(x=>x[1]);
@@ -50063,7 +50503,7 @@ function restoreView(v){const m=document.getElementById("main");if(!v||!m)return
50063
50503
  if(v.focus){const el=[...m.querySelectorAll(v.focus.tag)].filter(x=>fieldKey(x,m)===v.focus.key)[v.focus.n];
50064
50504
  if(el&&document.activeElement!==el){try{el.focus({preventScroll:true})}catch(e){}try{if(v.focus.st!=null)el.setSelectionRange(v.focus.st,v.focus.en)}catch(e){}}}
50065
50505
  if(window.scrollX!==v.x||window.scrollY!==v.y){try{window.scrollTo(v.x,v.y)}catch(e){}}}
50066
- function render(state,run){const v=captureView();try{renderPage(state,run)}finally{lastDrawnSig=dataSig(state,run);restoreView(v)}}
50506
+ function render(state,run){const v=captureView();try{if(state)icPrune(state);renderPage(state,run);icFlush()}finally{lastDrawnSig=dataSig(state,run);restoreView(v)}}
50067
50507
  function renderPage(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...state.roster)}syncReviews(state);lastState=state;lastRun=run;
50068
50508
  if(state&&state.protectedAccounts)PROTECTED=state.protectedAccounts;const m=document.getElementById("main");m.innerHTML="";
50069
50509
  /* Four screens share this page's data and differ only in what they show. */
@@ -50917,9 +51357,18 @@ async function runDashboard(argv) {
50917
51357
  if (req.method === "POST" && url === "/api/client-assessment") {
50918
51358
  readBody(req, res, 2e4, async (body) => {
50919
51359
  try {
50920
- const result = await assessmentAttachmentAction(JSON.parse(body), attachmentDeps);
51360
+ const request2 = JSON.parse(body);
51361
+ const result = await assessmentAttachmentAction(request2, attachmentDeps);
51362
+ let withdrawn = false;
51363
+ if (typeof request2?.clientLocationId === "string") {
51364
+ const after = withdrawIfBindingChanged(readCockpitState(), request2.clientLocationId);
51365
+ if (after.withdrawn) {
51366
+ writeCockpitState(after.state);
51367
+ withdrawn = true;
51368
+ }
51369
+ }
50921
51370
  res.writeHead(200, { "Content-Type": "application/json" });
50922
- res.end(JSON.stringify(result));
51371
+ res.end(JSON.stringify({ ...result, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_ASSESSMENT } : {} }));
50923
51372
  } catch (e) {
50924
51373
  res.writeHead(400, { "Content-Type": "application/json" });
50925
51374
  logQuietly(`client assessment: ${(0, import_node_util5.inspect)(e)}`);
@@ -51304,12 +51753,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51304
51753
  }
51305
51754
  const assessmentFigures = {};
51306
51755
  for (const loc of Object.keys(st.assessmentAttachments ?? {})) assessmentFigures[loc] = assessmentFiguresFor(st, loc);
51307
- const scopePrefill = {};
51308
- for (const loc of Object.keys(st.assessmentPurchases ?? {})) {
51309
- const p = scopePrefillFor(st, loc);
51310
- if (p) scopePrefill[loc] = p;
51311
- }
51312
51756
  const scopeOffered = Object.fromEntries(currentRoster().map((c) => [c.locationId, offeredFeaturesFor(st, c.locationId)]));
51757
+ const boughtViews = Object.fromEntries(currentRoster().map((c) => [c.locationId, boughtViewFor(st, c.locationId, scopeOffered[c.locationId])]));
51313
51758
  const buildViews = Object.fromEntries(currentRoster().map((c) => {
51314
51759
  const saved = readSavedPlan(c.locationId);
51315
51760
  const approval = st.planApprovals?.[c.locationId];
@@ -51340,9 +51785,9 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51340
51785
  delivery: void 0
51341
51786
  };
51342
51787
  }
51343
- for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || st.manualTasks?.[id]?.length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
51788
+ for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || openTasks(st.manualTasks?.[id] ?? []).length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
51344
51789
  const outcomes = Object.fromEntries(Object.entries(st.outcomes ?? {}).map(([key, value]) => [key, { ...value, ...key.endsWith(":4") && value.verification ? { plain: verifyPanelOutcome(value) } : { ...plainFallbackOutcome(value), plain: plainFallbackOutcome(value.plain ?? value) }, error: value.error ? "The step did not finish. Check the account before retrying." : void 0 }]));
51345
- res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, scopeOffered, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
51790
+ res.end(JSON.stringify({ ...st, assessmentFigures, boughtViews, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
51346
51791
  const records = st.outcomes?.[`${id}:4`]?.verification;
51347
51792
  const allowed = records ? verifyReadback(records).allowPublishing : false;
51348
51793
  return [id, tasks2.map((t) => ({ ...t, text: verifyPublishingText(plainFallback(t.text, { nextStep: true }), allowed) })).filter((t) => t.text)];
@@ -51404,7 +51849,9 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51404
51849
  if (stage === 3) {
51405
51850
  if (!hasChosenScope(st0, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_BEFORE_BUILD);
51406
51851
  const ap = st0.planApprovals?.[rk];
51407
- if (ap && ap.scopeHash !== scopeHashOf(st0.scope?.[rk])) throw new Error(SCOPE_CHANGED_SINCE_APPROVAL);
51852
+ const assessmentWait = assessmentGateRefusal(st0, rk, who.clientKey, who.name);
51853
+ if (assessmentWait) throw new Error(assessmentWait);
51854
+ if (ap && ap.scopeHash !== approvalScopeHash(st0, rk)) throw new Error(boughtUnchangedSinceApproval(ap, st0, rk) ? ASSESSMENT_CHANGED_SINCE_APPROVAL : SCOPE_CHANGED_SINCE_APPROVAL);
51408
51855
  }
51409
51856
  if (who.clientKey && [3, 4].includes(stage)) {
51410
51857
  inputHash = secondClientInputHash(readSavedPlan(rk));
@@ -51423,7 +51870,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51423
51870
  const client = clientFor(locationId2);
51424
51871
  if (!client) throw new Error(INTAKE_UNREADABLE);
51425
51872
  const snap = await readIntakeSnapshot(client, locationId2);
51426
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
51873
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
51427
51874
  normalizedAnswers = normalizedIntake(snap);
51428
51875
  inputHash = intakeInput(normalizedAnswers, st0.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: st0.accounts?.[locationId2]?.industry, agency: readOverlay() }));
51429
51876
  st0 = { ...st0, intakeInputs: { ...st0.intakeInputs, [locationId2]: { hash: inputHash, answers: normalizedAnswers, status: snap.status } } };
@@ -51444,7 +51891,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51444
51891
  const c = clientFor(locationId2);
51445
51892
  if (!c) throw new Error(INTAKE_UNREADABLE);
51446
51893
  const snap = await readIntakeSnapshot(c, locationId2);
51447
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
51894
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
51448
51895
  if (snap.status !== "received") throw new Error("Open the real form, submit it, then refresh.");
51449
51896
  const required = composeQuestionSet({ industry: st0.accounts?.[locationId2]?.industry, agency: readOverlay() }).filter((q3) => q3.required && !String(normalizedAnswers?.[q3.label] ?? normalizedAnswers?.[q3.key] ?? "").trim());
51450
51897
  if (required.length) throw new Error("Complete these required intake answers: " + required.map((q3) => q3.label).join(", ") + ".");
@@ -51530,7 +51977,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51530
51977
  const st = readCockpitState();
51531
51978
  if (!c && !st.pending?.[locationId2]) throw new Error(INTAKE_CONNECT_REQUIRED);
51532
51979
  const snap = c ? await readIntakeSnapshot(c, locationId2, Boolean(st.intakeSent?.[locationId2])) : { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
51533
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
51980
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
51534
51981
  const industry = st.accounts?.[locationId2]?.industry;
51535
51982
  const questions = composeQuestionSet({ industry, agency: readOverlay() }).map((q3) => ({ key: q3.key, contactTag: q3.field.kind === "standard" ? q3.field.tag : void 0, label: q3.label, sectionLabel: q3.sectionLabel, required: q3.required, options: q3.options ?? null, placeholder: q3.placeholder ?? "", multi: q3.field.kind === "custom" && q3.field.dataType === "MULTIPLE_OPTIONS" }));
51536
51983
  const answers = prefillTeamAnswers(normalizedIntake(snap, st.intakeDrafts?.[locationId2]), st.staffRoster?.[locationId2], questions);
@@ -51543,7 +51990,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51543
51990
  } catch (e) {
51544
51991
  res.writeHead(400, { "Content-Type": "application/json" });
51545
51992
  logQuietly(`client-intake ${new URL(req.url || "/", "http://x").searchParams.get("locationId") || ""}: ${e instanceof Error ? e.message : String(e)}`);
51546
- res.end(JSON.stringify({ ok: false, error: intakeRequestError(e) }));
51993
+ if (e instanceof Error && e.message === INTAKE_READ_TRANSIENT) res.end(JSON.stringify({ ok: false, transient: true, error: INTAKE_REFRESH_RETRYING }));
51994
+ else res.end(JSON.stringify({ ok: false, error: intakeRequestError(e) }));
51547
51995
  }
51548
51996
  })();
51549
51997
  return;
@@ -51966,7 +52414,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51966
52414
  }
51967
52415
  const name = currentRoster().find((r) => r.locationId === locationId2)?.name || locationId2;
51968
52416
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
51969
- res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2, readSavedPlan(locationId2)), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
52417
+ 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)), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
51970
52418
  return;
51971
52419
  }
51972
52420
  if (req.method === "GET" && url === "/api/review") {
@@ -51978,12 +52426,15 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51978
52426
  if (req.method === "POST" && url === "/api/build-approval") {
51979
52427
  readBody(req, res, 5e3, async (body) => {
51980
52428
  try {
51981
- const { locationId: locationId2, clientKey, confirmName, revoke, repairHashes } = JSON.parse(body);
52429
+ const { locationId: locationId2, clientKey, confirmName, revoke, repairHashes, by } = JSON.parse(body);
52430
+ const approvedBy = typeof by === "string" && by.trim() ? { by: by.trim().slice(0, 80) } : {};
51982
52431
  const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
51983
52432
  const rk = who.recordKey;
51984
52433
  if (PROTECTED_LOCATIONS[who.locationId]) throw new Error(PROTECTED_LOCATIONS[who.locationId] + " can never be approved for building.");
51985
52434
  const current = readCockpitState();
51986
52435
  if (!revoke && !hasChosenScope(current, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_WHAT_THEY_BOUGHT);
52436
+ const assessmentWait = revoke ? null : assessmentGateRefusal(current, rk, who.clientKey, who.name);
52437
+ if (assessmentWait) throw new Error(assessmentWait);
51987
52438
  if (!revoke && !readAccountRulesSafe().sandboxes[who.locationId] && String(confirmName || "").trim().toLowerCase() !== who.name.trim().toLowerCase()) {
51988
52439
  throw new Error(`Type the client's name exactly ("${who.name}") to approve automated building.`);
51989
52440
  }
@@ -51991,15 +52442,15 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51991
52442
  const approvals = { ...current.planApprovals };
51992
52443
  if (revoke) delete approvals[rk];
51993
52444
  else if (who.clientKey) {
51994
- approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: scopeHashOf(current.scope?.[rk]) };
52445
+ approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: approvalScopeHash(current, rk), boughtHash: approvalBoughtHash(current, rk), ...approvedBy };
51995
52446
  } else {
51996
52447
  const c = clientFor(who.locationId);
51997
52448
  if (!c) throw new Error(INTAKE_UNREADABLE);
51998
52449
  const snap = await readIntakeSnapshot(c, who.locationId);
51999
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
52450
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
52000
52451
  const hash = intakeInput(normalizedIntake(snap), current.accounts?.[who.locationId]?.industry, composeQuestionSet({ industry: current.accounts?.[who.locationId]?.industry, agency: readOverlay() }));
52001
52452
  if (current.outcomes?.[`${rk}:1`]?.inputHash && current.outcomes[`${rk}:1`].inputHash !== hash) throw new Error("The intake changed. Run Brief again before approving building.");
52002
- const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: scopeHashOf(current.scope?.[rk]) };
52453
+ const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: approvalScopeHash(current, rk), boughtHash: approvalBoughtHash(current, rk), ...approvedBy };
52003
52454
  approvals[rk] = approval;
52004
52455
  }
52005
52456
  const latest = readCockpitState();
@@ -52357,26 +52808,59 @@ ${opportunityNoteFailure?.detail ?? ""}`);
52357
52808
  });
52358
52809
  return;
52359
52810
  }
52811
+ if (req.method === "POST" && url === "/api/assessment-choice") {
52812
+ readBody(req, res, 5e3, (body) => {
52813
+ try {
52814
+ const { locationId: locationId2, clientKey, choice } = JSON.parse(body);
52815
+ const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
52816
+ if (!isAssessmentChoice(choice)) throw new Error("Choose one of the three answers.");
52817
+ const rk = who.recordKey;
52818
+ const current = readCockpitState();
52819
+ const { state: nextState, withdrawn } = withdrawIfBindingChanged({ ...current, assessmentChoice: { ...current.assessmentChoice, [rk]: { choice, at: (/* @__PURE__ */ new Date()).toISOString() } } }, rk);
52820
+ writeCockpitState(nextState);
52821
+ res.writeHead(200, { "Content-Type": "application/json" });
52822
+ res.end(JSON.stringify({ ok: true, choice, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_ASSESSMENT } : {} }));
52823
+ } catch (e) {
52824
+ res.writeHead(400, { "Content-Type": "application/json" });
52825
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
52826
+ }
52827
+ });
52828
+ return;
52829
+ }
52360
52830
  if (req.method === "POST" && url === "/api/scope") {
52361
52831
  readBody(req, res, 1e4, (body) => {
52362
52832
  try {
52363
- const { locationId: locationId2, clientKey, package: pkg2, features } = JSON.parse(body);
52833
+ const { locationId: locationId2, clientKey, package: pkg2, features, bought } = JSON.parse(body);
52364
52834
  const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
52365
- if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
52366
- const next = features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) };
52367
- if (Array.isArray(features)) {
52835
+ let ticks;
52836
+ let derived;
52837
+ if (bought !== void 0) {
52838
+ if (!Array.isArray(bought) || bought.some((b) => !isBoughtId(b))) throw new Error("Tick items from the list.");
52839
+ ticks = [...new Set(bought.map(String))];
52840
+ if (!ticks.length) throw new Error(NOTHING_BOUGHT);
52841
+ derived = scopeFromBought(ticks, offeredFeaturesFor(readCockpitState(), who.recordKey), typeof pkg2 === "string" && pkg2.trim() ? pkg2 : void 0);
52842
+ if (!("wholePreset" in derived) && !derived.features.length) throw new Error(NOTHING_BUILT_FROM_TICKS);
52843
+ } else if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
52844
+ const next = derived ?? (features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) });
52845
+ if (!derived && Array.isArray(features)) {
52368
52846
  const ticked = features.map(String).filter(isFeatureId);
52369
52847
  const offered = offeredFeaturesFor(readCockpitState(), who.recordKey);
52370
52848
  if (ticked.length && !ticked.some((f) => offered.includes(f))) throw new Error(NOTHING_TICKED_IN_PLAN);
52371
52849
  }
52372
52850
  const rk = who.recordKey;
52373
52851
  let nextState = setClientScope(readCockpitState(), rk, next);
52374
- const ap = nextState.planApprovals?.[rk];
52375
- const withdrawn = Boolean(ap && ap.scopeHash !== scopeHashOf(nextState.scope?.[rk]));
52376
- if (withdrawn) {
52377
- const approvals = { ...nextState.planApprovals };
52378
- delete approvals[rk];
52379
- nextState = { ...setBuildApproval(nextState, rk, null), planApprovals: approvals };
52852
+ const savedTicks = { ...nextState.bought ?? {} };
52853
+ if (ticks) savedTicks[rk] = ticks;
52854
+ else delete savedTicks[rk];
52855
+ nextState = { ...nextState, bought: savedTicks };
52856
+ if (!Object.keys(savedTicks).length) delete nextState.bought;
52857
+ const { state: bound, withdrawn } = withdrawIfBindingChanged(nextState, rk);
52858
+ nextState = bound;
52859
+ {
52860
+ const offered = offeredFeaturesFor(nextState, rk);
52861
+ replaceBuildObligationsOfType(rk, BOUGHT_JOB_TYPE, ticks ? boughtJobs(ticks, offered) : []);
52862
+ const tasks2 = withBoughtJobs(nextState.manualTasks?.[rk] ?? [], ticks ? boughtJobLines(ticks, offered) : []);
52863
+ if (tasks2.length || nextState.manualTasks?.[rk]) nextState = { ...nextState, manualTasks: { ...nextState.manualTasks, [rk]: tasks2 } };
52380
52864
  }
52381
52865
  writeCockpitState(nextState);
52382
52866
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -52435,7 +52919,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
52435
52919
  const client = clientFor(locationId2);
52436
52920
  if (!client) throw new Error(INTAKE_UNREADABLE);
52437
52921
  const snap = await readIntakeSnapshot(client, locationId2);
52438
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
52922
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
52439
52923
  const answers = normalizedIntake(snap);
52440
52924
  hash = intakeInput(answers, state.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: state.accounts?.[locationId2]?.industry, agency: readOverlay() }));
52441
52925
  state = { ...state, intakeInputs: { ...state.intakeInputs, [locationId2]: { answers, hash, status: snap.status } } };
@@ -52517,7 +53001,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
52517
53001
  process.on("SIGTERM", stop2);
52518
53002
  });
52519
53003
  }
52520
- var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, SCOPE_REFUSAL, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
53004
+ var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, SCOPE_REFUSAL, NOTHING_BOUGHT, NOTHING_BUILT_FROM_TICKS, ASSESSMENT_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_ASSESSMENT, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_READ_TRANSIENT, INTAKE_REFRESH_RETRYING, INTAKE_ANSWERS_MISSING, INTAKE_READ_FAILED, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
52521
53005
  var init_dashboard = __esm({
52522
53006
  "src/dashboard.ts"() {
52523
53007
  "use strict";
@@ -52585,6 +53069,7 @@ var init_dashboard = __esm({
52585
53069
  init_plan_from_preset();
52586
53070
  init_plan_guide();
52587
53071
  init_attachment();
53072
+ init_bought_list();
52588
53073
  init_intake_contact();
52589
53074
  init_client_intake();
52590
53075
  init_client_intake();
@@ -52642,6 +53127,10 @@ var init_dashboard = __esm({
52642
53127
  CHOOSE_WHAT_THEY_BOUGHT = "Choose what the client bought before approving: Blueprint Review, then What they bought, then Save.";
52643
53128
  CHOOSE_BEFORE_BUILD = "Choose what the client bought before building: Blueprint Review, then What they bought, then Save.";
52644
53129
  SCOPE_REFUSAL = /Nobody has chosen what|what this client bought|features (are )?selected for this client|No features are selected|features nobody ticked/i;
53130
+ NOTHING_BOUGHT = "Tick at least one item they bought, then save.";
53131
+ NOTHING_BUILT_FROM_TICKS = "None of the ticked items is built for this client's industry, so nothing was saved. Tick at least one item marked Built.";
53132
+ ASSESSMENT_CHANGED_SINCE_APPROVAL = "The Assessment answer or the attached Assessment changed after the build was approved, so Build did not start. Approve this plan again, then build.";
53133
+ APPROVAL_WITHDRAWN_FOR_ASSESSMENT = "The Assessment answer changed, so the build approval was withdrawn. Approve this plan again before building.";
52645
53134
  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.";
52646
53135
  SCOPE_CHANGED_SINCE_APPROVAL = "What they bought changed after the build was approved, so Build did not start. Approve this plan again, then build.";
52647
53136
  APPROVAL_WITHDRAWN_FOR_SCOPE = "What they bought changed, so the build approval was withdrawn. Approve this plan again before building.";
@@ -52653,10 +53142,17 @@ var init_dashboard = __esm({
52653
53142
  PARKED_NOT_BUILT = "Template and hidden accounts are not built from Command OS. Change the account's type to Client first.";
52654
53143
  NEEDS_AGENCY_KEY = "No agency key on file. Run register_agency_key in GHL Command first.";
52655
53144
  INTAKE_UNREADABLE = "Command OS could not read this client's account. Paste its key again in Build a client account.";
53145
+ INTAKE_READ_TRANSIENT = "Command OS could not reach this client's account just now. Try again in a moment.";
53146
+ INTAKE_REFRESH_RETRYING = "Could not refresh just now; retrying.";
53147
+ INTAKE_ANSWERS_MISSING = "The intake form shows a submission, but no contact in this client's account has the answers yet. If the client has not submitted the form, send it again. If they have, press Refresh status in a minute.";
53148
+ INTAKE_READ_FAILED = "The intake could not be read. Press Refresh status to try again.";
52656
53149
  INTAKE_FORM_REQUIRED = "Run the intake step in Client builds to create the form, then come back and submit.";
52657
53150
  INTAKE_CONNECT_REQUIRED = "Connect this sub-account by pasting its Private Integration key in Command OS.";
52658
53151
  INTAKE_OWN_MESSAGES = [
52659
53152
  INTAKE_UNREADABLE,
53153
+ INTAKE_READ_TRANSIENT,
53154
+ INTAKE_ANSWERS_MISSING,
53155
+ INTAKE_READ_FAILED,
52660
53156
  ...CLAUDE_REPAIR_MESSAGES,
52661
53157
  "The limit is 60,000 characters. Paste an excerpt or shorten this text file.",
52662
53158
  "That account is not on the board.",
@@ -53939,11 +54435,11 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
53939
54435
  };
53940
54436
  const dataA = await fetchWithOwnKey(locationA);
53941
54437
  const dataB = await fetchWithOwnKey(locationB);
53942
- const { comparison, unavailable } = buildLocationComparison(dataA, dataB, SECTIONS);
54438
+ const { comparison, unavailable: unavailable2 } = buildLocationComparison(dataA, dataB, SECTIONS);
53943
54439
  diff.comparison = comparison;
53944
54440
  if (usedRegisteredKey.length) diff.usedRegisteredKeyFor = usedRegisteredKey;
53945
- if (unavailable.length) {
53946
- diff.unavailable = unavailable;
54441
+ if (unavailable2.length) {
54442
+ diff.unavailable = unavailable2;
53947
54443
  diff.warning = "Some sections could not be read. Those sections are marked unavailable \u2014 do NOT read them as empty. A Private Integration key only works on the sub-account it was created in, so register each account's own key (register_location) before comparing them.";
53948
54444
  }
53949
54445
  return jsonResponse(diff);
@@ -54212,13 +54708,13 @@ function discoverAssets(input) {
54212
54708
  return assets;
54213
54709
  }
54214
54710
  function buildLocationComparison(dataA, dataB, sections) {
54215
- const unavailable = [];
54711
+ const unavailable2 = [];
54216
54712
  const comparison = {};
54217
54713
  for (const key of sections) {
54218
54714
  const a = dataA[key] ?? { error: "not read" };
54219
54715
  const b = dataB[key] ?? { error: "not read" };
54220
- if ("error" in a) unavailable.push(`locationA.${key}: ${a.error}`);
54221
- if ("error" in b) unavailable.push(`locationB.${key}: ${b.error}`);
54716
+ if ("error" in a) unavailable2.push(`locationA.${key}: ${a.error}`);
54717
+ if ("error" in b) unavailable2.push(`locationB.${key}: ${b.error}`);
54222
54718
  if ("error" in a || "error" in b) {
54223
54719
  comparison[key] = {
54224
54720
  A: "error" in a ? "unavailable" : a.names.length,
@@ -54238,7 +54734,7 @@ function buildLocationComparison(dataA, dataB, sections) {
54238
54734
  onlyInB: b.names.filter((n) => !setA.has(n))
54239
54735
  };
54240
54736
  }
54241
- return { comparison, unavailable };
54737
+ return { comparison, unavailable: unavailable2 };
54242
54738
  }
54243
54739
  var import_zod70, OFF_PLATFORM_ASSETS;
54244
54740
  var init_account_export = __esm({
@@ -64417,8 +64913,8 @@ function registerAccountHealthTools(server2, client) {
64417
64913
  conversations,
64418
64914
  phoneNumbers
64419
64915
  };
64420
- const unavailable = Object.entries(sections).filter(([, v]) => v.status === "unavailable").map(([k]) => k);
64421
- return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable };
64916
+ const unavailable2 = Object.entries(sections).filter(([, v]) => v.status === "unavailable").map(([k]) => k);
64917
+ return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable: unavailable2 };
64422
64918
  }
64423
64919
  );
64424
64920
  }