@elitedcs/ghl-mcp 3.81.1 → 3.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1006,10 +1006,10 @@ function parkedRefusal(current, next, parked) {
1006
1006
  const currentById = new Map(current.filter(hasId).map((a) => [a.id, a]));
1007
1007
  const nextById = new Map(next.filter(hasId).map((a) => [a.id, a]));
1008
1008
  const contactsByStep = new Map(occupied.map((p) => [p.currentStepId, p.total]));
1009
- const guarded = protectedClosure([...contactsByStep.keys()], currentById);
1009
+ const guarded2 = protectedClosure([...contactsByStep.keys()], currentById);
1010
1010
  const deleted = [];
1011
1011
  const altered = [];
1012
- for (const id of guarded) {
1012
+ for (const id of guarded2) {
1013
1013
  const before = currentById.get(id);
1014
1014
  if (!before) continue;
1015
1015
  const after = nextById.get(id);
@@ -1019,11 +1019,11 @@ function parkedRefusal(current, next, parked) {
1019
1019
  }
1020
1020
  if (stable(before) !== stable(after)) altered.push(id);
1021
1021
  }
1022
- for (const id of guarded) {
1022
+ for (const id of guarded2) {
1023
1023
  const head3 = currentById.get(id);
1024
1024
  if (!head3) continue;
1025
1025
  for (const target of branchTargetStepIds(head3)) {
1026
- if (guarded.has(target) || deleted.includes(target)) continue;
1026
+ if (guarded2.has(target) || deleted.includes(target)) continue;
1027
1027
  if (currentById.has(target) && !nextById.has(target)) deleted.push(target);
1028
1028
  }
1029
1029
  }
@@ -12921,7 +12921,8 @@ var init_schema = __esm({
12921
12921
  }).strict(),
12922
12922
  logo: import_zod42.z.object({
12923
12923
  inlineSvg: import_zod42.z.string().optional(),
12924
- dataUri: import_zod42.z.string().optional(),
12924
+ // A raster image carried inline (png/jpeg/gif/webp, base64): the brand page puts it straight into an <img src>.
12925
+ dataUri: import_zod42.z.string().regex(/^data:image\/(png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=]+$/, "logo.dataUri must be a base64 png, jpeg, gif or webp data URI").optional(),
12925
12926
  width: import_zod42.z.number().optional(),
12926
12927
  height: import_zod42.z.number().optional(),
12927
12928
  whiteFillFixed: import_zod42.z.boolean(),
@@ -13294,6 +13295,108 @@ var init_store = __esm({
13294
13295
  }
13295
13296
  });
13296
13297
 
13298
+ // src/command-os/design/svg-sanitize.ts
13299
+ function unquote(v) {
13300
+ return v.replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
13301
+ }
13302
+ function decoded(v) {
13303
+ let s = v;
13304
+ for (let i = 0; i < 5; i++) {
13305
+ const next = s.replace(/&#x([0-9a-f]{1,6});?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d{1,7});?/g, (_m, n) => String.fromCodePoint(Math.min(parseInt(n, 10), 1114111))).replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITIES[name.toLowerCase()] ?? m);
13306
+ if (next === s) break;
13307
+ s = next;
13308
+ }
13309
+ s = s.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/\\(.)/g, "$1");
13310
+ return s.replace(/[\u0000-\u0020\u007f-\u009f\u200b-\u200f\u2028\u2029\ufeff]/g, "");
13311
+ }
13312
+ function safeUrl(raw) {
13313
+ const s = decoded(unquote(raw)).toLowerCase();
13314
+ if (/[&\\<>]/.test(s)) return false;
13315
+ return s === "" || s.startsWith("#") || isRasterDataUri(s);
13316
+ }
13317
+ function safeStyle(v) {
13318
+ const s = decoded(v);
13319
+ if (/[&\\<>]/.test(s)) return false;
13320
+ return !/url\s*\(|@import|expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@charset|@namespace|image-set\s*\(|src\s*:/i.test(s);
13321
+ }
13322
+ function isRasterDataUri(v) {
13323
+ return typeof v === "string" && /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=]+$/i.test(v);
13324
+ }
13325
+ function sanitizeSvg(input) {
13326
+ if (typeof input !== "string") throw new Error(SVG_SHAPE_ERROR);
13327
+ let svg = input.replace(CDATA, "").replace(COMMENTS, "").replace(PROCESSING, "").replace(DOCTYPE, "").trim();
13328
+ if (!SHAPE.test(svg) || svg.length > SVG_MAX_BYTES) throw new Error(SVG_SHAPE_ERROR);
13329
+ let before = "";
13330
+ while (before !== svg) {
13331
+ before = svg;
13332
+ svg = svg.replace(DROP_ELEMENTS, "");
13333
+ }
13334
+ const classRules = /* @__PURE__ */ new Map();
13335
+ svg = svg.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (_m, css2) => {
13336
+ for (const rule5 of css2.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
13337
+ const decls = rule5[2].trim().replace(/;\s*$/, "");
13338
+ if (!decls || !safeStyle(decls)) continue;
13339
+ for (const sel of rule5[1].split(",")) {
13340
+ const cm = /^\s*\.([A-Za-z_][\w-]*)\s*$/.exec(sel);
13341
+ if (cm) classRules.set(cm[1], classRules.has(cm[1]) ? `${classRules.get(cm[1])};${decls}` : decls);
13342
+ }
13343
+ }
13344
+ return "";
13345
+ });
13346
+ const attrValue = (v) => `"${v.replace(/"/g, "&quot;")}"`;
13347
+ svg = svg.replace(TAG, (_m, name, attrs, selfClose) => {
13348
+ let kept = [];
13349
+ let classes = [];
13350
+ let ownStyle = "";
13351
+ for (const a of attrs.matchAll(ATTR)) {
13352
+ const key = a[1];
13353
+ const k = key.toLowerCase();
13354
+ const hasValue = a[2] !== void 0;
13355
+ const val = a[2] ?? "";
13356
+ if (k.startsWith("on")) continue;
13357
+ if (URL_ATTRS.has(k) && !safeUrl(val)) continue;
13358
+ if (k === "style" && !safeStyle(unquote(val))) continue;
13359
+ if (/javascript:|data:text|vbscript:|data:image\/svg/i.test(decoded(unquote(val)))) continue;
13360
+ if (k === "class") classes = unquote(val).split(/\s+/).filter(Boolean);
13361
+ if (k === "style") ownStyle = unquote(val);
13362
+ kept.push(hasValue ? `${key}=${/^["']/.test(val) ? val : attrValue(val)}` : key);
13363
+ }
13364
+ const fromClasses = classes.flatMap((c) => classRules.has(c) ? [classRules.get(c)] : []);
13365
+ if (fromClasses.length) {
13366
+ kept = kept.filter((a) => !/^style=/i.test(a));
13367
+ kept.push(`style=${attrValue([...fromClasses, ownStyle].filter(Boolean).join(";"))}`);
13368
+ }
13369
+ return `<${name}${kept.length ? " " + kept.join(" ") : ""}${selfClose ? "/" : ""}>`;
13370
+ });
13371
+ if (/<style\b/i.test(svg) || !SHAPE.test(svg)) throw new Error(SVG_SHAPE_ERROR);
13372
+ return svg;
13373
+ }
13374
+ function sanitizeSvgOrDrop(input) {
13375
+ try {
13376
+ return sanitizeSvg(input);
13377
+ } catch {
13378
+ return void 0;
13379
+ }
13380
+ }
13381
+ var SVG_MAX_BYTES, SVG_SHAPE_ERROR, DROP_ELEMENTS, COMMENTS, PROCESSING, DOCTYPE, CDATA, TAG, ATTR, URL_ATTRS, NAMED_ENTITIES, SHAPE;
13382
+ var init_svg_sanitize = __esm({
13383
+ "src/command-os/design/svg-sanitize.ts"() {
13384
+ "use strict";
13385
+ SVG_MAX_BYTES = 6e4;
13386
+ SVG_SHAPE_ERROR = "The logo must be one <svg>\u2026</svg> under 60 KB.";
13387
+ DROP_ELEMENTS = /<\s*(script|foreignObject|iframe|object|embed|handler|set|animate|animateMotion|animateTransform|discard|audio|video)\b[^>]*(?:\/\s*>|>[\s\S]*?<\/\s*\1\s*>)/gi;
13388
+ COMMENTS = /<!--[\s\S]*?-->/g;
13389
+ PROCESSING = /<\?[\s\S]*?\?>/g;
13390
+ DOCTYPE = /<!DOCTYPE[\s\S]*?(?:\[[\s\S]*?\])?\s*>/gi;
13391
+ CDATA = /<!\[CDATA\[[\s\S]*?\]\]>/g;
13392
+ TAG = /<([a-zA-Z][\w:.-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/g;
13393
+ ATTR = /([^\s=/]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
13394
+ URL_ATTRS = /* @__PURE__ */ new Set(["href", "xlink:href", "src", "from", "to", "values", "by", "formaction", "action"]);
13395
+ NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
13396
+ SHAPE = /^<svg[\s>][\s\S]*<\/svg>$/i;
13397
+ }
13398
+ });
13399
+
13297
13400
  // src/command-os/design/extract.ts
13298
13401
  async function fetchText(url, fetchImpl, timeoutMs = 15e3) {
13299
13402
  const ctl = new AbortController();
@@ -13702,10 +13805,15 @@ function deriveBrandDna(raw, opts = {}) {
13702
13805
  fixed = true;
13703
13806
  }
13704
13807
  const vb = svg.match(/viewBox=["']\s*[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)/i);
13705
- logoSvg = svg;
13706
- logo = { inlineSvg: svg.length <= 6e4 ? svg : void 0, whiteFillFixed: fixed, source: bl.where, rights: opts.rights ? "declared" : "inherits", ...vb && { width: parseFloat(vb[1]), height: parseFloat(vb[2]) } };
13707
- declared++;
13708
- evidence["logo"] = `${bl.where}${fixed ? "; white fills replaced with currentColor" : ""}`;
13808
+ const clean = sanitizeSvgOrDrop(svg);
13809
+ if (clean) {
13810
+ logoSvg = clean;
13811
+ logo = { inlineSvg: clean, whiteFillFixed: fixed, source: bl.where, rights: opts.rights ? "declared" : "inherits", ...vb && { width: parseFloat(vb[1]), height: parseFloat(vb[2]) } };
13812
+ declared++;
13813
+ evidence["logo"] = `${bl.where}${fixed ? "; white fills replaced with currentColor" : ""}${clean !== svg ? "; scripts, handlers and outside references stripped" : ""}`;
13814
+ } else {
13815
+ evidence["logo"] = `${bl.where}: not a plain drawing (or over 60 KB), so it was not kept`;
13816
+ }
13709
13817
  } else if (raw.logo?.bytes && raw.logo.kind !== "svg") {
13710
13818
  logoPng = raw.logo.bytes;
13711
13819
  const mime = raw.logo.kind === "png" ? "image/png" : raw.logo.kind === "webp" ? "image/webp" : "image/jpeg";
@@ -13769,6 +13877,7 @@ var init_extract = __esm({
13769
13877
  init_fonts();
13770
13878
  init_schema();
13771
13879
  init_store();
13880
+ init_svg_sanitize();
13772
13881
  UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 GHLCommand-DesignStudio/0.1";
13773
13882
  rank = (arr) => {
13774
13883
  const m = /* @__PURE__ */ new Map();
@@ -21870,7 +21979,7 @@ function safeParse(raw) {
21870
21979
  return {};
21871
21980
  }
21872
21981
  }
21873
- function safeUrl(x, max = MAX_URL) {
21982
+ function safeUrl2(x, max = MAX_URL) {
21874
21983
  const raw = str2(x, max);
21875
21984
  if (!raw) return void 0;
21876
21985
  let u;
@@ -21898,8 +22007,8 @@ function sanitizeProfile(raw) {
21898
22007
  agencyName: str2(r.agencyName, 80),
21899
22008
  legalName: str2(r.legalName),
21900
22009
  accent: normalizeAccent(r.accent),
21901
- logoUrl: safeUrl(r.logoUrl),
21902
- website: safeUrl(r.website),
22010
+ logoUrl: safeUrl2(r.logoUrl),
22011
+ website: safeUrl2(r.website),
21903
22012
  sender: drop({
21904
22013
  fromName: str2(sender?.fromName, 80),
21905
22014
  replyTo: str2(sender?.replyTo),
@@ -21940,9 +22049,44 @@ function sanitizeProfile(raw) {
21940
22049
  timezone: str2(defaults?.timezone, 64),
21941
22050
  industry: str2(defaults?.industry, 64)
21942
22051
  }),
22052
+ agencyOs: sanitizeAgencyOs(r.agencyOs),
21943
22053
  updated_at: str2(r.updated_at, 40)
21944
22054
  };
21945
22055
  }
22056
+ function sanitizeAgencyOs(raw) {
22057
+ if (!raw || typeof raw !== "object") return void 0;
22058
+ const r = raw;
22059
+ const ica = r.ica ?? {};
22060
+ const offer = r.offer ?? {};
22061
+ const lines = (x) => list(x, (s) => str2(s, MAX_STR * 2));
22062
+ const out = drop({
22063
+ story: str2(r.story, MAX_PARA),
22064
+ method: str2(r.method, MAX_PARA),
22065
+ voice: str2(r.voice, 400),
22066
+ values: lines(r.values),
22067
+ goals: str2(r.goals, 400),
22068
+ proof: lines(r.proof),
22069
+ ica: drop({
22070
+ who: str2(ica.who, 400),
22071
+ pains: lines(ica.pains),
22072
+ desires: lines(ica.desires),
22073
+ objections: lines(ica.objections),
22074
+ triggers: lines(ica.triggers),
22075
+ vocabulary: lines(ica.vocabulary),
22076
+ hangouts: lines(ica.hangouts),
22077
+ villains: lines(ica.villains)
22078
+ }),
22079
+ offer: drop({
22080
+ name: str2(offer.name, 120),
22081
+ promise: str2(offer.promise, 400),
22082
+ valueStack: lines(offer.valueStack),
22083
+ price: str2(offer.price, 120),
22084
+ guarantee: str2(offer.guarantee, 600),
22085
+ accelerators: lines(offer.accelerators)
22086
+ })
22087
+ });
22088
+ return out && Object.keys(out).length ? out : void 0;
22089
+ }
21946
22090
  function legacyBranding(profileFile) {
21947
22091
  try {
21948
22092
  const raw = JSON.parse(fs12.readFileSync(path11.join(path11.dirname(profileFile), "cockpit-state.json"), "utf8"));
@@ -21960,6 +22104,23 @@ function readAgencyProfile(file = agencyProfilePath()) {
21960
22104
  const legacy = legacyBranding(file);
21961
22105
  return legacy.agencyName || legacy.accent ? sanitizeProfile({ ...EMPTY, ...legacy }) : { ...EMPTY };
21962
22106
  }
22107
+ function mergeNested(base, patch, depth) {
22108
+ const out = { ...base };
22109
+ for (const [ck, cv] of Object.entries(patch)) {
22110
+ if (ck === "__proto__" || ck === "constructor" || ck === "prototype") continue;
22111
+ if (cv === void 0) continue;
22112
+ if (cv === null) {
22113
+ delete out[ck];
22114
+ continue;
22115
+ }
22116
+ if (depth > 1 && isPlainObject(cv) && isPlainObject(out[ck])) {
22117
+ out[ck] = mergeNested(out[ck], cv, depth - 1);
22118
+ continue;
22119
+ }
22120
+ out[ck] = cv;
22121
+ }
22122
+ return out;
22123
+ }
21963
22124
  function mergeAgencyProfile(existing, patch) {
21964
22125
  const p = isPlainObject(patch) ? patch : {};
21965
22126
  const merged = Object.assign(/* @__PURE__ */ Object.create(null), existing);
@@ -21972,17 +22133,7 @@ function mergeAgencyProfile(existing, patch) {
21972
22133
  continue;
21973
22134
  }
21974
22135
  if (NESTED.has(k) && isPlainObject(v)) {
21975
- const base = isPlainObject(merged[k]) ? { ...merged[k] } : {};
21976
- for (const [ck, cv] of Object.entries(v)) {
21977
- if (ck === "__proto__" || ck === "constructor" || ck === "prototype") continue;
21978
- if (cv === void 0) continue;
21979
- if (cv === null) {
21980
- delete base[ck];
21981
- continue;
21982
- }
21983
- base[ck] = cv;
21984
- }
21985
- merged[k] = base;
22136
+ merged[k] = mergeNested(isPlainObject(merged[k]) ? merged[k] : {}, v, 2);
21986
22137
  continue;
21987
22138
  }
21988
22139
  merged[k] = v;
@@ -22043,7 +22194,7 @@ function writeAgencyProfile(patch, file = agencyProfilePath()) {
22043
22194
  function profileIsUsable(p) {
22044
22195
  return Boolean(p.agencyName);
22045
22196
  }
22046
- var fs12, path11, AGENCY_PROFILE_ENV, EMPTY, MAX_URL, MAX_SOP, PATCHABLE, NESTED, isPlainObject;
22197
+ var fs12, path11, AGENCY_PROFILE_ENV, EMPTY, MAX_URL, MAX_SOP, MAX_PARA, PATCHABLE, NESTED, isPlainObject;
22047
22198
  var init_agency_profile = __esm({
22048
22199
  "src/agency-profile.ts"() {
22049
22200
  "use strict";
@@ -22055,6 +22206,7 @@ var init_agency_profile = __esm({
22055
22206
  EMPTY = { v: 1 };
22056
22207
  MAX_URL = 500;
22057
22208
  MAX_SOP = 2e3;
22209
+ MAX_PARA = 2e3;
22058
22210
  PATCHABLE = [
22059
22211
  "agencyName",
22060
22212
  "legalName",
@@ -22066,9 +22218,10 @@ var init_agency_profile = __esm({
22066
22218
  "pricing",
22067
22219
  "delivery",
22068
22220
  "staff",
22069
- "defaults"
22221
+ "defaults",
22222
+ "agencyOs"
22070
22223
  ];
22071
- NESTED = /* @__PURE__ */ new Set(["sender", "pricing", "delivery", "defaults"]);
22224
+ NESTED = /* @__PURE__ */ new Set(["sender", "pricing", "delivery", "defaults", "agencyOs"]);
22072
22225
  isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
22073
22226
  }
22074
22227
  });
@@ -22262,7 +22415,7 @@ function parseNote(body) {
22262
22415
  if (!s || typeof s !== "object" || typeof s.biz !== "string") return null;
22263
22416
  if (!s.ans || typeof s.ans !== "object") return null;
22264
22417
  return {
22265
- state: { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {} },
22418
+ state: { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {}, ...s.client && typeof s.client === "object" ? { client: s.client } : {} },
22266
22419
  computed: p.computed ?? null
22267
22420
  };
22268
22421
  }
@@ -22820,7 +22973,12 @@ function renderReport(opts) {
22820
22973
  out.push("<header>");
22821
22974
  if (agency.name) out.push('<p class="by">Prepared by ' + esc(agency.name) + "</p>");
22822
22975
  out.push("<h1>Where " + biz + " is leaking</h1>");
22976
+ var who = state.client && state.client.name ? esc(state.client.name) + (state.client.role ? ", " + esc(state.client.role) : "") : "";
22977
+ if (who) out.push('<p class="for">Prepared for ' + who + "</p>");
22823
22978
  if (date) out.push('<p class="when">' + esc(date) + "</p>");
22979
+ if (agency.method || agency.promise) {
22980
+ out.push('<p class="about">' + [agency.method, agency.promise].filter(Boolean).map(esc).join(" ") + "</p>");
22981
+ }
22824
22982
  out.push("</header>");
22825
22983
  if (r.verdict === "insufficient") {
22826
22984
  out.push('<div class="verdict stop"><b>We could not put a number on this yet</b>' + esc(r.line || "") + "</div>");
@@ -22939,7 +23097,7 @@ function sourceLine(state, r, labels) {
22939
23097
  }
22940
23098
  function footer(agency) {
22941
23099
  if (!agency.name) return "";
22942
- return "<footer>" + esc(agency.name) + (agency.contact ? " &middot; " + esc(agency.contact) : "") + "</footer>";
23100
+ return "<footer>" + esc(agency.name) + (agency.contact ? " &middot; " + esc(agency.contact) : "") + (agency.guarantee ? '<br><span class="guarantee">' + esc(agency.guarantee) + "</span>" : "") + "</footer>";
22943
23101
  }
22944
23102
  function css(accent) {
22945
23103
  return [
@@ -22951,6 +23109,9 @@ function css(accent) {
22951
23109
  ".by{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--a);margin:0 0 .4rem;font-weight:600}",
22952
23110
  "h1{font-size:1.9rem;line-height:1.15;margin:0;font-weight:700}",
22953
23111
  ".when{color:var(--mut);font-size:.82rem;margin:.4rem 0 0}",
23112
+ ".for{color:var(--ink);font-size:.95rem;margin:.5rem 0 0}",
23113
+ ".about{color:var(--mut);font-size:.95rem;margin:.6rem 0 0;max-width:38rem}",
23114
+ ".guarantee{color:var(--mut);font-size:.85rem}",
22954
23115
  "h2{font-size:1.15rem;margin:2rem 0 .5rem;font-weight:700}",
22955
23116
  "table{border-collapse:collapse;width:100%;margin:.6rem 0;font-size:.94rem}",
22956
23117
  "th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--rule);vertical-align:top}",
@@ -22988,7 +23149,7 @@ function today() {
22988
23149
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
22989
23150
  }
22990
23151
  function normalise(s) {
22991
- return { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {} };
23152
+ return { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {}, ...s.client ? { client: s.client } : {} };
22992
23153
  }
22993
23154
  function computedFor(state) {
22994
23155
  const r = compute(state.ans, state.skip, state.asm);
@@ -23004,9 +23165,11 @@ function computedFor(state) {
23004
23165
  async function createPipeline(client, locationId2) {
23005
23166
  try {
23006
23167
  await client.post("/opportunities/pipelines", {
23007
- locationId: locationId2,
23008
- name: PIPELINE_NAME,
23009
- stages: STAGES.map((name, position2) => ({ name, position: position2, showInFunnel: true, showInPieChart: true }))
23168
+ body: {
23169
+ locationId: locationId2,
23170
+ name: PIPELINE_NAME,
23171
+ stages: STAGES.map((name, position2) => ({ name, position: position2, showInFunnel: true, showInPieChart: true }))
23172
+ }
23010
23173
  });
23011
23174
  return { ok: true };
23012
23175
  } catch (e) {
@@ -23045,6 +23208,87 @@ async function findStage(client, locationId2, stage, mayCreate = false) {
23045
23208
  }
23046
23209
  return { pipelineId: pipe.id, stageId: st.id, stageName: stage };
23047
23210
  }
23211
+ async function saveAssessment(client, locationId2, args) {
23212
+ const state = normalise(args.assessment);
23213
+ const computed = computedFor(state);
23214
+ const stage = stageFor({ verdict: computed.verdict, proposalSent: args.proposalSent, outcome: args.outcome });
23215
+ const { payload: contactBody, dedupable } = buildContact(state, stage, {
23216
+ name: args.contactName,
23217
+ email: args.email,
23218
+ phone: args.phone
23219
+ });
23220
+ const upserted = await client.post("/contacts/upsert", {
23221
+ body: { ...contactBody, locationId: locationId2 }
23222
+ });
23223
+ const contactId = upserted?.contact?.id ?? upserted?.id;
23224
+ if (!contactId) {
23225
+ return { saved: false, error: "GoHighLevel did not return a contact id, so nothing was written. Nothing partial was left behind." };
23226
+ }
23227
+ const bodies = buildNoteBodies(buildNote(state, computed, LOOKUP, today()));
23228
+ const noteIds = [];
23229
+ for (const body of bodies) {
23230
+ const note = await client.post(`/contacts/${contactId}/notes`, { body: { body } });
23231
+ const id = note?.note?.id ?? note?.id;
23232
+ if (id) noteIds.push(id);
23233
+ }
23234
+ if (noteIds.length !== bodies.length) {
23235
+ return {
23236
+ saved: false,
23237
+ contactId,
23238
+ error: `the answer sheet needed ${bodies.length} note(s) and only ${noteIds.length} were written. A part-written record will NOT load \u2014 it refuses rather than returning half an assessment. Save again.`
23239
+ };
23240
+ }
23241
+ const hit = await findStage(client, locationId2, stage, args.createPipeline === true);
23242
+ const opp = buildOpportunity(state, computed, stage, args.proposalValue);
23243
+ let opportunity = null;
23244
+ let opportunityNote = null;
23245
+ if ("error" in hit) {
23246
+ opportunityNote = hit.error;
23247
+ } else {
23248
+ const body = {
23249
+ locationId: locationId2,
23250
+ contactId,
23251
+ name: opp.name,
23252
+ status: opp.status,
23253
+ monetaryValue: opp.monetaryValue,
23254
+ pipelineId: hit.pipelineId,
23255
+ pipelineStageId: hit.stageId,
23256
+ source: "Command OS assessment"
23257
+ };
23258
+ try {
23259
+ opportunity = await client.post("/opportunities/", { body });
23260
+ } catch (e) {
23261
+ const raw = String(e?.message ?? e);
23262
+ const existing = /OPPORTUNITY_NO_DUPLICATE/.test(raw) ? raw.match(/"existingId"\s*:\s*"([^"]+)"/)?.[1] : void 0;
23263
+ if (!existing) throw e;
23264
+ opportunity = await client.put(`/opportunities/${existing}`, {
23265
+ body: {
23266
+ name: opp.name,
23267
+ status: opp.status,
23268
+ monetaryValue: opp.monetaryValue,
23269
+ pipelineId: hit.pipelineId,
23270
+ pipelineStageId: hit.stageId
23271
+ }
23272
+ });
23273
+ opportunityNote = `This business already had a deal on your Assessments board, so it was moved to "${stage}" and its value updated rather than a second one being created.`;
23274
+ }
23275
+ }
23276
+ return {
23277
+ saved: true,
23278
+ contactId,
23279
+ noteIds,
23280
+ notesWritten: bodies.length,
23281
+ stage,
23282
+ tag: tagFor(stage),
23283
+ verdict: computed.verdict,
23284
+ monthly: Math.round(computed.monthly),
23285
+ hoursPerWeek: Number(computed.hrsWk.toFixed(1)),
23286
+ opportunity,
23287
+ opportunityNote,
23288
+ warning: dedupable ? null : "No email or phone was given, so this contact cannot be matched next time and a second assessment for the same business would create a second contact.",
23289
+ whereItLives: `Contact ${contactId} in location ${locationId2}. Anyone on your team with a GoHighLevel login can open it.`
23290
+ };
23291
+ }
23048
23292
  function registerAssessmentTools(server2, client) {
23049
23293
  safeTool(
23050
23294
  server2,
@@ -23061,88 +23305,7 @@ function registerAssessmentTools(server2, client) {
23061
23305
  proposalSent: import_zod62.z.boolean().optional(),
23062
23306
  createPipeline: import_zod62.z.boolean().optional().describe('Create the "Assessments" pipeline in your own GoHighLevel if it is not there yet. Off by default: it is a write to your account, so it is asked for rather than assumed. Say yes the first time and never again.')
23063
23307
  },
23064
- async (args) => {
23065
- const locationId2 = client.resolveLocationId(args.locationId);
23066
- const state = normalise(args.assessment);
23067
- const computed = computedFor(state);
23068
- const stage = stageFor({ verdict: computed.verdict, proposalSent: args.proposalSent, outcome: args.outcome });
23069
- const { payload: contactBody, dedupable } = buildContact(state, stage, {
23070
- name: args.contactName,
23071
- email: args.email,
23072
- phone: args.phone
23073
- });
23074
- const upserted = await client.post("/contacts/upsert", {
23075
- body: { ...contactBody, locationId: locationId2 }
23076
- });
23077
- const contactId = upserted?.contact?.id ?? upserted?.id;
23078
- if (!contactId) {
23079
- return { saved: false, error: "GoHighLevel did not return a contact id, so nothing was written. Nothing partial was left behind." };
23080
- }
23081
- const bodies = buildNoteBodies(buildNote(state, computed, LOOKUP, today()));
23082
- const noteIds = [];
23083
- for (const body of bodies) {
23084
- const note = await client.post(`/contacts/${contactId}/notes`, { body: { body } });
23085
- const id = note?.note?.id ?? note?.id;
23086
- if (id) noteIds.push(id);
23087
- }
23088
- if (noteIds.length !== bodies.length) {
23089
- return {
23090
- saved: false,
23091
- contactId,
23092
- error: `the answer sheet needed ${bodies.length} note(s) and only ${noteIds.length} were written. A part-written record will NOT load \u2014 it refuses rather than returning half an assessment. Save again.`
23093
- };
23094
- }
23095
- const hit = await findStage(client, locationId2, stage, args.createPipeline === true);
23096
- const opp = buildOpportunity(state, computed, stage, args.proposalValue);
23097
- let opportunity = null;
23098
- let opportunityNote = null;
23099
- if ("error" in hit) {
23100
- opportunityNote = hit.error;
23101
- } else {
23102
- const body = {
23103
- locationId: locationId2,
23104
- contactId,
23105
- name: opp.name,
23106
- status: opp.status,
23107
- monetaryValue: opp.monetaryValue,
23108
- pipelineId: hit.pipelineId,
23109
- pipelineStageId: hit.stageId,
23110
- source: "Command OS assessment"
23111
- };
23112
- try {
23113
- opportunity = await client.post("/opportunities/", { body });
23114
- } catch (e) {
23115
- const raw = String(e?.message ?? e);
23116
- const existing = /OPPORTUNITY_NO_DUPLICATE/.test(raw) ? raw.match(/"existingId"\s*:\s*"([^"]+)"/)?.[1] : void 0;
23117
- if (!existing) throw e;
23118
- opportunity = await client.put(`/opportunities/${existing}`, {
23119
- body: {
23120
- name: opp.name,
23121
- status: opp.status,
23122
- monetaryValue: opp.monetaryValue,
23123
- pipelineId: hit.pipelineId,
23124
- pipelineStageId: hit.stageId
23125
- }
23126
- });
23127
- opportunityNote = `This business already had a deal on your Assessments board, so it was moved to "${stage}" and its value updated rather than a second one being created.`;
23128
- }
23129
- }
23130
- return {
23131
- saved: true,
23132
- contactId,
23133
- noteIds,
23134
- notesWritten: bodies.length,
23135
- stage,
23136
- tag: tagFor(stage),
23137
- verdict: computed.verdict,
23138
- monthly: Math.round(computed.monthly),
23139
- hoursPerWeek: Number(computed.hrsWk.toFixed(1)),
23140
- opportunity,
23141
- opportunityNote,
23142
- warning: dedupable ? null : "No email or phone was given, so this contact cannot be matched next time and a second assessment for the same business would create a second contact.",
23143
- whereItLives: `Contact ${contactId} in location ${locationId2}. Anyone on your team with a GoHighLevel login can open it.`
23144
- };
23145
- }
23308
+ async (args) => saveAssessment(client, client.resolveLocationId(args.locationId), args)
23146
23309
  );
23147
23310
  safeTool(
23148
23311
  server2,
@@ -23340,7 +23503,8 @@ var init_assessment = __esm({
23340
23503
  trade: import_zod62.z.string().optional().describe("Their trade. Decides which benchmarks may be applied."),
23341
23504
  ans: import_zod62.z.record(import_zod62.z.union([import_zod62.z.string(), import_zod62.z.number()])).describe("questionId -> answer, exactly as typed."),
23342
23505
  skip: import_zod62.z.record(import_zod62.z.boolean()).optional().describe("questionId -> true where they did not know."),
23343
- asm: import_zod62.z.record(import_zod62.z.number()).optional().describe("assumption id -> rate as a decimal.")
23506
+ asm: import_zod62.z.record(import_zod62.z.number()).optional().describe("assumption id -> rate as a decimal."),
23507
+ client: import_zod62.z.object({ name: import_zod62.z.string().max(120), role: import_zod62.z.string().max(80), email: import_zod62.z.string().max(200), phone: import_zod62.z.string().max(40), website: import_zod62.z.string().max(300), city: import_zod62.z.string().max(120) }).partial().optional().describe("The person and place this is for, as typed on The client step. Rides with the answers so a load brings it back.")
23344
23508
  });
23345
23509
  }
23346
23510
  });
@@ -24315,9 +24479,9 @@ function scanTags(html2) {
24315
24479
  if (!m[1]) {
24316
24480
  const raw = m[3];
24317
24481
  let a;
24318
- ATTR.lastIndex = 0;
24319
- while (a = ATTR.exec(raw)) {
24320
- if (a.index === ATTR.lastIndex) ATTR.lastIndex++;
24482
+ ATTR2.lastIndex = 0;
24483
+ while (a = ATTR2.exec(raw)) {
24484
+ if (a.index === ATTR2.lastIndex) ATTR2.lastIndex++;
24321
24485
  const k = a[1].toLowerCase();
24322
24486
  if (k === "/") continue;
24323
24487
  attrs[k] = (a[2] ?? a[3] ?? a[4] ?? "").trim();
@@ -24420,13 +24584,13 @@ function lintDesignContract(html2, medium) {
24420
24584
  }
24421
24585
  };
24422
24586
  }
24423
- var SLIDE_TYPES, ROLES, ATTR;
24587
+ var SLIDE_TYPES, ROLES, ATTR2;
24424
24588
  var init_lint = __esm({
24425
24589
  "src/command-os/design/lint.ts"() {
24426
24590
  "use strict";
24427
24591
  SLIDE_TYPES = ["title", "section", "content", "quote", "closing"];
24428
24592
  ROLES = ["headline", "body", "caption", "logo", "cta-primary", "cta-secondary", "nav", "eyebrow"];
24429
- ATTR = /([\w:.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
24593
+ ATTR2 = /([\w:.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
24430
24594
  }
24431
24595
  });
24432
24596
 
@@ -24556,8 +24720,9 @@ function composeBrief(d, brief) {
24556
24720
  ];
24557
24721
  const chosenContract = conversionMode ? conversionContract : contract;
24558
24722
  const checklist = design.map((r) => `${r.id} ${r.name} \u2014 ${r.threshold}`);
24559
- const logoBlock = d.logo?.inlineSvg ? `Logo markup (use verbatim, colour via currentColor):
24560
- ${d.logo.inlineSvg.slice(0, 6e3)}` : d.logo?.dataUri ? `Logo markup: <img src="${d.logo.dataUri.slice(0, 40)}\u2026" alt="${d.name}" data-role="logo" data-media="semantic"> (the full data URI is in the brand file)` : 'No logo is stored for this brand; use the brand name set in the display face as a wordmark, data-role="logo".';
24723
+ const cleanLogo = d.logo?.inlineSvg ? sanitizeSvgOrDrop(d.logo.inlineSvg) : void 0;
24724
+ const logoBlock = cleanLogo ? `Logo markup (use verbatim, colour via currentColor):
24725
+ ${cleanLogo.slice(0, 6e3)}` : d.logo?.dataUri ? `Logo markup: <img src="${d.logo.dataUri.slice(0, 40)}\u2026" alt="${d.name}" data-role="logo" data-media="semantic"> (the full data URI is in the brand file)` : 'No logo is stored for this brand; use the brand name set in the display face as a wordmark, data-role="logo".';
24561
24726
  const markdown = [
24562
24727
  `# Design brief \u2014 ${brief.medium} for ${d.name}`,
24563
24728
  "",
@@ -24610,6 +24775,7 @@ var init_brief = __esm({
24610
24775
  init_color();
24611
24776
  init_fonts();
24612
24777
  init_lint();
24778
+ init_svg_sanitize();
24613
24779
  }
24614
24780
  });
24615
24781
 
@@ -26562,7 +26728,7 @@ function readAgencyBranding(base = appDataDir()) {
26562
26728
  const out = {};
26563
26729
  if (typeof st.agencyName === "string" && st.agencyName.trim()) out.agencyName = st.agencyName.trim();
26564
26730
  if (isHex(st.agencyAccent)) out.accent = st.agencyAccent;
26565
- if (typeof st.agencyLogoDataUri === "string" && st.agencyLogoDataUri.startsWith("data:image/")) out.logoDataUri = st.agencyLogoDataUri;
26731
+ if (isRasterDataUri(st.agencyLogoDataUri)) out.logoDataUri = st.agencyLogoDataUri;
26566
26732
  return out;
26567
26733
  } catch {
26568
26734
  return {};
@@ -26572,7 +26738,7 @@ function resolveBranding(overrides = {}, base) {
26572
26738
  const saved = readAgencyBranding(base);
26573
26739
  const agencyName = overrides.agencyName?.trim() || saved.agencyName;
26574
26740
  const accent = isHex(overrides.accent) ? overrides.accent : saved.accent ?? DEFAULT_ACCENT;
26575
- const logoDataUri = overrides.logoDataUri ?? saved.logoDataUri;
26741
+ const logoDataUri = isRasterDataUri(overrides.logoDataUri) ? overrides.logoDataUri : saved.logoDataUri;
26576
26742
  return { ...agencyName && { agencyName }, accent, ...logoDataUri && { logoDataUri } };
26577
26743
  }
26578
26744
  var import_node_fs2, import_node_path2, DEFAULT_ACCENT;
@@ -26582,6 +26748,7 @@ var init_branding = __esm({
26582
26748
  import_node_fs2 = require("node:fs");
26583
26749
  import_node_path2 = require("node:path");
26584
26750
  init_credentials_store();
26751
+ init_svg_sanitize();
26585
26752
  DEFAULT_ACCENT = "#B8934A";
26586
26753
  }
26587
26754
  });
@@ -26631,7 +26798,7 @@ td.ev{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:var(--mute
26631
26798
  .shots a{font-size:13px;color:var(--accent)}
26632
26799
  footer{margin-top:40px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);padding-top:12px}
26633
26800
  </style></head><body><div class="wrap">
26634
- <header><div>${b.logoDataUri ? `<img src="${b.logoDataUri}" alt="" style="height:28px;margin-bottom:8px">` : ""}<h1>Design check</h1><div class="agency">${b.agencyName ? esc2(b.agencyName) + " \xB7 " : ""}${esc2(v.medium)} \xB7 ${esc2(v.target)} \xB7 ${esc2(v.at.slice(0, 16).replace("T", " "))} \xB7 ${v.renderedWith === "browser" ? "rendered in a browser" : "static checks only"}</div></div><div class="verdict ${verdictClass}">${v.verdict === "PASS" ? "Passes every measured rule" : v.verdict === "FAIL" ? "Fails a measured rule" : "Not graded"}</div></header>
26801
+ <header><div>${isRasterDataUri(b.logoDataUri) ? `<img src="${esc2(b.logoDataUri)}" alt="" style="height:28px;margin-bottom:8px">` : ""}<h1>Design check</h1><div class="agency">${b.agencyName ? esc2(b.agencyName) + " \xB7 " : ""}${esc2(v.medium)} \xB7 ${esc2(v.target)} \xB7 ${esc2(v.at.slice(0, 16).replace("T", " "))} \xB7 ${v.renderedWith === "browser" ? "rendered in a browser" : "static checks only"}</div></div><div class="verdict ${verdictClass}">${v.verdict === "PASS" ? "Passes every measured rule" : v.verdict === "FAIL" ? "Fails a measured rule" : "Not graded"}</div></header>
26635
26802
  <p class="scope"><strong>What this report is not.</strong> Every rule here measures craft \u2014 type, spacing, colour, contrast, structure, performance, and the tells that make a page look machine-made. <strong>None of them measures whether the page persuades anyone.</strong> A page can pass every rule and still be boring, unconvincing and unsellable: no hook, no proof, no reason to care. Judge the writing separately, and never read a pass here as "this is a good page".</p>
26636
26803
  <div class="conf ${v.conformance.ok ? "ok" : ""}"><b>${v.conformance.ok ? "The file honours the output contract." : "The file breaks the output contract, so the design rules were not graded."}</b>${v.conformance.problems.length ? `<ul>${v.conformance.problems.map((p) => `<li>${esc2(p)}</li>`).join("")}</ul>` : ""}${v.conformance.warnings.length ? `<div class="det">Warnings: ${v.conformance.warnings.map(esc2).join(" \xB7 ")}</div>` : ""}</div>
26637
26804
  <div class="sum"><div><b>${s.measuredPass}</b><span>measured, pass</span></div><div><b>${s.measuredFail}</b><span>measured, fail</span></div><div><b>${s.proxyPass}</b><span>proxy, pass</span></div><div><b>${s.proxyFail}</b><span>proxy, fail</span></div><div><b>${s.asserted}</b><span>judgment calls</span></div><div><b>${s.na + s.unchecked}</b><span>not applicable / not checked</span></div></div>
@@ -26646,6 +26813,7 @@ var init_report2 = __esm({
26646
26813
  "src/command-os/design/report.ts"() {
26647
26814
  "use strict";
26648
26815
  init_branding();
26816
+ init_svg_sanitize();
26649
26817
  esc2 = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
26650
26818
  STATE_LABEL = {
26651
26819
  "MEASURED-PASS": "Pass",
@@ -27428,8 +27596,14 @@ function registerDesignStudioTools(server2) {
27428
27596
  d.provenance.evidence["shape.radius"] = `set by ${stamp}`;
27429
27597
  }
27430
27598
  if (a.logoSvg) {
27431
- if (!/^\s*<svg[\s\S]*<\/svg>\s*$/i.test(a.logoSvg) || a.logoSvg.length > 6e4) throw new Error("logoSvg must be one <svg>\u2026</svg> under 60 KB. Nothing was changed.");
27432
- d.logo = { inlineSvg: a.logoSvg, whiteFillFixed: false, source: `set by ${stamp}`, rights: "declared" };
27599
+ let clean;
27600
+ try {
27601
+ clean = sanitizeSvg(a.logoSvg);
27602
+ } catch (e) {
27603
+ throw new Error(`${e.message} Nothing was changed.`);
27604
+ }
27605
+ a.logoSvg = clean;
27606
+ d.logo = { inlineSvg: clean, whiteFillFixed: false, source: `set by ${stamp}`, rights: "declared" };
27433
27607
  d.provenance.evidence["logo"] = `set by ${stamp}`;
27434
27608
  }
27435
27609
  if (a.tone) d.voice.tone = a.tone;
@@ -27655,6 +27829,7 @@ var init_design_studio = __esm({
27655
27829
  init_fonts();
27656
27830
  init_color();
27657
27831
  init_branding();
27832
+ init_svg_sanitize();
27658
27833
  summarise2 = (d) => ({
27659
27834
  slug: d.slug,
27660
27835
  name: d.name,
@@ -28820,9 +28995,9 @@ function registerConnectorTools(server2) {
28820
28995
  links.push(...proposeClientLinks(result.customers, known, observedAt));
28821
28996
  const timeZone = readAgencyProfile()?.defaults?.timezone;
28822
28997
  const bridged = bridgeToEngagements(identities, links, observations, clients, { timeZone });
28823
- const nameOf = (t) => clients[t]?.clientName ?? t;
28824
- const owed = owedByClient(outstanding(result.invoices), identities, links, nameOf);
28825
- const abandoned = owedByClient(writtenOff(result.invoices), identities, links, nameOf);
28998
+ const nameOf2 = (t) => clients[t]?.clientName ?? t;
28999
+ const owed = owedByClient(outstanding(result.invoices), identities, links, nameOf2);
29000
+ const abandoned = owedByClient(writtenOff(result.invoices), identities, links, nameOf2);
28826
29001
  const renewals = renewalsDue(result.subs);
28827
29002
  let written = 0;
28828
29003
  let created = 0;
@@ -28864,7 +29039,7 @@ function registerConnectorTools(server2) {
28864
29039
  stripeCustomerId: c.id,
28865
29040
  email: c.email ?? null,
28866
29041
  name: c.name ?? null,
28867
- looksLike: hits.length === 1 ? nameOf(hits[0]) : null,
29042
+ looksLike: hits.length === 1 ? nameOf2(hits[0]) : null,
28868
29043
  looksLikeClientKey: hits.length === 1 ? hits[0] : null
28869
29044
  };
28870
29045
  });
@@ -33396,7 +33571,7 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
33396
33571
  const permission = buildPermission(locationId2, { name: opts.name, approvedAt: opts.approvedAt });
33397
33572
  if (!permission.allowed) return Promise.reject(new Error(`Refused: ${permission.reason}`));
33398
33573
  const locationName = SANDBOX_ALLOWLIST[locationId2] || opts.name || locationId2;
33399
- const spec = STAGE_SPECS[stage];
33574
+ const spec = opts.spec ?? STAGE_SPECS[stage];
33400
33575
  if (!spec) return Promise.reject(new Error(`Stage ${stage} is not powered yet.`));
33401
33576
  return new Promise((resolve11) => {
33402
33577
  void (async () => {
@@ -33736,6 +33911,7 @@ var init_handoff_pack = __esm({
33736
33911
  "use strict";
33737
33912
  init_report_tokens();
33738
33913
  import_zod69 = require("zod");
33914
+ init_svg_sanitize();
33739
33915
  RunnerCheckSchema = import_zod69.z.object({
33740
33916
  name: import_zod69.z.string().min(1),
33741
33917
  state: import_zod69.z.enum(["CHECKED", "DONE_UNCHECKED"]),
@@ -34808,7 +34984,7 @@ function idFor(kind, what) {
34808
34984
  for (let i = 0; i < what.length; i++) h = h * 31 + what.charCodeAt(i) >>> 0;
34809
34985
  return `site-${kind}-${h.toString(36)}`;
34810
34986
  }
34811
- function safeUrl2(u) {
34987
+ function safeUrl3(u) {
34812
34988
  const s = trim(u, 500);
34813
34989
  if (!s) return "";
34814
34990
  try {
@@ -34841,7 +35017,7 @@ function siteObservationsToFindings(input) {
34841
35017
  refused.push({ what: "(no description)", reason: "the observation said nothing" });
34842
35018
  continue;
34843
35019
  }
34844
- const evidence = (o.evidence ?? []).map((e) => ({ url: safeUrl2(e?.url), quote: trim(e?.quote, 300) })).filter((e) => e.url && e.quote);
35020
+ const evidence = (o.evidence ?? []).map((e) => ({ url: safeUrl3(e?.url), quote: trim(e?.quote, 300) })).filter((e) => e.url && e.quote);
34845
35021
  if (o.kind === "present") {
34846
35022
  if (!evidence.length) {
34847
35023
  refused.push({ what, reason: "said the site does something, without quoting where" });
@@ -34865,7 +35041,7 @@ function siteObservationsToFindings(input) {
34865
35041
  }
34866
35042
  }
34867
35043
  if (o.kind === "absent") {
34868
- const checked = (o.checkedPages ?? []).map(safeUrl2).filter(Boolean);
35044
+ const checked = (o.checkedPages ?? []).map(safeUrl3).filter(Boolean);
34869
35045
  if (!checked.length) {
34870
35046
  refused.push({ what, reason: "claimed something is missing without saying what was read \u2014 unverifiable, so it does not ship" });
34871
35047
  continue;
@@ -34884,7 +35060,7 @@ function siteObservationsToFindings(input) {
34884
35060
  remedy: remedyFor(kind),
34885
35061
  fix: "manual",
34886
35062
  minutes: kind === "contradiction" ? 15 : 30,
34887
- evidence: kind === "contradiction" ? evidence.map((e) => ({ tool: "website", detail: `"${e.quote}" \u2014 ${e.url}` })) : (o.checkedPages ?? []).map(safeUrl2).filter(Boolean).map((u) => ({ tool: "website", detail: `checked, not found: ${u}` }))
35063
+ evidence: kind === "contradiction" ? evidence.map((e) => ({ tool: "website", detail: `"${e.quote}" \u2014 ${e.url}` })) : (o.checkedPages ?? []).map(safeUrl3).filter(Boolean).map((u) => ({ tool: "website", detail: `checked, not found: ${u}` }))
34888
35064
  });
34889
35065
  }
34890
35066
  return {
@@ -36123,8 +36299,22 @@ async function submitIntakeAnswers(client, locationId2, snapshot, answers, conta
36123
36299
  return { ok: false, unmapped, error: e instanceof Error ? e.message : String(e) };
36124
36300
  }
36125
36301
  }
36302
+ function escapeHtml2(s) {
36303
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
36304
+ }
36305
+ function intakeEmailHtml(to, formUrl, agencyName) {
36306
+ if (!/^https:\/\/[^\s"'<>]+$/.test(formUrl)) throw new Error("the form link is not a web address");
36307
+ return [
36308
+ `<p>Hi ${escapeHtml2(to.firstName?.trim() || "there")},</p>`,
36309
+ `<p>Before we build out your account, we need the details only you can give us. It takes about ten minutes, and you can stop and come back to it.</p>`,
36310
+ `<p><a href="${escapeHtml2(formUrl)}"><strong>Open your onboarding questionnaire</strong></a></p>`,
36311
+ `<p>Answer what you know. Anything you're unsure about, leave it and we'll cover it on our next call.</p>`,
36312
+ `<p>${escapeHtml2(agencyName)}</p>`
36313
+ ].join("");
36314
+ }
36126
36315
  async function emailIntakeLink(client, locationId2, to, formUrl, agencyName) {
36127
36316
  try {
36317
+ const html2 = intakeEmailHtml(to, formUrl, agencyName);
36128
36318
  const found = await client.get("/contacts/", { params: { locationId: locationId2, query: to.email, limit: 5 } });
36129
36319
  let contactId = (found.contacts ?? []).find((c) => c.email?.toLowerCase() === to.email.toLowerCase())?.id;
36130
36320
  if (!contactId) {
@@ -36134,13 +36324,6 @@ async function emailIntakeLink(client, locationId2, to, formUrl, agencyName) {
36134
36324
  contactId = made.contact?.id;
36135
36325
  }
36136
36326
  if (!contactId) throw new Error("could not create a contact to email");
36137
- const html2 = [
36138
- `<p>Hi ${to.firstName || "there"},</p>`,
36139
- `<p>Before we build out your account, we need the details only you can give us. It takes about ten minutes, and you can stop and come back to it.</p>`,
36140
- `<p><a href="${formUrl}"><strong>Open your onboarding questionnaire</strong></a></p>`,
36141
- `<p>Answer what you know. Anything you're unsure about, leave it and we'll cover it on our next call.</p>`,
36142
- `<p>${agencyName}</p>`
36143
- ].join("");
36144
36327
  await client.post("/conversations/messages", {
36145
36328
  body: { type: "Email", contactId, emailTo: to.email, subject: "Your onboarding questionnaire", html: html2 }
36146
36329
  });
@@ -36165,9 +36348,14 @@ function prefillTools(source, recorderPatterns = []) {
36165
36348
  const deny = "Bash,Write,Edit,MultiEdit,NotebookEdit,Task";
36166
36349
  if (source.kind === "text") return { allow: "", deny: deny + ",WebFetch,WebSearch,Read,Glob,Grep" };
36167
36350
  if (source.kind === "url") return { allow: "WebFetch", deny: deny + ",WebSearch,Read,Glob,Grep" };
36351
+ if (source.kind === "file") return { allow: "Read", deny: deny + ",WebFetch,WebSearch,Glob,Grep" };
36168
36352
  return { allow: recorderPatterns.join(","), deny: deny + ",Read,Glob,Grep" };
36169
36353
  }
36170
36354
  function sourcePreamble(source) {
36355
+ if (source.kind === "file") {
36356
+ return `Read the document at this exact path with the Read tool and use its contents as the material: ${source.path}
36357
+ It may be a PDF, a Word document or plain text (the file is "${source.name}"). If it cannot be read or is empty, say so \u2014 do not guess from the file name.`;
36358
+ }
36171
36359
  if (source.kind === "url") {
36172
36360
  return `Fetch this and use it as the material: ${source.url}
36173
36361
  If it needs a login or returns nothing usable, say so \u2014 do not guess from the URL alone.`;
@@ -36204,7 +36392,7 @@ function prefillPromptForSource(labels, source) {
36204
36392
  ${body}` : body;
36205
36393
  }
36206
36394
  async function prefillFromSource(spawnClaude, labels, source, recorderPatterns = []) {
36207
- const empty = source.kind === "text" && !source.text.trim() || source.kind === "url" && !source.url.trim() || source.kind === "recorder" && !source.instruction.trim();
36395
+ const empty = source.kind === "text" && !source.text.trim() || source.kind === "url" && !source.url.trim() || source.kind === "recorder" && !source.instruction.trim() || source.kind === "file" && !source.path.trim();
36208
36396
  if (empty) return { ok: false, answers: {}, unknown: labels, error: "nothing to read" };
36209
36397
  return runPrefill(() => spawnClaude(prefillPromptForSource(labels, source), prefillTools(source, recorderPatterns)), labels);
36210
36398
  }
@@ -38928,10 +39116,10 @@ async function executeBackbone(plan, deps, opts = {}) {
38928
39116
  }
38929
39117
  return { ok: true, idMap, built, manual, published, publishFailed, deferred, staff, templates, templatesUnverified, templatesFormatNote, calendarPending, refreshed, missingSteps, recipients };
38930
39118
  }
38931
- async function buildSimple(objects, type, list2, create, nameOf, pollForNew, idMap, built) {
39119
+ async function buildSimple(objects, type, list2, create, nameOf2, pollForNew, idMap, built) {
38932
39120
  if (objects.length === 0) return null;
38933
39121
  for (const obj of objects) {
38934
- const name = nameOf(obj);
39122
+ const name = nameOf2(obj);
38935
39123
  let existing;
38936
39124
  try {
38937
39125
  existing = await list2();
@@ -40938,6 +41126,340 @@ var init_intake_contact = __esm({
40938
41126
  }
40939
41127
  });
40940
41128
 
41129
+ // src/command-os/client-capabilities.ts
41130
+ function findArray(raw, key) {
41131
+ if (Array.isArray(raw)) return raw;
41132
+ if (!raw || typeof raw !== "object") return null;
41133
+ const seen = /* @__PURE__ */ new Set();
41134
+ const byKey = (o, depth) => {
41135
+ if (!o || typeof o !== "object" || seen.has(o) || depth > 3) return null;
41136
+ seen.add(o);
41137
+ const r = o;
41138
+ if (key && Array.isArray(r[key])) return r[key];
41139
+ for (const v of Object.values(r)) {
41140
+ const hit = byKey(v, depth + 1);
41141
+ if (hit) return hit;
41142
+ }
41143
+ return null;
41144
+ };
41145
+ if (key) {
41146
+ const hit = byKey(raw, 0);
41147
+ if (hit) return hit;
41148
+ const r = raw;
41149
+ for (const env of ["data", "results"]) {
41150
+ const inner = r[env];
41151
+ if (Array.isArray(inner)) return inner;
41152
+ if (inner && typeof inner === "object" && Array.isArray(inner[key])) return inner[key];
41153
+ }
41154
+ return null;
41155
+ }
41156
+ seen.clear();
41157
+ const anyArray = (o, depth) => {
41158
+ if (!o || typeof o !== "object" || seen.has(o) || depth > 2) return null;
41159
+ seen.add(o);
41160
+ for (const v of Object.values(o)) if (Array.isArray(v)) return v;
41161
+ for (const v of Object.values(o)) {
41162
+ const hit = anyArray(v, depth + 1);
41163
+ if (hit) return hit;
41164
+ }
41165
+ return null;
41166
+ };
41167
+ return anyArray(raw, 0);
41168
+ }
41169
+ function capabilityByKey(key) {
41170
+ return CAPABILITIES.find((c) => c.key === key);
41171
+ }
41172
+ function nameOf(item, field) {
41173
+ if (!item || typeof item !== "object") return "";
41174
+ const o = item;
41175
+ for (const k of [field, "name", "title", "label", "agentName", "value", "phoneNumber"].filter(Boolean)) {
41176
+ const v = o[k];
41177
+ if (typeof v === "string" && v.trim()) return v.trim();
41178
+ if (v && typeof v === "object" && typeof v.singular === "string") return String(v.singular);
41179
+ }
41180
+ return "";
41181
+ }
41182
+ var CAPABILITY_GROUPS, N, AI2, CAPABILITIES, NO_BUILD_BUTTON;
41183
+ var init_client_capabilities = __esm({
41184
+ "src/command-os/client-capabilities.ts"() {
41185
+ "use strict";
41186
+ CAPABILITY_GROUPS = [
41187
+ { key: "setup", label: "Set up", blurb: "The bones of the account: who, where, what it sells and where people book." },
41188
+ { key: "messaging", label: "Messaging and automation", blurb: "What runs on its own once a lead arrives, and what goes out to the whole list." },
41189
+ { key: "ai", label: "AI employee", blurb: "The bot that answers texts, the agent that answers calls, and the demo you show a prospect." },
41190
+ { key: "money", label: "Money and paperwork", blurb: "Getting paid and getting it signed." },
41191
+ { key: "reputation", label: "Reputation and content", blurb: "Reviews, social posts and the blog." },
41192
+ { key: "health", label: "Health and handoff", blurb: "Proving it works, keeping it clean, and handing it over." }
41193
+ ];
41194
+ N = (label, path36, params, key, nameField, version) => ({ label, path: path36, params, key, nameField, version });
41195
+ AI2 = "2021-04-15";
41196
+ CAPABILITIES = [
41197
+ // ── Set up ──────────────────────────────────────────────────────────────
41198
+ {
41199
+ key: "intake",
41200
+ group: "setup",
41201
+ label: "Intake and documents",
41202
+ what: "What the client told you, in one place: the intake answers, and the documents and links they sent.",
41203
+ says: "Show me {client}'s intake answers and what is still unanswered.",
41204
+ hub: "guides/blueprint-build-a-client-account.html",
41205
+ reads: [N("forms on the account", "/forms/", { locationId: "{loc}", limit: "100" }, "forms", "name")],
41206
+ blueprint: "The Blueprint intake form and its answers. Document and link uploads are the next build."
41207
+ },
41208
+ {
41209
+ key: "pipelines",
41210
+ group: "setup",
41211
+ label: "Pipelines and stages",
41212
+ what: "The board every lead moves across, from first contact to paid, with a stage for each step the client actually takes.",
41213
+ says: "Stand up a pipeline for {client} with the stages a new lead moves through, from first contact to paid.",
41214
+ hub: "guides/stand-up-a-pipeline.html",
41215
+ reads: [N("pipelines", "/opportunities/pipelines", { locationId: "{loc}" }, "pipelines", "name")],
41216
+ blueprint: "One pipeline, Patient Journey or the industry's equivalent, with seven stages."
41217
+ },
41218
+ {
41219
+ key: "calendars",
41220
+ group: "setup",
41221
+ label: "Calendars and booking",
41222
+ what: "Where people book: the calendar, its hours, who gets the appointment, and the reminders around it.",
41223
+ says: "Set up a booking calendar for {client}: consultation, 30 minutes, weekdays 9 to 5, round robin across the staff.",
41224
+ hub: "guides/set-up-a-booking-calendar.html",
41225
+ reads: [N("calendars", "/calendars/", { locationId: "{loc}" }, "calendars", "name")],
41226
+ blueprint: "One consultation calendar, round robin, 30-minute slots, pointed at the staff from the brief."
41227
+ },
41228
+ {
41229
+ key: "forms",
41230
+ group: "setup",
41231
+ label: "Forms and surveys",
41232
+ what: "The questions a lead answers before anyone calls them, and where those answers land.",
41233
+ says: "Build an intake form for {client} that asks for name, email, phone and what they are interested in, and show me where the answers land.",
41234
+ hub: "guides/create-forms-and-read-submissions.html",
41235
+ reads: [N("forms", "/forms/", { locationId: "{loc}", limit: "100" }, "forms", "name"), N("surveys", "/surveys/", { locationId: "{loc}", limit: "100" }, "surveys", "name")],
41236
+ blueprint: "One New Client Inquiry form, wired to the intake funnel."
41237
+ },
41238
+ {
41239
+ key: "pages",
41240
+ group: "setup",
41241
+ label: "Pages and funnels",
41242
+ what: "The website, the landing page and the funnel a lead lands on. Brands is only the shelf; this is where a page gets built.",
41243
+ says: "Build {client} a landing page for their main offer using their brand from the shelf, with a booking button that opens their calendar. Show me the draft before it goes live.",
41244
+ hub: "guides/page-studio-pages-and-sites.html",
41245
+ reads: [N("funnels", "/funnels/funnel/list", { locationId: "{loc}", limit: "50" }, "funnels", "name")],
41246
+ blueprint: "One intake funnel with the inquiry form on it. Full sites and offer pages are built here, one at a time."
41247
+ },
41248
+ {
41249
+ key: "fields",
41250
+ group: "setup",
41251
+ label: "Fields, values and tags",
41252
+ what: "The custom fields the account records, the values every message reads from, and the tags that move people between sequences.",
41253
+ says: "Show me {client}'s custom fields, custom values and tags, and add the ones the build needs.",
41254
+ hub: "guides/custom-fields-and-objects.html",
41255
+ reads: [
41256
+ N("custom fields", "/locations/{loc}/customFields", void 0, "customFields", "name"),
41257
+ N("custom values", "/locations/{loc}/customValues", void 0, "customValues", "name"),
41258
+ N("tags", "/locations/{loc}/tags", void 0, "tags", "name")
41259
+ ],
41260
+ blueprint: "Six fields, four custom values and 24 tags, including the hand-off tags the sequences use."
41261
+ },
41262
+ {
41263
+ key: "staff",
41264
+ group: "setup",
41265
+ label: "Staff and users",
41266
+ what: "Who works in the account, what each one may see, and who gets the alerts and the appointments.",
41267
+ says: "Show me the staff on {client}'s account and who is assigned to the calendar and the alerts.",
41268
+ hub: "guides/account-admin-basics.html",
41269
+ reads: [N("users", "/users/", { locationId: "{loc}" }, "users", "name")],
41270
+ blueprint: "One user per staff member in the brief, added in GoHighLevel's own screen because the API refuses to create them."
41271
+ },
41272
+ {
41273
+ key: "phone",
41274
+ group: "setup",
41275
+ label: "Phone numbers and A2P",
41276
+ what: "The number the account texts and calls from, and the registration without which no text is delivered.",
41277
+ says: "Show me {client}'s phone numbers and whether A2P registration is done, and what is left to do by hand.",
41278
+ hub: "guides/account-admin-basics.html",
41279
+ reads: [N("phone numbers", "/phone-system/numbers/", { locationId: "{loc}" }, "phoneNumbers", "title")],
41280
+ blueprint: "Nothing. Numbers and A2P are bought and registered by hand in GoHighLevel; the build checks they exist."
41281
+ },
41282
+ // ── Messaging and automation ────────────────────────────────────────────
41283
+ {
41284
+ key: "workflows",
41285
+ group: "messaging",
41286
+ label: "Workflows and automations",
41287
+ what: "What happens on its own when a lead arrives, books, goes quiet or replies: speed to lead, missed-call text-back, nurture, reminders, no-show rescue.",
41288
+ says: "Show me {client}'s workflows and which of the standard eight are missing, then build the missing ones as drafts for my review.",
41289
+ hub: "guides/speed-to-lead.html",
41290
+ reads: [N("workflows", "/workflows/", { locationId: "{loc}" }, "workflows", "name")],
41291
+ blueprint: "Eight workflows: Speed to Lead, Lead Nurture, Win-back, Replied Stop and Route, Missed Call Text-Back, Confirm and Move, Reminders, No-Show Rescue."
41292
+ },
41293
+ {
41294
+ key: "reactivation",
41295
+ group: "messaging",
41296
+ label: "Reactivation campaigns",
41297
+ what: "Bringing a cold list back: one reason to return per message, over a month, to everyone who went quiet.",
41298
+ says: "Build a reactivation campaign for {client}: find everyone who has not booked in 90 days and run the win-back sequence on them, as a draft first.",
41299
+ hub: "guides/win-back-a-cold-list.html",
41300
+ reads: [N("workflows", "/workflows/", { locationId: "{loc}" }, "workflows", "name"), N("campaigns", "/campaigns/", { locationId: "{loc}" }, "campaigns", "name")],
41301
+ blueprint: "The Win-back sequence, five touches over 31 days, started by the winback-start tag."
41302
+ },
41303
+ {
41304
+ key: "templates",
41305
+ group: "messaging",
41306
+ label: "Email and SMS templates",
41307
+ what: "The messages themselves, written once in the client's voice and reused by every workflow and broadcast.",
41308
+ says: "Show me {client}'s email and text templates, and write the ones the sequences still need in their voice.",
41309
+ hub: "guides/build-email-templates.html",
41310
+ reads: [N("email templates", "/emails/builder", { locationId: "{loc}", limit: "100" }, "builders", "name")],
41311
+ blueprint: "Fifteen emails and seventeen texts, send-ready, one copy guide."
41312
+ },
41313
+ {
41314
+ key: "broadcasts",
41315
+ group: "messaging",
41316
+ label: "Broadcasts and email campaigns",
41317
+ what: "One message to a whole list, now or scheduled: an offer, an event, a newsletter.",
41318
+ says: "Draft an email to {client}'s list about their next event and show it to me. Do not send or schedule anything; I do that.",
41319
+ hub: "guides/send-texts-and-emails.html",
41320
+ reads: [N("scheduled emails", "/emails/schedule", { locationId: "{loc}", limit: "50" }, "schedules", "name"), N("email templates", "/emails/builder", { locationId: "{loc}", limit: "100" }, "builders", "name")]
41321
+ },
41322
+ // ── AI employee ─────────────────────────────────────────────────────────
41323
+ {
41324
+ key: "conversation-ai",
41325
+ group: "ai",
41326
+ label: "Conversation AI",
41327
+ what: "GoHighLevel's name for the bot that answers texts and chats from the client's own website and written answers.",
41328
+ says: "Build {client} a Conversation AI bot: read their website into a knowledge base, build the bot in suggestive mode, and show me its first answers.",
41329
+ hub: "command-os/ai-employee.html",
41330
+ reads: [
41331
+ N("knowledge bases", "/knowledge-bases/", { locationId: "{loc}", limit: "50" }, "knowledgeBases", "name", AI2),
41332
+ N("bots", "/conversation-ai/agents/search", { limit: "50" }, "agents", "name", AI2)
41333
+ ]
41334
+ },
41335
+ {
41336
+ key: "voice-ai",
41337
+ group: "ai",
41338
+ label: "Voice AI",
41339
+ what: "GoHighLevel's name for the agent that answers the phone. Built without a number, given one on purpose, proven by the transcript.",
41340
+ says: "Check whether a call can land on {client}'s account, then build a Voice AI agent that answers, books and takes a message. Leave it unbound until I say.",
41341
+ hub: "modules/11-your-ai-employee/lesson-05-the-phone-agent.html",
41342
+ reads: [N("phone agents", "/voice-ai/agents", { locationId: "{loc}", page: "1", pageSize: "50" }, "agents", "agentName", AI2), N("phone numbers", "/phone-system/numbers/", { locationId: "{loc}" }, "phoneNumbers", "title")]
41343
+ },
41344
+ {
41345
+ key: "demo",
41346
+ group: "ai",
41347
+ label: "Prospect demo",
41348
+ what: "One sentence about a prospect's business becomes a bot they can text, on your own demo account, and one command puts it back. It messages a real prospect, so it runs from your own Claude with you watching, never from a button.",
41349
+ says: "Prepare an AI employee demo for {client} from their website and arm it so they can text it.",
41350
+ hub: "modules/11-your-ai-employee/lesson-04-the-five-minute-demo.html",
41351
+ reads: []
41352
+ },
41353
+ // ── Money and paperwork ─────────────────────────────────────────────────
41354
+ {
41355
+ key: "payments",
41356
+ group: "money",
41357
+ label: "Products, invoices and payments",
41358
+ what: "What the client sells, the invoices that ask for it, and the payments that came in.",
41359
+ says: "Show me {client}'s products and open invoices, and set up the three packages with payment links.",
41360
+ hub: "guides/invoices-and-payments.html",
41361
+ reads: [N("products", "/products/", { locationId: "{loc}", limit: "100" }, "products", "name"), N("invoices", "/invoices/", { altId: "{loc}", altType: "location", limit: "50", offset: "0" }, "invoices", "name")]
41362
+ },
41363
+ {
41364
+ key: "documents",
41365
+ group: "money",
41366
+ label: "Documents and signatures",
41367
+ what: "The templates a new patient agreement or a proposal is made from.",
41368
+ says: "Show me {client}'s document templates and tell me what a new patient agreement from the template would say. I press send.",
41369
+ hub: "guides/documents-and-contracts.html",
41370
+ reads: [N("document templates", "/proposals/templates", { locationId: "{loc}", limit: "50" }, "data", "name")]
41371
+ },
41372
+ {
41373
+ key: "memberships",
41374
+ group: "money",
41375
+ label: "Memberships and courses",
41376
+ what: "Courses, memberships and the content behind a paywall.",
41377
+ says: "Set up a members area for {client} with their program as a course, and show me the offer page.",
41378
+ hub: "guides/courses-and-memberships.html",
41379
+ reads: []
41380
+ },
41381
+ // ── Reputation and content ──────────────────────────────────────────────
41382
+ {
41383
+ key: "reviews",
41384
+ group: "reputation",
41385
+ label: "Reviews",
41386
+ what: "Asking for reviews at the right moment, and watching the ones that arrive.",
41387
+ says: "Show me {client}'s latest reviews and set up a review request after every completed appointment.",
41388
+ hub: "guides/watch-your-reviews.html",
41389
+ reads: []
41390
+ },
41391
+ {
41392
+ key: "social",
41393
+ group: "reputation",
41394
+ label: "Social posting",
41395
+ what: "Posts scheduled to the client's connected social accounts.",
41396
+ says: "Show me which social accounts {client} has connected, then write this week's posts for me to review. Nothing is posted until I say.",
41397
+ hub: "guides/social-posting.html",
41398
+ reads: [N("connected social accounts", "/social-media-posting/{loc}/accounts", void 0, "accounts", "name")]
41399
+ },
41400
+ {
41401
+ key: "blogs",
41402
+ group: "reputation",
41403
+ label: "Blogs",
41404
+ what: "The client's blog and its posts.",
41405
+ says: "Show me {client}'s blogs and draft a post on their most asked question.",
41406
+ hub: "guides/social-posting.html",
41407
+ reads: [N("blogs", "/blogs/site/all", { locationId: "{loc}", limit: "50" }, "data", "name")]
41408
+ },
41409
+ // ── Health and handoff ──────────────────────────────────────────────────
41410
+ {
41411
+ key: "checkup",
41412
+ group: "health",
41413
+ label: "Checkup and audit",
41414
+ what: "Whether the account works the way a lead would meet it, and whether every workflow can still be saved.",
41415
+ says: "Run a checkup on {client}'s account and audit every workflow, then tell me what is broken before the client finds it.",
41416
+ hub: "guides/run-a-checkup.html",
41417
+ reads: [N("workflows", "/workflows/", { locationId: "{loc}" }, "workflows", "name")],
41418
+ blueprint: "The Verify stage runs this after every build."
41419
+ },
41420
+ {
41421
+ key: "contacts",
41422
+ group: "health",
41423
+ label: "Contacts and data cleanup",
41424
+ what: "The people in the account, the duplicates, the tags, and the lists you report from.",
41425
+ says: "Show me how many contacts {client} has, find the duplicates, and tag everyone who has not booked in 90 days.",
41426
+ hub: "guides/bulk-tag-and-clean-up.html",
41427
+ reads: [N("tags", "/locations/{loc}/tags", void 0, "tags", "name")]
41428
+ },
41429
+ {
41430
+ key: "integrations",
41431
+ group: "health",
41432
+ label: "Integrations and webhooks",
41433
+ what: "Sending the account's data to other apps, tracked links, and custom objects.",
41434
+ says: "Set up a webhook so every new {client} lead lands in their spreadsheet, and give me a tracked link for the ad.",
41435
+ hub: "guides/send-data-to-other-apps.html",
41436
+ reads: [N("trigger links", "/links/", { locationId: "{loc}" }, "links", "name"), N("custom objects", "/objects/", { locationId: "{loc}" }, "objects", "labels")]
41437
+ },
41438
+ {
41439
+ key: "handoff",
41440
+ group: "health",
41441
+ label: "Go-live and handoff",
41442
+ what: "The final sign-off, the handoff page the client receives, and the snapshot that lets the build be reused.",
41443
+ says: "Build {client}'s handoff page and the offboarding kit, and tell me what is still waiting on someone else.",
41444
+ hub: "guides/snapshots-and-reusing-a-build.html",
41445
+ reads: [],
41446
+ blueprint: "Stage 7, Go-live, ends with the handoff page."
41447
+ }
41448
+ ];
41449
+ NO_BUILD_BUTTON = /* @__PURE__ */ new Set(["demo", "documents", "social"]);
41450
+ }
41451
+ });
41452
+
41453
+ // src/command-os/script-json.ts
41454
+ function scriptJson(value) {
41455
+ return JSON.stringify(value === void 0 ? null : value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
41456
+ }
41457
+ var init_script_json = __esm({
41458
+ "src/command-os/script-json.ts"() {
41459
+ "use strict";
41460
+ }
41461
+ });
41462
+
40941
41463
  // src/command-os/cockpit-shell.ts
40942
41464
  function cockpitTokensCss(opts = {}) {
40943
41465
  const webfont = opts.webfont !== false;
@@ -41020,21 +41542,74 @@ function cockpitChromeCss() {
41020
41542
  .stepper button:disabled{opacity:.35;cursor:default}
41021
41543
  .stepper output{font-size:var(--fs-label);min-width:2.6rem;text-align:center;color:var(--ink-2)}
41022
41544
  footer{color:var(--ink-3);font-size:var(--fs-sm);text-align:center;margin:2rem 0;padding:0 1rem}
41023
- @media (max-width: 52rem){.brand-sub,.localpill{display:none}}
41545
+ /* \u2500\u2500 The two-column shell: the hub's sidebar, ported (hub.css .side) \u2500\u2500 */
41546
+ .shell{display:flex;align-items:stretch;min-height:calc(100vh - 3rem)}
41547
+ .side{width:13.5rem;flex:none;box-sizing:border-box;background:var(--navy-deep);color:rgba(250,247,242,.74);padding:1rem .6rem 3rem;position:sticky;top:3rem;height:calc(100vh - 3rem);overflow-y:auto}
41548
+ .side-group{margin-bottom:1rem}
41549
+ .side-label{font-size:var(--fs-label);font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:rgba(250,247,242,.42);padding:.3rem .7rem}
41550
+ .side a,.side .active{display:flex;align-items:center;gap:.55rem;padding:.42rem .7rem;border-radius:.45rem;color:rgba(250,247,242,.78);font-size:var(--fs-sm);font-weight:500;text-decoration:none}
41551
+ .side a:hover{background:rgba(255,255,255,.08);color:#FAF7F2;text-decoration:none}
41552
+ .side .active{background:rgba(255,255,255,.09);color:#FAF7F2;box-shadow:inset .18rem 0 0 var(--gold-light)}
41553
+ .side .tag{margin-left:auto;font-size:var(--fs-label);font-weight:500;color:rgba(250,247,242,.5);white-space:nowrap}
41554
+ .side .tag.soon{color:#CEAB6A}
41555
+ .side .side-client{display:block;text-transform:none;letter-spacing:0;font-weight:500;color:#CEAB6A;margin-top:.15rem}
41556
+ .side a.sub,.side .active.sub{padding-left:1.5rem;font-size:var(--fs-label);font-weight:500}
41557
+ .side .active.grp{box-shadow:none;background:none;color:#FAF7F2}
41558
+ .side-foot{margin-top:.6rem;padding:.7rem .7rem;border-top:1px solid rgba(255,255,255,.09);font-size:var(--fs-label);color:rgba(250,247,242,.45);line-height:1.5;display:flex;flex-direction:column;gap:.3rem}
41559
+ .side-foot a{display:inline;padding:0;color:rgba(250,247,242,.78)}
41560
+ .content{flex:1;min-width:0}
41561
+ .topbar .screens{display:none}
41562
+ @media (max-width: 52rem){.brand-sub,.localpill{display:none}.side{display:none}.topbar .screens{display:flex;align-items:center;gap:.4rem}}
41024
41563
  `;
41025
41564
  }
41026
- function cockpitHeaderHtml(active) {
41565
+ function forThisClientHtml(capability) {
41566
+ const current = capability ? CAPABILITIES.find((c) => c.key === capability) : void 0;
41567
+ const rows = CAPABILITY_GROUPS.map((g) => {
41568
+ const caps = CAPABILITIES.filter((c) => c.group === g.key);
41569
+ const first = caps[0];
41570
+ const open = current?.group === g.key;
41571
+ const head3 = open ? `<span class="active grp" aria-current="true">${g.label}</span>` : `<a class="grp" href="/for/${first?.key ?? ""}" title="${g.blurb}">${g.label}</a>`;
41572
+ const subs = open ? caps.map((c) => c.key === capability ? `<span class="active sub" aria-current="page">${c.label}</span>` : `<a class="sub" href="/for/${c.key}" title="${c.what.replace(/"/g, "&quot;")}">${c.label}</a>`).join("\n ") : "";
41573
+ return head3 + (subs ? "\n " + subs : "");
41574
+ }).join("\n ");
41575
+ return `<div class="side-group"><div class="side-label">For this client <span id="side-client" class="side-client"></span></div>
41576
+ ${rows}
41577
+ </div>`;
41578
+ }
41579
+ function cockpitSidebarHtml(active, capability) {
41580
+ const groups = SIDEBAR.map(([label, rows]) => {
41581
+ const links = rows.map((r) => {
41582
+ const tag = r.note ? `<span class="tag soon">${r.note}</span>` : r.hub ? `<span class="tag">hub &nearr;</span>` : "";
41583
+ return r.screen === active ? `<span class="active" aria-current="page">${r.label}</span>` : `<a href="${r.href}"${r.title ? ` title="${r.title}"` : ""}${r.hub ? ' target="_blank" rel="noopener"' : ""}>${r.label}${tag}</a>`;
41584
+ }).join("\n ");
41585
+ return `<div class="side-group"><div class="side-label">${label}</div>
41586
+ ${links}
41587
+ </div>`;
41588
+ }).join("\n ");
41589
+ return `<aside class="side" aria-label="Command OS">
41590
+ ${groups}
41591
+ ${forThisClientHtml(capability)}
41592
+ <div class="side-foot"><a href="${HUB_URL}#do">&larr; Back to hub</a><span>Runs on this computer. What you type stays here. What Claude reads for you goes to your own Claude account; a link or a call recording is fetched from its own site or recorder first. No copy comes to us.</span></div>
41593
+ </aside>`;
41594
+ }
41595
+ function cockpitHeaderHtml(active, opts = {}) {
41027
41596
  const links = NAV.filter((n) => n.key !== active).map((n) => `<a class="backhub" href="${n.href}" title="${n.title}">${n.label}</a>`).join("");
41028
41597
  const here = NAV.find((n) => n.key === active);
41029
41598
  return `<header class="topbar">
41030
41599
  <a class="brand" href="${HUB_URL}#do" title="Back to the member hub"><span class="brand-mark">G</span><span><span class="brand-name">GHL Command</span><span class="brand-sub">&nbsp; Command OS</span></span></a>
41031
41600
  <a class="backhub" href="${HUB_URL}#do">&larr; Back to hub</a>
41032
41601
  <div class="topbar-right">
41033
- ${here ? `<span class="backhub" aria-current="page">${here.label}</span>` : ""}${links}
41602
+ <span class="screens">${here ? `<span class="backhub" aria-current="page">${here.label}</span>` : ""}${links}</span>
41034
41603
  <span class="localpill">Runs on this computer</span>
41035
41604
  <div class="stepper" role="group" aria-label="Type size"><button id="sz-down" type="button" aria-label="Smaller type">A&minus;</button><output id="sz-val" aria-live="polite">${TYPE_DEFAULT}px</output><button id="sz-up" type="button" aria-label="Larger type">A+</button></div>
41036
41605
  </div>
41037
- </header>`;
41606
+ </header>
41607
+ <div class="shell">
41608
+ ${cockpitSidebarHtml(active, opts.capability)}
41609
+ <div class="content">`;
41610
+ }
41611
+ function cockpitShellClose() {
41612
+ return `</div></div>`;
41038
41613
  }
41039
41614
  function cockpitHeadHtml(title2, opts = {}) {
41040
41615
  const fonts = opts.webfont === false ? "" : COCKPIT_FONT_LINKS;
@@ -41042,10 +41617,12 @@ function cockpitHeadHtml(title2, opts = {}) {
41042
41617
  ${fonts}
41043
41618
  <style>${cockpitTokensCss(opts)}${cockpitChromeCss()}${opts.extraCss ?? ""}</style></head>`;
41044
41619
  }
41045
- var HUB_URL, COCKPIT_FONT_LINKS, TYPE_MIN, TYPE_MAX, TYPE_DEFAULT, TYPE_STORAGE_KEY, NAV, COCKPIT_TYPE_JS;
41620
+ var HUB_URL, COCKPIT_FONT_LINKS, TYPE_MIN, TYPE_MAX, TYPE_DEFAULT, TYPE_STORAGE_KEY, NAV, SIDEBAR, COCKPIT_TYPE_JS;
41046
41621
  var init_cockpit_shell = __esm({
41047
41622
  "src/command-os/cockpit-shell.ts"() {
41048
41623
  "use strict";
41624
+ init_client_capabilities();
41625
+ init_script_json();
41049
41626
  HUB_URL = "https://os.ghlcommand.com/";
41050
41627
  COCKPIT_FONT_LINKS = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">`;
41051
41628
  TYPE_MIN = 20;
@@ -41053,13 +41630,28 @@ var init_cockpit_shell = __esm({
41053
41630
  TYPE_DEFAULT = 22;
41054
41631
  TYPE_STORAGE_KEY = "cmdos-cockpit-fs";
41055
41632
  NAV = [
41056
- { key: "board", href: "/", label: "Board", title: "Every client account and where its build stands" },
41057
- { key: "agency", href: "/agency", label: "Your agency", title: "Your agency's own details, used on every client" },
41058
- { key: "brands", href: "/brands", label: "Brands", title: "Brands: pull a client's brand from their site, or start from a reference look" },
41059
- { key: "assessment", href: "/assessment", label: "Assessment", title: "Run the paid assessment on a prospect. Everything stays on this computer." }
41633
+ { key: "board", href: "/", label: "Board", title: "What is waiting on you, and every client account" },
41634
+ { key: "agency", href: "/agency", label: "Your agency", title: "Your agency's own details, set up once and used on every client" },
41635
+ { key: "assessment", href: "/assessment", label: "Assessment", title: "Run the paid assessment on a prospect. The answers stay on this computer until you save them to your own GoHighLevel account." },
41636
+ { key: "brands", href: "/brands", label: "Brands", title: "The shelf every deck, page and report starts from" }
41637
+ ];
41638
+ SIDEBAR = [
41639
+ ["Start", [
41640
+ { label: "Board", href: "/", screen: "board", title: NAV[0].title },
41641
+ { label: "Your agency", href: "/agency", screen: "agency", title: NAV[1].title }
41642
+ ]],
41643
+ ["Clients", [
41644
+ { label: "Assessment", href: "/assessment", screen: "assessment", title: NAV[2].title },
41645
+ { label: "Build a Client Account", href: "/new", screen: "new", title: "Register the account, answer the intake, then run the seven stages" },
41646
+ { label: "Work Queue", href: "/queue", screen: "queue", title: "The steps waiting on you, across every client" },
41647
+ { label: "Client Builds", href: "/clients", screen: "clients", title: "Every client account and where its build stands" }
41648
+ ]],
41649
+ ["Design Studio", [
41650
+ { label: "Brands", href: "/brands", screen: "brands", title: NAV[3].title }
41651
+ ]]
41060
41652
  ];
41061
41653
  COCKPIT_TYPE_JS = `/* Type size stepper \u2014 same behaviour as the hub (${TYPE_MIN}\u2013${TYPE_MAX}px, remembered). */
41062
- (function(){var MIN=${TYPE_MIN},MAX=${TYPE_MAX},STEP=2,DEF=${TYPE_DEFAULT},KEY=${JSON.stringify(TYPE_STORAGE_KEY)};
41654
+ (function(){var MIN=${TYPE_MIN},MAX=${TYPE_MAX},STEP=2,DEF=${TYPE_DEFAULT},KEY=${scriptJson(TYPE_STORAGE_KEY)};
41063
41655
  var down=document.getElementById("sz-down"),up=document.getElementById("sz-up"),val=document.getElementById("sz-val");
41064
41656
  if(!down||!up||!val)return;
41065
41657
  var size=DEF;try{var s=parseInt(localStorage.getItem(KEY),10);if(s>=MIN&&s<=MAX)size=s}catch(e){}
@@ -41073,6 +41665,8 @@ var init_cockpit_shell = __esm({
41073
41665
  // src/agency-profile-page.ts
41074
41666
  function agencyProfilePageHtml(p, clients = [], seatToken = "") {
41075
41667
  const offers = (p.offers ?? []).map((o) => `${o.name}${o.priceFloor ? ` \u2014 ${o.priceFloor}${o.cadence === "monthly" ? "/mo" : ""}` : ""}`).join("\n");
41668
+ const os7 = p.agencyOs ?? {};
41669
+ const lines = (xs) => (xs ?? []).join("\n");
41076
41670
  const sops = (p.delivery?.sops ?? []).join("\n");
41077
41671
  const staff = (p.staff ?? []).map((m) => [m.name, m.role, m.email].filter(Boolean).join(" \xB7 ")).join("\n");
41078
41672
  const clientBlocks = clients.map((c) => {
@@ -41139,21 +41733,21 @@ ${cockpitHeaderHtml("agency")}
41139
41733
  <form id="f">
41140
41734
  <fieldset><legend>Brand</legend>
41141
41735
  <label for="agencyName">Agency name, as clients see it</label>
41142
- <input id="agencyName" name="agencyName" value="${esc3(p.agencyName)}" placeholder="Elite DCs">
41736
+ <input id="agencyName" name="agencyName" value="${esc3(p.agencyName)}" placeholder="Your Agency">
41143
41737
  <div class="row">
41144
41738
  <div><label for="accent">Brand colour</label><input type="color" id="accent" name="accent" value="${esc3(p.accent || "#b8934a")}"></div>
41145
- <div><label for="website">Website</label><input id="website" name="website" value="${esc3(p.website)}" placeholder="https://elitedcs.com"></div>
41739
+ <div><label for="website">Website</label><input id="website" name="website" value="${esc3(p.website)}" placeholder="https://youragency.com"></div>
41146
41740
  </div>
41147
41741
  <label for="logoUrl">Logo URL</label>
41148
- <input id="logoUrl" name="logoUrl" value="${esc3(p.logoUrl)}" placeholder="https://elitedcs.com/logo.svg">
41742
+ <input id="logoUrl" name="logoUrl" value="${esc3(p.logoUrl)}" placeholder="https://youragency.com/logo.svg">
41149
41743
  <p class="hint">Must start with https. A logo link that isn't https is refused, because this one goes on pages your prospects open.</p>
41150
41744
  <label for="legalName">Registered entity, if different</label>
41151
- <input id="legalName" name="legalName" value="${esc3(p.legalName)}" placeholder="Elite DCS LLC \u2014 used on contracts and A2P">
41745
+ <input id="legalName" name="legalName" value="${esc3(p.legalName)}" placeholder="Your Agency LLC \u2014 used on contracts and A2P">
41152
41746
  </fieldset>
41153
41747
 
41154
41748
  <fieldset><legend>Who messages come from</legend>
41155
41749
  <div class="row">
41156
- <div><label for="fromName">From name</label><input id="fromName" name="fromName" value="${esc3(p.sender?.fromName)}" placeholder="Dr Jerry Relth"></div>
41750
+ <div><label for="fromName">From name</label><input id="fromName" name="fromName" value="${esc3(p.sender?.fromName)}" placeholder="Dr Pat Owner"></div>
41157
41751
  <div><label for="replyTo">Reply-to email</label><input id="replyTo" name="replyTo" value="${esc3(p.sender?.replyTo)}"></div>
41158
41752
  <div><label for="phone">Phone</label><input id="phone" name="phone" value="${esc3(p.sender?.phone)}"></div>
41159
41753
  </div>
@@ -41171,11 +41765,61 @@ ${cockpitHeaderHtml("agency")}
41171
41765
  <p class="hint">The floors are the numbers below which you don't take the work. Anything quoting a price checks them first.</p>
41172
41766
  </fieldset>
41173
41767
 
41768
+ <fieldset><legend>Brand DNA \u2014 who you are (Agency OS build 1)</legend>
41769
+ <p class="hint">Filled in once, in your own words. Used on the assessment report, in proposals, in every Claude session's opening brief, and by the Agency OS builds.</p>
41770
+ <label for="story">Your story \u2014 why this agency exists, in two or three sentences</label>
41771
+ <textarea id="story" name="story" placeholder="I ran a clinic for twelve years and watched good practices lose patients to voicemail\u2026">${esc3(os7.story)}</textarea>
41772
+ <label for="method">Your method \u2014 how you get results, and what you call it</label>
41773
+ <textarea id="method" name="method" placeholder="The Full Room method: one weekend, a full calendar, a system that keeps booking after we leave">${esc3(os7.method)}</textarea>
41774
+ <div class="two">
41775
+ <div><label for="voice">How you sound \u2014 three words, then a sentence</label><textarea id="voice" name="voice" placeholder="Direct, warm, clinical. Specifics over hype.">${esc3(os7.voice)}</textarea></div>
41776
+ <div><label for="goals">Your goal for the next twelve months</label><textarea id="goals" name="goals" placeholder="$50k a month from 51 subscribers">${esc3(os7.goals)}</textarea></div>
41777
+ </div>
41778
+ <label for="values">What you stand for and what you refuse to do \u2014 one per line</label>
41779
+ <textarea id="values" name="values" placeholder="Never a false all-clear&#10;No cold texting anyone who did not opt in">${esc3(lines(os7.values))}</textarea>
41780
+ <label for="proof">Proof you may state, with permission \u2014 one per line</label>
41781
+ <textarea id="proof" name="proof" placeholder="Radiance Med Spa: 34 consults booked in one weekend (permission on file)">${esc3(lines(os7.proof))}</textarea>
41782
+ </fieldset>
41783
+
41784
+ <fieldset><legend>Ideal client \u2014 who you serve (build 2)</legend>
41785
+ <label for="icaWho">Who they are \u2014 role, business, size, where</label>
41786
+ <textarea id="icaWho" name="icaWho" placeholder="The owner of a two-provider med spa in a mid-size US city, doing $40k a month and answering the phone herself">${esc3(os7.ica?.who)}</textarea>
41787
+ <div class="two">
41788
+ <div><label for="icaPains">Their pains, worst first \u2014 one per line</label><textarea id="icaPains" name="icaPains">${esc3(lines(os7.ica?.pains))}</textarea></div>
41789
+ <div><label for="icaDesires">What they want, most first \u2014 one per line</label><textarea id="icaDesires" name="icaDesires">${esc3(lines(os7.ica?.desires))}</textarea></div>
41790
+ </div>
41791
+ <div class="two">
41792
+ <div><label for="icaObjections">Why they say no \u2014 one per line</label><textarea id="icaObjections" name="icaObjections">${esc3(lines(os7.ica?.objections))}</textarea></div>
41793
+ <div><label for="icaTriggers">What makes them buy now \u2014 one per line</label><textarea id="icaTriggers" name="icaTriggers">${esc3(lines(os7.ica?.triggers))}</textarea></div>
41794
+ </div>
41795
+ <div class="two">
41796
+ <div><label for="icaVocabulary">Their own words for the problem \u2014 one per line</label><textarea id="icaVocabulary" name="icaVocabulary">${esc3(lines(os7.ica?.vocabulary))}</textarea></div>
41797
+ <div><label for="icaHangouts">Where they read, listen and gather \u2014 one per line</label><textarea id="icaHangouts" name="icaHangouts">${esc3(lines(os7.ica?.hangouts))}</textarea></div>
41798
+ </div>
41799
+ <label for="icaVillains">What they have tried and blame \u2014 one per line</label>
41800
+ <textarea id="icaVillains" name="icaVillains" placeholder="The last agency that charged $3,000 a month for two posts a week">${esc3(lines(os7.ica?.villains))}</textarea>
41801
+ </fieldset>
41802
+
41803
+ <fieldset><legend>The offer \u2014 what you promise (build 3)</legend>
41804
+ <div class="two">
41805
+ <div><label for="offerName">The offer's name</label><input id="offerName" name="offerName" value="${esc3(os7.offer?.name)}" placeholder="The Full Room"></div>
41806
+ <div><label for="offerPrice">Price, and what it is per</label><input id="offerPrice" name="offerPrice" value="${esc3(os7.offer?.price)}" placeholder="$5,000 per event plus 30% of revenue"></div>
41807
+ </div>
41808
+ <label for="offerPromise">The promise \u2014 one sentence a prospect could repeat</label>
41809
+ <textarea id="offerPromise" name="offerPromise" placeholder="A full room in one weekend, and a system that keeps booking after we leave">${esc3(os7.offer?.promise)}</textarea>
41810
+ <label for="offerStack">What is in it \u2014 one per line</label>
41811
+ <textarea id="offerStack" name="offerStack" placeholder="The campaign page&#10;The AI receptionist&#10;The 30-day nurture">${esc3(lines(os7.offer?.valueStack))}</textarea>
41812
+ <div class="two">
41813
+ <div><label for="offerGuarantee">The guarantee, exactly as you would say it</label><textarea id="offerGuarantee" name="offerGuarantee">${esc3(os7.offer?.guarantee)}</textarea></div>
41814
+ <div><label for="offerAccelerators">Why now \u2014 bonuses, deadlines, scarcity, one per line</label><textarea id="offerAccelerators" name="offerAccelerators">${esc3(lines(os7.offer?.accelerators))}</textarea></div>
41815
+ </div>
41816
+ </fieldset>
41817
+
41174
41818
  <fieldset><legend>How you deliver</legend>
41175
41819
  <label for="sops">Delivery SOPs \u2014 one per line</label>
41176
41820
  <textarea id="sops" name="sops" placeholder="Every build gets a rollback journal before go-live">${esc3(sops)}</textarea>
41177
41821
  <label for="staff">Staff \u2014 one per line, "name \xB7 role \xB7 email"</label>
41178
- <textarea id="staff" name="staff" placeholder="Dr Jerry Relth \xB7 owner \xB7 jerry@elitedcs.com">${esc3(staff)}</textarea>
41822
+ <textarea id="staff" name="staff" placeholder="Dr Pat Owner \xB7 owner \xB7 pat@youragency.com">${esc3(staff)}</textarea>
41179
41823
  <p class="hint">Who may approve what is recorded here for reference. The approval gates themselves are still enforced by the ritual, not by this list.</p>
41180
41824
  </fieldset>
41181
41825
 
@@ -41198,12 +41842,12 @@ ${cockpitHeaderHtml("agency")}
41198
41842
  </fieldset>
41199
41843
 
41200
41844
  ${clientsSection}
41201
- </main>
41845
+ </main>${cockpitShellClose()}
41202
41846
  <script>
41203
41847
  ${READ_NUMBER_JS}
41204
41848
  /* Served key first: this page is reachable on its own, so it cannot depend on
41205
41849
  the board having been opened first in this browser. */
41206
- const SERVED=${JSON.stringify(seatToken)};
41850
+ const SERVED=${scriptJson(seatToken)};
41207
41851
  const KEY=SERVED||new URLSearchParams(location.search).get("seat")||localStorage.getItem("cockpit_seat")||"";
41208
41852
  try{if(SERVED)localStorage.setItem("cockpit_seat",SERVED)}catch(e){}
41209
41853
  /* The Anthropic key: saved on its own, and CHECKED before it is kept. A key that
@@ -41254,7 +41898,14 @@ document.getElementById("f").addEventListener("submit",async e=>{
41254
41898
  offers:offers.length?offers:null,
41255
41899
  pricing:{minMonthly:num("minMonthly"),minSetup:num("minSetup"),currency:v("currency")||null},
41256
41900
  delivery:{sops:lines("sops").length?lines("sops"):null},
41257
- staff:staff.length?staff:null};
41901
+ staff:staff.length?staff:null,
41902
+ agencyOs:{story:v("story")||null,method:v("method")||null,voice:v("voice")||null,goals:v("goals")||null,
41903
+ values:lines("values").length?lines("values"):null,proof:lines("proof").length?lines("proof"):null,
41904
+ ica:{who:v("icaWho")||null,pains:lines("icaPains").length?lines("icaPains"):null,desires:lines("icaDesires").length?lines("icaDesires"):null,
41905
+ objections:lines("icaObjections").length?lines("icaObjections"):null,triggers:lines("icaTriggers").length?lines("icaTriggers"):null,
41906
+ vocabulary:lines("icaVocabulary").length?lines("icaVocabulary"):null,hangouts:lines("icaHangouts").length?lines("icaHangouts"):null,villains:lines("icaVillains").length?lines("icaVillains"):null},
41907
+ offer:{name:v("offerName")||null,promise:v("offerPromise")||null,price:v("offerPrice")||null,guarantee:v("offerGuarantee")||null,
41908
+ valueStack:lines("offerStack").length?lines("offerStack"):null,accelerators:lines("offerAccelerators").length?lines("offerAccelerators"):null}}};
41258
41909
  const m=document.getElementById("msg");
41259
41910
  const badNum=["minMonthly","minSetup"].find(id=>body.pricing[id]===undefined);
41260
41911
  if(badNum){m.textContent="That is not a number I can read, so nothing was saved. Check the "+(badNum==="minMonthly"?"minimum monthly":"minimum setup fee")+".";return}
@@ -41302,6 +41953,7 @@ var init_agency_profile_page = __esm({
41302
41953
  "use strict";
41303
41954
  init_cockpit_shell();
41304
41955
  init_client_engagements();
41956
+ init_script_json();
41305
41957
  READ_NUMBER_JS = `function readNumber(raw){
41306
41958
  var s=String(raw==null?"":raw).trim();
41307
41959
  if(!s)return{clear:true};
@@ -41856,7 +42508,14 @@ function renderReport(opts) {
41856
42508
  out.push("<header>");
41857
42509
  if (agency.name) out.push('<p class="by">Prepared by ' + esc(agency.name) + "</p>");
41858
42510
  out.push("<h1>Where " + biz + " is leaking</h1>");
42511
+ var who = state.client && state.client.name ? esc(state.client.name) + (state.client.role ? ", " + esc(state.client.role) : "") : "";
42512
+ if (who) out.push('<p class="for">Prepared for ' + who + "</p>");
41859
42513
  if (date) out.push('<p class="when">' + esc(date) + "</p>");
42514
+ // The agency's own method and promise, from Layer 1's Agency OS inputs. Their
42515
+ // words, never ours; absent when they have not written them.
42516
+ if (agency.method || agency.promise) {
42517
+ out.push('<p class="about">' + [agency.method, agency.promise].filter(Boolean).map(esc).join(" ") + "</p>");
42518
+ }
41860
42519
  out.push("</header>");
41861
42520
 
41862
42521
  // \u2500\u2500 the three verdicts are three different documents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -42029,7 +42688,8 @@ function sourceLine(state, r, labels) {
42029
42688
 
42030
42689
  function footer(agency) {
42031
42690
  if (!agency.name) return "";
42032
- return '<footer>' + esc(agency.name) + (agency.contact ? " &middot; " + esc(agency.contact) : "") + "</footer>";
42691
+ return '<footer>' + esc(agency.name) + (agency.contact ? " &middot; " + esc(agency.contact) : "")
42692
+ + (agency.guarantee ? '<br><span class="guarantee">' + esc(agency.guarantee) + "</span>" : "") + "</footer>";
42033
42693
  }
42034
42694
 
42035
42695
  function css(accent) {
@@ -42042,6 +42702,9 @@ function css(accent) {
42042
42702
  ".by{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--a);margin:0 0 .4rem;font-weight:600}",
42043
42703
  "h1{font-size:1.9rem;line-height:1.15;margin:0;font-weight:700}",
42044
42704
  ".when{color:var(--mut);font-size:.82rem;margin:.4rem 0 0}",
42705
+ ".for{color:var(--ink);font-size:.95rem;margin:.5rem 0 0}",
42706
+ ".about{color:var(--mut);font-size:.95rem;margin:.6rem 0 0;max-width:38rem}",
42707
+ ".guarantee{color:var(--mut);font-size:.85rem}",
42045
42708
  "h2{font-size:1.15rem;margin:2rem 0 .5rem;font-weight:700}",
42046
42709
  "table{border-collapse:collapse;width:100%;margin:.6rem 0;font-size:.94rem}",
42047
42710
  "th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--rule);vertical-align:top}",
@@ -42071,9 +42734,10 @@ function css(accent) {
42071
42734
 
42072
42735
  /* \u2500\u2500 state (persistence only; all maths lives in engine.js) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
42073
42736
  var K="cmdos-assessment-v1";
42074
- var S={biz:"",trade:"",ans:{},skip:{},asm:{},tab:"ask",area:"money"};
42737
+ var S={biz:"",trade:"",client:{},saveTo:"",ans:{},skip:{},asm:{},tab:"ask",area:"client"};
42075
42738
  ASSUMPTIONS.forEach(function(a){S.asm[a[0]]=a[2]});
42076
42739
  try{var raw=localStorage.getItem(K);if(raw)S=Object.assign(S,JSON.parse(raw))}catch(e){}
42740
+ if(!S.client)S.client={};if(S.saveTo===undefined)S.saveTo="";
42077
42741
  function save(){try{localStorage.setItem(K,JSON.stringify(S))}catch(e){}}
42078
42742
  function txt(id){return S.skip[id]?null:(S.ans[id]||null)}
42079
42743
  function money(n){return "$"+Math.round(n).toLocaleString()}
@@ -42088,7 +42752,7 @@ function head(){
42088
42752
  document.getElementById("lHrs").textContent=r.hrsWk?r.hrsWk.toFixed(1):"\u2014";
42089
42753
  document.getElementById("lMon").textContent=r.monthly?money(r.monthly):"\u2014";
42090
42754
  document.getElementById("bizname").textContent=S.biz||"New assessment";
42091
- document.getElementById("bizmeta").textContent=S.trade||"";
42755
+ document.getElementById("bizmeta").textContent=[S.trade,(S.client&&S.client.name)?"with "+S.client.name:""].filter(Boolean).join(" \xB7 ");
42092
42756
  }
42093
42757
 
42094
42758
  /**
@@ -42124,7 +42788,7 @@ function bizWarnHtml(){
42124
42788
  +'The report below is addressed to &ldquo;your business&rdquo; until you give it one. It renders either way, and that is the problem: it looks finished and is not sendable.'
42125
42789
  +' <button class="mini" onclick="goBiz()">Take me to it</button></div>';
42126
42790
  }
42127
- function goBiz(){S.tab="set";save();draw();
42791
+ function goBiz(){S.tab="ask";S.area="client";save();draw();
42128
42792
  var el=document.getElementById("f_biz");
42129
42793
  if(el){el.scrollIntoView({behavior:"smooth",block:"center"});el.focus()}}
42130
42794
  /** Every standing warning, in one place, so all three tabs show the same set. */
@@ -42135,9 +42799,12 @@ function goCore(){S.tab="ask";S.area="money";save();draw();
42135
42799
  if(g.length){var el=document.getElementById("q_"+g[0]);
42136
42800
  if(el){el.scrollIntoView({behavior:"smooth",block:"center"});var i=el.querySelector("input");if(i)i.focus()}}}
42137
42801
 
42802
+ /** How much of the client step is filled: the two the report needs plus the contact. */
42803
+ function clientCount(){var c=S.client||{};var f=[S.biz,S.trade,c.name,c.email||c.phone].filter(function(v){return v&&String(v).trim()}).length;return f+"/4"}
42138
42804
  function railHtml(){
42139
42805
  if(S.tab!=="ask")return "";
42140
- return AREAS.map(function(a){return '<button class="'+(S.area===a[0]?"on":"")+'" onclick="goArea(\\''+a[0]+'\\')">'
42806
+ return '<button class="'+(S.area==="client"?"on":"")+'" onclick="goArea(\\'client\\')">The client<span class="c">'+clientCount()+'</span></button>'
42807
+ +AREAS.map(function(a){return '<button class="'+(S.area===a[0]?"on":"")+'" onclick="goArea(\\''+a[0]+'\\')">'
42141
42808
  +a[1]+'<span class="c">'+areaCount(a[0])+'</span></button>'}).join("")}
42142
42809
 
42143
42810
  function inputFor(q){
@@ -42150,7 +42817,38 @@ function inputFor(q){
42150
42817
  +'<button class="mini '+(sk?"on":"")+'" onclick="skip(\\''+id+'\\')">'+(sk?"marked don\\'t know":"don\\'t know")+'</button>'
42151
42818
  +'<button class="mini" onclick="why(\\''+id+'\\')">why</button></div>'}
42152
42819
 
42820
+ /**
42821
+ * The first step: who this is for. Most operators use the assessment as the
42822
+ * sales pitch and the proposal, so the client and the business come before any
42823
+ * number (Jerry, 2026-09-05). It also says which account on the board the
42824
+ * assessment belongs to, so Save to GoHighLevel knows where to put it.
42825
+ */
42826
+ function escH(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}
42827
+ function cf(k,ph,type){var c=S.client||{};return '<input class="a" type="'+(type||"text")+'" placeholder="'+escH(ph)+'" value="'+escH(c[k]||"")+'" oninput="setClient(\\''+k+'\\',this.value)">'}
42828
+ function setClient(k,v){if(!S.client)S.client={};S.client[k]=v;save();head()}
42829
+ function setSaveTo(v){S.saveTo=v;save();try{localStorage.setItem(K+":saveTo",v)}catch(e){}}
42830
+ function accountSelectHtml(){
42831
+ var list=window.COMMAND_OS_CLIENTS;
42832
+ if(!list||!list.length)return '<p class="probe">Open this page from Command OS to choose the account it is saved into.</p>';
42833
+ var cur=S.saveTo;try{if(!cur)cur=localStorage.getItem(K+":saveTo")||""}catch(e){}
42834
+ return '<div class="row"><select class="a" onchange="setSaveTo(this.value)"><option value="">Which account does this belong to?</option>'
42835
+ +list.map(function(c){return '<option value="'+escH(c.locationId)+'"'+(c.locationId===cur?" selected":"")+'>'+escH(c.name)+'</option>'}).join("")
42836
+ +'</select></div><p class="why" style="display:block">Usually your own agency account: the prospect becomes a contact there, the answers a note on that contact, and the deal a card on your Assessments board. A client account only once they are one.</p>';
42837
+ }
42838
+ function clientHtml(){
42839
+ return warnsHtml()+'<h2>The client</h2><p class="intro">&ldquo;Before any number: who this is for, and where it lives.&rdquo;</p>'
42840
+ +'<div class="q done" id="q_client"><p class="ask">The business</p>'
42841
+ +'<div class="row"><input class="a" id="f_biz" placeholder="Business name" value="'+escH(S.biz||"")+'" oninput="S.biz=this.value;save();head()">'
42842
+ +'<input class="a" placeholder="Trade (med spa, dental, home care\u2026)" value="'+escH(S.trade||"")+'" oninput="S.trade=this.value;save();head()"></div>'
42843
+ +'<div class="row">'+cf("website","Website","url")+cf("city","City, state")+'</div></div>'
42844
+ +'<div class="q done"><p class="ask">The person you are talking to</p>'
42845
+ +'<div class="row">'+cf("name","Their name")+cf("role","Their role (owner, office manager\u2026)")+'</div>'
42846
+ +'<div class="row">'+cf("email","Email","email")+cf("phone","Phone","tel")+'</div>'
42847
+ +'<p class="why" style="display:block">An email or a phone number is what lets the same business be found again next time instead of a second contact being created.</p></div>'
42848
+ +'<div class="q done"><p class="ask">Where this assessment is saved</p>'+accountSelectHtml()+'</div>'
42849
+ +'<div class="btnrow"><button class="btn" onclick="goArea(\\''+AREAS[0][0]+'\\')">Next: '+AREAS[0][1]+'</button></div>'}
42153
42850
  function askHtml(){
42851
+ if(S.area==="client")return clientHtml();
42154
42852
  var a=AREAS.filter(function(x){return x[0]===S.area})[0];
42155
42853
  var qs=Q.filter(function(q){return q[1]===S.area});
42156
42854
  return warnsHtml()+'<h2>'+a[1]+'</h2><p class="intro">&ldquo;'+a[2]+'&rdquo;</p>'
@@ -42169,8 +42867,7 @@ function setHtml(){
42169
42867
  return '<div class="assume"><div><b>'+a[1]+'</b><br><span class="unit">'+a[3]+'</span></div>'
42170
42868
  +'<input class="a" style="max-width:5rem" value="'+(S.asm[a[0]])+'" oninput="setAsm(\\''+a[0]+'\\',this.value)"></div>'}).join("")
42171
42869
  +'</div><div class="card"><h2 style="font-size:1rem">Who is this for?</h2>'
42172
- +'<div class="row"><input class="a" id="f_biz" placeholder="Business name" value="'+(S.biz||"")+'" oninput="S.biz=this.value;save();head()">'
42173
- +'<input class="a" placeholder="Trade" value="'+(S.trade||"")+'" oninput="S.trade=this.value;save();head()"></div></div>'}
42870
+ +'<p class="intro">'+(S.biz?escH(S.biz)+(S.trade?", "+escH(S.trade):""):"No business named yet")+'. <button class="mini" onclick="goBiz()">Edit on The client step</button></p></div>'}
42174
42871
 
42175
42872
  function findHtml(){
42176
42873
  var r=R();
@@ -42205,8 +42902,8 @@ function reportHtml(){
42205
42902
  var labels={};
42206
42903
  ASSUMPTIONS.forEach(function(a){labels[a[0]]=a[3]});
42207
42904
  return renderReport({
42208
- state:{biz:S.biz,trade:S.trade,ans:S.ans},
42209
- computed:r, agency:S.agency||{}, services:S.services||[],
42905
+ state:{biz:S.biz,trade:S.trade,ans:S.ans,client:S.client||{}},
42906
+ computed:r, agency:S.agency||window.COMMAND_OS_AGENCY||{}, services:S.services||[],
42210
42907
  assumptionLabels:labels,
42211
42908
  date:new Date().toLocaleDateString(undefined,{day:"numeric",month:"long",year:"numeric"}),
42212
42909
  answered:answered
@@ -42220,7 +42917,9 @@ function repHtml(){
42220
42917
  +'<div class="card" style="padding:0;overflow:hidden"><iframe id="rpv" style="width:100%;height:34rem;border:0;background:#fff" title="Report preview"></iframe></div>'
42221
42918
  +'<div class="btnrow"><button class="btn" onclick="openReport()">Open it full size</button>'
42222
42919
  +'<button class="btn ghost" onclick="showSource()">Save the report</button>'
42223
- +'<button class="btn ghost" onclick="dl()">Save answers</button></div>'
42920
+ +'<button class="btn ghost" onclick="dl()">Save answers</button>'
42921
+ +(window.COMMAND_OS_CLIENTS?'<button class="btn" onclick="saveToGhl()">Save to GoHighLevel</button>':'')+'</div>'
42922
+ +'<div id="savemsg"></div>'
42224
42923
  +'<div class="note"><b>Publishing it</b>Open it full size and print to PDF, or hand the file to your own site. Putting it on your domain automatically is the next piece, and it is not built yet.</div>';
42225
42924
  }
42226
42925
  function paintReport(){
@@ -42250,7 +42949,7 @@ function skip(id){S.skip[id]=!S.skip[id];if(S.skip[id])S.ans[id]="";save();draw(
42250
42949
  function why(id){var el=document.getElementById("q_"+id);if(el)el.classList.toggle("open")}
42251
42950
  function setAsm(k,v){S.asm[k]=parseRate(v);save();head()}
42252
42951
  function goArea(a){S.area=a;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
42253
- function nextArea(){var i=AREAS.map(function(x){return x[0]}).indexOf(S.area);
42952
+ function nextArea(){if(S.area==="client"){goArea(AREAS[0][0]);return}var i=AREAS.map(function(x){return x[0]}).indexOf(S.area);
42254
42953
  if(i<AREAS.length-1)goArea(AREAS[i+1][0]);else tab("find")}
42255
42954
  function tab(t){S.tab=t;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
42256
42955
  /** One copy box, two callers. */
@@ -42301,7 +43000,23 @@ function dl(){
42301
43000
  var text=JSON.stringify(S,null,2), name=slug(S.biz)+"-answers.json";
42302
43001
  dump(savedMsg(saveFile(name,text,"application/json"),name), text);
42303
43002
  }
42304
- function wipe(){if(!confirm)return;S.ans={};S.skip={};S.biz="";S.trade="";S.tab="ask";S.area="money";save();draw()}
43003
+ function wipe(){if(!confirm)return;S.ans={};S.skip={};S.biz="";S.trade="";S.client={};S.tab="ask";S.area="client";save();draw()}
43004
+ /** Save into the chosen account through Command OS on this computer: the same
43005
+ * write the save_assessment tool makes, nothing else. */
43006
+ function saveToGhl(){
43007
+ var box=document.getElementById("savemsg");
43008
+ var loc=S.saveTo;try{if(!loc)loc=localStorage.getItem(K+":saveTo")||""}catch(e){}
43009
+ if(!loc){if(box)box.innerHTML='<div class="note warn"><b>Which account?</b>Pick it on The client step first. <button class="mini" onclick="goBiz()">Take me there</button></div>';return}
43010
+ if(!S.biz||!S.biz.trim()){if(box)box.innerHTML='<div class="note warn"><b>No business name yet</b><button class="mini" onclick="goBiz()">Take me to it</button></div>';return}
43011
+ if(box)box.innerHTML='<div class="note"><b>Saving\u2026</b>The prospect as a contact, the answers as a note, the deal on your Assessments board.</div>';
43012
+ var c=S.client||{};
43013
+ fetch("/api/assessment-save",{method:"POST",headers:{"Content-Type":"application/json","x-command-os-seat":window.COMMAND_OS_SEAT||""},
43014
+ body:JSON.stringify({locationId:loc,assessment:{biz:S.biz,trade:S.trade,ans:S.ans,skip:S.skip,asm:S.asm,client:c},contactName:c.name||"",email:c.email||"",phone:c.phone||""})})
43015
+ .then(function(r){return r.json()}).then(function(j){
43016
+ if(!box)return;
43017
+ if(!j.ok||j.saved===false){box.innerHTML='<div class="note stop"><b>Not saved</b>'+String(j.error||"unknown").replace(/</g,"&lt;")+'</div>';return}
43018
+ box.innerHTML='<div class="note go"><b>Saved</b>'+String(j.whereItLives||"").replace(/</g,"&lt;")+' Stage: '+String(j.stage||"")+'. '+String(j.opportunityNote||"").replace(/</g,"&lt;")+(j.warning?' '+String(j.warning).replace(/</g,"&lt;"):'')+'</div>'})
43019
+ .catch(function(e){if(box)box.innerHTML='<div class="note stop"><b>Not saved</b>Command OS is not answering on this computer.</div>'})}
42305
43020
 
42306
43021
  Array.prototype.forEach.call(document.querySelectorAll(".tabs button"),function(b){
42307
43022
  b.onclick=function(){tab(b.dataset.t)}});
@@ -42350,7 +43065,8 @@ function brandCardHtml(d, opts = {}) {
42350
43065
  const sw = (label, t) => `<tr><td><span class="sw" style="margin:0"><span style="background:${t.hex}"></span></span></td><td>${esc4(label)}</td><td class="hex">${esc4(t.hex)}</td><td class="small">${esc4(t.source)}</td></tr>`;
42351
43066
  const face = (label, f) => `<tr><td>${esc4(label)}</td><td><b>${esc4(f.family)}</b></td><td>${f.renderIntent === "google" ? `<span class="pill ok">Google Fonts</span>` : `<span class="pill warn">${f.renderIntent === "licensed-webfont" ? "licensed" : "system"}</span> renders as <b>${esc4(renderedFamily(f))}</b>`}</td><td class="small">${esc4(f.source)}</td></tr>`;
42352
43067
  const pairs = d.contrast.pairs.map((p) => `<tr><td class="hex">${esc4(p.fg)} on ${esc4(p.bg)}</td><td>${p.ratio}:1 ${p.ratio >= 4.5 ? `<span class="pill ok">AA</span>` : `<span class="pill warn">under 4.5</span>`}</td><td class="small">${p.correctedFg ? `text shade corrected to <span class="hex">${esc4(p.correctedFg)}</span> (${p.correctedRatio}:1)` : ""}</td></tr>`).join("");
42353
- const logo = d.logo?.inlineSvg ? d.logo.inlineSvg : d.logo?.dataUri ? `<img src="${d.logo.dataUri}" alt="">` : `<span class="small">no logo stored</span>`;
43068
+ const cleanSvg = d.logo?.inlineSvg ? sanitizeSvgOrDrop(d.logo.inlineSvg) : void 0;
43069
+ const logo = cleanSvg ? `<img src="data:image/svg+xml;base64,${Buffer.from(cleanSvg.replace(/currentColor/g, c.fg.hex)).toString("base64")}" alt="">` : d.logo?.inlineSvg ? `<span class="small">the stored logo is not a plain drawing, so it is not shown</span>` : isRasterDataUri(d.logo?.dataUri) ? `<img src="${esc4(d.logo.dataUri)}" alt="">` : d.logo?.dataUri ? `<span class="small">the stored logo image is not a plain picture, so it is not shown</span>` : `<span class="small">no logo stored</span>`;
42354
43070
  return `${head2(`${d.name} \xB7 brand`)}
42355
43071
  <h1>${esc4(d.name)}</h1>
42356
43072
  <p class="muted">${esc4(d.website ?? "")} \xB7 <span class="pill">${esc4(d.provenance.extractedVia)}</span><span class="pill ${d.provenance.confidence >= 0.7 ? "ok" : "warn"}">confidence ${Math.round(d.provenance.confidence * 100)}%</span> ${esc4(d.scheme)} scheme \xB7 ${esc4(d.provenance.extractedAt.slice(0, 10))}</p>
@@ -42401,7 +43117,7 @@ document.getElementById("make").addEventListener("submit",async function(e){
42401
43117
  var g=function(id){return document.getElementById(id).value.trim()};
42402
43118
  var pts=g("mk-points").split("|").map(function(x){return x.trim()}).filter(Boolean);
42403
43119
  if(!g("mk-headline")||!pts.length){say("mk-status","A headline and at least one point, please.","err");return}
42404
- var body={brand:${JSON.stringify(d.slug)},medium:g("mk-medium"),headline:g("mk-headline"),points:pts};
43120
+ var body={brand:${scriptJson(d.slug)},medium:g("mk-medium"),headline:g("mk-headline"),points:pts};
42405
43121
  if(g("mk-audience"))body.audience=g("mk-audience");
42406
43122
  if(g("mk-cta"))body.cta=g("mk-cta");
42407
43123
  if(g("mk-grade"))body.gradeCopyFor=g("mk-grade");
@@ -42431,7 +43147,7 @@ document.getElementById("mk-cancel").addEventListener("click",async function(){
42431
43147
  try{await post("/api/design/cancel",{id:runId});stopPolling();say("mk-status","Stopped.");document.getElementById("mk-go").disabled=false;this.hidden=true}catch(e){say("mk-status",e.message,"err")}
42432
43148
  });
42433
43149
  })();
42434
- document.getElementById("override").addEventListener("submit",async(e)=>{e.preventDefault();const g=(id)=>document.getElementById(id).value.trim();const body={slug:${JSON.stringify(d.slug)}};if(g("accent"))body.accent=g("accent");if(g("displayFont"))body.displayFont=g("displayFont");if(g("bodyFont"))body.bodyFont=g("bodyFont");if(g("brandName"))body.name=g("brandName");if(g("logoSvg"))body.logoSvg=g("logoSvg");if(Object.keys(body).length===1){say("save-status","Nothing to change.");return}const b=document.getElementById("save");b.disabled=true;try{await post("/api/brand/set",body);say("save-status","Saved.","ok");location.reload()}catch(err){say("save-status",err.message,"err");b.disabled=false}});
43150
+ document.getElementById("override").addEventListener("submit",async(e)=>{e.preventDefault();const g=(id)=>document.getElementById(id).value.trim();const body={slug:${scriptJson(d.slug)}};if(g("accent"))body.accent=g("accent");if(g("displayFont"))body.displayFont=g("displayFont");if(g("bodyFont"))body.bodyFont=g("bodyFont");if(g("brandName"))body.name=g("brandName");if(g("logoSvg"))body.logoSvg=g("logoSvg");if(Object.keys(body).length===1){say("save-status","Nothing to change.");return}const b=document.getElementById("save");b.disabled=true;try{await post("/api/brand/set",body);say("save-status","Saved.","ok");location.reload()}catch(err){say("save-status",err.message,"err");b.disabled=false}});
42435
43151
  `)}`;
42436
43152
  }
42437
43153
  var esc4, STYLE, SHELL_JS, foot;
@@ -42440,6 +43156,8 @@ var init_brand_page = __esm({
42440
43156
  "use strict";
42441
43157
  init_fonts();
42442
43158
  init_cockpit_shell();
43159
+ init_script_json();
43160
+ init_svg_sanitize();
42443
43161
  esc4 = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
42444
43162
  STYLE = `
42445
43163
  main{max-width:1080px;margin:0 auto;padding:28px 24px 80px}
@@ -42467,13 +43185,13 @@ td.hex{font-family:ui-monospace,Menlo,monospace}
42467
43185
  .shot{max-width:100%;border:1px solid var(--line);border-radius:var(--radius-sm)}
42468
43186
  `;
42469
43187
  SHELL_JS = (seatToken) => `
42470
- const SERVED=${JSON.stringify(seatToken)};
43188
+ const SERVED=${scriptJson(seatToken)};
42471
43189
  const KEY=SERVED||new URLSearchParams(location.search).get("seat")||localStorage.getItem("cockpit_seat")||"";
42472
43190
  try{if(SERVED)localStorage.setItem("cockpit_seat",SERVED)}catch(e){}
42473
43191
  async function post(url,body){const r=await fetch(url,{method:"POST",headers:{"Content-Type":"application/json","x-command-os-seat":KEY},body:JSON.stringify(body)});let j={};try{j=await r.json()}catch(e){}if(!r.ok||j.ok===false)throw new Error(j.error||("Request failed ("+r.status+")"));return j}
42474
43192
  function say(id,msg,cls){const el=document.getElementById(id);if(!el)return;el.textContent=msg;el.style.color=cls==="err"?"#A63D3D":cls==="ok"?"#2F7A5A":""}
42475
43193
  `;
42476
- foot = (js) => `</main><script>${js}</script><script>${COCKPIT_TYPE_JS}</script></body></html>`;
43194
+ foot = (js) => `</main>${cockpitShellClose()}<script>${js}</script><script>${COCKPIT_TYPE_JS}</script></body></html>`;
42477
43195
  }
42478
43196
  });
42479
43197
 
@@ -42761,7 +43479,7 @@ function renderClientStatusHtml(input) {
42761
43479
  </div>
42762
43480
  </div></div>
42763
43481
  <div class="page"><div class="sheet">
42764
- ${agency || input.logoDataUri ? `<div class="brand">${input.logoDataUri ? `<img src="${esc5(input.logoDataUri)}" alt="${esc5(agency ?? "")}">` : ""}${agency ? `<span class="agency">${esc5(agency)}</span>` : ""}<span class="for">Prepared for<br><b>${esc5(input.accountName)}</b></span></div>` : ""}
43482
+ ${agency || isRasterDataUri(input.logoDataUri) ? `<div class="brand">${isRasterDataUri(input.logoDataUri) ? `<img src="${esc5(input.logoDataUri)}" alt="${esc5(agency ?? "")}">` : ""}${agency ? `<span class="agency">${esc5(agency)}</span>` : ""}<span class="for">Prepared for<br><b>${esc5(input.accountName)}</b></span></div>` : ""}
42765
43483
  <p class="eyebrow">What is live in your account</p>
42766
43484
  <h1>${esc5(input.accountName)}</h1>
42767
43485
  <p class="stamp">${esc5(revLine)}</p>
@@ -42810,6 +43528,7 @@ var init_client_status = __esm({
42810
43528
  "use strict";
42811
43529
  init_handoff_pack();
42812
43530
  init_branding();
43531
+ init_svg_sanitize();
42813
43532
  CLIENT_STATE_LABEL = {
42814
43533
  "in-place": "Live in your account",
42815
43534
  "not-yet": "Not confirmed yet",
@@ -43473,6 +44192,76 @@ var init_blueprint_review = __esm({
43473
44192
  }
43474
44193
  });
43475
44194
 
44195
+ // src/command-os/capability-runs.ts
44196
+ function capabilitySentence(cap, clientName) {
44197
+ return cap.says.replace(/\{client\}/g, clientName);
44198
+ }
44199
+ function capabilitySpec(cap, clientName) {
44200
+ const tools = CAPABILITY_TOOLS[cap.key] ?? [];
44201
+ if (!tools.length) throw new Error(`${cap.label} is not built from a button. Run it from your own Claude, watching.`);
44202
+ return {
44203
+ stage: -1,
44204
+ name: cap.label,
44205
+ allowedTools: T([...ALWAYS, ...tools]),
44206
+ maxTurns: 30,
44207
+ timeoutMs: 15 * 6e4,
44208
+ prompt: (locationId2, locationName) => `You are Command OS, working inside ONE GoHighLevel sub-account for a client: ${locationName} (${locationId2}).
44209
+ 1. Call switch_location with locationId ${locationId2}.
44210
+ 2. Call get_current_location and STOP with a line "RESULT: {\\"ok\\":false,\\"error\\":\\"wrong location\\"}" if it is not ${locationId2}.
44211
+ 3. Do exactly this, and nothing beyond it: ${capabilitySentence(cap, clientName)}
44212
+ Rules that are not negotiable:
44213
+ - Read what the account already has BEFORE you create anything, and reuse what is there instead of duplicating it.
44214
+ - Any workflow you create or change stays a DRAFT. Never publish. Never delete anything. Never send a message to a real contact.
44215
+ - Every id you use (calendar, pipeline, stage, field, user, workflow) comes from a read of THIS account in this run, never from memory or another account.
44216
+ - If a step needs a human (a number to buy, a Stripe connection, a staff member to add, a decision), do not guess: list it as an issue.
44217
+ - Say what you did in the owner's words, not tool names.
44218
+ Finish with ONE line, exactly:
44219
+ RESULT: {"ok":true,"summary":"<what exists now versus what was already there, with counts>","issues":["<anything skipped or waiting on a person>"]}
44220
+ If it cannot be done, finish with: RESULT: {"ok":false,"error":"<short reason>"}`
44221
+ };
44222
+ }
44223
+ var T, ALWAYS, CAPABILITY_TOOLS;
44224
+ var init_capability_runs = __esm({
44225
+ "src/command-os/capability-runs.ts"() {
44226
+ "use strict";
44227
+ T = (names) => names.map((n) => `mcp__ghl__${n}`);
44228
+ ALWAYS = ["switch_location", "get_current_location"];
44229
+ CAPABILITY_TOOLS = {
44230
+ intake: ["get_forms", "get_form_full", "get_form_submissions", "get_intake_question_set"],
44231
+ pipelines: ["get_pipelines", "get_pipeline_full", "create_pipeline", "update_pipeline"],
44232
+ calendars: ["get_calendars", "get_calendar", "get_users", "create_calendar", "update_calendar"],
44233
+ forms: ["get_forms", "get_form_full", "get_form_submissions", "create_form", "update_form", "get_surveys", "get_custom_fields"],
44234
+ pages: ["get_funnels", "get_funnel_pages", "get_page_full", "list_brands", "get_brand", "get_calendars", "find_ghl_template", "install_ghl_template", "create_funnel", "create_funnel_page", "compose_page", "update_page_content", "verify_funnel", "check_page_theme"],
44235
+ fields: ["get_custom_fields", "create_custom_field", "update_custom_field", "get_custom_values", "create_custom_value", "update_custom_value", "get_location_tags", "create_location_tag"],
44236
+ staff: ["get_users", "get_user", "get_calendars"],
44237
+ phone: ["list_phone_numbers", "list_number_pools", "get_location"],
44238
+ workflows: ["get_workflows", "get_workflow_full", "get_trigger_registry", "get_users", "get_calendars", "get_pipelines", "get_pipeline_full", "get_custom_fields", "get_custom_values", "get_location_tags", "list_email_templates", "list_message_templates", "validate_workflow", "create_workflow", "update_workflow_actions", "audit_workflows"],
44239
+ reactivation: ["get_contact_count", "search_contacts", "get_location_tags", "create_location_tag", "get_workflows", "get_workflow_full", "validate_workflow", "create_workflow", "update_workflow_actions", "create_smart_list", "list_smart_lists"],
44240
+ // no bulk tagging: that is a mass write the operator does by hand
44241
+ templates: ["list_email_templates", "create_email_template", "update_email_template", "list_message_templates", "create_sms_template", "get_custom_values"],
44242
+ broadcasts: ["list_email_templates", "create_email_template", "update_email_template", "list_smart_lists", "get_email_campaigns"],
44243
+ // drafts the email as a template; sending or scheduling is the operator's click
44244
+ "conversation-ai": ["list_knowledge_bases", "get_knowledge_base", "create_knowledge_base", "crawl_kb_website", "get_kb_crawl_status", "train_kb_urls", "list_kb_urls", "list_kb_faqs", "create_kb_faq", "list_conversation_agents", "get_conversation_agent", "create_conversation_agent", "list_conversation_actions", "create_conversation_action", "get_agent_generations", "get_calendars", "get_users"],
44245
+ "voice-ai": ["check_voice_ai_readiness", "list_voice_agents", "get_voice_agent", "create_voice_agent", "get_voice_action", "create_voice_action", "update_voice_action", "get_voice_call_logs", "get_calendars", "get_users", "get_workflows", "list_knowledge_bases"],
44246
+ demo: [],
44247
+ // messages a real prospect: never from a button (NO_BUILD_BUTTON)
44248
+ payments: ["list_products", "get_product", "list_product_prices", "create_product", "update_product", "list_invoices", "get_invoice", "create_invoice", "list_coupons"],
44249
+ documents: [],
44250
+ // reads only would be a brochure and a send is not a build: no button (NO_BUILD_BUTTON)
44251
+ memberships: ["get_courses", "create_course", "list_membership_categories", "create_membership_category", "list_membership_lessons", "create_membership_lesson", "list_membership_offers", "create_membership_offer"],
44252
+ reviews: ["list_reviews", "get_review_link_list", "get_workflows", "get_workflow_full", "validate_workflow", "create_workflow", "update_workflow_actions", "get_calendars"],
44253
+ social: [],
44254
+ // a post is a send: no button (NO_BUILD_BUTTON)
44255
+ blogs: ["get_blogs", "get_blog_posts", "get_blog_post", "get_blog_authors", "get_blog_categories", "check_blog_slug"],
44256
+ checkup: ["run_checkup", "audit_workflows", "get_account_health_summary", "audit_my_delivery", "build_audit_report", "get_workflows"],
44257
+ contacts: ["get_contact_count", "search_contacts", "get_contact", "get_location_tags", "create_location_tag", "create_smart_list", "list_smart_lists"],
44258
+ // no bulk writes from a button
44259
+ integrations: ["list_webhooks", "get_webhook", "create_webhook", "get_trigger_links", "create_trigger_link", "list_custom_objects", "get_custom_object"],
44260
+ handoff: ["build_offboarding_kit", "build_audit_report", "list_snapshots", "create_snapshot_share_link", "export_account", "run_checkup"]
44261
+ };
44262
+ }
44263
+ });
44264
+
43476
44265
  // src/shared-brain.ts
43477
44266
  function isSharedRecord(x) {
43478
44267
  const r = x;
@@ -43556,32 +44345,47 @@ var init_shared_brain = __esm({
43556
44345
  var dashboard_exports = {};
43557
44346
  __export(dashboard_exports, {
43558
44347
  ACCOUNT_TYPES: () => ACCOUNT_TYPES,
44348
+ INTAKE_OWN_MESSAGES: () => INTAKE_OWN_MESSAGES,
43559
44349
  NOT_SIGNED_IN_MSG: () => NOT_SIGNED_IN_MSG,
44350
+ SEAT_GUARDED_READS: () => SEAT_GUARDED_READS,
43560
44351
  SEAT_TOKEN_HEADER: () => SEAT_TOKEN_HEADER,
43561
44352
  STAGES: () => STAGES2,
43562
44353
  accountTypeOf: () => accountTypeOf,
44354
+ assessmentAgencyTag: () => assessmentAgencyTag,
43563
44355
  checkMutationGuard: () => checkMutationGuard,
43564
44356
  checkSeatToken: () => checkSeatToken,
43565
44357
  clearPending: () => clearPending,
44358
+ clientDocsDir: () => clientDocsDir,
44359
+ cockpitGate: () => cockpitGate,
43566
44360
  cockpitStatePath: () => cockpitStatePath,
43567
44361
  forgetAnthropicKey: () => forgetAnthropicKey,
44362
+ guarded: () => guarded,
44363
+ insideDir: () => insideDir,
43568
44364
  isLoopback: () => isLoopback,
43569
44365
  launcherAlreadyOffered: () => launcherAlreadyOffered,
44366
+ listClientDocs: () => listClientDocs,
44367
+ memberFacingError: () => memberFacingError,
43570
44368
  overlayPath: () => overlayPath,
44369
+ readBody: () => readBody,
43571
44370
  readCockpitState: () => readCockpitState,
44371
+ readFailureText: () => readFailureText,
43572
44372
  readOverlay: () => readOverlay,
44373
+ readableAccount: () => readableAccount,
43573
44374
  recordLauncherOffered: () => recordLauncherOffered,
43574
44375
  recoverStuckStages: () => recoverStuckStages,
43575
44376
  renderCockpitHtml: () => renderCockpitHtml,
43576
44377
  resolvePlan: () => resolvePlan2,
43577
44378
  runDashboard: () => runDashboard,
44379
+ safeDocName: () => safeDocName,
43578
44380
  setAccountIndustry: () => setAccountIndustry,
43579
44381
  setAccountType: () => setAccountType,
43580
44382
  setBuildApproval: () => setBuildApproval,
43581
44383
  setPending: () => setPending,
43582
44384
  setStage: () => setStage,
44385
+ uniqueDocName: () => uniqueDocName,
43583
44386
  withCockpitShell: () => withCockpitShell,
43584
44387
  writeCockpitState: () => writeCockpitState,
44388
+ writeDocAtomically: () => writeDocAtomically,
43585
44389
  writeOverlay: () => writeOverlay
43586
44390
  });
43587
44391
  function recoverStuckStages(state) {
@@ -43675,7 +44479,7 @@ async function resolvePlan2(creds) {
43675
44479
  return { error: "Could not verify the license (offline and no valid cached attestation)." };
43676
44480
  }
43677
44481
  function startRun(locationId2, stage, onDone, meta = {}) {
43678
- activeRun2 = { locationId: locationId2, stage, running: true, lines: [] };
44482
+ activeRun2 = { locationId: locationId2, stage, running: true, lines: [], ...meta.capability ? { capability: meta.capability } : {} };
43679
44483
  const run = activeRun2;
43680
44484
  const state = readCockpitState();
43681
44485
  const industry = state.accounts?.[locationId2]?.industry;
@@ -43701,7 +44505,7 @@ function startRun(locationId2, stage, onDone, meta = {}) {
43701
44505
  if (run.lines.length > 200) run.lines.shift();
43702
44506
  }, { context, name: meta.name, approvedAt: meta.approvedAt, onProgress: (p) => {
43703
44507
  run.progress = p;
43704
- } }).then(async (outcome) => {
44508
+ }, spec: meta.spec }).then(async (outcome) => {
43705
44509
  try {
43706
44510
  const at = (/* @__PURE__ */ new Date()).toISOString();
43707
44511
  const shared = { at, locationId: locationId2, stage, engineVersion: outcome.engineVersion, engineModel: outcome.engineModel };
@@ -43753,7 +44557,7 @@ function startRun(locationId2, stage, onDone, meta = {}) {
43753
44557
  }
43754
44558
  run.ok = outcome.ok;
43755
44559
  const before = readCockpitState().clients[locationId2]?.stages;
43756
- onDone(outcome.ok);
44560
+ onDone(outcome.ok, outcome);
43757
44561
  const after = readCockpitState().clients[locationId2]?.stages;
43758
44562
  if (stage === 4) await refreshAfterVerify(locationId2, meta.name || locationId2, (l) => run.lines.push(l));
43759
44563
  if (meta.writeReview && shouldAutoGenerateReview(before, after, Boolean(readCockpitState().reviews?.[locationId2]?.ok))) {
@@ -43833,16 +44637,164 @@ function recordLauncherOffered() {
43833
44637
  } catch {
43834
44638
  }
43835
44639
  }
43836
- function withCockpitShell(page, screen) {
43837
- const css2 = `<style>${cockpitTokensCss({ webfont: false })}${cockpitChromeCss()}</style>`;
44640
+ function assessmentAgencyTag(ap = readAgencyProfile()) {
44641
+ const forReport = {
44642
+ name: ap.agencyName,
44643
+ accent: ap.accent,
44644
+ contact: [ap.sender?.replyTo, ap.sender?.phone, ap.website].filter(Boolean).join(" \xB7 ") || void 0,
44645
+ method: ap.agencyOs?.method,
44646
+ promise: ap.agencyOs?.offer?.promise,
44647
+ guarantee: ap.agencyOs?.offer?.guarantee
44648
+ };
44649
+ return `<script>window.COMMAND_OS_AGENCY=${scriptJson(forReport)}</script>`;
44650
+ }
44651
+ function withCockpitShell(page, screen, extraHead = "") {
44652
+ const css2 = `<style>${cockpitTokensCss({ webfont: false })}${cockpitChromeCss()}</style>${extraHead}`;
43838
44653
  const header = `${cockpitHeaderHtml(screen)}<script>${COCKPIT_TYPE_JS}</script>`;
43839
44654
  const withCss = page.includes("<!--COCKPIT_SHELL_CSS-->") ? page.replace("<!--COCKPIT_SHELL_CSS-->", css2) : page.replace("</head>", `${css2}</head>`);
43840
- return withCss.includes("<!--COCKPIT_HEADER-->") ? withCss.replace("<!--COCKPIT_HEADER-->", header) : withCss.replace(/<body([^>]*)>/, `<body$1>${header}`);
44655
+ const withHeader = withCss.includes("<!--COCKPIT_HEADER-->") ? withCss.replace("<!--COCKPIT_HEADER-->", header) : withCss.replace(/<body([^>]*)>/, `<body$1>${header}`);
44656
+ return withHeader.includes("</body>") ? withHeader.replace("</body>", `${cockpitShellClose()}</body>`) : withHeader + cockpitShellClose();
44657
+ }
44658
+ function clientDocsDir(locationId2, base = appDataDir()) {
44659
+ const safeLoc = String(locationId2).replace(/[^A-Za-z0-9_-]/g, "");
44660
+ if (!safeLoc) throw new Error("no client");
44661
+ return path31.join(base, "clients", safeLoc, "documents");
44662
+ }
44663
+ function safeDocName(name) {
44664
+ const cleaned = path31.basename(String(name ?? "")).replace(/[^A-Za-z0-9._ -]/g, "_").replace(/\s+/g, " ").trim();
44665
+ const ext = path31.extname(cleaned).toLowerCase();
44666
+ if (!cleaned || cleaned.startsWith(".") || !DOC_EXTS.has(ext)) throw new Error("Only PDF, Word (.docx/.doc), text, Markdown, RTF and CSV files can be saved here.");
44667
+ const stem = cleaned.slice(0, cleaned.length - ext.length).slice(0, 120 - ext.length).trim();
44668
+ return `${stem || "document"}${ext}`;
44669
+ }
44670
+ function listClientDocs(locationId2, base = appDataDir()) {
44671
+ const dir = clientDocsDir(locationId2, base);
44672
+ if (!fs32.existsSync(dir)) return [];
44673
+ return fs32.readdirSync(dir).filter((f) => DOC_EXTS.has(path31.extname(f).toLowerCase())).map((f) => {
44674
+ const st = fs32.statSync(path31.join(dir, f));
44675
+ return { name: f, bytes: st.size, savedAt: st.mtime.toISOString() };
44676
+ }).sort((a, b) => b.savedAt.localeCompare(a.savedAt));
44677
+ }
44678
+ function readFailureText(e) {
44679
+ const m = /GHL API Error (\d{3})/.exec(e instanceof Error ? e.message : String(e));
44680
+ if (m) {
44681
+ const code = m[1];
44682
+ if (code === "401" || code === "403") return "could not read: the key on file for this account is not allowed to";
44683
+ if (code === "404") return "could not read: GoHighLevel has no such list on this account";
44684
+ if (code === "429") return "could not read: GoHighLevel asked us to slow down; try again in a minute";
44685
+ return `could not read (HTTP ${code})`;
44686
+ }
44687
+ return "could not read";
44688
+ }
44689
+ function logQuietly(message) {
44690
+ try {
44691
+ ensureAppDataDir();
44692
+ fs32.appendFileSync(path31.join(appDataDir(), "cockpit.log"), `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}
44693
+ `);
44694
+ } catch {
44695
+ }
44696
+ }
44697
+ function readBody(req, res, max, onDone, tooLarge = "That request is too large.") {
44698
+ let body = "";
44699
+ let tooBig = false;
44700
+ const fail = (e) => {
44701
+ logQuietly(`request handler: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
44702
+ try {
44703
+ if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
44704
+ res.end(JSON.stringify({ ok: false, error: "That did not finish. The detail is in cockpit.log." }));
44705
+ } catch {
44706
+ }
44707
+ };
44708
+ req.on("data", (chunk) => {
44709
+ if (tooBig) return;
44710
+ body += chunk;
44711
+ if (body.length > max) {
44712
+ tooBig = true;
44713
+ body = "";
44714
+ res.writeHead(413, { "Content-Type": "application/json" });
44715
+ res.end(JSON.stringify({ ok: false, error: tooLarge }));
44716
+ req.resume?.();
44717
+ }
44718
+ });
44719
+ req.on("end", () => {
44720
+ if (tooBig) return;
44721
+ try {
44722
+ const r = onDone(body);
44723
+ if (r && typeof r.catch === "function") r.catch(fail);
44724
+ } catch (e) {
44725
+ fail(e);
44726
+ }
44727
+ });
44728
+ }
44729
+ function memberFacingError(e, own = []) {
44730
+ const raw = e instanceof Error ? e.message : String(e);
44731
+ return own.includes(raw) ? raw : readFailureText(e);
44732
+ }
44733
+ function insideDir(dir, p) {
44734
+ try {
44735
+ const real = fs32.realpathSync(p);
44736
+ const base = fs32.realpathSync(dir);
44737
+ return real === base || real.startsWith(base + path31.sep);
44738
+ } catch {
44739
+ return false;
44740
+ }
43841
44741
  }
43842
- function html(roster, seatToken) {
43843
- return `${cockpitHeadHtml("Command OS \u2014 GHL Command", { extraCss: `
44742
+ function uniqueDocName(dir, name) {
44743
+ const ext = path31.extname(name), stem = name.slice(0, name.length - ext.length);
44744
+ let candidate = name;
44745
+ for (let n = 2; fs32.existsSync(path31.join(dir, candidate)); n++) candidate = `${stem} (${n})${ext}`;
44746
+ return candidate;
44747
+ }
44748
+ function writeDocAtomically(dir, name, bytes) {
44749
+ fs32.mkdirSync(dir, { recursive: true, mode: 448 });
44750
+ const finalName = uniqueDocName(dir, name);
44751
+ const tmp = path31.join(dir, `.${finalName}.${process.pid}.tmp`);
44752
+ const fd = fs32.openSync(tmp, fs32.constants.O_WRONLY | fs32.constants.O_CREAT | fs32.constants.O_EXCL | (fs32.constants.O_NOFOLLOW ?? 0), 384);
44753
+ try {
44754
+ fs32.writeSync(fd, bytes);
44755
+ fs32.fsyncSync(fd);
44756
+ } finally {
44757
+ fs32.closeSync(fd);
44758
+ }
44759
+ const dest = path31.join(dir, finalName);
44760
+ try {
44761
+ if (fs32.lstatSync(dest).isSymbolicLink()) {
44762
+ fs32.unlinkSync(tmp);
44763
+ throw new Error("that name is taken by a link");
44764
+ }
44765
+ } catch (e) {
44766
+ if (e.code !== "ENOENT") throw e;
44767
+ }
44768
+ fs32.renameSync(tmp, dest);
44769
+ return finalName;
44770
+ }
44771
+ function readableAccount(locationId2, roster = currentRosterRef(), state = readCockpitState()) {
44772
+ if (!locationId2 || !roster.some((r) => r.locationId === locationId2)) return false;
44773
+ const t = state.accounts?.[locationId2]?.type;
44774
+ return t !== "hidden" && t !== "template";
44775
+ }
44776
+ function html(roster, seatToken, screen = "board", cap) {
44777
+ const capForPage = cap ? { ...cap, groupLabel: CAPABILITY_GROUPS.find((g) => g.key === cap.group)?.label ?? cap.group, noBuild: NO_BUILD_BUTTON.has(cap.key) } : null;
44778
+ return `${cockpitHeadHtml(cap ? `${cap.label} \u2014 Command OS` : BOARD_TITLES[screen] ?? BOARD_TITLES.board, { extraCss: `
43844
44779
 
43845
44780
  main{max-width:56rem;margin:1.4rem auto 4rem;padding:0 1rem}
44781
+ .screen-head{margin:0 0 1.2rem}.screen-head h1{font-family:var(--font-display);font-weight:500;font-size:1.6rem;color:var(--heading);margin:0 0 .3rem}
44782
+ .screen-head .lead{color:var(--ink-2);font-size:var(--fs-body);margin:0;max-width:40rem}
44783
+ .home{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}
44784
+ .home .tile{display:block;padding:1rem 1.1rem;color:inherit;text-decoration:none}
44785
+ .home .tile:hover{border-color:var(--gold);text-decoration:none}
44786
+ .home .big{font-family:var(--font-display);font-size:2.1rem;line-height:1.1;color:var(--heading);margin:.25rem 0 .35rem}
44787
+ .home .tile p{color:var(--ink-2);font-size:var(--fs-sm);margin:0}
44788
+ .home .go{display:inline-block;margin-top:.7rem;font-weight:600;font-size:var(--fs-sm);color:var(--gold-light)}
44789
+ a.newbtn{display:inline-flex;align-items:center;text-decoration:none}
44790
+ .capsel{font:inherit;font-size:var(--fs-body);padding:.45rem .7rem;border-radius:var(--radius-sm);border:1px solid var(--line);background:var(--surface);color:var(--heading);min-width:16rem}
44791
+ .cap-reads .cap-row{padding:.35rem 0;border-bottom:1px solid var(--line);font-size:var(--fs-body)}.cap-reads .cap-row:last-of-type{border-bottom:0}
44792
+ .cap-words{margin:0;padding:.8rem 1rem;border-left:3px solid var(--gold);background:var(--bg-2);border-radius:var(--radius-sm);font-size:var(--fs-lg);line-height:1.5;color:var(--heading)}
44793
+ .cap-log{margin:.8rem 0 0;padding:.8rem 1rem;background:var(--bg-2);border-radius:var(--radius-sm);font-size:var(--fs-sm);line-height:1.5;white-space:pre-wrap;max-height:22rem;overflow:auto}
44794
+ a.intakebtn{text-decoration:none;display:inline-flex;align-items:center}
44795
+ .muted{color:var(--ink-2)}.small{font-size:var(--fs-sm)}
44796
+ .idrop{border:1px dashed var(--line);border-radius:var(--radius-sm);padding:.9rem 1rem;color:var(--ink-2);font-size:var(--fs-sm);background:var(--surface)}
44797
+ .idocs{margin-top:.6rem}.idoc{display:flex;align-items:center;justify-content:space-between;gap:.6rem;padding:.45rem 0;border-bottom:1px solid var(--line);font-size:var(--fs-sm)}.idoc:last-child{border-bottom:0}
43846
44798
  footer{color:var(--ink-3);font-size:var(--fs-sm);text-align:center;margin:2rem 0;padding:0 1rem}
43847
44799
 
43848
44800
  /* \u2500\u2500 Building blocks (hub) \u2500\u2500 */
@@ -43975,7 +44927,7 @@ function html(roster, seatToken) {
43975
44927
  .ifield label{display:block;font-size:var(--fs-sm);color:var(--ink-2);margin-bottom:.2rem}
43976
44928
  .ifield input,.ifield textarea,.ifield select{width:100%;box-sizing:border-box;padding:.5rem .65rem;border:1px solid var(--line);border-radius:var(--radius-sm);font-size:var(--fs-sm);font-family:inherit;background:var(--surface)}
43977
44929
  .ifield.prefilled input,.ifield.prefilled textarea{border-color:var(--ok);background:var(--ok-soft)}
43978
- .ifield.prefilled label:after{content:" \xB7 from your call";color:var(--ok);font-weight:600}
44930
+ .ifield.prefilled label:after{content:" \xB7 from " attr(data-src);color:var(--ok);font-weight:600}
43979
44931
  .istatus{font-size:var(--fs-label);font-weight:700;letter-spacing:.04em;padding:.14rem .5rem;border-radius:999px;background:var(--bg-2);color:var(--ink-2);vertical-align:middle}
43980
44932
  .istatus.received{background:var(--ok-soft);color:var(--ok)}.istatus.sent{background:var(--gold-soft);color:var(--gold-ink)}
43981
44933
  .iq{font-size:1.05rem;color:var(--heading);font-weight:600;margin:.3rem 0 .1rem}
@@ -44002,10 +44954,11 @@ function html(roster, seatToken) {
44002
44954
  .stage{min-width:4.2rem}
44003
44955
  }
44004
44956
  @media (prefers-reduced-motion: reduce){*{transition:none !important}}
44005
- ` })}<body>
44006
- ${cockpitHeaderHtml("board")}
44957
+ ` })}<body data-screen="${screen}"${cap ? ` data-capability="${cap.key}"` : ""}>
44958
+ ${cockpitHeaderHtml(screen, { capability: cap?.key })}
44007
44959
  <main id="main">Loading\u2026</main>
44008
- <footer>Command OS runs only on this computer \u2014 your client accounts and their logins never leave it.<br>Steps marked "your call" are yours to mark, and your name goes on them. The steps Command OS runs are settled by the run, never by a click.</footer>
44960
+ <footer>Command OS runs on this computer, and your client accounts' logins stay on it. What Claude reads or writes for you goes through your own Claude account and to GoHighLevel. A pre-fill from a web address or a call recorder also fetches from that site or that recorder. No copy comes to us.<br>Steps marked "your call" are yours to mark, and your name goes on them. The steps Command OS runs are settled by the run, never by a click.</footer>
44961
+ ${cockpitShellClose()}
44009
44962
  <script>
44010
44963
  /* \u2500\u2500 Seat key bootstrap (PRD row 0) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
44011
44964
  This page was handed out by the copy of Command OS running on this computer,
@@ -44017,7 +44970,7 @@ ${cockpitHeaderHtml("board")}
44017
44970
  every call on the page is covered, including ones added later. Same-origin
44018
44971
  only: an absolute or protocol-relative URL goes out untouched, so the key can
44019
44972
  never ride along to somebody else's server. */
44020
- const SEAT_HEADER=${JSON.stringify(SEAT_TOKEN_HEADER)};const SEAT=${JSON.stringify(seatToken)};
44973
+ const SEAT_HEADER=${scriptJson(SEAT_TOKEN_HEADER)};const SEAT=${scriptJson(seatToken)};
44021
44974
  (function(){var raw=window.fetch.bind(window);
44022
44975
  window.fetch=function(input,init){
44023
44976
  var u=String(typeof input==="string"?input:(input&&input.url)||"");
@@ -44026,17 +44979,20 @@ const SEAT_HEADER=${JSON.stringify(SEAT_TOKEN_HEADER)};const SEAT=${JSON.stringi
44026
44979
  var opts={};for(var k in (init||{}))opts[k]=init[k];
44027
44980
  var h=new Headers((init&&init.headers)||{});h.set(SEAT_HEADER,SEAT);opts.headers=h;
44028
44981
  return raw(input,opts)};})();
44029
- const STAGES=${JSON.stringify(STAGES2)};const ROSTER=${JSON.stringify(roster)};
44030
- const SANDBOX=${JSON.stringify(Object.keys(SANDBOX_ALLOWLIST))};const POWERED=${JSON.stringify(Object.keys(STAGE_SPECS).map(Number))};
44031
- const GATES=${JSON.stringify(HUMAN_GATE_STAGES)};let PROTECTED=${JSON.stringify(PROTECTED_LOCATIONS)};
44982
+ const STAGES=${scriptJson(STAGES2)};const ROSTER=${scriptJson(roster)};
44983
+ const SANDBOX=${scriptJson(Object.keys(SANDBOX_ALLOWLIST))};const POWERED=${scriptJson(Object.keys(STAGE_SPECS).map(Number))};
44984
+ const GATES=${scriptJson(HUMAN_GATE_STAGES)};let PROTECTED=${scriptJson(PROTECTED_LOCATIONS)};
44032
44985
  const TYPES=["client","sandbox","template","hidden"];
44033
44986
  const TYPE_LABELS={client:"Client",sandbox:"Practice sandbox",template:"Template",hidden:"Hidden"};
44034
44987
  const LABELS=["Intake","Brief","Approve","Build","Verify","Manual tasks","Go-live"];
44035
44988
  const STATUS_TEXT={pending:"not started",in_progress:"in progress",done:"done"};
44036
44989
  const NEXT={pending:"in_progress",in_progress:"done",done:"pending"};
44037
44990
  let polling=false,query="",showParked=false,showNew=false,snapshots=null;
44991
+ /* Which of the board's four screens this page is: board (overview), new, queue, clients. */
44992
+ const SCREEN=document.body.getAttribute("data-screen")||"board";
44993
+ const CAP=${scriptJson(capForPage)};const HUB=${scriptJson(HUB_URL)};
44038
44994
  const INDUSTRIES=["Med spa","Clinic / practice","Coach / consultant","Local service","Ecommerce","Agency","Other"];
44039
- const INDUSTRIES2=${JSON.stringify(INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })))};
44995
+ const INDUSTRIES2=${scriptJson(INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })))};
44040
44996
  /* One account at a time (Jerry, 2026-08-26: "way too busy\u2026 tempting to work on multiple
44041
44997
  accounts at once"). One card is ever expanded; every other account is a one-line row. */
44042
44998
  let activeId=null;try{activeId=localStorage.getItem("cmdos-active")||null}catch(e){}
@@ -44044,7 +45000,7 @@ function setActive(id){activeId=id;try{localStorage.setItem("cmdos-active",id)}c
44044
45000
  function initials(name){const p=String(name||"").trim().split(/\\s+/);return ((p[0]||"?")[0]+(p[1]?p[1][0]:"")).toUpperCase()}
44045
45001
  function typeOf(state,id){return (state.accounts&&state.accounts[id]&&state.accounts[id].type)||(SANDBOX.includes(id)?"sandbox":"client")}
44046
45002
  function activityScore(state,id){const c=state.clients[id];if(!c)return 0;return c.stages.some(s=>s!=="pending")?1:0}
44047
- async function load(){const s=await(await fetch("/api/state")).json();const r=await(await fetch("/api/run/status")).json();await loadQueue();render(s,r);
45003
+ async function load(){const s=await(await fetch("/api/state")).json();const r=await(await fetch("/api/run/status")).json();await loadQueue();if(SCREEN==="new"&&!snapshots){try{const sr=await(await fetch("/api/snapshots")).json();snapshots=sr.ok?sr.snapshots:[]}catch(e){snapshots=[]}}render(s,r);
44048
45004
  if(r.running&&!polling){polling=true;const t=setInterval(async()=>{if(userIsBusy())return;const rr=await(await fetch("/api/run/status")).json();const ss=await(await fetch("/api/state")).json();await loadQueue();render(ss,rr);if(!rr.running){clearInterval(t);polling=false}},1500)}}
44049
45005
  // Heartbeat (2026-08-25): the fast poller above only starts for a run THIS page saw begin. A page that was
44050
45006
  // already open, or opened mid-run, or a run kicked off from another seat/the API, showed "pending" until a
@@ -44092,7 +45048,8 @@ function intakePanel(){const d=document.createElement("div");d.className="ncform
44092
45048
  reset.onclick=()=>{if(confirm("Remove all your customizations and go back to the base question set?"))saveOverlay({})};
44093
45049
  d.appendChild(reset);
44094
45050
  return d}
44095
- let iOpen={},iData={},iMode={},iAnswers={},iIdx={},iPre={},iSub={},oOpen={};
45051
+ let iOpen={},iData={},iMode={},iAnswers={},iIdx={},iPre={},iSub={},oOpen={},iDocs={},iPreSrc={};
45052
+ async function iDocsLoad(id){try{const r=await(await fetch("/api/intake-documents?locationId="+encodeURIComponent(id))).json();iDocs[id]=(r&&r.files)||[]}catch(e){iDocs[id]=[]}render(lastState,lastRun)}
44096
45053
  // What came back, written for the operator (oc.plain). The raw report the stage produced stays
44097
45054
  // behind "Details for the operator" (2026-08-26: "The Verify report was written for you, not them").
44098
45055
  function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc)return null;
@@ -44115,7 +45072,7 @@ function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc
44115
45072
  async function iLoad(id){const r=await(await fetch("/api/client-intake?locationId="+id)).json();iData[id]=r;
44116
45073
  if(r.ok&&!iAnswers[id]){iAnswers[id]={}}render(lastState,lastRun)}
44117
45074
  function iField(id,q,val,prefilled){const w=document.createElement("div");w.className="ifield"+(prefilled?" prefilled":"");
44118
- const l=document.createElement("label");l.textContent=q.label+(q.required?" *":"");w.appendChild(l);
45075
+ const l=document.createElement("label");l.textContent=q.label+(q.required?" *":"");if(prefilled)l.setAttribute("data-src",iPreSrc[id]||"your call");w.appendChild(l);
44119
45076
  let inp;
44120
45077
  if(q.multi&&q.options&&q.options.length){
44121
45078
  // Several answers allowed (the GHL field is multi-choice) \u2014 checkboxes, stored as a list. (2026-08-25, finding 8)
@@ -44135,13 +45092,13 @@ function iField(id,q,val,prefilled){const w=document.createElement("div");w.clas
44135
45092
  w.appendChild(inp);return w}
44136
45093
  function intakeWorkspace(id,name){const d=iData[id];const box=document.createElement("div");box.className="ipanel";
44137
45094
  if(!d){box.textContent="Loading intake\u2026";return box}
44138
- if(!d.ok){box.innerHTML="<h3>Intake</h3><p style='font-size:var(--fs-sm);color:var(--warn)'>"+d.error+"</p>";return box}
44139
- box.innerHTML="<h3>Intake for "+name+"</h3>";
45095
+ if(!d.ok){box.innerHTML="<h3>Intake</h3><p style='font-size:var(--fs-sm);color:var(--warn)'>"+escT(d.error)+"</p>";return box}
45096
+ box.innerHTML="<h3>Intake for "+escT(name)+"</h3>";
44140
45097
  const sub=document.createElement("p");sub.className="small muted";sub.style.margin=".1rem 0 .2rem";
44141
45098
  sub.textContent=d.status==="received"?((d.channel==="form"?"Submitted through the GHL form":"Saved from Command OS")+(d.answeredAt?" \xB7 "+new Date(d.answeredAt).toLocaleString():"")):d.status==="sent"?"Link sent \u2014 waiting on the client":"Ready to complete";
44142
45099
  box.appendChild(sub);
44143
45100
  const ch=document.createElement("div");ch.className="ichoice";
44144
- const modes=[["fill","Fill it here"],["interview","Interview mode"],["transcript","From a call"],["send","Send to client"],["link","Copy link"]];
45101
+ const modes=[["fill","Fill it here"],["interview","Interview mode"],["transcript","From a call or a document"],["send","Send to client"],["link","Copy link"]];
44145
45102
  for(const [k,label] of modes){const b=document.createElement("button");b.textContent=label;if(iMode[id]===k)b.className="on";
44146
45103
  b.onclick=async()=>{if(k==="link"){try{await navigator.clipboard.writeText(d.formUrl);b.textContent="Copied \u2713";setTimeout(()=>render(lastState,lastRun),1200)}catch(e){prompt("Copy this link:",d.formUrl)}return}
44147
45104
  iMode[id]=iMode[id]===k?null:k;iIdx[id]=0;render(lastState,lastRun)};
@@ -44162,7 +45119,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
44162
45119
  if(mode==="transcript"){const w=document.createElement("div");
44163
45120
  const tabs=document.createElement("div");tabs.className="ichoice";tabs.style.margin=".2rem 0 .6rem";
44164
45121
  iSub[id]=iSub[id]||"paste";
44165
- for(const [k,lab] of [["paste","Paste / drop file"],["url","From a link"],["recorder","From my recorder"]]){
45122
+ for(const [k,lab] of [["paste","Paste a transcript"],["doc","A document (PDF, Word, text)"],["url","From a link"],["recorder","From my recorder"]]){
44166
45123
  const tb=document.createElement("button");tb.textContent=lab;if(iSub[id]===k)tb.className="on";
44167
45124
  tb.onclick=()=>{iSub[id]=k;render(lastState,lastRun)};tabs.appendChild(tb)}
44168
45125
  w.appendChild(tabs);
@@ -44179,13 +45136,13 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
44179
45136
  else if(sub==="recorder"){if(!recIn.value.trim())return alert("Tell it which recording to grab");payload.instruction=recIn.value.trim()}
44180
45137
  else {if(!ta.value.trim())return alert("Paste or drop the transcript first");payload.transcript=ta.value}
44181
45138
  b.disabled=true;b.textContent="Reading the call\u2026";
44182
- st.textContent=sub==="paste"?"Your own Claude is reading this locally \u2014 nothing is uploaded anywhere."
44183
- :sub==="url"?"Your own Claude is fetching that link \u2014 it can read the web but nothing else."
44184
- :"Your own Claude is searching your connected recorder (Plaud, Otter, Fireflies\u2026) for that call.";
45139
+ st.textContent=sub==="paste"?"This text goes to your own Claude account to be read, and nowhere else."
45140
+ :sub==="url"?"Your own Claude fetches that link, so that site sees the request; the page text then goes to your Claude account and nowhere else."
45141
+ :"Your own Claude asks the recorder you connected (Plaud, Otter, Fireflies\u2026) for that call; the transcript then goes to your Claude account and nowhere else.";
44185
45142
  const r=await(await fetch("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json();
44186
45143
  b.disabled=false;b.textContent="Fill the intake from this";
44187
45144
  if(!r.ok){st.textContent="Could not read that: "+(r.error||"unknown");return}
44188
- iAnswers[id]=Object.assign({},iAnswers[id],r.answers);iPre[id]=Object.keys(r.answers);
45145
+ iAnswers[id]=Object.assign({},iAnswers[id],r.answers);iPre[id]=Object.keys(r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";
44189
45146
  iMode[id]="fill";render(lastState,lastRun)};
44190
45147
  const urlIn=document.createElement("input");urlIn.placeholder="https://\u2026 a shared transcript or recording page";
44191
45148
  urlIn.style.cssText="width:100%;box-sizing:border-box;padding:.6rem;border:1px solid var(--line);border-radius:7px;font-size:var(--fs-sm);background:var(--surface)";
@@ -44193,13 +45150,45 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
44193
45150
  recIn.style.cssText=urlIn.style.cssText;
44194
45151
  const hint=document.createElement("p");hint.className="small muted";hint.style.margin=".4rem 0 0";
44195
45152
  if(iSub[id]==="paste"){w.appendChild(ta)}
45153
+ else if(iSub[id]==="doc"){
45154
+ /* A document the client sent: PDF, Word or text. Stored with the client on this
45155
+ computer; your own Claude reads it here and fills the intake from it. */
45156
+ const docs=iDocs[id];
45157
+ if(docs===undefined){iDocs[id]=[];iDocsLoad(id)}
45158
+ const drop=document.createElement("div");drop.className="idrop";
45159
+ drop.textContent="Drop a PDF, Word or text file here, or choose one";
45160
+ const fi=document.createElement("input");fi.type="file";fi.accept=".pdf,.docx,.doc,.md,.txt,.rtf,.csv";fi.style.display="block";fi.style.marginTop=".5rem";
45161
+ const up=async(f)=>{if(!f)return;if(f.size>20*1024*1024){alert("That file is over 20 MB. Split it or export the part that matters.");return}
45162
+ drop.textContent="Saving "+f.name+"\u2026";
45163
+ const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(String(rd.result).split(",")[1]||"");rd.onerror=rej;rd.readAsDataURL(f)});
45164
+ const r=await(await fetch("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json();
45165
+ if(!r.ok){alert(r.error||"Could not save that file");drop.textContent="Drop a PDF, Word or text file here, or choose one";return}
45166
+ iDocs[id]=r.files||[];render(lastState,lastRun)};
45167
+ drop.ondragover=(e)=>{e.preventDefault();drop.style.borderColor="var(--gold)"};
45168
+ drop.ondragleave=()=>{drop.style.borderColor="var(--line)"};
45169
+ drop.ondrop=(e)=>{e.preventDefault();drop.style.borderColor="var(--line)";up(e.dataTransfer.files[0])};
45170
+ fi.onchange=()=>up(fi.files[0]);
45171
+ drop.appendChild(fi);w.appendChild(drop);
45172
+ const list=document.createElement("div");list.className="idocs";
45173
+ if(!docs||!docs.length){const p=document.createElement("p");p.className="small muted";p.style.margin=".5rem 0 0";p.textContent="No documents saved for this client yet.";list.appendChild(p)}
45174
+ for(const f of (docs||[])){const row=document.createElement("div");row.className="idoc";
45175
+ const nm=document.createElement("span");nm.textContent=f.name+" \xB7 "+Math.max(1,Math.round(f.bytes/1024))+" KB";row.appendChild(nm);
45176
+ const rb=document.createElement("button");rb.className="intakebtn";rb.textContent="Fill the intake from this";
45177
+ rb.onclick=async()=>{rb.disabled=true;rb.textContent="Reading it\u2026";st.textContent="The text of "+f.name+" goes to your own Claude account to be read, and nowhere else.";
45178
+ const r=await(await fetch("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,labels:d.questions.map(q=>q.label),file:f.name})})).json();
45179
+ rb.disabled=false;rb.textContent="Fill the intake from this";
45180
+ if(!r.ok){st.textContent="Could not read that: "+(r.error||"unknown");return}
45181
+ iAnswers[id]=Object.assign({},iAnswers[id],r.answers);iPre[id]=Object.keys(r.answers);iPreSrc[id]="the document";iMode[id]="fill";render(lastState,lastRun)};
45182
+ row.appendChild(rb);list.appendChild(row)}
45183
+ w.appendChild(list);
45184
+ hint.textContent="Stored with this client on this computer. When you press the button, the document's text goes to your own Claude account to be read, and nowhere else. Only what it does not answer is left for you.";w.appendChild(hint);w.appendChild(st)}
44196
45185
  else if(iSub[id]==="url"){w.appendChild(urlIn);hint.textContent="Works for links that don't need a login. Behind a login? Use \\u201cFrom my recorder\\u201d.";w.appendChild(hint)}
44197
45186
  else {w.appendChild(recIn);hint.textContent="Uses the recorder you already have connected to Claude (Plaud, Otter, Fireflies, Zoom). Nothing new to set up if it's already there.";w.appendChild(hint)}
44198
- w.appendChild(b);w.appendChild(st);box.appendChild(w)}
45187
+ if(iSub[id]!=="doc"){w.appendChild(b);w.appendChild(st)}box.appendChild(w)}
44199
45188
  if(mode==="fill"||mode==="interview"){
44200
45189
  const qs=d.questions;const ans=iAnswers[id]||{};const pre=iPre[id]||[];
44201
45190
  if(pre.length&&mode==="fill"){const banner=document.createElement("div");banner.className="pendpanel";
44202
- banner.textContent="\u2713 "+pre.length+" answers pre-filled from your call. Correct anything wrong; "+(qs.length-pre.length)+" still need you.";box.appendChild(banner)}
45191
+ banner.textContent="\u2713 "+pre.length+" answers pre-filled from "+(iPreSrc[id]||"your call")+". Correct anything wrong; "+(qs.length-pre.length)+" still need you.";box.appendChild(banner)}
44203
45192
  if(mode==="interview"){const i=Math.min(iIdx[id]||0,qs.length-1);const q=qs[i];
44204
45193
  const prog=document.createElement("p");prog.className="small muted";prog.style.margin=".2rem 0";prog.textContent="Question "+(i+1)+" of "+qs.length+" \xB7 "+q.sectionLabel;
44205
45194
  const qq=document.createElement("div");qq.className="iq";qq.textContent=q.label;
@@ -44250,8 +45239,10 @@ async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)
44250
45239
  // which wiped the button's "Thinking it through\u2026" \u2014 a click looked like nothing happened. The in-flight flag
44251
45240
  // lives outside the card so re-renders keep it; a failure is shown in the panel instead of vanishing.
44252
45241
  function reviewBusy(id,run){return Boolean(rBusy[id])||Boolean(run&&run.running&&run.reviewing&&run.locationId===id)}
45242
+ /* The review is written by the model from the client's intake answers, so every value in it is untrusted text (Codex, round 2). */
45243
+ function escT(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}
44253
45244
  function reviewPanel(id,name,state,run){const r=rData[id];const d=document.createElement("div");d.className="review";
44254
- d.innerHTML="<h3>Blueprint Review \u2014 "+name+"</h3>";
45245
+ d.innerHTML="<h3>Blueprint Review \u2014 "+escT(name)+"</h3>";
44255
45246
  const busy=reviewBusy(id,run);
44256
45247
  const gen=document.createElement("button");gen.className="runbtn";gen.style.marginTop=".4rem";
44257
45248
  gen.textContent=busy?"Thinking it through\u2026":(r&&r.ok)?"Regenerate":"Generate the review";gen.disabled=busy;
@@ -44268,24 +45259,24 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
44268
45259
  if(r.headline){const h=document.createElement("div");h.className="headline";h.textContent=r.headline;d.appendChild(h)}
44269
45260
  if(r.ica){const s1=document.createElement("div");s1.innerHTML="<h4>Who they actually sell to</h4>";
44270
45261
  const p1=document.createElement("p");p1.textContent=r.ica.who||"";s1.appendChild(p1);
44271
- if((r.ica.painPoints||[]).length){const p2=document.createElement("p");p2.innerHTML="<b>Pain:</b> "+r.ica.painPoints.join(" \xB7 ");s1.appendChild(p2)}
44272
- if((r.ica.objections||[]).length){const p3=document.createElement("p");p3.innerHTML="<b>Objections:</b> "+r.ica.objections.join(" \xB7 ");s1.appendChild(p3)}
44273
- if(r.ica.buyingTrigger){const p4=document.createElement("p");p4.innerHTML="<b>Buys when:</b> "+r.ica.buyingTrigger;s1.appendChild(p4)}
44274
- if(r.ica.whereTheyAre){const p5=document.createElement("p");p5.innerHTML="<b>Found at:</b> "+r.ica.whereTheyAre;s1.appendChild(p5)}
45262
+ if((r.ica.painPoints||[]).length){const p2=document.createElement("p");p2.innerHTML="<b>Pain:</b> "+r.ica.painPoints.map(escT).join(" \xB7 ");s1.appendChild(p2)}
45263
+ if((r.ica.objections||[]).length){const p3=document.createElement("p");p3.innerHTML="<b>Objections:</b> "+r.ica.objections.map(escT).join(" \xB7 ");s1.appendChild(p3)}
45264
+ if(r.ica.buyingTrigger){const p4=document.createElement("p");p4.innerHTML="<b>Buys when:</b> "+escT(r.ica.buyingTrigger);s1.appendChild(p4)}
45265
+ if(r.ica.whereTheyAre){const p5=document.createElement("p");p5.innerHTML="<b>Found at:</b> "+escT(r.ica.whereTheyAre);s1.appendChild(p5)}
44275
45266
  d.appendChild(s1)}
44276
- if(r.offer){const s2=document.createElement("div");s2.innerHTML='<h4>The offer <span class="score">'+(r.offer.score||0)+'/10</span></h4>';
45267
+ if(r.offer){const s2=document.createElement("div");s2.innerHTML='<h4>The offer <span class="score">'+(Number(r.offer.score)||0)+'/10</span></h4>';
44277
45268
  for(const [k,lab] of [["dreamOutcome","Worth wanting"],["believability","Believable"],["speed","Speed to result"],["effort","Effort asked"]]){
44278
- if(r.offer[k]){const p=document.createElement("p");p.innerHTML="<b>"+lab+":</b> "+r.offer[k];s2.appendChild(p)}}
44279
- if(r.offer.biggestLever){const p=document.createElement("p");p.innerHTML="<b>Biggest lever:</b> "+r.offer.biggestLever;s2.appendChild(p)}
45269
+ if(r.offer[k]){const p=document.createElement("p");p.innerHTML="<b>"+escT(lab)+":</b> "+escT(r.offer[k]);s2.appendChild(p)}}
45270
+ if(r.offer.biggestLever){const p=document.createElement("p");p.innerHTML="<b>Biggest lever:</b> "+escT(r.offer.biggestLever);s2.appendChild(p)}
44280
45271
  if(r.offer.recommendedOffer){const p=document.createElement("p");p.className="headline";p.textContent="Run this instead: "+r.offer.recommendedOffer;s2.appendChild(p)}
44281
45272
  d.appendChild(s2)}
44282
- if((r.gaps||[]).length){const s3=document.createElement("div");s3.innerHTML="<h4>What's missing ("+r.gaps.length+")</h4>";
45273
+ if((r.gaps||[]).length){const s3=document.createElement("div");s3.innerHTML="<h4>What's missing ("+(Number(r.gaps.length)||0)+")</h4>";
44283
45274
  for(const g of r.gaps){const gg=document.createElement("div");gg.className="gap "+(g.priority||"soon");
44284
- gg.innerHTML="<b>"+g.title+"</b><span>"+(g.cost?g.cost+" \u2014 ":"")+(g.fix||"")+"</span>";s3.appendChild(gg)}
45275
+ gg.innerHTML="<b>"+escT(g.title)+"</b><span>"+(g.cost?escT(g.cost)+" \u2014 ":"")+escT(g.fix||"")+"</span>";s3.appendChild(gg)}
44285
45276
  d.appendChild(s3)}
44286
45277
  if((r.build||[]).length){const s4=document.createElement("div");s4.innerHTML="<h4>What we build</h4>";
44287
45278
  for(const b of r.build){const bb=document.createElement("div");bb.className="buildline";
44288
- bb.innerHTML=b.item+"<em>"+(b.because||"")+"</em>";s4.appendChild(bb)}
45279
+ bb.innerHTML=escT(b.item)+"<em>"+escT(b.because||"")+"</em>";s4.appendChild(bb)}
44289
45280
  d.appendChild(s4)}
44290
45281
  if((r.unknowns||[]).length){const s5=document.createElement("div");s5.innerHTML="<h4>Ask them next</h4>";
44291
45282
  const p=document.createElement("p");p.textContent=r.unknowns.join(" \xB7 ");s5.appendChild(p);d.appendChild(s5)}
@@ -44318,7 +45309,7 @@ function pendingCard(c,state){const div=document.createElement("div");div.classN
44318
45309
  div.appendChild(head);
44319
45310
  const p=document.createElement("div");p.className="pendpanel";
44320
45311
  p.innerHTML="<strong>Account created \u2014 one 2-minute step left.</strong> GHL requires a key made inside the new sub-account:"+
44321
- "<ol><li>Open the new sub-account in GHL (switch into <em>"+c.name+"</em>)</li>"+
45312
+ "<ol><li>Open the new sub-account in GHL (switch into <em>"+escT(c.name)+"</em>)</li>"+
44322
45313
  "<li>Settings \u2192 Private Integrations \u2192 Create New Integration \u2192 name it anything \u2192 Scopes: <strong>Select all</strong> \u2192 Create \u2192 Confirm</li>"+
44323
45314
  "<li>Copy the <code>pit-</code> key and paste it below</li></ol>";
44324
45315
  const inp=document.createElement("input");inp.placeholder="pit-\u2026";p.appendChild(inp);
@@ -44578,7 +45569,7 @@ function proofLine(p){const d=document.createElement("div");
44578
45569
  const when=new Date(p.at).toLocaleDateString(undefined,{month:"short",day:"numeric"});
44579
45570
  d.textContent=p.step+" \u2014 "+(bad?(p.issues.length+" to sort out: "+shorten(p.issues[0],120)):shorten(p.summary,150))+" \xB7 "+when;
44580
45571
  return d}
44581
- function queueOpen(id){setActive(id);try{window.scrollTo({top:0,behavior:"smooth"})}catch(e){}}
45572
+ function queueOpen(id){activeId=id;try{localStorage.setItem("cmdos-active",id)}catch(e){};if(SCREEN!=="clients"){location.href="/clients";return}setActive(id);try{window.scrollTo({top:0,behavior:"smooth"})}catch(e){}}
44582
45573
  function queueRow(it){const row=document.createElement("div");row.className="q-row";
44583
45574
  row.setAttribute("role","button");row.tabIndex=0;
44584
45575
  const pill=document.createElement("span");pill.className=it.needs==="you"?"state warn":"state live";
@@ -44621,7 +45612,8 @@ function queueBlock(match){const q=queueData;if(!q||!q.ok)return null;
44621
45612
  const live=items.filter(i=>i.rank<6),later=items.filter(i=>i.rank>=6);
44622
45613
  const fin=(q.finished||[]).filter(f=>match({name:f.clientName,locationId:f.locationId}));
44623
45614
  const head=document.createElement("div");head.className="q-head";
44624
- const eb=document.createElement("div");eb.className="eyebrow";eb.textContent="What needs doing";head.appendChild(eb);
45615
+ head.id="queue";
45616
+ const eb=document.createElement("div");eb.className="eyebrow";eb.textContent="Waiting on you";head.appendChild(eb);
44625
45617
  if(q.counts&&q.counts.you){const c=document.createElement("span");c.className="chip space";
44626
45618
  c.textContent=q.counts.you+" waiting on you";head.appendChild(c)}
44627
45619
  const who=document.createElement("span");who.className="q-who";
@@ -44657,31 +45649,114 @@ function syncReviews(state){const revs=(state&&state.reviews)||{};
44657
45649
  rData[id]=rv;
44658
45650
  if(!lastState){rSeen[id]=true}
44659
45651
  else if(rv&&rv.ok&&!had&&briefDone&&!rSeen[id]){rOpen[id]=true;rSeen[id]=true}}}
45652
+ /* \u2500\u2500 One capability, for the open client (Jerry, 2026-09-05) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
45653
+ let capReads=null,capReadsFor="",capRun=null,capPoll=null;
45654
+ function capClients(state){return ROSTER.filter(c=>["client","sandbox"].includes(typeOf(state,c.locationId)))}
45655
+ function capActive(state){const cs=capClients(state);let act=(activeId&&cs.some(c=>c.locationId===activeId))?activeId:null;if(!act&&cs.length)act=cs[0].locationId;return cs.find(c=>c.locationId===act)||null}
45656
+ async function loadCapReads(id){capReadsFor=id;capReads=null;try{capReads=await(await fetch("/api/capability?key="+encodeURIComponent(CAP.key)+"&locationId="+encodeURIComponent(id))).json()}catch(e){capReads={ok:false,error:String(e)}}if(lastState)render(lastState,lastRun)}
45657
+ function renderCapability(m,state,run){
45658
+ const c=capActive(state);
45659
+ const head=document.createElement("div");head.className="eyebrow";head.textContent=CAP.groupLabel;m.appendChild(head);
45660
+ m.appendChild(screenHead(CAP.label,CAP.what));
45661
+ if(!c){const d=document.createElement("div");d.className="client";d.innerHTML="<h2>No client account is open</h2><p style='margin-top:.4rem'>Register one in GHL Command or <a href='/new'>build a client account</a>, then come back here.</p>";m.appendChild(d);return}
45662
+ /* Who this is for */
45663
+ const who=document.createElement("div");who.className="toolbar";
45664
+ const lbl=document.createElement("span");lbl.className="q-who";lbl.textContent="For ";who.appendChild(lbl);
45665
+ const sel=document.createElement("select");sel.className="capsel";for(const cl of capClients(state)){const o=document.createElement("option");o.value=cl.locationId;o.textContent=cl.name;if(cl.locationId===c.locationId)o.selected=true;sel.appendChild(o)}
45666
+ sel.onchange=()=>{capReads=null;capReadsFor="";setActive(sel.value)};who.appendChild(sel);m.appendChild(who);
45667
+ const sc=document.getElementById("side-client");if(sc)sc.textContent=c.name;
45668
+ /* Already in this account */
45669
+ const eb=document.createElement("div");eb.className="eyebrow";eb.textContent="Already in this account";m.appendChild(eb);
45670
+ const card=document.createElement("div");card.className="card pad cap-reads";
45671
+ if(!CAP.reads.length){card.innerHTML="<p class='muted'>Nothing here is read back yet; the page cannot tell you what this account already has for this job. Ask Claude with the words below and it reads it live.</p>"}
45672
+ else if(capReadsFor!==c.locationId){card.innerHTML="<p class='muted'>Reading this account\u2026</p>";loadCapReads(c.locationId)}
45673
+ else if(!capReads||!capReads.ok){card.innerHTML="<p class='muted'></p>";card.querySelector("p").textContent=(capReads&&capReads.error)?capReads.error:"Could not read this account."}
45674
+ else{for(const r of capReads.reads){const row=document.createElement("div");row.className="cap-row";
45675
+ const n=document.createElement("b");n.textContent=r.status==="ok"?(r.count+" "+r.label):r.label;row.appendChild(n);
45676
+ const t=document.createElement("span");t.className="muted";t.textContent=r.status==="ok"?(r.names.length?": "+r.names.slice(0,8).join(" \xB7 ")+(r.names.length>8?" \u2026":""):(r.count?"":": none yet")):(" \u2014 could not read: "+(r.reason||"unavailable"));row.appendChild(t);card.appendChild(row)}
45677
+ if(CAP.blueprint){const bp=document.createElement("p");bp.className="muted";bp.style.marginTop=".6rem";bp.textContent="A full Blueprint build sets up: "+CAP.blueprint;card.appendChild(bp)}}
45678
+ m.appendChild(card);
45679
+ /* Do it now */
45680
+ const eb2=document.createElement("div");eb2.className="eyebrow";eb2.textContent="Do it now";m.appendChild(eb2);
45681
+ const doc=document.createElement("div");doc.className="card pad";
45682
+ const words=CAP.says.split("{client}").join(c.name);
45683
+ const q=document.createElement("blockquote");q.className="cap-words";q.textContent=words;doc.appendChild(q);
45684
+ const row=document.createElement("div");row.className="row wrap";row.style.gap=".6rem";row.style.marginTop=".7rem";
45685
+ const bb=document.createElement("button");bb.className="runbtn";bb.textContent="Build this for "+c.name;
45686
+ const busy=run&&run.running;if(busy)bb.disabled=true;
45687
+ if(CAP.noBuild){bb.disabled=true;bb.title="This one messages a real person, so it runs from your own Claude with you watching."}
45688
+ bb.onclick=async()=>{bb.disabled=true;bb.textContent="Starting\u2026";
45689
+ const r=await(await fetch("/api/run-capability",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,key:CAP.key})})).json();
45690
+ if(!r.ok){alert(r.error||"Could not start");bb.disabled=false;bb.textContent="Build this for "+c.name;return}
45691
+ capRun={locationId:c.locationId};pollCap()};
45692
+ row.appendChild(bb);
45693
+ const cp=document.createElement("button");cp.className="intakebtn";cp.textContent="Copy the words";cp.onclick=async()=>{try{await navigator.clipboard.writeText(words);cp.textContent="Copied"}catch(e){cp.textContent="Select and copy the text above"}setTimeout(()=>{cp.textContent="Copy the words"},2000)};row.appendChild(cp);
45694
+ if(CAP.hub){const h=document.createElement("a");h.className="intakebtn";h.href=HUB+CAP.hub;h.target="_blank";h.rel="noopener";h.textContent="Step by step in the hub \u2197";row.appendChild(h)}
45695
+ doc.appendChild(row);
45696
+ const note=document.createElement("p");note.className="muted small";note.style.marginTop=".6rem";
45697
+ note.textContent=CAP.noBuild?"No button for this one on purpose: it messages a real person. Paste the words into your own Claude and watch it work.":busy?"Another run is in progress. This button wakes up when it finishes.":"Build this runs through your own Claude account, with only the tools this job needs: the words above and what it reads from this account go to Claude, and nowhere else. Workflows are left as drafts; nothing is sent, posted or deleted; every id is read from this account first. Or paste the words into your own Claude and watch it work.";
45698
+ doc.appendChild(note);
45699
+ /* The run, live */
45700
+ if(run&&run.capability===CAP.key&&run.locationId===c.locationId){const pre=document.createElement("pre");pre.className="cap-log";pre.textContent=(run.lines||[]).slice(-40).join(String.fromCharCode(10))||"Starting\u2026";doc.appendChild(pre);
45701
+ if(run.running&&!capPoll)pollCap()}
45702
+ const last=(state.capabilityRuns||{})[c.locationId+":"+CAP.key];
45703
+ if(last){const l=document.createElement("p");l.className="muted small";l.style.marginTop=".5rem";l.textContent=(last.ok?"Last built ":"Last attempt ")+new Date(last.at).toLocaleString()+": "+(last.ok?"done. ":"did not finish. "+(last.error||"")+" ")+(last.summary||"")+(last.issues&&last.issues.length?" Waiting on a person: "+last.issues.join("; "):"");doc.appendChild(l)}
45704
+ m.appendChild(doc)}
45705
+ function pollCap(){if(capPoll)return;capPoll=setInterval(async()=>{try{const r=await(await fetch("/api/run/status")).json();const s=await(await fetch("/api/state")).json();if(!r.running){clearInterval(capPoll);capPoll=null;capRun=null;capReads=null;capReadsFor=""}render(s,r)}catch(e){}},2000)}
45706
+
45707
+ function screenHead(title,sub){const d=document.createElement("div");d.className="screen-head";d.innerHTML="<h1></h1><p class='lead'></p>";d.querySelector("h1").textContent=title;d.querySelector("p").textContent=sub;return d}
45708
+ /* The board's overview: where the work is waiting, in numbers, each one a door to its own page. */
45709
+ function homeBlock(state,run){const wrap=document.createElement("div");
45710
+ wrap.appendChild(screenHead("Command OS","Everything here runs on this computer. Pick up where the work is waiting."));
45711
+ const grid=document.createElement("div");grid.className="home";
45712
+ const q=queueData&&queueData.ok?queueData:null;const you=q&&q.counts?(q.counts.you||0):0;const total=q?(q.items||[]).length:0;
45713
+ const clients=ROSTER.filter(c=>["client","sandbox"].includes(typeOf(state,c.locationId)));
45714
+ let act=(activeId&&clients.some(c=>c.locationId===activeId))?activeId:null;
45715
+ if(!act&&run&&run.running&&clients.some(c=>c.locationId===run.locationId))act=run.locationId;if(!act&&clients.length)act=clients[0].locationId;
45716
+ const actC=clients.find(c=>c.locationId===act);
45717
+ let stage="";if(actC){const st=((state.clients||{})[actC.locationId]||{}).stages||[];const i=st.findIndex(s=>s!=="done");
45718
+ stage=i<0?"Every stage is done":((run&&run.running&&run.locationId===actC.locationId)?"Building now: ":"Next: ")+(LABELS[i]||"")}
45719
+ const tile=(eyebrow,big,text,href,cta)=>{const c=document.createElement("a");c.className="card tile";c.href=href;
45720
+ c.innerHTML="<div class='eyebrow'></div><div class='big'></div><p></p><span class='go'></span>";
45721
+ c.querySelector(".eyebrow").textContent=eyebrow;c.querySelector(".big").textContent=big;c.querySelector("p").textContent=text;c.querySelector(".go").textContent=cta;return c};
45722
+ grid.appendChild(tile("Waiting on you",String(you),total?(total+" step"+(total===1?"":"s")+" in the queue, "+you+" need"+(you===1?"s":"")+" your hand"):"Nothing is waiting on you","/queue","Open the work queue \u2192"));
45723
+ grid.appendChild(tile("Working on now",actC?actC.name:"\u2014",actC?stage:"No client account is open","/clients","Open client builds \u2192"));
45724
+ grid.appendChild(tile("Client accounts",String(clients.length),clients.length===1?"One account on the board":"Accounts on the board, templates and hidden ones aside","/clients","See every account \u2192"));
45725
+ grid.appendChild(tile("New client","+","Register the sub-account, then answer the intake","/new","Build a client account \u2192"));
45726
+ wrap.appendChild(grid);return wrap}
44660
45727
  function render(state,run){syncReviews(state);lastState=state;lastRun=run;
44661
45728
  if(state&&state.protectedAccounts)PROTECTED=state.protectedAccounts;const m=document.getElementById("main");m.innerHTML="";
45729
+ /* Four screens share this page's data and differ only in what they show. */
45730
+ if(SCREEN==="new"){m.appendChild(screenHead("Build a client account","Register the sub-account, then answer the intake. The seven stages run from Client Builds."));
45731
+ m.appendChild(newClientForm());
45732
+ /* Step 2 on the same page: the intake for the client that is open, with the
45733
+ document, link, transcript and recorder ways in (Jerry, 2026-09-05). */
45734
+ const c0=capActive(state);
45735
+ if(c0){const eb2=document.createElement("div");eb2.className="eyebrow";eb2.style.marginTop="1.4rem";eb2.textContent="Step 2 \u2014 the intake for "+c0.name;m.appendChild(eb2);
45736
+ const pick=document.createElement("p");pick.className="small muted";pick.style.margin="0 0 .6rem";pick.textContent="A different client? Open it under Client Builds first.";m.appendChild(pick);
45737
+ if(!iData[c0.locationId])iLoad(c0.locationId);
45738
+ m.appendChild(intakeWorkspace(c0.locationId,c0.name))}
45739
+ const ib=document.createElement("button");ib.className="intakebtn";ib.style.marginTop="1rem";ib.textContent=intakeOpen?"\xD7 Close the intake questions":"Intake questions";
45740
+ ib.onclick=async()=>{intakeOpen=!intakeOpen;if(intakeOpen&&!intakeData)await loadIntake();else render(state,run)};m.appendChild(ib);
45741
+ if(intakeOpen)m.appendChild(intakePanel());return}
45742
+ if(SCREEN==="for"&&CAP){renderCapability(m,state,run);return}
44662
45743
  if(!ROSTER.length){const d=document.createElement("div");d.className="client";
44663
- d.innerHTML="<h2>No clients yet</h2><p style='margin-top:.4rem'>Register a sub-account in GHL Command and it appears here.</p>";m.appendChild(d);return}
45744
+ d.innerHTML="<h2>No clients yet</h2><p style='margin-top:.4rem'>Register a sub-account in GHL Command and it appears here, or <a href='/new'>build a client account</a>.</p>";m.appendChild(d);return}
45745
+ if(SCREEN==="board"){m.appendChild(homeBlock(state,run));return}
44664
45746
  const bar=document.createElement("div");bar.className="toolbar";
44665
45747
  const inp=document.createElement("input");inp.placeholder="Find a client\u2026";inp.value=query;
44666
45748
  inp.oninput=()=>{query=inp.value;render(state,run);const i2=document.querySelector(".toolbar input");i2.focus();i2.setSelectionRange(query.length,query.length)};
44667
45749
  bar.appendChild(inp);
44668
- const nb=document.createElement("button");nb.className="newbtn";nb.textContent=showNew?"\xD7 Close":"\uFF0B New Client";
44669
- nb.onclick=async()=>{showNew=!showNew;
44670
- if(showNew&&!snapshots){const r=await(await fetch("/api/snapshots")).json();snapshots=r.ok?r.snapshots:[]}
44671
- render(state,run)};
44672
- bar.appendChild(nb);
44673
- const sy=document.createElement("button");sy.className="intakebtn";sy.textContent="\u27F3 Sync";sy.title="Pull teammate updates from your GHL";
44674
- sy.onclick=async()=>{sy.textContent="Syncing\u2026";const r=await(await fetch("/api/sync",{method:"POST",headers:{"Content-Type":"application/json"}})).json();
44675
- sy.textContent=r.adopted?("\u27F3 "+r.adopted+" updated"):"\u27F3 Up to date";setTimeout(()=>{sy.textContent="\u27F3 Sync"},2500);load()};
44676
- bar.appendChild(sy);
44677
- const ib=document.createElement("button");ib.className="intakebtn";ib.textContent=intakeOpen?"\xD7 Intake":"Intake questions";
44678
- ib.onclick=async()=>{intakeOpen=!intakeOpen;if(intakeOpen&&!intakeData)await loadIntake();else render(state,run)};
44679
- bar.appendChild(ib);m.appendChild(bar);
44680
- if(showNew)m.appendChild(newClientForm());
44681
- if(intakeOpen)m.appendChild(intakePanel());
45750
+ if(SCREEN==="clients"){
45751
+ const nb=document.createElement("a");nb.className="newbtn";nb.href="/new";nb.textContent="\uFF0B New Client";bar.appendChild(nb);
45752
+ const sy=document.createElement("button");sy.className="intakebtn";sy.textContent="\u27F3 Sync";sy.title="Pull teammate updates from your GHL";
45753
+ sy.onclick=async()=>{sy.textContent="Syncing\u2026";const r=await(await fetch("/api/sync",{method:"POST",headers:{"Content-Type":"application/json"}})).json();
45754
+ sy.textContent=r.adopted?("\u27F3 "+r.adopted+" updated"):"\u27F3 Up to date";setTimeout(()=>{sy.textContent="\u27F3 Sync"},2500);load()};
45755
+ bar.appendChild(sy)}
45756
+ m.appendChild(bar);
44682
45757
  const q=query.trim().toLowerCase();
44683
45758
  const match=(c)=>!q||c.name.toLowerCase().includes(q)||c.locationId.toLowerCase().includes(q);
44684
- const qb=queueBlock(match);if(qb)m.appendChild(qb);
45759
+ if(SCREEN==="queue"){const qb=queueBlock(match);if(qb)m.appendChild(qb);else{const d=document.createElement("div");d.className="client";d.innerHTML="<h2>Nothing is waiting on you</h2>";m.appendChild(d)}return}
44685
45760
  const sorted=[...ROSTER].sort((a,b)=>activityScore(state,b.locationId)-activityScore(state,a.locationId)||a.name.localeCompare(b.name));
44686
45761
  const board=sorted.filter(c=>match(c)&&["client","sandbox"].includes(typeOf(state,c.locationId)));
44687
45762
  const parked=sorted.filter(c=>match(c)&&["template","hidden"].includes(typeOf(state,c.locationId)));
@@ -44698,10 +45773,10 @@ function render(state,run){syncReviews(state);lastState=state;lastRun=run;
44698
45773
  const tx=document.createElement("span");tx.style.fontSize="var(--fs-sm)";tx.textContent=other.name+" \u2014 "+(LABELS[run.stage]||"")+" is running.";ban.appendChild(tx);
44699
45774
  const go2=document.createElement("button");go2.className="intakebtn";go2.style.marginLeft="auto";go2.textContent="Watch it";
44700
45775
  go2.onclick=()=>setActive(other.locationId);ban.appendChild(go2);m.appendChild(ban)}}
44701
- if(actC){const eb=document.createElement("div");eb.className="eyebrow";eb.textContent="Working on now";m.appendChild(eb);
45776
+ if(actC){const eb=document.createElement("div");eb.className="eyebrow";eb.id="clients";eb.textContent="Working on now";m.appendChild(eb);
44702
45777
  m.appendChild(actC.pending?pendingCard(actC,state):card(actC,state,run))}
44703
45778
  const rest=board.filter(c=>c.locationId!==act);
44704
- if(rest.length){const eb2=document.createElement("div");eb2.className="eyebrow";eb2.textContent="Your other accounts";m.appendChild(eb2);
45779
+ if(rest.length){const eb2=document.createElement("div");eb2.className="eyebrow";if(!actC)eb2.id="clients";eb2.textContent=actC?"Your other client accounts":"Your client accounts";m.appendChild(eb2);
44705
45780
  const list=document.createElement("div");list.className="card acct-list";
44706
45781
  for(const c of rest)list.appendChild(acctRow(c,state,run));
44707
45782
  m.appendChild(list);
@@ -44761,6 +45836,24 @@ function checkMutationGuard(req, port) {
44761
45836
  }
44762
45837
  return null;
44763
45838
  }
45839
+ function cockpitGate(req, seatToken, port) {
45840
+ const denied = checkMutationGuard(req, port) ?? checkSeatToken(req, seatToken);
45841
+ if (denied) return denied;
45842
+ const pathname = (req.url ?? "/").split("?")[0];
45843
+ if (SEAT_GUARDED_READS.includes(pathname)) return checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
45844
+ return null;
45845
+ }
45846
+ function guarded(seatToken, port, routes) {
45847
+ return (req, res) => {
45848
+ const denied = cockpitGate(req, seatToken, port);
45849
+ if (denied) {
45850
+ res.writeHead(denied.status, { "Content-Type": "application/json" });
45851
+ res.end(JSON.stringify({ ok: false, error: denied.error }));
45852
+ return;
45853
+ }
45854
+ routes(req, res);
45855
+ };
45856
+ }
44764
45857
  async function runDashboard(argv) {
44765
45858
  const portArg = argv.find((a) => a.startsWith("--port="));
44766
45859
  const port = portArg ? Number(portArg.split("=")[1]) : 7300;
@@ -44812,6 +45905,7 @@ async function runDashboard(argv) {
44812
45905
  }
44813
45906
  return process.env.GHL_COMPANY_ID;
44814
45907
  };
45908
+ currentRosterRef = () => currentRoster();
44815
45909
  const clientFor = (locationId2) => {
44816
45910
  const tok = registry2.getToken(locationId2);
44817
45911
  return tok?.apiKey ? new GHLClient({ apiKey: tok.apiKey, locationId: locationId2 }) : void 0;
@@ -44870,17 +45964,175 @@ async function runDashboard(argv) {
44870
45964
  const pend = Object.entries(readCockpitState().pending ?? {}).filter(([id]) => !ids.has(id)).map(([locationId2, p]) => ({ locationId: locationId2, name: p.name, pending: true }));
44871
45965
  return [...registered, ...pend];
44872
45966
  };
44873
- const server2 = http.createServer((req, res) => {
45967
+ const server2 = http.createServer(guarded(seatToken, port, (req, res) => {
44874
45968
  const url = req.url || "/";
44875
- const denied = checkMutationGuard(req, port) ?? checkSeatToken(req, seatToken);
44876
- if (denied) {
44877
- res.writeHead(denied.status, { "Content-Type": "application/json" });
44878
- res.end(JSON.stringify({ ok: false, error: denied.error }));
45969
+ if (req.method === "GET" && url.startsWith("/for/")) {
45970
+ const cap = capabilityByKey(url.slice("/for/".length).replace(/\/$/, ""));
45971
+ if (!cap) {
45972
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
45973
+ res.end("No such page. The sidebar lists every one.");
45974
+ return;
45975
+ }
45976
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
45977
+ res.end(html(currentRoster(), isLoopback(req) ? seatToken : "", "for", cap));
45978
+ return;
45979
+ }
45980
+ if (req.method === "GET" && url.startsWith("/api/capability")) {
45981
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
45982
+ const cap = capabilityByKey(u.searchParams.get("key") ?? "");
45983
+ const locationId2 = u.searchParams.get("locationId") ?? "";
45984
+ const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
45985
+ if (seat) {
45986
+ res.writeHead(seat.status, { "Content-Type": "application/json" });
45987
+ res.end(JSON.stringify({ ok: false, error: seat.error }));
45988
+ return;
45989
+ }
45990
+ res.writeHead(200, { "Content-Type": "application/json" });
45991
+ if (!cap) {
45992
+ res.end(JSON.stringify({ ok: false, error: "no such capability" }));
45993
+ return;
45994
+ }
45995
+ if (!readableAccount(locationId2)) {
45996
+ res.end(JSON.stringify({ ok: false, error: "That account is not on the board." }));
45997
+ return;
45998
+ }
45999
+ const c = clientFor(locationId2);
46000
+ if (!c) {
46001
+ res.end(JSON.stringify({ ok: false, error: "No key on file for this account. Register it in GHL Command first." }));
46002
+ return;
46003
+ }
46004
+ void (async () => {
46005
+ const reads = await Promise.all(cap.reads.map(async (r) => {
46006
+ const p = r.path.replace(/\{loc\}/g, locationId2);
46007
+ const params = r.params ? Object.fromEntries(Object.entries(r.params).map(([k, v]) => [k, v.replace(/\{loc\}/g, locationId2)])) : void 0;
46008
+ try {
46009
+ const raw = await c.get(p, { ...params ? { params } : {}, ...r.version ? { version: r.version } : {} });
46010
+ const items = findArray(raw, r.key);
46011
+ if (!items) {
46012
+ logQuietly(`capability read ${cap.key}/${r.label}: unrecognised shape from ${p}`);
46013
+ return { label: r.label, status: "unavailable", count: 0, names: [], reason: "could not read: the answer had a shape this page does not know" };
46014
+ }
46015
+ return { label: r.label, status: "ok", count: items.length, names: items.map((i) => nameOf(i, r.nameField)).filter(Boolean) };
46016
+ } catch (e) {
46017
+ logQuietly(`capability read ${cap.key}/${r.label} on ${locationId2}: ${e instanceof Error ? e.message : String(e)}`);
46018
+ return { label: r.label, status: "unavailable", count: 0, names: [], reason: readFailureText(e) };
46019
+ }
46020
+ }));
46021
+ res.end(JSON.stringify({ ok: true, key: cap.key, locationId: locationId2, reads }));
46022
+ })();
46023
+ return;
46024
+ }
46025
+ if (req.method === "POST" && url === "/api/run-capability") {
46026
+ readBody(req, res, 1e4, (body) => {
46027
+ res.writeHead(200, { "Content-Type": "application/json" });
46028
+ try {
46029
+ const { locationId: locationId2, key } = JSON.parse(body || "{}");
46030
+ const cap = capabilityByKey(String(key ?? ""));
46031
+ if (!cap) throw new Error("no such capability");
46032
+ if (activeRun2?.running) throw new Error("a run is already in progress");
46033
+ const st = readCockpitState();
46034
+ const client = currentRoster().find((r) => r.locationId === locationId2);
46035
+ if (!client) throw new Error("that account is not on the board");
46036
+ const approvedAt = st.buildApproved?.[locationId2];
46037
+ startRun(locationId2, -1, (ok, outcome) => {
46038
+ try {
46039
+ const now = readCockpitState();
46040
+ const last = activeRun2;
46041
+ const runs = { ...now.capabilityRuns ?? {} };
46042
+ const done = (last?.lines ?? []).filter((l) => /^Done[:.]/.test(l)).pop() ?? "";
46043
+ runs[`${locationId2}:${cap.key}`] = {
46044
+ at: (/* @__PURE__ */ new Date()).toISOString(),
46045
+ ok,
46046
+ summary: outcome?.plain?.summary ?? outcome?.summary ?? done.replace(/^Done[:.]\s*/, ""),
46047
+ issues: outcome?.plain?.issues ?? outcome?.issues ?? [],
46048
+ error: ok ? void 0 : outcome?.error ?? "the run did not finish"
46049
+ };
46050
+ writeCockpitState({ ...now, capabilityRuns: runs });
46051
+ } catch {
46052
+ }
46053
+ }, { name: client.name, approvedAt, spec: capabilitySpec(cap, client.name), capability: cap.key });
46054
+ res.end(JSON.stringify({ ok: true }));
46055
+ } catch (e) {
46056
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
46057
+ }
46058
+ });
46059
+ return;
46060
+ }
46061
+ if (req.method === "POST" && url === "/api/assessment-save") {
46062
+ readBody(req, res, 2e6, (body) => {
46063
+ void (async () => {
46064
+ res.writeHead(200, { "Content-Type": "application/json" });
46065
+ try {
46066
+ const args = JSON.parse(body || "{}");
46067
+ const locationId2 = String(args.locationId ?? "");
46068
+ if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("Pick which account this assessment belongs to first.");
46069
+ const c = clientFor(locationId2);
46070
+ if (!c) throw new Error("No key on file for that account. Register it in GHL Command first.");
46071
+ const assessment = StateSchema.parse(args.assessment);
46072
+ const out = await saveAssessment(c, locationId2, {
46073
+ assessment,
46074
+ contactName: args.contactName || void 0,
46075
+ email: args.email || void 0,
46076
+ phone: args.phone || void 0,
46077
+ createPipeline: args.createPipeline === true
46078
+ });
46079
+ res.end(JSON.stringify({ ok: true, ...out }));
46080
+ } catch (e) {
46081
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
46082
+ }
46083
+ })();
46084
+ });
46085
+ return;
46086
+ }
46087
+ if (req.method === "GET" && url.startsWith("/api/intake-documents")) {
46088
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
46089
+ const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
46090
+ if (seat) {
46091
+ res.writeHead(seat.status, { "Content-Type": "application/json" });
46092
+ res.end(JSON.stringify({ ok: false, error: seat.error }));
46093
+ return;
46094
+ }
46095
+ res.writeHead(200, { "Content-Type": "application/json" });
46096
+ const loc = u.searchParams.get("locationId") ?? "";
46097
+ if (!readableAccount(loc)) {
46098
+ res.end(JSON.stringify({ ok: false, error: "That account is not on the board." }));
46099
+ return;
46100
+ }
46101
+ try {
46102
+ res.end(JSON.stringify({ ok: true, files: listClientDocs(loc) }));
46103
+ } catch (e) {
46104
+ logQuietly(`list documents ${loc}: ${e instanceof Error ? e.message : String(e)}`);
46105
+ res.end(JSON.stringify({ ok: false, error: "Could not list this client's documents." }));
46106
+ }
44879
46107
  return;
44880
46108
  }
44881
- if (req.method === "GET" && url === "/") {
46109
+ if (req.method === "POST" && url === "/api/intake-document") {
46110
+ readBody(req, res, Math.ceil(DOC_MAX_BYTES * 1.4), (body) => {
46111
+ res.writeHead(200, { "Content-Type": "application/json" });
46112
+ try {
46113
+ const args = JSON.parse(body || "{}");
46114
+ if (!readableAccount(String(args.locationId ?? ""))) throw new Error("That account is not on the board.");
46115
+ const name = safeDocName(args.name);
46116
+ const bytes = Buffer.from(String(args.base64 ?? ""), "base64");
46117
+ if (!bytes.length) throw new Error("that file is empty");
46118
+ if (bytes.length > DOC_MAX_BYTES) throw new Error("that file is over 20 MB");
46119
+ const dir = clientDocsDir(args.locationId);
46120
+ fs32.mkdirSync(dir, { recursive: true, mode: 448 });
46121
+ if (!insideDir(path31.join(appDataDir(), "clients"), dir)) throw new Error("This client's documents folder is not where it should be, so nothing was saved.");
46122
+ const saved = writeDocAtomically(dir, name, bytes);
46123
+ res.end(JSON.stringify({ ok: true, name: saved, files: listClientDocs(args.locationId) }));
46124
+ } catch (e) {
46125
+ logQuietly(`save document: ${e instanceof Error ? e.message : String(e)}`);
46126
+ const msg3 = e instanceof Error ? e.message : String(e);
46127
+ res.end(JSON.stringify({ ok: false, error: /\/|\\/.test(msg3) ? "That file could not be saved." : msg3 }));
46128
+ }
46129
+ });
46130
+ return;
46131
+ }
46132
+ const boardScreen = BOARD_ROUTES[url];
46133
+ if (req.method === "GET" && boardScreen) {
44882
46134
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
44883
- res.end(html(currentRoster(), isLoopback(req) ? seatToken : ""));
46135
+ res.end(html(currentRoster(), isLoopback(req) ? seatToken : "", boardScreen));
44884
46136
  return;
44885
46137
  }
44886
46138
  if ((req.method === "GET" || req.method === "HEAD") && (url === "/assessment" || url === "/assessment.html")) {
@@ -44891,23 +46143,33 @@ async function runDashboard(argv) {
44891
46143
  "Cache-Control": "no-store",
44892
46144
  "X-Robots-Tag": "noindex, nofollow"
44893
46145
  });
44894
- res.end(req.method === "HEAD" ? void 0 : withCockpitShell(ASSESSMENT_HTML, "assessment"));
46146
+ const roster2 = currentRoster().map((r) => ({ locationId: r.locationId, name: r.name }));
46147
+ const clientsTag = `<script>window.COMMAND_OS_CLIENTS=${scriptJson(roster2)};window.COMMAND_OS_SEAT=${scriptJson(isLoopback(req) ? seatToken : "")};</script>`;
46148
+ res.end(req.method === "HEAD" ? void 0 : withCockpitShell(ASSESSMENT_HTML, "assessment", assessmentAgencyTag() + clientsTag));
44895
46149
  return;
44896
46150
  }
44897
46151
  if (req.method === "GET" && url === "/api/snapshots") {
46152
+ const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
46153
+ if (seat) {
46154
+ res.writeHead(seat.status, { "Content-Type": "application/json" });
46155
+ res.end(JSON.stringify({ ok: false, error: seat.error }));
46156
+ return;
46157
+ }
44898
46158
  (async () => {
44899
46159
  try {
44900
46160
  const agencyKey = registry2.getAgencyKey();
44901
46161
  const companyId = agencyCompanyId();
44902
- if (!agencyKey || !companyId) throw new Error("agency key or company id missing \u2014 run register_agency_key");
46162
+ if (!agencyKey || !companyId) throw new Error(NEEDS_AGENCY_KEY);
44903
46163
  const agencyClient = new GHLClient({ apiKey: agencyKey });
44904
46164
  const raw = await agencyClient.get("/snapshots/", { params: { companyId } });
44905
46165
  const snaps = (raw.snapshots ?? []).sort((a, b) => (a.type === "own" ? 0 : 1) - (b.type === "own" ? 0 : 1) || a.name.localeCompare(b.name));
44906
46166
  res.writeHead(200, { "Content-Type": "application/json" });
44907
46167
  res.end(JSON.stringify({ ok: true, snapshots: snaps }));
44908
46168
  } catch (e) {
44909
- res.writeHead(500, { "Content-Type": "application/json" });
44910
- res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "snapshot list failed" }));
46169
+ const shown = memberFacingError(e, [NEEDS_AGENCY_KEY]);
46170
+ logQuietly(`snapshot list: ${e instanceof Error ? e.message : String(e)}`);
46171
+ res.writeHead(shown === NEEDS_AGENCY_KEY ? 400 : 500, { "Content-Type": "application/json" });
46172
+ res.end(JSON.stringify({ ok: false, error: shown === NEEDS_AGENCY_KEY ? shown : `Could not list your snapshots: ${shown}.` }));
44911
46173
  }
44912
46174
  })();
44913
46175
  return;
@@ -44935,12 +46197,7 @@ async function runDashboard(argv) {
44935
46197
  return;
44936
46198
  }
44937
46199
  if (req.method === "POST" && url === "/api/run") {
44938
- let body = "";
44939
- req.on("data", (c) => {
44940
- body += c;
44941
- if (body.length > 1e4) req.destroy();
44942
- });
44943
- req.on("end", () => {
46200
+ readBody(req, res, 1e4, (body) => {
44944
46201
  try {
44945
46202
  const { locationId: locationId2, stage } = JSON.parse(body);
44946
46203
  if (activeRun2?.running) throw new Error("a run is already in progress");
@@ -44986,12 +46243,7 @@ async function runDashboard(argv) {
44986
46243
  return;
44987
46244
  }
44988
46245
  if (req.method === "POST" && url === "/api/overlay") {
44989
- let body = "";
44990
- req.on("data", (c) => {
44991
- body += c;
44992
- if (body.length > 1e5) req.destroy();
44993
- });
44994
- req.on("end", () => {
46246
+ readBody(req, res, 1e5, (body) => {
44995
46247
  try {
44996
46248
  const layer = JSON.parse(body);
44997
46249
  if (typeof layer !== "object" || layer === null) throw new Error("bad overlay");
@@ -45009,6 +46261,13 @@ async function runDashboard(argv) {
45009
46261
  (async () => {
45010
46262
  try {
45011
46263
  const locationId2 = new URL(url, "http://x").searchParams.get("locationId") || "";
46264
+ const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
46265
+ if (seat) {
46266
+ res.writeHead(seat.status, { "Content-Type": "application/json" });
46267
+ res.end(JSON.stringify({ ok: false, error: seat.error }));
46268
+ return;
46269
+ }
46270
+ if (!readableAccount(locationId2)) throw new Error("That account is not on the board.");
45012
46271
  const c = clientFor(locationId2);
45013
46272
  if (!c) throw new Error("this client isn't connected yet \u2014 paste its key first");
45014
46273
  const st = readCockpitState();
@@ -45019,18 +46278,14 @@ async function runDashboard(argv) {
45019
46278
  res.end(JSON.stringify({ ok: true, ...snap, questions }));
45020
46279
  } catch (e) {
45021
46280
  res.writeHead(400, { "Content-Type": "application/json" });
45022
- res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "failed" }));
46281
+ logQuietly(`client-intake ${new URL(url, "http://x").searchParams.get("locationId") || ""}: ${e instanceof Error ? e.message : String(e)}`);
46282
+ res.end(JSON.stringify({ ok: false, error: memberFacingError(e, INTAKE_OWN_MESSAGES) }));
45023
46283
  }
45024
46284
  })();
45025
46285
  return;
45026
46286
  }
45027
46287
  if (req.method === "POST" && url === "/api/intake-prefill-url") {
45028
- let body = "";
45029
- req.on("data", (c) => {
45030
- body += c;
45031
- if (body.length > 2e5) req.destroy();
45032
- });
45033
- req.on("end", async () => {
46288
+ readBody(req, res, 2e5, async (body) => {
45034
46289
  try {
45035
46290
  const args = JSON.parse(body);
45036
46291
  const c = clientFor(args.locationId);
@@ -45048,18 +46303,22 @@ async function runDashboard(argv) {
45048
46303
  return;
45049
46304
  }
45050
46305
  if (req.method === "POST" && (url === "/api/intake-submit" || url === "/api/intake-send" || url === "/api/intake-prefill")) {
45051
- let body = "";
45052
- req.on("data", (c) => {
45053
- body += c;
45054
- if (body.length > 4e5) req.destroy();
45055
- });
45056
- req.on("end", async () => {
46306
+ readBody(req, res, 4e5, async (body) => {
45057
46307
  try {
45058
46308
  const args = JSON.parse(body);
45059
46309
  const c = clientFor(args.locationId);
45060
46310
  if (!c) throw new Error("this client isn't connected yet");
45061
46311
  if (url === "/api/intake-prefill") {
45062
- const source = args.url ? { kind: "url", url: String(args.url) } : args.instruction ? { kind: "recorder", instruction: String(args.instruction) } : { kind: "text", text: String(args.transcript ?? "") };
46312
+ let fileSource = null;
46313
+ if (args.file) {
46314
+ const name = safeDocName(args.file);
46315
+ const dir = clientDocsDir(args.locationId);
46316
+ const p = path31.join(dir, name);
46317
+ if (!fs32.existsSync(p)) throw new Error("that document is not saved for this client");
46318
+ if (fs32.lstatSync(p).isSymbolicLink() || !insideDir(dir, p)) throw new Error("that document is not saved for this client");
46319
+ fileSource = { kind: "file", path: p, name };
46320
+ }
46321
+ const source = fileSource ? fileSource : args.url ? { kind: "url", url: String(args.url) } : args.instruction ? { kind: "recorder", instruction: String(args.instruction) } : { kind: "text", text: String(args.transcript ?? "") };
45063
46322
  let recorderPatterns = [];
45064
46323
  if (source.kind === "recorder") {
45065
46324
  recorderPatterns = await new Promise((resolve11) => {
@@ -45167,12 +46426,7 @@ async function runDashboard(argv) {
45167
46426
  return;
45168
46427
  }
45169
46428
  if (req.method === "POST" && url === "/api/design/run") {
45170
- let body = "";
45171
- req.on("data", (c) => {
45172
- body += c;
45173
- if (body.length > 6e4) req.destroy();
45174
- });
45175
- req.on("end", () => {
46429
+ readBody(req, res, 6e4, (body) => {
45176
46430
  try {
45177
46431
  const checked = validateRunRequest(JSON.parse(body || "{}"));
45178
46432
  if (!checked.ok) {
@@ -45198,12 +46452,7 @@ async function runDashboard(argv) {
45198
46452
  return;
45199
46453
  }
45200
46454
  if (req.method === "POST" && url === "/api/design/cancel") {
45201
- let body = "";
45202
- req.on("data", (c) => {
45203
- body += c;
45204
- if (body.length > 4e3) req.destroy();
45205
- });
45206
- req.on("end", () => {
46455
+ readBody(req, res, 4e3, (body) => {
45207
46456
  const id = (() => {
45208
46457
  try {
45209
46458
  return String(JSON.parse(body || "{}").id ?? "");
@@ -45218,12 +46467,7 @@ async function runDashboard(argv) {
45218
46467
  return;
45219
46468
  }
45220
46469
  if (req.method === "POST" && (url === "/api/brand/extract" || url === "/api/brand/import-look" || url === "/api/brand/set")) {
45221
- let body = "";
45222
- req.on("data", (c) => {
45223
- body += c;
45224
- if (body.length > 12e4) req.destroy();
45225
- });
45226
- req.on("end", () => {
46470
+ readBody(req, res, 12e4, (body) => {
45227
46471
  void (async () => {
45228
46472
  try {
45229
46473
  const a = JSON.parse(body || "{}");
@@ -45282,8 +46526,11 @@ async function runDashboard(argv) {
45282
46526
  }
45283
46527
  let logoSvg;
45284
46528
  if (typeof a.logoSvg === "string" && a.logoSvg.trim()) {
45285
- if (!/^\s*<svg[\s\S]*<\/svg>\s*$/i.test(a.logoSvg) || a.logoSvg.length > 6e4) throw new Error("The logo must be one <svg>\u2026</svg> under 60 KB. Nothing was changed.");
45286
- logoSvg = a.logoSvg.trim();
46529
+ try {
46530
+ logoSvg = sanitizeSvg(a.logoSvg);
46531
+ } catch (e) {
46532
+ throw new Error(`${e.message} Nothing was changed.`);
46533
+ }
45287
46534
  d.logo = { inlineSvg: logoSvg, whiteFillFixed: false, source: `set by ${stamp}`, rights: "declared" };
45288
46535
  d.provenance.evidence["logo"] = `set by ${stamp}`;
45289
46536
  }
@@ -45308,12 +46555,7 @@ async function runDashboard(argv) {
45308
46555
  return;
45309
46556
  }
45310
46557
  if (req.method === "POST" && url === "/api/client-engagement") {
45311
- let body = "";
45312
- req.on("data", (c) => {
45313
- body += c;
45314
- if (body.length > 2e4) req.destroy();
45315
- });
45316
- req.on("end", () => {
46558
+ readBody(req, res, 2e4, (body) => {
45317
46559
  try {
45318
46560
  const { locationId: locationId2, ...patch } = JSON.parse(body);
45319
46561
  const savedAll = writeClientEngagement(String(locationId2 ?? ""), patch);
@@ -45351,12 +46593,7 @@ async function runDashboard(argv) {
45351
46593
  return;
45352
46594
  }
45353
46595
  if (req.method === "POST" && url === "/api/build-approval") {
45354
- let body = "";
45355
- req.on("data", (c) => {
45356
- body += c;
45357
- if (body.length > 5e3) req.destroy();
45358
- });
45359
- req.on("end", () => {
46596
+ readBody(req, res, 5e3, (body) => {
45360
46597
  try {
45361
46598
  const { locationId: locationId2, confirmName, revoke } = JSON.parse(body);
45362
46599
  const entry = currentRoster().find((r) => r.locationId === locationId2);
@@ -45376,12 +46613,7 @@ async function runDashboard(argv) {
45376
46613
  return;
45377
46614
  }
45378
46615
  if (req.method === "POST" && url === "/api/protect") {
45379
- let body = "";
45380
- req.on("data", (c) => {
45381
- body += c;
45382
- if (body.length > 5e3) req.destroy();
45383
- });
45384
- req.on("end", () => {
46616
+ readBody(req, res, 5e3, (body) => {
45385
46617
  try {
45386
46618
  const { locationId: locationId2, protect, confirmName } = JSON.parse(body);
45387
46619
  const entry = currentRoster().find((r) => r.locationId === locationId2);
@@ -45405,12 +46637,7 @@ async function runDashboard(argv) {
45405
46637
  return;
45406
46638
  }
45407
46639
  if (req.method === "POST" && url === "/api/manual-task") {
45408
- let body = "";
45409
- req.on("data", (c) => {
45410
- body += c;
45411
- if (body.length > 5e3) req.destroy();
45412
- });
45413
- req.on("end", () => {
46640
+ readBody(req, res, 5e3, (body) => {
45414
46641
  try {
45415
46642
  const { locationId: locationId2, id, claimed } = JSON.parse(body);
45416
46643
  if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
@@ -45441,12 +46668,7 @@ async function runDashboard(argv) {
45441
46668
  return;
45442
46669
  }
45443
46670
  if (req.method === "POST" && url === "/api/anthropic-key") {
45444
- let body = "";
45445
- req.on("data", (c) => {
45446
- body += c;
45447
- if (body.length > 8e3) req.destroy();
45448
- });
45449
- req.on("end", () => {
46671
+ readBody(req, res, 8e3, (body) => {
45450
46672
  anthropicKeyWrites = anthropicKeyWrites.then(async () => {
45451
46673
  try {
45452
46674
  let parsed;
@@ -45510,12 +46732,7 @@ async function runDashboard(argv) {
45510
46732
  return;
45511
46733
  }
45512
46734
  if (req.method === "POST" && url === "/api/agency-profile") {
45513
- let body = "";
45514
- req.on("data", (c) => {
45515
- body += c;
45516
- if (body.length > 4e4) req.destroy();
45517
- });
45518
- req.on("end", () => {
46735
+ readBody(req, res, 4e4, (body) => {
45519
46736
  try {
45520
46737
  const saved = writeAgencyProfile(JSON.parse(body));
45521
46738
  const st = readCockpitState();
@@ -45530,12 +46747,7 @@ async function runDashboard(argv) {
45530
46747
  return;
45531
46748
  }
45532
46749
  if (req.method === "POST" && url === "/api/branding") {
45533
- let body = "";
45534
- req.on("data", (c) => {
45535
- body += c;
45536
- if (body.length > 5e3) req.destroy();
45537
- });
45538
- req.on("end", () => {
46750
+ readBody(req, res, 5e3, (body) => {
45539
46751
  try {
45540
46752
  const { agencyName, agencyAccent } = JSON.parse(body);
45541
46753
  const st = readCockpitState();
@@ -45551,12 +46763,7 @@ async function runDashboard(argv) {
45551
46763
  return;
45552
46764
  }
45553
46765
  if (req.method === "POST" && url === "/api/review") {
45554
- let body = "";
45555
- req.on("data", (c) => {
45556
- body += c;
45557
- if (body.length > 2e4) req.destroy();
45558
- });
45559
- req.on("end", async () => {
46766
+ readBody(req, res, 2e4, async (body) => {
45560
46767
  try {
45561
46768
  const { locationId: locationId2, clientName } = JSON.parse(body);
45562
46769
  const review = await writeReviewFor(locationId2, clientName || locationId2);
@@ -45572,12 +46779,7 @@ async function runDashboard(argv) {
45572
46779
  return;
45573
46780
  }
45574
46781
  if (req.method === "POST" && url === "/api/new-client") {
45575
- let body = "";
45576
- req.on("data", (c) => {
45577
- body += c;
45578
- if (body.length > 2e4) req.destroy();
45579
- });
45580
- req.on("end", async () => {
46782
+ readBody(req, res, 2e4, async (body) => {
45581
46783
  try {
45582
46784
  const args = JSON.parse(body);
45583
46785
  if (!args.name || typeof args.name !== "string") throw new Error("business name is required");
@@ -45609,12 +46811,7 @@ async function runDashboard(argv) {
45609
46811
  return;
45610
46812
  }
45611
46813
  if (req.method === "POST" && url === "/api/register-location") {
45612
- let body = "";
45613
- req.on("data", (c) => {
45614
- body += c;
45615
- if (body.length > 1e4) req.destroy();
45616
- });
45617
- req.on("end", async () => {
46814
+ readBody(req, res, 1e4, async (body) => {
45618
46815
  try {
45619
46816
  const { locationId: locationId2, apiKey: apiKey2 } = JSON.parse(body);
45620
46817
  const pendingEntry = readCockpitState().pending?.[locationId2];
@@ -45634,12 +46831,7 @@ async function runDashboard(argv) {
45634
46831
  return;
45635
46832
  }
45636
46833
  if (req.method === "POST" && url === "/api/pending-remove") {
45637
- let body = "";
45638
- req.on("data", (c) => {
45639
- body += c;
45640
- if (body.length > 1e4) req.destroy();
45641
- });
45642
- req.on("end", () => {
46834
+ readBody(req, res, 1e4, (body) => {
45643
46835
  try {
45644
46836
  const { locationId: locationId2 } = JSON.parse(body);
45645
46837
  writeCockpitState(clearPending(readCockpitState(), locationId2));
@@ -45661,12 +46853,7 @@ async function runDashboard(argv) {
45661
46853
  return;
45662
46854
  }
45663
46855
  if (req.method === "POST" && url === "/api/industry") {
45664
- let body = "";
45665
- req.on("data", (c) => {
45666
- body += c;
45667
- if (body.length > 1e4) req.destroy();
45668
- });
45669
- req.on("end", () => {
46856
+ readBody(req, res, 1e4, (body) => {
45670
46857
  try {
45671
46858
  const { locationId: locationId2, industry } = JSON.parse(body);
45672
46859
  if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
@@ -45681,12 +46868,7 @@ async function runDashboard(argv) {
45681
46868
  return;
45682
46869
  }
45683
46870
  if (req.method === "POST" && url === "/api/account") {
45684
- let body = "";
45685
- req.on("data", (c) => {
45686
- body += c;
45687
- if (body.length > 1e4) req.destroy();
45688
- });
45689
- req.on("end", () => {
46871
+ readBody(req, res, 1e4, (body) => {
45690
46872
  try {
45691
46873
  const { locationId: locationId2, type } = JSON.parse(body);
45692
46874
  if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
@@ -45701,12 +46883,7 @@ async function runDashboard(argv) {
45701
46883
  return;
45702
46884
  }
45703
46885
  if (req.method === "POST" && url === "/api/stage") {
45704
- let body = "";
45705
- req.on("data", (c) => {
45706
- body += c;
45707
- if (body.length > 1e4) req.destroy();
45708
- });
45709
- req.on("end", () => {
46886
+ readBody(req, res, 1e4, (body) => {
45710
46887
  try {
45711
46888
  const { locationId: locationId2, stage, status, by } = JSON.parse(body);
45712
46889
  if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
@@ -45745,7 +46922,7 @@ async function runDashboard(argv) {
45745
46922
  }
45746
46923
  res.writeHead(404);
45747
46924
  res.end();
45748
- });
46925
+ }));
45749
46926
  try {
45750
46927
  await new Promise((resolve11, reject) => {
45751
46928
  server2.once("error", reject);
@@ -45781,7 +46958,7 @@ async function runDashboard(argv) {
45781
46958
  process.on("SIGTERM", stop);
45782
46959
  });
45783
46960
  }
45784
- var fs32, path31, http, import_child_process5, import_child_process6, osmod, STAGES2, ACCOUNT_TYPES, activeRun2, UPGRADE_MSG, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG;
46961
+ var fs32, path31, http, import_child_process5, import_child_process6, osmod, STAGES2, ACCOUNT_TYPES, activeRun2, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, NEEDS_AGENCY_KEY, INTAKE_OWN_MESSAGES, currentRosterRef, BOARD_ROUTES, BOARD_TITLES, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS;
45785
46962
  var init_dashboard = __esm({
45786
46963
  "src/dashboard.ts"() {
45787
46964
  "use strict";
@@ -45828,9 +47005,14 @@ var init_dashboard = __esm({
45828
47005
  init_stage_runner();
45829
47006
  import_child_process6 = require("child_process");
45830
47007
  init_ghl_client();
47008
+ init_client_capabilities();
47009
+ init_capability_runs();
47010
+ init_assessment();
45831
47011
  init_shared_brain();
45832
47012
  init_work_queue();
45833
47013
  osmod = __toESM(require("os"));
47014
+ init_script_json();
47015
+ init_svg_sanitize();
45834
47016
  STAGES2 = [
45835
47017
  "Intake",
45836
47018
  "Brief",
@@ -45843,9 +47025,22 @@ var init_dashboard = __esm({
45843
47025
  ACCOUNT_TYPES = ["client", "sandbox", "template", "hidden"];
45844
47026
  activeRun2 = null;
45845
47027
  UPGRADE_MSG = "\n The cockpit is not included in this licence, so it did not start.\n Nothing was changed.\n\n";
47028
+ DOC_EXTS = /* @__PURE__ */ new Set([".pdf", ".docx", ".doc", ".md", ".txt", ".rtf", ".csv"]);
47029
+ DOC_MAX_BYTES = 20 * 1024 * 1024;
47030
+ NEEDS_AGENCY_KEY = "No agency key on file. Run register_agency_key in GHL Command first.";
47031
+ INTAKE_OWN_MESSAGES = ["That account is not on the board.", "this client isn't connected yet \u2014 paste its key first"];
47032
+ currentRosterRef = () => [];
47033
+ BOARD_ROUTES = { "/": "board", "/new": "new", "/queue": "queue", "/clients": "clients" };
47034
+ BOARD_TITLES = {
47035
+ board: "Command OS \u2014 GHL Command",
47036
+ new: "Build a Client Account \u2014 Command OS",
47037
+ queue: "Work Queue \u2014 Command OS",
47038
+ clients: "Client Builds \u2014 Command OS"
47039
+ };
45846
47040
  anthropicKeyWrites = Promise.resolve();
45847
47041
  SEAT_TOKEN_HEADER = "x-command-os-seat";
45848
47042
  NOT_SIGNED_IN_MSG = "This browser isn't signed in to Command OS on this computer. Open Command OS on the machine it runs on, and use the page it opens for you.";
47043
+ SEAT_GUARDED_READS = ["/api/capability", "/api/client-intake", "/api/intake-documents", "/api/snapshots"];
45849
47044
  }
45850
47045
  });
45851
47046
 
@@ -46723,7 +47918,7 @@ var require_package = __commonJS({
46723
47918
  "package.json"(exports2, module2) {
46724
47919
  module2.exports = {
46725
47920
  name: "@elitedcs/ghl-mcp",
46726
- version: "3.81.1",
47921
+ version: "3.82.0",
46727
47922
  mcpName: "io.github.drjerryrelth/ghl-command",
46728
47923
  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.",
46729
47924
  main: "dist/index.js",
@@ -46801,6 +47996,7 @@ var require_package = __commonJS({
46801
47996
  "@clack/prompts": "^1.1.0",
46802
47997
  "@types/node": "^26.2.0",
46803
47998
  esbuild: "^0.28.1",
47999
+ "happy-dom": "^20.14.0",
46804
48000
  open: "^11.0.0",
46805
48001
  typescript: "^7.0.2",
46806
48002
  vitest: "^4.1.6"
@@ -47191,6 +48387,11 @@ function agencyPrimer(p) {
47191
48387
  if (staff.length) lines.push(`Their team: ${staff.join("; ")}.`);
47192
48388
  const sops = (p.delivery?.sops ?? []).filter(Boolean);
47193
48389
  if (sops.length) lines.push(`How they deliver, in their words: ${sops.map((s) => `"${s}"`).join(" ")}`);
48390
+ const os7 = p.agencyOs;
48391
+ if (os7?.method) lines.push(`Their method: ${os7.method}`);
48392
+ if (os7?.voice) lines.push(`How they sound, and how you should sound for them: ${os7.voice}`);
48393
+ if (os7?.ica?.who) lines.push(`Who they serve: ${os7.ica.who}`);
48394
+ if (os7?.offer?.name || os7?.offer?.promise) lines.push(`Their core offer: ${[os7.offer?.name, os7.offer?.promise].filter(Boolean).join(" \u2014 ")}`);
47194
48395
  if (!lines.length) return void 0;
47195
48396
  lines.push("These are the agency's own details, not any client's. Client-by-client records are a tool call away, not in here.");
47196
48397
  const text = lines.join("\n");