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

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
@@ -2185,6 +2185,128 @@ var init_workflow_publish_gate = __esm({
2185
2185
  });
2186
2186
 
2187
2187
  // src/workflow-builder-client.ts
2188
+ function firebaseIdTokenStore() {
2189
+ const g = globalThis;
2190
+ let store2 = g[FIREBASE_ID_TOKEN_STORE_KEY];
2191
+ if (!store2) {
2192
+ store2 = { cache: /* @__PURE__ */ new Map(), inflight: /* @__PURE__ */ new Map() };
2193
+ g[FIREBASE_ID_TOKEN_STORE_KEY] = store2;
2194
+ }
2195
+ return store2;
2196
+ }
2197
+ function firebaseIdTokenKey(firebaseApiKey, userId, refreshToken) {
2198
+ return `${firebaseApiKey}\0${userId}\0${refreshToken}`;
2199
+ }
2200
+ function idTokenFilePath() {
2201
+ return path4.join(appDataDir(), ID_TOKEN_FILE_NAME);
2202
+ }
2203
+ function idTokenFileKey(firebaseApiKey, userId, refreshToken) {
2204
+ return (0, import_crypto2.createHash)("sha256").update(`${firebaseApiKey}|${userId}|${refreshToken}`).digest("hex");
2205
+ }
2206
+ function validFileEntry(e, now = Date.now()) {
2207
+ const v = e;
2208
+ return !!v && typeof v === "object" && typeof v.idToken === "string" && v.idToken.length > 0 && typeof v.expiresAt === "number" && Number.isFinite(v.expiresAt) && now < v.expiresAt;
2209
+ }
2210
+ function readIdTokenFile() {
2211
+ try {
2212
+ const parsed2 = JSON.parse(fs4.readFileSync(idTokenFilePath(), "utf8"));
2213
+ if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) return {};
2214
+ const out = {};
2215
+ for (const [k, v] of Object.entries(parsed2)) {
2216
+ if (validFileEntry(v)) out[k] = { idToken: v.idToken, expiresAt: v.expiresAt };
2217
+ }
2218
+ return out;
2219
+ } catch {
2220
+ return {};
2221
+ }
2222
+ }
2223
+ function readIdTokenFileEntry(key) {
2224
+ return readIdTokenFile()[key];
2225
+ }
2226
+ function sleepSync(ms) {
2227
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2228
+ }
2229
+ function withIdTokenFileLock(file2, fn) {
2230
+ const lock = `${file2}.lock`;
2231
+ const deadline = Date.now() + ID_TOKEN_LOCK_WAIT_MS;
2232
+ let fd;
2233
+ let ino;
2234
+ while (fd === void 0 && Date.now() <= deadline) {
2235
+ try {
2236
+ fd = fs4.openSync(lock, "wx", 384);
2237
+ try {
2238
+ ino = fs4.fstatSync(fd).ino;
2239
+ } catch {
2240
+ ino = void 0;
2241
+ }
2242
+ break;
2243
+ } catch (error) {
2244
+ if (error.code !== "EEXIST") break;
2245
+ }
2246
+ try {
2247
+ if (Date.now() - fs4.statSync(lock).mtimeMs > ID_TOKEN_LOCK_STALE_MS) {
2248
+ const moved = `${lock}.${process.pid}.${(0, import_crypto2.randomBytes)(4).toString("hex")}.stale`;
2249
+ fs4.renameSync(lock, moved);
2250
+ try {
2251
+ fs4.unlinkSync(moved);
2252
+ } catch {
2253
+ }
2254
+ continue;
2255
+ }
2256
+ } catch {
2257
+ }
2258
+ sleepSync(15);
2259
+ }
2260
+ try {
2261
+ fn();
2262
+ } finally {
2263
+ if (fd !== void 0) {
2264
+ let ours = false;
2265
+ try {
2266
+ ours = ino === void 0 || fs4.statSync(lock).ino === ino;
2267
+ } catch {
2268
+ ours = false;
2269
+ }
2270
+ try {
2271
+ fs4.closeSync(fd);
2272
+ } catch {
2273
+ }
2274
+ if (ours) {
2275
+ try {
2276
+ fs4.unlinkSync(lock);
2277
+ } catch {
2278
+ }
2279
+ }
2280
+ }
2281
+ }
2282
+ }
2283
+ function updateIdTokenFile(change) {
2284
+ let tmp;
2285
+ try {
2286
+ const file2 = idTokenFilePath();
2287
+ fs4.mkdirSync(path4.dirname(file2), { recursive: true, mode: 448 });
2288
+ withIdTokenFileLock(file2, () => {
2289
+ const entries = readIdTokenFile();
2290
+ change(entries);
2291
+ tmp = `${file2}.${process.pid}.${(0, import_crypto2.randomBytes)(6).toString("hex")}.tmp`;
2292
+ fs4.writeFileSync(tmp, JSON.stringify(entries), { mode: 384 });
2293
+ fs4.renameSync(tmp, file2);
2294
+ tmp = void 0;
2295
+ try {
2296
+ fs4.chmodSync(file2, 384);
2297
+ } catch {
2298
+ }
2299
+ });
2300
+ } catch {
2301
+ } finally {
2302
+ if (tmp) {
2303
+ try {
2304
+ fs4.unlinkSync(tmp);
2305
+ } catch {
2306
+ }
2307
+ }
2308
+ }
2309
+ }
2188
2310
  function isParkedContactsError(e) {
2189
2311
  return typeof e === "object" && e !== null && e.code === "PARKED_CONTACTS";
2190
2312
  }
@@ -2507,13 +2629,16 @@ function validateActionChain(actions, existingIds) {
2507
2629
  async function builderRequest(client, method, url, opts = {}) {
2508
2630
  assertWriteAllowed(method, url, { body: opts.body, defaultLocationId: client.locationId });
2509
2631
  const headers = { ...await client.buildHeaders(), ...opts.headers ?? {} };
2632
+ const usedIdToken = headers["token-id"];
2510
2633
  const init = { method, headers };
2511
2634
  if (opts.body !== void 0) init.body = JSON.stringify(opts.body);
2512
2635
  const controller = new AbortController();
2513
2636
  const timeout = setTimeout(() => controller.abort(), 3e4);
2514
2637
  init.signal = controller.signal;
2515
2638
  try {
2516
- return await fetch(url, init);
2639
+ const response = await fetch(url, init);
2640
+ if (response.status === 401) client.invalidateIdToken?.(usedIdToken ?? null);
2641
+ return response;
2517
2642
  } catch (error) {
2518
2643
  if (error instanceof Error && error.name === "AbortError") {
2519
2644
  throw new Error(`Request timeout (30s): ${method} ${url}`);
@@ -2523,7 +2648,7 @@ async function builderRequest(client, method, url, opts = {}) {
2523
2648
  clearTimeout(timeout);
2524
2649
  }
2525
2650
  }
2526
- var fs4, path4, dotenv, import_zod4, GATED_PUBLISH, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, ParkedContactsError, NodeLossError, CARRIED_WORKFLOW_SETTINGS, UUID_V4, StaleWorkflowError, WorkflowStatusChangedError, WorkflowBuilderClient;
2651
+ var fs4, path4, dotenv, import_zod4, import_crypto2, GATED_PUBLISH, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, FIREBASE_ID_TOKEN_STORE_KEY, ID_TOKEN_EXPIRY_MARGIN_MS, ID_TOKEN_FALLBACK_TTL_MS, FIREBASE_429_DELAYS_MS, FIREBASE_EXCHANGE_TIMEOUT_MS, ID_TOKEN_FILE_NAME, ID_TOKEN_LOCK_WAIT_MS, ID_TOKEN_LOCK_STALE_MS, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, ParkedContactsError, NodeLossError, CARRIED_WORKFLOW_SETTINGS, UUID_V4, StaleWorkflowError, WorkflowStatusChangedError, WorkflowBuilderClient;
2527
2652
  var init_workflow_builder_client = __esm({
2528
2653
  "src/workflow-builder-client.ts"() {
2529
2654
  "use strict";
@@ -2533,6 +2658,7 @@ var init_workflow_builder_client = __esm({
2533
2658
  import_zod4 = require("zod");
2534
2659
  init_account_rules();
2535
2660
  init_credentials_store();
2661
+ import_crypto2 = require("crypto");
2536
2662
  init_account_read_retry();
2537
2663
  init_retry();
2538
2664
  init_firebase_claims();
@@ -2551,6 +2677,14 @@ var init_workflow_builder_client = __esm({
2551
2677
  refresh_token: import_zod4.z.string(),
2552
2678
  expires_in: import_zod4.z.string()
2553
2679
  });
2680
+ FIREBASE_ID_TOKEN_STORE_KEY = /* @__PURE__ */ Symbol.for("ghl-mcp.firebaseIdTokenCache");
2681
+ ID_TOKEN_EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
2682
+ ID_TOKEN_FALLBACK_TTL_MS = 55 * 60 * 1e3;
2683
+ FIREBASE_429_DELAYS_MS = [2e3, 5e3, 1e4];
2684
+ FIREBASE_EXCHANGE_TIMEOUT_MS = 15e3;
2685
+ ID_TOKEN_FILE_NAME = "firebase-id-tokens.json";
2686
+ ID_TOKEN_LOCK_WAIT_MS = 2e3;
2687
+ ID_TOKEN_LOCK_STALE_MS = 1e4;
2554
2688
  WorkflowActionSchema = import_zod4.z.custom(
2555
2689
  (value) => typeof value === "object" && value !== null && "type" in value
2556
2690
  );
@@ -2637,6 +2771,9 @@ var init_workflow_builder_client = __esm({
2637
2771
  cachedIdToken = null;
2638
2772
  tokenExpiry = 0;
2639
2773
  tokenRefreshPromise = null;
2774
+ // Bumped whenever the credentials change (company switch). An exchange that
2775
+ // started under an older generation never lands on this client.
2776
+ tokenGeneration = 0;
2640
2777
  // The company_id claim from the most recently minted ID token. Lets
2641
2778
  // health_check report the company we're ACTUALLY authenticated as (vs. the
2642
2779
  // one we intended), and backs PIT/Firebase mismatch detection.
@@ -2741,11 +2878,34 @@ var init_workflow_builder_client = __esm({
2741
2878
  this.cachedIdToken = null;
2742
2879
  this.tokenExpiry = 0;
2743
2880
  this.tokenRefreshPromise = null;
2881
+ this.tokenGeneration++;
2744
2882
  }
2745
2883
  /**
2746
- * Probe Firebase auth by refreshing the ID token. Used by the health_check
2747
- * diagnostic tool. Does not touch GHL backend — just exchanges the refresh
2748
- * token at securetoken.googleapis.com.
2884
+ * GHL answered 401 to a request sent with `usedToken`: forget THAT token
2885
+ * everywhere (this client, the process-wide cache and the cross-process
2886
+ * file), so no client or process reuses it. Only entries holding exactly
2887
+ * that token go: a late 401 from a request that carried an older token (a
2888
+ * parallel request, or the company before a switch) never wipes the fresh
2889
+ * one that replaced it. Without an argument it means this client's current
2890
+ * token.
2891
+ */
2892
+ invalidateIdToken(usedToken = this.cachedIdToken) {
2893
+ if (!usedToken) return;
2894
+ const store2 = firebaseIdTokenStore();
2895
+ for (const [k, e] of store2.cache) if (e.idToken === usedToken) store2.cache.delete(k);
2896
+ updateIdTokenFile((entries) => {
2897
+ for (const k of Object.keys(entries)) if (entries[k].idToken === usedToken) delete entries[k];
2898
+ });
2899
+ if (this.cachedIdToken === usedToken) {
2900
+ this.cachedIdToken = null;
2901
+ this.tokenExpiry = 0;
2902
+ }
2903
+ }
2904
+ /**
2905
+ * Probe Firebase auth by getting an ID token. Used by the health_check
2906
+ * diagnostic tool. Does not touch GHL backend. A fresh cached ID token (this
2907
+ * process's or another's) answers without an exchange; otherwise it exchanges
2908
+ * the refresh token at securetoken.googleapis.com.
2749
2909
  */
2750
2910
  async checkAuth() {
2751
2911
  try {
@@ -2812,8 +2972,9 @@ var init_workflow_builder_client = __esm({
2812
2972
  return null;
2813
2973
  }
2814
2974
  /**
2815
- * Get a fresh Firebase ID token using the refresh token.
2816
- * Uses a promise lock to prevent concurrent refresh races.
2975
+ * Get a Firebase ID token: this client's own copy, then the process-wide
2976
+ * cache, and only then an exchange at securetoken (shared by every client
2977
+ * asking for the same credential at the same moment).
2817
2978
  */
2818
2979
  async getIdToken() {
2819
2980
  if (this.cachedIdToken && Date.now() < this.tokenExpiry) {
@@ -2822,31 +2983,74 @@ var init_workflow_builder_client = __esm({
2822
2983
  if (this.tokenRefreshPromise) {
2823
2984
  return this.tokenRefreshPromise;
2824
2985
  }
2825
- this.tokenRefreshPromise = this.performTokenRefresh();
2986
+ const refresh = this.obtainIdToken();
2987
+ this.tokenRefreshPromise = refresh;
2826
2988
  try {
2827
- return await this.tokenRefreshPromise;
2989
+ return await refresh;
2828
2990
  } finally {
2829
- this.tokenRefreshPromise = null;
2991
+ if (this.tokenRefreshPromise === refresh) this.tokenRefreshPromise = null;
2830
2992
  }
2831
2993
  }
2832
- async performTokenRefresh() {
2833
- const url = `${FIREBASE_TOKEN_URL}?key=${encodeURIComponent(this.firebaseApiKey)}`;
2834
- const body = `grant_type=refresh_token&refresh_token=${encodeURIComponent(this.refreshToken)}`;
2835
- const response = await fetch(url, {
2836
- method: "POST",
2837
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
2838
- body
2839
- });
2840
- if (!response.ok) {
2841
- this.cachedIdToken = null;
2842
- this.tokenExpiry = 0;
2843
- const text2 = await response.text();
2844
- throw new Error(`Firebase token refresh failed (${response.status}): ${text2}`);
2994
+ async obtainIdToken() {
2995
+ const generation = this.tokenGeneration;
2996
+ const store2 = firebaseIdTokenStore();
2997
+ let key = firebaseIdTokenKey(this.firebaseApiKey, this.userId, this.refreshToken);
2998
+ const known = store2.cache.get(key);
2999
+ if (known && known.refreshToken !== this.refreshToken) {
3000
+ this.followRotation(known.refreshToken);
3001
+ key = firebaseIdTokenKey(this.firebaseApiKey, this.userId, this.refreshToken);
2845
3002
  }
2846
- const data = FirebaseTokenSchema.parse(await response.json());
2847
- this.cachedIdToken = data.id_token;
2848
- this.tokenExpiry = Date.now() + 55 * 60 * 1e3;
2849
- this.tokenCompanyId = decodeFirebaseClaims(data.id_token).companyId;
3003
+ const hit = store2.cache.get(key);
3004
+ if (hit && Date.now() < hit.expiresAt) {
3005
+ return this.adoptIdToken(hit);
3006
+ }
3007
+ const shared = readIdTokenFileEntry(idTokenFileKey(this.firebaseApiKey, this.userId, this.refreshToken));
3008
+ if (shared) {
3009
+ const entry2 = { idToken: shared.idToken, expiresAt: shared.expiresAt, refreshToken: this.refreshToken };
3010
+ store2.cache.set(key, entry2);
3011
+ return this.adoptIdToken(entry2);
3012
+ }
3013
+ let pending = store2.inflight.get(key);
3014
+ if (!pending) {
3015
+ const started = this.performTokenRefresh();
3016
+ pending = started;
3017
+ store2.inflight.set(key, started);
3018
+ const clear = () => {
3019
+ if (store2.inflight.get(key) === started) store2.inflight.delete(key);
3020
+ };
3021
+ started.then(clear, clear);
3022
+ }
3023
+ let entry;
3024
+ try {
3025
+ entry = await pending;
3026
+ } catch (error) {
3027
+ if (generation === this.tokenGeneration) {
3028
+ this.cachedIdToken = null;
3029
+ this.tokenExpiry = 0;
3030
+ }
3031
+ throw error;
3032
+ }
3033
+ if (generation !== this.tokenGeneration) return this.getIdToken();
3034
+ return this.adoptIdToken(entry);
3035
+ }
3036
+ /** Take on a rotated refresh token another client already persisted. */
3037
+ followRotation(newToken2) {
3038
+ if (this.refreshToken === this.homeRefreshToken && this.currentCompanyId === this.homeCompanyId) {
3039
+ this.homeRefreshToken = newToken2;
3040
+ }
3041
+ this.refreshToken = newToken2;
3042
+ }
3043
+ /**
3044
+ * Make a (fresh or shared) ID token this client's own: cache it on the
3045
+ * instance, follow a rotation, and run the company check below.
3046
+ */
3047
+ adoptIdToken(entry) {
3048
+ this.cachedIdToken = entry.idToken;
3049
+ this.tokenExpiry = entry.expiresAt;
3050
+ if (entry.refreshToken !== this.refreshToken) {
3051
+ this.followRotation(entry.refreshToken);
3052
+ }
3053
+ this.tokenCompanyId = decodeFirebaseClaims(entry.idToken).companyId;
2850
3054
  const expectedCompanyId = this.intendedCompanyId ?? this.currentCompanyId;
2851
3055
  if (expectedCompanyId && this.tokenCompanyId && expectedCompanyId !== this.tokenCompanyId) {
2852
3056
  const warnKey = `${expectedCompanyId}->${this.tokenCompanyId}`;
@@ -2858,11 +3062,65 @@ var init_workflow_builder_client = __esm({
2858
3062
  );
2859
3063
  }
2860
3064
  }
2861
- if (data.refresh_token && data.refresh_token !== this.refreshToken) {
2862
- this.refreshToken = data.refresh_token;
2863
- this.persistRotatedToken(data.refresh_token);
3065
+ return entry.idToken;
3066
+ }
3067
+ /**
3068
+ * Exchange the refresh token at securetoken and store the result in the
3069
+ * process-wide cache. A 429 (Google's per-minute exchange quota) is retried
3070
+ * after 2s, 5s and 10s; any other failure, a bad token included, is not.
3071
+ */
3072
+ async performTokenRefresh() {
3073
+ const firebaseApiKey = this.firebaseApiKey;
3074
+ const userId = this.userId;
3075
+ const oldRefreshToken = this.refreshToken;
3076
+ const company = this.currentCompanyId;
3077
+ const generation = this.tokenGeneration;
3078
+ const url = `${FIREBASE_TOKEN_URL}?key=${encodeURIComponent(firebaseApiKey)}`;
3079
+ const body = `grant_type=refresh_token&refresh_token=${encodeURIComponent(oldRefreshToken)}`;
3080
+ let response;
3081
+ for (let attempt = 0; ; attempt++) {
3082
+ try {
3083
+ response = await fetch(url, {
3084
+ method: "POST",
3085
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3086
+ body,
3087
+ signal: AbortSignal.timeout(FIREBASE_EXCHANGE_TIMEOUT_MS)
3088
+ });
3089
+ } catch (error) {
3090
+ const name = error?.name;
3091
+ if (name === "TimeoutError" || name === "AbortError") {
3092
+ throw new Error(`Firebase token refresh timed out after ${FIREBASE_EXCHANGE_TIMEOUT_MS / 1e3}s`);
3093
+ }
3094
+ throw error;
3095
+ }
3096
+ if (response.status !== 429 || attempt >= FIREBASE_429_DELAYS_MS.length) break;
3097
+ const delay5 = FIREBASE_429_DELAYS_MS[attempt];
3098
+ process.stderr.write(`[ghl-mcp] Firebase token exchange rate-limited (429), retry ${attempt + 1}/${FIREBASE_429_DELAYS_MS.length} in ${delay5}ms
3099
+ `);
3100
+ await new Promise((r) => setTimeout(r, delay5));
3101
+ }
3102
+ if (!response.ok) {
3103
+ const text2 = await response.text();
3104
+ throw new Error(`Firebase token refresh failed (${response.status}): ${text2}`);
3105
+ }
3106
+ const data = FirebaseTokenSchema.parse(await response.json());
3107
+ const expiresInMs = Number(data.expires_in) * 1e3;
3108
+ const ttl = Number.isFinite(expiresInMs) && expiresInMs > 0 ? Math.max(expiresInMs - ID_TOKEN_EXPIRY_MARGIN_MS, 0) : ID_TOKEN_FALLBACK_TTL_MS;
3109
+ const newRefreshToken = data.refresh_token || oldRefreshToken;
3110
+ const entry = { idToken: data.id_token, expiresAt: Date.now() + ttl, refreshToken: newRefreshToken };
3111
+ const store2 = firebaseIdTokenStore();
3112
+ store2.cache.set(firebaseIdTokenKey(firebaseApiKey, userId, oldRefreshToken), entry);
3113
+ const shared = { idToken: entry.idToken, expiresAt: entry.expiresAt };
3114
+ updateIdTokenFile((entries) => {
3115
+ entries[idTokenFileKey(firebaseApiKey, userId, oldRefreshToken)] = shared;
3116
+ if (newRefreshToken !== oldRefreshToken) entries[idTokenFileKey(firebaseApiKey, userId, newRefreshToken)] = shared;
3117
+ });
3118
+ if (newRefreshToken !== oldRefreshToken) {
3119
+ store2.cache.set(firebaseIdTokenKey(firebaseApiKey, userId, newRefreshToken), entry);
3120
+ if (generation === this.tokenGeneration) this.refreshToken = newRefreshToken;
3121
+ this.persistRotatedToken(newRefreshToken, company);
2864
3122
  }
2865
- return data.id_token;
3123
+ return entry;
2866
3124
  }
2867
3125
  /**
2868
3126
  * Persist a rotated refresh token to the slot that owns the ACTIVE company.
@@ -2873,8 +3131,7 @@ var init_workflow_builder_client = __esm({
2873
3131
  * keep the in-memory home token in sync so resetToHomeFirebase() restores
2874
3132
  * the fresh value rather than a stale one.
2875
3133
  */
2876
- persistRotatedToken(newToken2) {
2877
- const company = this.currentCompanyId;
3134
+ persistRotatedToken(newToken2, company = this.currentCompanyId) {
2878
3135
  const hasCompanySlot = company !== void 0 && this.registry?.getCompanyFirebase(company) !== void 0;
2879
3136
  if (hasCompanySlot) {
2880
3137
  this.registry?.updateCompanyFirebaseRefreshToken(company, newToken2);
@@ -2968,8 +3225,10 @@ var init_workflow_builder_client = __esm({
2968
3225
  return retryAccountRead(() => this.parkedContacts(workflowId), "workflow parked contacts");
2969
3226
  }
2970
3227
  const url = `https://backend.leadconnectorhq.com/workflows/status/search/count-per-step?workflowId=${encodeURIComponent(workflowId)}&locationId=${encodeURIComponent(this.locationId)}`;
2971
- const res = await fetch(url, { headers: await this.buildHeaders() });
3228
+ const headers = await this.buildHeaders();
3229
+ const res = await fetch(url, { headers });
2972
3230
  if (!res.ok) {
3231
+ if (res.status === 401) this.invalidateIdToken(headers["token-id"] ?? null);
2973
3232
  const upstream = inBuildRequestScope() ? ` ${redactText(await res.text())}` : "";
2974
3233
  throw new Error(
2975
3234
  `Could not check whether anyone is part-way through this workflow (HTTP ${res.status}). Nothing was written. This check has to succeed before a step can be changed, because changing a step somebody is waiting on drops them from the sequence permanently.${upstream}`
@@ -3006,6 +3265,7 @@ var init_workflow_builder_client = __esm({
3006
3265
  }
3007
3266
  const url = `${BACKEND_BASE}${urlPath}`;
3008
3267
  const headers = await this.buildHeaders();
3268
+ const usedIdToken = headers["token-id"];
3009
3269
  const options = { method, headers };
3010
3270
  if (body && (method === "POST" || method === "PUT")) {
3011
3271
  options.body = JSON.stringify(body);
@@ -3040,6 +3300,7 @@ var init_workflow_builder_client = __esm({
3040
3300
  return this.request(method, urlPath, body, attempt + 1);
3041
3301
  }
3042
3302
  if (!response.ok) {
3303
+ if (response.status === 401) this.invalidateIdToken(usedIdToken ?? null);
3043
3304
  let errorBody = "";
3044
3305
  try {
3045
3306
  errorBody = await response.text();
@@ -13071,11 +13332,13 @@ __export(page_reads_exports, {
13071
13332
  });
13072
13333
  async function readPageFull(client, pageId, fetcher = fetch) {
13073
13334
  const headers = await client.buildHeaders();
13335
+ const usedIdToken = headers["token-id"];
13074
13336
  headers.Origin = "https://app.gohighlevel.com";
13075
13337
  headers.Referer = "https://app.gohighlevel.com/";
13076
13338
  const path50 = `/page/${pageId}?locationId=${client.locationId}`;
13077
13339
  const response = await fetcher(`https://backend.leadconnectorhq.com/funnels${path50}`, { method: "GET", headers });
13078
13340
  if (!response.ok) {
13341
+ if (response.status === 401) client.invalidateIdToken?.(usedIdToken);
13079
13342
  const text3 = await response.text();
13080
13343
  throw new Error(`Funnel API Error ${response.status}: GET ${path50}
13081
13344
  ${text3}`);
@@ -24127,7 +24390,9 @@ var init_client_engagements = __esm({
24127
24390
  function readBoardState() {
24128
24391
  try {
24129
24392
  const raw = JSON.parse(fs15.readFileSync(path14.join(appDataDir(), "cockpit-state.json"), "utf8"));
24130
- if (raw && typeof raw === "object" && raw.clients && typeof raw.clients === "object") return raw;
24393
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
24394
+ return { ...raw, clients: raw.clients && typeof raw.clients === "object" ? raw.clients : {} };
24395
+ }
24131
24396
  } catch {
24132
24397
  }
24133
24398
  return { clients: {} };
@@ -27949,6 +28214,77 @@ var init_claude_locate = __esm({
27949
28214
  }
27950
28215
  });
27951
28216
 
28217
+ // src/result-text.ts
28218
+ function hasParseableResult(text2) {
28219
+ const m = RESULT_RE.exec(text2);
28220
+ if (!m) return false;
28221
+ try {
28222
+ JSON.parse(m[1]);
28223
+ return true;
28224
+ } catch {
28225
+ return false;
28226
+ }
28227
+ }
28228
+ function pickResultText(texts, firstEligible = 0) {
28229
+ for (let i = texts.length - 1; i >= firstEligible; i--) if (hasParseableResult(texts[i])) return texts[i];
28230
+ for (let i = texts.length - 1; i >= firstEligible; i--) if (/RESULT:\s*\{/.test(texts[i])) return texts[i];
28231
+ if (!texts.length) return "";
28232
+ if (texts.length <= firstEligible) return texts[texts.length - 1].replace(/RESULT:\s*\{[\s\S]*\}/, "").trim();
28233
+ return texts[texts.length - 1];
28234
+ }
28235
+ function resultEventFailure(ev) {
28236
+ if (ev?.type !== "result") return void 0;
28237
+ if (typeof ev.subtype === "string" && ev.subtype !== "success") return ev.subtype;
28238
+ if (ev.is_error === true) return "error";
28239
+ return void 0;
28240
+ }
28241
+ var NO_MEMBER_HOOKS, RESULT_RE, RunTexts;
28242
+ var init_result_text = __esm({
28243
+ "src/result-text.ts"() {
28244
+ "use strict";
28245
+ NO_MEMBER_HOOKS = ["--settings", '{"disableAllHooks":true}'];
28246
+ RESULT_RE = /RESULT:\s*(\{[\s\S]*\})/;
28247
+ RunTexts = class {
28248
+ texts = [];
28249
+ afterLastTool = 0;
28250
+ /** Set when the result event reported the run did not finish (e.g. error_max_turns). */
28251
+ failure;
28252
+ /** Record an assistant text block or the result string (blank ones are ignored). */
28253
+ text(t) {
28254
+ if (typeof t === "string" && t.trim()) this.texts.push(t);
28255
+ }
28256
+ /** Record a tool_use block, in stream order. */
28257
+ tool() {
28258
+ this.afterLastTool = this.texts.length;
28259
+ }
28260
+ /** Record every block of one stream-json event. */
28261
+ event(ev) {
28262
+ if (ev?.type === "assistant") {
28263
+ for (const b of ev.message?.content ?? []) {
28264
+ if (b?.type === "tool_use") this.tool();
28265
+ else if (b?.type === "text") this.text(b.text);
28266
+ }
28267
+ } else if (ev?.type === "result") this.result(ev);
28268
+ }
28269
+ /**
28270
+ * Record the result event: its failure, and its text only when that text was
28271
+ * not already said (it normally repeats the last assistant text, and a repeat
28272
+ * of a RESULT line said before a tool call must not become eligible).
28273
+ */
28274
+ result(ev) {
28275
+ this.failure = resultEventFailure(ev) ?? this.failure;
28276
+ if (typeof ev.result === "string" && !this.texts.includes(ev.result)) this.text(ev.result);
28277
+ }
28278
+ get empty() {
28279
+ return this.texts.length === 0;
28280
+ }
28281
+ pick() {
28282
+ return pickResultText(this.texts, this.afterLastTool);
28283
+ }
28284
+ };
28285
+ }
28286
+ });
28287
+
27952
28288
  // src/claude-cli.ts
27953
28289
  function parseVersion(text2) {
27954
28290
  const m = /(\d+)\.(\d+)\.(\d+)/.exec(text2 || "");
@@ -28028,6 +28364,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
28028
28364
  const cmd = claudeCommand(bin, [
28029
28365
  "-p",
28030
28366
  SMOKE_PROMPT,
28367
+ ...NO_MEMBER_HOOKS,
28031
28368
  "--output-format",
28032
28369
  "stream-json",
28033
28370
  "--verbose",
@@ -28045,7 +28382,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
28045
28382
  done({ ok: false, error: "the engine check did not finish in time" });
28046
28383
  }, timeoutMs);
28047
28384
  let buffered = "";
28048
- let lastText = "";
28385
+ const texts = new RunTexts();
28049
28386
  let version;
28050
28387
  let model;
28051
28388
  let costUsd;
@@ -28062,13 +28399,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
28062
28399
  if (found.version) version = found.version;
28063
28400
  if (found.model) model = found.model;
28064
28401
  if (found.costUsd !== void 0) costUsd = found.costUsd;
28065
- if (ev.type === "assistant") {
28066
- for (const b of ev.message?.content ?? []) {
28067
- if (b.type === "text" && b.text?.trim()) lastText = b.text;
28068
- }
28069
- } else if (ev.type === "result" && typeof ev.result === "string" && ev.result.trim()) {
28070
- lastText = ev.result;
28071
- }
28402
+ texts.event(ev);
28072
28403
  } catch {
28073
28404
  }
28074
28405
  }
@@ -28083,7 +28414,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
28083
28414
  });
28084
28415
  child.on("close", () => {
28085
28416
  clearTimeout(timer);
28086
- if (/RESULT:\s*\{/.test(lastText)) done({ ok: true, version, model, costUsd });
28417
+ if (/RESULT:\s*\{/.test(texts.pick())) done({ ok: true, version, model, costUsd });
28087
28418
  else done({
28088
28419
  ok: false,
28089
28420
  version,
@@ -28142,6 +28473,7 @@ var init_claude_cli = __esm({
28142
28473
  path19 = __toESM(require("path"));
28143
28474
  init_credentials_store();
28144
28475
  init_claude_locate();
28476
+ init_result_text();
28145
28477
  MIN_CLAUDE_VERSION = "2.1.0";
28146
28478
  SMOKE_PROMPT = 'Reply with exactly this one line and nothing else: RESULT: {"ok":true,"summary":"engine check"}';
28147
28479
  }
@@ -28168,6 +28500,9 @@ function clientKeysInLocation(keys, locationId2) {
28168
28500
  }
28169
28501
  return [...hasDefault ? [void 0] : [], ...others.sort()];
28170
28502
  }
28503
+ function secondClientChooseMessage(clientKey, name) {
28504
+ return `Nobody has chosen what ${name ? name : `the second clinic "${clientKey}"`} bought, so nothing was approved or built. The cockpit has no What they bought screen for a second clinic in a sub-account yet, so the choice is saved for it directly: send the cockpit POST /api/scope with this sub-account's locationId, clientKey "${clientKey}" and the features they bought (features: null for everything in the plan). Then approve and build again.`;
28505
+ }
28171
28506
  var import_zod58, CLIENT_RECORD_SEPARATOR, CLIENT_KEY_ENV, clientKeySchema;
28172
28507
  var init_client_record = __esm({
28173
28508
  "src/client-record.ts"() {
@@ -28244,7 +28579,7 @@ function verifyReadbackPrompt(ctx) {
28244
28579
  function buildClaudeArgs(spec, locationId2, locationName, ctx) {
28245
28580
  return [
28246
28581
  "-p",
28247
- spec.prompt(locationId2, locationName, ctx) + (spec.stage === 4 ? verifyReadbackPrompt(ctx) : "") + (spec.stage === 4 ? "\nCheck EACH carried manual obligation with an account read. Return manualChecks:[{ref,type,ok,evidence}] in RESULT, where evidence names the actual read-back. Set ok:false for any obligation you cannot verify. Carried obligations: " + JSON.stringify(ctx?.manualObligations ?? []) : ""),
28582
+ spec.prompt(locationId2, locationName, ctx) + (spec.stage === 4 ? verifyReadbackPrompt(ctx) : "") + (spec.stage === 4 ? "\nCheck EACH carried manual obligation with an account read. Return manualChecks:[{ref,type,ok,evidence}] in RESULT, where evidence names the actual read-back. Set ok:false for any obligation you cannot verify. Carried obligations: " + JSON.stringify(ctx?.manualObligations ?? []) : "") + RESULT_LAST_RULE,
28248
28583
  "--output-format",
28249
28584
  "stream-json",
28250
28585
  "--verbose",
@@ -28259,7 +28594,10 @@ function buildClaudeArgs(spec, locationId2, locationName, ctx) {
28259
28594
  // tools. The deny list is COMPUTED: every built-in that touches the machine
28260
28595
  // plus every registered GHL tool outside this stage's allowlist.
28261
28596
  "--disallowedTools",
28262
- denyListFor(verifyTools(spec, ctx)).join(",")
28597
+ denyListFor(verifyTools(spec, ctx)).join(","),
28598
+ // The member's own hooks never run inside a stage: a Stop hook added a turn
28599
+ // after the RESULT line and ended build steps early (2026-09-29).
28600
+ ...NO_MEMBER_HOOKS
28263
28601
  ];
28264
28602
  }
28265
28603
  function parseResultLine(text2) {
@@ -28325,11 +28663,13 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
28325
28663
  };
28326
28664
  const progressPoll = progressDir ? setInterval(pollProgress, 1e3) : void 0;
28327
28665
  progressPoll?.unref?.();
28666
+ let timedOut = false;
28328
28667
  const timeout = setTimeout(() => {
28668
+ timedOut = true;
28329
28669
  onEvent({ kind: "error", line: "Run timed out \u2014 stopping it." });
28330
28670
  child.kill("SIGKILL");
28331
28671
  }, opts.timeoutMs ?? spec.timeoutMs ?? 10 * 60 * 1e3);
28332
- let lastText = "";
28672
+ const texts = new RunTexts();
28333
28673
  let buffered = "";
28334
28674
  let engineVersion;
28335
28675
  let engineModel;
@@ -28353,14 +28693,17 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
28353
28693
  if (facts.durationMs !== void 0) durationMs = facts.durationMs;
28354
28694
  if (ev.type === "assistant") {
28355
28695
  for (const block of ev.message?.content ?? []) {
28356
- if (block.type === "tool_use") onEvent({ kind: "tool", line: `\u2192 ${String(block.name).replace(/^mcp__ghl__/, "")}` });
28696
+ if (block.type === "tool_use") {
28697
+ texts.tool();
28698
+ onEvent({ kind: "tool", line: `\u2192 ${String(block.name).replace(/^mcp__ghl__/, "")}` });
28699
+ }
28357
28700
  if (block.type === "text" && block.text?.trim()) {
28358
- lastText = block.text;
28701
+ texts.text(block.text);
28359
28702
  onEvent({ kind: "text", line: block.text.trim().slice(0, 200) });
28360
28703
  }
28361
28704
  }
28362
28705
  } else if (ev.type === "result") {
28363
- if (typeof ev.result === "string" && ev.result.trim()) lastText = ev.result;
28706
+ texts.result(ev);
28364
28707
  }
28365
28708
  } catch {
28366
28709
  }
@@ -28387,13 +28730,13 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
28387
28730
  noteObservedEngine({ version: engineVersion, model: engineModel });
28388
28731
  } catch {
28389
28732
  }
28390
- if (code !== 0 && !lastText) {
28733
+ if (!timedOut && code !== 0 && texts.empty) {
28391
28734
  const outcome2 = { ok: false, error: `claude exited ${code}: ${stderrTail.trim().slice(-200) || "no output"}`, ...engine };
28392
28735
  onEvent({ kind: "error", line: outcome2.error });
28393
28736
  resolve13(outcome2);
28394
28737
  return;
28395
28738
  }
28396
- const outcome = { ...parseResultLine(lastText), ...engine };
28739
+ const outcome = timedOut ? { ok: false, error: "the run timed out and was stopped before it finished", ...engine } : texts.failure ? { ok: false, error: `the run did not finish (${texts.failure})`, raw: texts.pick().slice(-400), ...engine } : { ...parseResultLine(texts.pick()), ...engine };
28397
28740
  if (stage === 3) {
28398
28741
  try {
28399
28742
  outcome.runId = findBuildRun(locationId2)?.runId;
@@ -28419,7 +28762,7 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
28419
28762
  });
28420
28763
  });
28421
28764
  }
28422
- var import_child_process3, fs21, STAFF_NOTIFICATION_RULE, STAFF_PAUSE_RULE, MISSING_STEP_RULE, NO_INVENTED_CAUSE_RULE, STAGE_SPECS, HUMAN_GATE_STAGES;
28765
+ var import_child_process3, fs21, STAFF_NOTIFICATION_RULE, STAFF_PAUSE_RULE, MISSING_STEP_RULE, NO_INVENTED_CAUSE_RULE, STAGE_SPECS, HUMAN_GATE_STAGES, RESULT_LAST_RULE;
28423
28766
  var init_stage_runner = __esm({
28424
28767
  "src/stage-runner.ts"() {
28425
28768
  "use strict";
@@ -28434,6 +28777,7 @@ var init_stage_runner = __esm({
28434
28777
  init_build_journal();
28435
28778
  init_claude_cli();
28436
28779
  init_claude_locate();
28780
+ init_result_text();
28437
28781
  init_account_rules();
28438
28782
  init_account_rules();
28439
28783
  init_client_record();
@@ -28513,7 +28857,7 @@ If anything fails, finish with: RESULT: {"ok":false,"error":"<short reason>"}`
28513
28857
  2. Find the answers. They arrive one of TWO ways and you must check BOTH:
28514
28858
  (a) a real form submission \u2014 get_forms then get_form_submissions_full; or
28515
28859
  (b) an intake completed in the cockpit \u2014 search_contacts for the tag "blueprint-intake-received" and read that contact's custom fields (get_contact), which hold the answers under "Intake: <question>" fields.
28516
- 3. Turn whichever you found into a Brief with normalize_submission_to_brief (for a form submission), then check it with validate_brief. If the answers came from the tagged contact, build the Brief from those field values directly and call validate_brief on it too. Report validate_brief gaps in issues as warnings and finish ok:true.
28860
+ 3. Turn whichever you found into a Brief with normalize_submission_to_brief (for a form submission), then check it with validate_brief. If the answers came from the tagged contact, build the Brief from those field values directly and call validate_brief on it too. Booking calendars go at the TOP level of the Brief as calendars:[{"name":"...","type":"one_on_one"|"round_robin"|"class","staffNames":["..."],"durationMinutes":30}], one entry per calendar, never inside goal. Report validate_brief gaps in issues as warnings and finish ok:true.
28517
28861
  Use these authoritative normalized intake answers when provided: ${JSON.stringify(ctx?.normalizedAnswers ?? {})}.
28518
28862
  Do NOT create, update or delete anything \u2014 this step only reads and normalizes.
28519
28863
  Only if BOTH paths come up empty, finish with RESULT: {"ok":false,"error":"Open the real form, submit it, then refresh."}.
@@ -28609,6 +28953,7 @@ RESULT: {"ok":true,"summary":"<what you checked and the headline result>","issue
28609
28953
  5: "Manual tasks only a human can do (A2P, Google auth, staff, phone)",
28610
28954
  6: "Go-live sign-off"
28611
28955
  };
28956
+ RESULT_LAST_RULE = "\nThe RESULT line must be the very last thing you print: make no tool calls after it, and print nothing after it.";
28612
28957
  }
28613
28958
  });
28614
28959
 
@@ -29631,10 +29976,10 @@ function askNoTools(prompt, opts = {}) {
29631
29976
  resolve13("");
29632
29977
  return;
29633
29978
  }
29634
- const cmd = claudeCommand(bin, ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns ?? 3), "--allowedTools", "", "--disallowedTools", NO_TOOLS_DENY]);
29979
+ const cmd = claudeCommand(bin, ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns ?? 3), "--allowedTools", "", "--disallowedTools", NO_TOOLS_DENY, ...NO_MEMBER_HOOKS]);
29635
29980
  const child = (0, import_node_child_process2.spawn)(cmd.command, cmd.args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } });
29636
29981
  const timeout = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs ?? 3 * 60 * 1e3);
29637
- let lastText = "";
29982
+ const texts = new RunTexts();
29638
29983
  let buffered = "";
29639
29984
  let costUsd;
29640
29985
  let reported = false;
@@ -29654,11 +29999,7 @@ function askNoTools(prompt, opts = {}) {
29654
29999
  try {
29655
30000
  const ev = JSON.parse(line3);
29656
30001
  if (ev.type === "result" && typeof ev.total_cost_usd === "number") costUsd = ev.total_cost_usd;
29657
- if (ev.type === "assistant") {
29658
- for (const b of ev.message?.content ?? []) if (b.type === "text" && b.text?.trim()) lastText = b.text;
29659
- } else if (ev.type === "result" && typeof ev.result === "string" && ev.result.trim()) {
29660
- lastText = ev.result;
29661
- }
30002
+ texts.event(ev);
29662
30003
  } catch {
29663
30004
  }
29664
30005
  }
@@ -29666,12 +30007,12 @@ function askNoTools(prompt, opts = {}) {
29666
30007
  child.on("error", () => {
29667
30008
  clearTimeout(timeout);
29668
30009
  report3();
29669
- resolve13(lastText);
30010
+ resolve13(texts.pick());
29670
30011
  });
29671
30012
  child.on("close", () => {
29672
30013
  clearTimeout(timeout);
29673
30014
  report3();
29674
- resolve13(lastText);
30015
+ resolve13(texts.pick());
29675
30016
  });
29676
30017
  });
29677
30018
  }
@@ -29682,6 +30023,7 @@ var init_handoff_run = __esm({
29682
30023
  import_node_child_process2 = require("node:child_process");
29683
30024
  init_stage_runner();
29684
30025
  init_claude_locate();
30026
+ init_result_text();
29685
30027
  init_handoff_pack();
29686
30028
  CLIENT_SENTENCE_RULES = "Write for the business owner, not a developer. Complete sentences that end with a period; never trail off. One or two sentences per item, no more. Everyday words: say 'text messaging registration (A2P)' not 'A2P'; 'the calendar is not connected to Google or Outlook yet' not 'OAuth'; 'nothing was wrong' not 'status ok'. No tool names, no ids, no field names in code style, no JSON, no arrows. Keep the names of workflows, pipelines and forms because the client will recognise them. If a list is long, name the first few and say how many in total. State what is in place, or what is missing and why, plainly. An item marked 'done but not confirmed' has NOT been confirmed: never describe it as having happened or been observed; say what is still to be confirmed and how (for example, 'this is confirmed on the handoff call, when you complete one task in the account without help').";
29687
30029
  NO_TOOLS_DENY = "Bash,Write,Edit,MultiEdit,NotebookEdit,WebFetch,WebSearch,Task,TodoWrite,Read,Glob,Grep";
@@ -32152,7 +32494,7 @@ var require_package = __commonJS({
32152
32494
  "package.json"(exports2, module2) {
32153
32495
  module2.exports = {
32154
32496
  name: "@elitedcs/ghl-mcp",
32155
- version: "3.89.0-beta.1",
32497
+ version: "3.89.0-beta.2",
32156
32498
  mcpName: "io.github.drjerryrelth/ghl-command",
32157
32499
  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.",
32158
32500
  main: "dist/index.js",
@@ -33376,10 +33718,12 @@ function judgeSteps(rawRows, contactId, workflowNames) {
33376
33718
  }
33377
33719
  };
33378
33720
  }
33379
- function liveStepLogReader(locationId2, headers, fetchFn = fetch) {
33721
+ function liveStepLogReader(locationId2, headers, fetchFn = fetch, onUnauthorized) {
33380
33722
  return async (workflowId, fromMs, toMs) => {
33381
33723
  const q3 = new URLSearchParams({ action: "first", dateType: "custom", fromDate: String(Math.floor(fromMs)), toDate: String(Math.ceil(toMs)), limit: String(STEP_LOG_LIMIT), locationId: locationId2, workflowId });
33382
- const res = await fetchFn(`https://backend.leadconnectorhq.com/workflows/logs/v2?${q3}`, { headers: await headers() });
33724
+ const used = await headers();
33725
+ const res = await fetchFn(`https://backend.leadconnectorhq.com/workflows/logs/v2?${q3}`, { headers: used });
33726
+ if (res.status === 401) onUnauthorized?.(used);
33383
33727
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
33384
33728
  const body = await res.json();
33385
33729
  if (!Array.isArray(body)) throw new Error("the log came back in a shape this version does not know");
@@ -35892,7 +36236,7 @@ function workflowReadConnection(registry2, client, companyId) {
35892
36236
  const home = companyId && companyId === process.env.GHL_COMPANY_ID ? registry2.getFirebase() : void 0;
35893
36237
  const config3 = fb ?? home ?? (companyId && companyId === process.env.GHL_COMPANY_ID && process.env.GHL_FIREBASE_REFRESH_TOKEN ? { apiKey: process.env.GHL_FIREBASE_API_KEY, refreshToken: process.env.GHL_FIREBASE_REFRESH_TOKEN, userId: process.env.GHL_USER_ID } : void 0);
35894
36238
  if (!config3?.apiKey || !config3.userId) throw new Error(BUILDER_REPAIR_MESSAGE);
35895
- return new WorkflowBuilderClient({ firebaseApiKey: config3.apiKey, refreshToken: config3.refreshToken, userId: config3.userId, apiKey: client.getApiKey(), locationId: locationId2, companyId });
36239
+ return new WorkflowBuilderClient({ firebaseApiKey: config3.apiKey, refreshToken: config3.refreshToken, userId: config3.userId, apiKey: client.getApiKey(), locationId: locationId2, companyId, registry: registry2 });
35896
36240
  }
35897
36241
  var BUILDER_REPAIR_MESSAGE;
35898
36242
  var init_workflow_read_connection = __esm({
@@ -38873,12 +39217,12 @@ var init_run_cost = __esm({
38873
39217
  function callClaudeRecorded(bin, argv, context, onCost = () => {
38874
39218
  }) {
38875
39219
  return new Promise((resolve13, reject) => {
38876
- let text2 = "";
39220
+ const run = new RunTexts();
38877
39221
  let buffer = "";
38878
39222
  let stderr = "";
38879
39223
  let facts = {};
38880
39224
  let settled = false;
38881
- const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose"]);
39225
+ const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose", ...NO_MEMBER_HOOKS]);
38882
39226
  const child = (0, import_node_child_process5.spawn)(cmd.command, cmd.args, { stdio: ["ignore", "pipe", "pipe"] });
38883
39227
  const timer = setTimeout(() => child.kill("SIGKILL"), 6 * 60 * 1e3);
38884
39228
  const finish3 = (ok) => {
@@ -38891,17 +39235,14 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
38891
39235
  onCost("The cost record could not be saved.");
38892
39236
  }
38893
39237
  onCost(`${context.kind}: ${facts.costUsd === void 0 ? "cost not reported" : formatUsd(facts.costUsd)}.`);
38894
- if (ok) resolve13(text2);
39238
+ if (ok) resolve13(run.pick());
38895
39239
  else reject(new Error("Check your Claude authentication in Your agency before reading this source."));
38896
39240
  };
38897
39241
  const consume = (line3) => {
38898
39242
  try {
38899
39243
  const ev = JSON.parse(line3);
38900
39244
  facts = { ...facts, ...readEngineFacts(ev) };
38901
- if (ev.type === "assistant") {
38902
- for (const block of ev.message?.content ?? []) if (block.type === "text") text2 = block.text;
38903
- }
38904
- if (ev.type === "result" && typeof ev.result === "string") text2 = ev.result;
39245
+ run.event(ev);
38905
39246
  } catch {
38906
39247
  }
38907
39248
  };
@@ -38919,7 +39260,7 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
38919
39260
  child.on("error", () => finish3(false));
38920
39261
  child.on("close", (code) => {
38921
39262
  if (buffer.trim()) consume(buffer);
38922
- finish3(code === 0 && Boolean(text2));
39263
+ finish3(code === 0 && !run.empty);
38923
39264
  });
38924
39265
  });
38925
39266
  }
@@ -38931,6 +39272,7 @@ var init_claude_call = __esm({
38931
39272
  init_run_cost();
38932
39273
  init_claude_cli();
38933
39274
  init_claude_locate();
39275
+ init_result_text();
38934
39276
  }
38935
39277
  });
38936
39278
 
@@ -39151,10 +39493,40 @@ function briefWarnings(brief) {
39151
39493
  }
39152
39494
  return out;
39153
39495
  }
39496
+ function liftGoalCalendars(input) {
39497
+ if (!input || typeof input !== "object" || Array.isArray(input)) return { input, notes: [] };
39498
+ const brief = input;
39499
+ const goal = brief.goal;
39500
+ if (!goal || typeof goal !== "object" || Array.isArray(goal)) return { input, notes: [] };
39501
+ const misplaced = goal.calendars;
39502
+ if (!Array.isArray(misplaced)) return { input, notes: [] };
39503
+ const top = brief.calendars;
39504
+ if (Array.isArray(top) && top.length > 0) {
39505
+ if (misplaced.length === 0) return { input, notes: [] };
39506
+ const names = misplaced.map((c, i) => {
39507
+ const n = c?.name;
39508
+ return typeof n === "string" && n.trim() ? `"${n.trim()}"` : `entry ${i + 1}`;
39509
+ });
39510
+ return {
39511
+ input,
39512
+ notes: [`The brief lists calendars at the top level, so the ${misplaced.length === 1 ? "calendar" : "calendars"} under goal.calendars (${names.join(", ")}) ${misplaced.length === 1 ? "was" : "were"} ignored; move any that are needed into the top-level calendars list.`]
39513
+ };
39514
+ }
39515
+ const lifted = [];
39516
+ const notes = [];
39517
+ misplaced.forEach((c, i) => {
39518
+ const r = briefCalendarSchema.safeParse(c);
39519
+ if (r.success) lifted.push(r.data);
39520
+ else notes.push(`Calendar ${i + 1} under goal.calendars could not be read (${r.error.issues.map((x) => `${x.path.join(".") || "entry"}: ${x.message}`).join("; ")}); give it a name, a type (${BRIEF_CALENDAR_TYPES.join(", ")}) and staffNames.`);
39521
+ });
39522
+ const { calendars: _moved, ...restGoal } = goal;
39523
+ return { input: { ...brief, goal: restGoal, ...lifted.length ? { calendars: lifted } : {} }, notes };
39524
+ }
39154
39525
  function validateBrief(input) {
39155
- const parsed2 = briefSchema.safeParse(input);
39526
+ const lifted = liftGoalCalendars(input);
39527
+ const parsed2 = briefSchema.safeParse(lifted.input);
39156
39528
  if (parsed2.success) {
39157
- return { valid: true, errors: [], warnings: briefWarnings(parsed2.data), brief: parsed2.data };
39529
+ return { valid: true, errors: [], warnings: [...lifted.notes, ...briefWarnings(parsed2.data)], brief: parsed2.data };
39158
39530
  }
39159
39531
  return {
39160
39532
  valid: false,
@@ -40034,18 +40406,33 @@ function keptByCondition(items, ctx) {
40034
40406
  function isFillBlock(v) {
40035
40407
  return !!v && typeof v === "object" && !Array.isArray(v) && ("fillFrom" in v || "transform" in v);
40036
40408
  }
40037
- function composeStages(block, ctx, referencedStageRefs) {
40409
+ function slugHasPhrase(slug4, phrase) {
40410
+ return `_${slug4}_`.includes(`_${slugify2(phrase)}_`);
40411
+ }
40412
+ function isTerminalStage(name) {
40413
+ const s = slugify2(name);
40414
+ return TERMINAL_STAGE_WORDS.some((w) => slugHasPhrase(s, w));
40415
+ }
40416
+ function stringList(v) {
40417
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
40418
+ }
40419
+ function composeStages(block, ctx, uses, remaps) {
40038
40420
  const defaults = isFillBlock(block) ? keptByCondition(block.default ?? [], ctx) : keptByCondition(block, ctx);
40039
- const defaultList = defaults.map((d) => ({ ref: String(d.ref), name: fillString(String(d.name), ctx) }));
40421
+ const defaultList = defaults.map((d) => ({
40422
+ ref: String(d.ref),
40423
+ name: fillString(String(d.name), ctx),
40424
+ aliases: stringList(d.aliases)
40425
+ }));
40040
40426
  const briefStages = (ctx.brief.goal.salesStages ?? []).map((s) => s.trim()).filter(Boolean);
40041
40427
  if (!isFillBlock(block) || briefStages.length === 0) {
40042
- return defaultList.map((d, i) => ({ ...d, position: i }));
40428
+ return defaultList.map((d, i) => ({ ref: d.ref, name: d.name, position: i }));
40043
40429
  }
40044
40430
  const bySlug = /* @__PURE__ */ new Map();
40045
40431
  for (const d of defaultList) {
40046
40432
  bySlug.set(slugify2(d.name), d);
40047
40433
  bySlug.set(d.ref.replace(/^stage\./, ""), d);
40048
40434
  }
40435
+ const defaultRefs = new Set(defaultList.map((d) => d.ref));
40049
40436
  const out = [];
40050
40437
  const usedRefs = /* @__PURE__ */ new Set();
40051
40438
  for (const name of briefStages) {
@@ -40056,39 +40443,160 @@ function composeStages(block, ctx, referencedStageRefs) {
40056
40443
  } else {
40057
40444
  let ref = `stage.${slugify2(name)}`;
40058
40445
  let n = 2;
40059
- while (usedRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
40446
+ while (usedRefs.has(ref) || defaultRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
40060
40447
  usedRefs.add(ref);
40061
40448
  out.push({ ref, name });
40062
40449
  }
40063
40450
  }
40064
- for (const d of defaultList) {
40065
- if (usedRefs.has(d.ref)) continue;
40066
- if (!referencedStageRefs.has(d.ref)) continue;
40451
+ const findClientStage = (phrases, allowTerminal, skipOwned) => {
40452
+ for (const phrase of phrases) {
40453
+ for (const s of out) {
40454
+ if (!allowTerminal && isTerminalStage(s.name)) continue;
40455
+ if (skipOwned && defaultRefs.has(s.ref)) continue;
40456
+ if (slugHasPhrase(slugify2(s.name), phrase)) return s;
40457
+ }
40458
+ }
40459
+ return void 0;
40460
+ };
40461
+ const defaultName = (ref) => defaultList.find((d) => d.ref === ref)?.name ?? ref.replace(/^stage\./, "");
40462
+ const stillReferenced = /* @__PURE__ */ new Set();
40463
+ const preferred = /* @__PURE__ */ new Set();
40464
+ for (const u of uses) {
40465
+ const key = `${u.workflowRef}\0${u.ref}`;
40466
+ if (preferred.has(key)) continue;
40467
+ const target = u.prefer?.length ? findClientStage(u.prefer, true, false) : void 0;
40468
+ if (!target) {
40469
+ stillReferenced.add(u.ref);
40470
+ continue;
40471
+ }
40472
+ preferred.add(key);
40473
+ if (target.ref === u.ref) continue;
40474
+ let m = remaps.byWorkflow.get(u.workflowRef);
40475
+ if (!m) {
40476
+ m = /* @__PURE__ */ new Map();
40477
+ remaps.byWorkflow.set(u.workflowRef, m);
40478
+ }
40479
+ m.set(u.ref, target.ref);
40480
+ ctx.warnings.push(
40481
+ `Mapped "${defaultName(u.ref)}" automations in ${u.workflowName} onto your "${target.name}" stage.`
40482
+ );
40483
+ }
40484
+ const pending = defaultList.filter((d) => !usedRefs.has(d.ref) && stillReferenced.has(d.ref));
40485
+ const candidates = [];
40486
+ for (const d of pending) {
40487
+ const terminalRef = isTerminalStage(d.name);
40488
+ const phrases = [d.name, ...d.aliases];
40489
+ for (const [sIdx, s] of out.entries()) {
40490
+ if (defaultRefs.has(s.ref)) continue;
40491
+ if (!terminalRef && isTerminalStage(s.name)) continue;
40492
+ const slug4 = slugify2(s.name);
40493
+ const aliasIdx = phrases.findIndex((p) => slugHasPhrase(slug4, p));
40494
+ if (aliasIdx < 0) continue;
40495
+ const best = phrases.filter((p) => slugHasPhrase(slug4, p)).map(slugify2).sort((a, b) => b.split("_").length - a.split("_").length || b.length - a.length)[0];
40496
+ candidates.push({ d, s, words: best.split("_").length, len: best.length, aliasIdx, dIdx: defaultList.indexOf(d), sIdx });
40497
+ }
40498
+ }
40499
+ candidates.sort((a, b) => b.words - a.words || b.len - a.len || a.aliasIdx - b.aliasIdx || a.dIdx - b.dIdx || a.sIdx - b.sIdx);
40500
+ const taken = /* @__PURE__ */ new Set();
40501
+ const mapped = /* @__PURE__ */ new Set();
40502
+ for (const c of candidates) {
40503
+ if (mapped.has(c.d.ref) || taken.has(c.s.ref)) continue;
40504
+ mapped.add(c.d.ref);
40505
+ taken.add(c.s.ref);
40506
+ remaps.global.set(c.d.ref, c.s.ref);
40507
+ }
40508
+ const first2 = defaultList[0];
40509
+ if (first2 && pending.includes(first2) && !mapped.has(first2.ref) && out[0] && !defaultRefs.has(out[0].ref) && !taken.has(out[0].ref) && !isTerminalStage(out[0].name)) {
40510
+ mapped.add(first2.ref);
40511
+ taken.add(out[0].ref);
40512
+ remaps.global.set(first2.ref, out[0].ref);
40513
+ }
40514
+ for (const d of pending) {
40515
+ const to = remaps.global.get(d.ref);
40516
+ const s = to ? out.find((x) => x.ref === to) : void 0;
40517
+ if (s) ctx.warnings.push(`Mapped "${d.name}" automations onto your "${s.name}" stage.`);
40518
+ }
40519
+ const placedAt = (ref) => {
40520
+ const target = remaps.global.get(ref) ?? ref;
40521
+ return out.findIndex((x) => x.ref === target);
40522
+ };
40523
+ for (const d of pending) {
40524
+ if (mapped.has(d.ref)) continue;
40525
+ const i = defaultList.indexOf(d);
40526
+ const firstTerminal = out.findIndex((s) => isTerminalStage(s.name));
40527
+ let at = -1;
40528
+ let where = "";
40529
+ for (let j = i - 1; j >= 0 && at < 0; j--) {
40530
+ const k = placedAt(defaultList[j].ref);
40531
+ if (k >= 0 && !isTerminalStage(out[k].name)) {
40532
+ at = k + 1;
40533
+ where = `just after your "${out[k].name}" stage`;
40534
+ }
40535
+ }
40536
+ for (let j = i + 1; j < defaultList.length && at < 0; j++) {
40537
+ const k = placedAt(defaultList[j].ref);
40538
+ if (k >= 0 && !isTerminalStage(out[k].name)) {
40539
+ at = k;
40540
+ where = `just before your "${out[k].name}" stage`;
40541
+ }
40542
+ }
40543
+ if (at >= 0 && firstTerminal >= 0 && at > firstTerminal) at = -1;
40544
+ if (at < 0 && firstTerminal >= 0) {
40545
+ at = firstTerminal;
40546
+ where = `just before your "${out[firstTerminal].name}" stage`;
40547
+ }
40548
+ if (at < 0) {
40549
+ at = out.length;
40550
+ where = "at the end of your stages";
40551
+ }
40552
+ out.splice(at, 0, { ref: d.ref, name: d.name });
40067
40553
  usedRefs.add(d.ref);
40068
- out.push(d);
40069
40554
  ctx.warnings.push(
40070
- `Kept the "${d.name}" pipeline stage even though the intake's stage list did not mention it \u2014 the automations move deals into it.`
40555
+ `Kept the "${d.name}" pipeline stage even though the intake's stage list did not mention it and none of its stages matches it; the automations move deals into it. It sits ${where}.`
40071
40556
  );
40072
40557
  }
40073
40558
  return out.map((s, i) => ({ ...s, position: i }));
40074
40559
  }
40075
- function collectReferencedStageRefs(workflows, ctx) {
40076
- const refs2 = /* @__PURE__ */ new Set();
40077
- const scanActions = (actions) => {
40078
- for (const a of keptByCondition(actions, ctx)) {
40079
- if (typeof a.stageRef === "string") refs2.add(a.stageRef);
40080
- if (a.type === "find_opportunity") {
40081
- scanActions(a.found);
40082
- scanActions(a.notFound);
40083
- }
40084
- }
40085
- };
40560
+ function collectStageUses(workflows, ctx) {
40561
+ const uses = [];
40086
40562
  for (const w of workflows) {
40563
+ const workflowRef = String(w.ref ?? "");
40564
+ const workflowName = typeof w.name === "string" ? fillString(w.name, ctx) : workflowRef;
40565
+ const pref = w.stagePreference && typeof w.stagePreference === "object" ? w.stagePreference : {};
40566
+ const add2 = (ref) => {
40567
+ const prefer = stringList(pref[ref]);
40568
+ uses.push({ workflowRef, workflowName, ref, ...prefer.length ? { prefer } : {} });
40569
+ };
40570
+ const scanActions = (actions) => {
40571
+ for (const a of keptByCondition(actions, ctx)) {
40572
+ if (typeof a.stageRef === "string") add2(a.stageRef);
40573
+ if (a.type === "find_opportunity") {
40574
+ scanActions(a.found);
40575
+ scanActions(a.notFound);
40576
+ }
40577
+ }
40578
+ };
40087
40579
  const trig = w.trigger;
40088
- if (trig && typeof trig.stageRef === "string") refs2.add(trig.stageRef);
40580
+ if (trig && typeof trig.stageRef === "string") add2(trig.stageRef);
40089
40581
  scanActions(w.actions);
40090
40582
  }
40091
- return refs2;
40583
+ return uses;
40584
+ }
40585
+ function remapWorkflowStages(w, remaps) {
40586
+ const own = remaps.byWorkflow.get(String(w.ref));
40587
+ const to = (ref) => own?.get(ref) ?? remaps.global.get(ref) ?? ref;
40588
+ const walk2 = (actions) => {
40589
+ for (const a of actions ?? []) {
40590
+ if (typeof a.stageRef === "string") a.stageRef = to(a.stageRef);
40591
+ if (a.type === "find_opportunity") {
40592
+ walk2(a.found);
40593
+ walk2(a.notFound);
40594
+ }
40595
+ }
40596
+ };
40597
+ const trig = w.trigger;
40598
+ if (trig && typeof trig.stageRef === "string") trig.stageRef = to(trig.stageRef);
40599
+ walk2(w.actions);
40092
40600
  }
40093
40601
  function calendarTypeForBrief(briefType, calName, teamSize) {
40094
40602
  const mapped = BRIEF_CALENDAR_TYPE_MAP[briefType] ?? "event";
@@ -40246,12 +40754,13 @@ function planFromPreset(input) {
40246
40754
  ...omitted.length ? { omitted } : {}
40247
40755
  };
40248
40756
  })() : void 0;
40249
- const referencedStageRefs = collectReferencedStageRefs(keptWorkflowSkeletons, ctx);
40757
+ const stageUses = collectStageUses(keptWorkflowSkeletons, ctx);
40758
+ const stageRemaps = { global: /* @__PURE__ */ new Map(), byWorkflow: /* @__PURE__ */ new Map() };
40250
40759
  const users = ctx.users;
40251
40760
  const pipelines = keptByCondition(sk.pipelines, ctx).map((pl) => ({
40252
40761
  ref: String(pl.ref),
40253
40762
  name: fillString(String(pl.name), ctx),
40254
- stages: composeStages(pl.stages, ctx, referencedStageRefs)
40763
+ stages: composeStages(pl.stages, ctx, stageUses, stageRemaps)
40255
40764
  }));
40256
40765
  const customFields = keptByCondition(sk.customFields, ctx).map((f) => fillNode(f, ctx));
40257
40766
  const tags = keptByCondition(sk.tags, ctx).map((t) => fillNode(t, ctx));
@@ -40303,7 +40812,9 @@ function planFromPreset(input) {
40303
40812
  const workflows = keptWorkflowSkeletons.map((raw) => {
40304
40813
  const { actions, ...rest } = raw;
40305
40814
  const w = fillNode(rest, ctx);
40815
+ delete w.stagePreference;
40306
40816
  w.actions = composeActions(actions, ctx, templateRefByAsset);
40817
+ remapWorkflowStages(w, stageRemaps);
40307
40818
  return w;
40308
40819
  });
40309
40820
  if (scope) {
@@ -40362,6 +40873,8 @@ function planFromPreset(input) {
40362
40873
  }
40363
40874
  const handoffs = keptByCondition(sk.handoffs, ctx).map((raw) => {
40364
40875
  const h = fillNode(raw, ctx);
40876
+ if (typeof h.produces === "string") h.produces = stageRemaps.global.get(h.produces) ?? h.produces;
40877
+ if (Array.isArray(h.blocks)) h.blocks = h.blocks.map((b) => stageRemaps.global.get(b) ?? b);
40365
40878
  if (typeof h.produces === "string" && !definedRefs.has(h.produces)) delete h.produces;
40366
40879
  if (Array.isArray(h.blocks)) {
40367
40880
  h.blocks = h.blocks.filter((b) => b.endsWith(".*") || definedRefs.has(b));
@@ -40554,7 +41067,7 @@ function loadPresetChoice(requested, briefPreset) {
40554
41067
  if (generic) return { ok: true, preset: generic, file: "generic-client.preset.json", note: "Using the generic preset." };
40555
41068
  return { ok: false, error: "No usable preset file was found in the library." };
40556
41069
  }
40557
- var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, BRIEF_CALENDAR_TYPE_MAP;
41070
+ var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, TERMINAL_STAGE_WORDS, BRIEF_CALENDAR_TYPE_MAP;
40558
41071
  var init_plan_from_preset = __esm({
40559
41072
  "src/intake-to-build/plan-from-preset.ts"() {
40560
41073
  "use strict";
@@ -40571,6 +41084,28 @@ var init_plan_from_preset = __esm({
40571
41084
  };
40572
41085
  INLINE_TOKEN_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+|\[\d+\])*)\s*(?:\|\|([^}]*))?\}\}/g;
40573
41086
  ADMIN_ROLE_RE = /\b(owner|founder|admin|administrator|ceo|principal|director)\b/i;
41087
+ TERMINAL_STAGE_WORDS = [
41088
+ "lost",
41089
+ "closed",
41090
+ "won",
41091
+ "no_show",
41092
+ "noshow",
41093
+ "no_sale",
41094
+ "not_interested",
41095
+ "not_a_fit",
41096
+ "no_fit",
41097
+ "disqualified",
41098
+ "unqualified",
41099
+ "dead",
41100
+ "cancelled",
41101
+ "canceled",
41102
+ "declined",
41103
+ "dnd",
41104
+ "do_not_disturb",
41105
+ "churned",
41106
+ "lapsed",
41107
+ "archived"
41108
+ ];
40574
41109
  BRIEF_CALENDAR_TYPE_MAP = {
40575
41110
  one_on_one: "event",
40576
41111
  round_robin: "round_robin",
@@ -41958,8 +42493,8 @@ function savePlanRecord(rec2, base) {
41958
42493
  return file2;
41959
42494
  }
41960
42495
  function savedPlanMatchesScope(saved, current) {
41961
- if (!saved) return false;
41962
- if (!current || current.wholePreset) return !saved.scope;
42496
+ if (!saved || !current) return false;
42497
+ if (current.wholePreset) return !saved.scope;
41963
42498
  if (!saved.scope) return false;
41964
42499
  const omitted = (saved.scope.omitted ?? []).map((f) => f.id);
41965
42500
  const offered = /* @__PURE__ */ new Set([...saved.scope.built, ...saved.scope.notBuilt.map((f) => f.id), ...omitted]);
@@ -42764,7 +43299,7 @@ async function assessmentAttachmentAction(args, deps) {
42764
43299
  if (!Array.isArray(chosen) || chosen.some((id2) => !ASSESSMENT_FEATURES.some((f) => f.id === id2))) throw new Error("Choose features from the list.");
42765
43300
  const state2 = deps.read();
42766
43301
  deps.write({ ...state2, assessmentPurchases: { ...state2.assessmentPurchases, [id]: [...new Set(chosen)] } });
42767
- detail.message = state2.scope?.[id] ? "What they bought is saved for this client document. The build still follows the selection confirmed on the Review screen." : "What they bought is saved. Nothing is built until you confirm what to build on the Review screen, which is ticked from this.";
43302
+ detail.message = state2.scope?.[id] ? "The Assessment record is saved. The build still follows the What they bought list saved on the Blueprint Review." : "The Assessment record is saved. Nothing is built until you save What they bought on the Blueprint Review, which is ticked from this.";
42768
43303
  break;
42769
43304
  }
42770
43305
  default:
@@ -42813,12 +43348,12 @@ function assessmentPanel(id){
42813
43348
  const box=document.createElement('section');box.className='cds-stack';
42814
43349
  const text=(tag,value)=>{const el=document.createElement(tag);if(tag==='h4')el.className='cds-panel-label';el.textContent=value;box.appendChild(el);return el};
42815
43350
  const button=(label,run,disabled=false)=>{const b=text('button',label);b.className='cds-btn ghost';b.disabled=p.busy||disabled;b.onclick=run;return b};
42816
- text('h4','What they bought');
42817
- text('p','Record the features they bought for their Blueprint document.');
43351
+ text('h4','Bought in the Assessment');
43352
+ text('p','Record what they bought in your sales conversation. The What they bought list further down is ticked from this, and only that list decides what gets built.');
42818
43353
  if(p.data){
42819
43354
  for(const feature of p.data.features){const label=text('label','');label.className='cds-check';const cb=document.createElement('input');cb.type='checkbox';cb.checked=p.choices.includes(feature.id);cb.disabled=p.busy;cb.onchange=()=>{p.choices=cb.checked?[...p.choices,feature.id]:p.choices.filter(v=>v!==feature.id)};label.appendChild(cb);const span=document.createElement('span');span.textContent=feature.name;label.appendChild(span)}
42820
- button('Save what they bought',()=>assessmentRequest(id,'purchases',{purchasedFeatureIds:p.choices}));
42821
- if(p.data.purchasedFeatureIds===null)text('p','What they bought has not been recorded yet.');
43355
+ button('Save the Assessment record',()=>assessmentRequest(id,'purchases',{purchasedFeatureIds:p.choices}));
43356
+ if(p.data.purchasedFeatureIds===null)text('p','Nothing is recorded from the Assessment yet.');
42822
43357
  }
42823
43358
  text('h4','Assessment');
42824
43359
  text('p','The numbers from your sales conversation follow the client into the intake, the Review and their Blueprint.');
@@ -44713,7 +45248,7 @@ function errLine(msg3) {
44713
45248
  function preflightWritable() {
44714
45249
  try {
44715
45250
  const dir = ensureAppDataDir();
44716
- const probe = path36.join(dir, `.write-probe.${process.pid}.${(0, import_crypto2.randomBytes)(4).toString("hex")}`);
45251
+ const probe = path36.join(dir, `.write-probe.${process.pid}.${(0, import_crypto3.randomBytes)(4).toString("hex")}`);
44717
45252
  fs38.writeFileSync(probe, "ok");
44718
45253
  fs38.unlinkSync(probe);
44719
45254
  return true;
@@ -44997,14 +45532,14 @@ async function runCli(subcommand, argv) {
44997
45532
  return EXIT_USAGE2;
44998
45533
  }
44999
45534
  }
45000
- var import_node_util3, fs38, path36, import_crypto2, EXIT_OK2, EXIT_USAGE2, EXIT_VALIDATION, EXIT_FS, USAGE;
45535
+ var import_node_util3, fs38, path36, import_crypto3, EXIT_OK2, EXIT_USAGE2, EXIT_VALIDATION, EXIT_FS, USAGE;
45001
45536
  var init_cli = __esm({
45002
45537
  "src/cli.ts"() {
45003
45538
  "use strict";
45004
45539
  import_node_util3 = require("node:util");
45005
45540
  fs38 = __toESM(require("fs"));
45006
45541
  path36 = __toESM(require("path"));
45007
- import_crypto2 = require("crypto");
45542
+ import_crypto3 = require("crypto");
45008
45543
  init_ghl_client();
45009
45544
  init_token_registry();
45010
45545
  init_credentials_store();
@@ -45662,7 +46197,7 @@ function reviewDocumentHtml(review, opts) {
45662
46197
  ${review.gaps?.length ? `<div class="head">What's leaking today (${review.gaps.length})</div>${review.gaps.map(gap).join("")}` : ""}
45663
46198
  ${review.build?.length ? `<div class="head">What we're building</div>${review.build.map((b) => `<div class="build"><b>${esc14(b.item)}</b><span>${esc14(b.because)}</span></div>`).join("")}` : ""}
45664
46199
  ${opts.scope?.awaitingConfirmation ? `<div class="head">What this build includes</div>
45665
- <p>Not confirmed yet. What you bought is recorded, and the build waits until the features to build are confirmed.</p>
46200
+ <p>Not confirmed yet. The build waits until the features to build are confirmed.</p>
45666
46201
  ` : opts.scope ? `<div class="head">What this build includes</div>
45667
46202
  <p>${opts.scope.package ? `Built to your <b>${esc14(opts.scope.package)}</b> package: ` : ""}${opts.scope.built.length ? esc14(opts.scope.built.map((b) => b.name).join(", ")) : "the foundation only"}. Always included: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing.</p>
45668
46203
  ` : ""}
@@ -47136,7 +47671,7 @@ function prefixReservations(locationId2, base) {
47136
47671
  function refusal(prefix, owner) {
47137
47672
  return `The clinic prefix "${prefix}" already belongs to another client in this sub-account (client key "${owner}"). Two clients sharing a prefix would reuse each other's workflows, tags and fields, so nothing was built. Choose a different prefix for this client.`;
47138
47673
  }
47139
- function sleepSync(ms) {
47674
+ function sleepSync2(ms) {
47140
47675
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
47141
47676
  }
47142
47677
  function locationLockPath(dir, locationId2) {
@@ -47165,7 +47700,7 @@ function withLocationLock(dir, locationId2, fn, opts = {}) {
47165
47700
  age > STALE_LOCK_MS ? `A clinic prefix could not be reserved because an earlier build left this sub-account's prefix lock behind (it may have crashed), so nothing was built. If no build is running for this sub-account, delete the file ${lock} and run it again.` : "Another build in this sub-account is reserving a clinic prefix right now, so nothing was built. Run it again in a moment."
47166
47701
  );
47167
47702
  }
47168
- sleepSync(15);
47703
+ sleepSync2(15);
47169
47704
  }
47170
47705
  }
47171
47706
  try {
@@ -47244,13 +47779,19 @@ var init_clinic_prefix_registry = __esm({
47244
47779
  var dashboard_exports = {};
47245
47780
  __export(dashboard_exports, {
47246
47781
  ACCOUNT_TYPES: () => ACCOUNT_TYPES,
47782
+ APPROVAL_WITHDRAWN_FOR_SCOPE: () => APPROVAL_WITHDRAWN_FOR_SCOPE,
47783
+ CHOOSE_BEFORE_BUILD: () => CHOOSE_BEFORE_BUILD,
47784
+ CHOOSE_WHAT_THEY_BOUGHT: () => CHOOSE_WHAT_THEY_BOUGHT,
47247
47785
  CLAUDE_REPAIR_MESSAGES: () => CLAUDE_REPAIR_MESSAGES,
47248
47786
  INTAKE_CONNECT_REQUIRED: () => INTAKE_CONNECT_REQUIRED,
47249
47787
  INTAKE_FORM_REQUIRED: () => INTAKE_FORM_REQUIRED,
47250
47788
  INTAKE_OWN_MESSAGES: () => INTAKE_OWN_MESSAGES,
47251
47789
  INTAKE_UNREADABLE: () => INTAKE_UNREADABLE,
47790
+ NOTHING_TICKED_IN_PLAN: () => NOTHING_TICKED_IN_PLAN,
47252
47791
  NOT_SIGNED_IN_MSG: () => NOT_SIGNED_IN_MSG,
47253
47792
  PARKED_NOT_BUILT: () => PARKED_NOT_BUILT,
47793
+ SCOPE_CHANGED_SINCE_APPROVAL: () => SCOPE_CHANGED_SINCE_APPROVAL,
47794
+ SCOPE_REFUSAL: () => SCOPE_REFUSAL,
47254
47795
  SCREEN_HEADS: () => SCREEN_HEADS,
47255
47796
  SEAT_GUARDED_READS: () => SEAT_GUARDED_READS,
47256
47797
  SEAT_TOKEN_HEADER: () => SEAT_TOKEN_HEADER,
@@ -47278,6 +47819,7 @@ __export(dashboard_exports, {
47278
47819
  ensureEngineReady: () => ensureEngineReady,
47279
47820
  forgetAnthropicKey: () => forgetAnthropicKey,
47280
47821
  guarded: () => guarded,
47822
+ hasChosenScope: () => hasChosenScope,
47281
47823
  insideDir: () => insideDir,
47282
47824
  intakeInput: () => intakeInput,
47283
47825
  intakeRequestError: () => intakeRequestError,
@@ -47289,10 +47831,13 @@ __export(dashboard_exports, {
47289
47831
  loadingMainHtml: () => loadingMainHtml,
47290
47832
  mayServeSeat: () => mayServeSeat,
47291
47833
  memberFacingError: () => memberFacingError,
47834
+ mergeQueueIntakes: () => mergeQueueIntakes,
47292
47835
  newClientFailure: () => newClientFailure,
47293
47836
  normalizedIntake: () => normalizedIntake,
47837
+ offeredFeaturesFor: () => offeredFeaturesFor,
47294
47838
  overlayPath: () => overlayPath,
47295
47839
  prefillTeamAnswers: () => prefillTeamAnswers,
47840
+ queueScanFilter: () => queueScanFilter,
47296
47841
  readBody: () => readBody,
47297
47842
  readClientStages: () => readClientStages,
47298
47843
  readCockpitState: () => readCockpitState,
@@ -47304,9 +47849,11 @@ __export(dashboard_exports, {
47304
47849
  recoverStuckStages: () => recoverStuckStages,
47305
47850
  renderCockpitHtml: () => renderCockpitHtml,
47306
47851
  reportSeatAllowed: () => reportSeatAllowed,
47852
+ rereadQueueIntakes: () => rereadQueueIntakes,
47307
47853
  resolvePlan: () => resolvePlan2,
47308
47854
  runDashboard: () => runDashboard,
47309
47855
  safeDocName: () => safeDocName,
47856
+ scopeHashOf: () => scopeHashOf,
47310
47857
  scopePrefillFor: () => scopePrefillFor,
47311
47858
  secondClientInputHash: () => secondClientInputHash,
47312
47859
  setAccountIndustry: () => setAccountIndustry,
@@ -47388,6 +47935,15 @@ function setAccountIndustry(state, locationId2, industry) {
47388
47935
  if (!next.industry) delete next.industry;
47389
47936
  return { ...state, accounts: { ...state.accounts ?? {}, [locationId2]: next } };
47390
47937
  }
47938
+ function scopeHashOf(sel) {
47939
+ if (!sel) return contentHash({ none: true });
47940
+ if (sel.wholePreset) return contentHash({ wholePreset: true });
47941
+ return contentHash({ package: sel.package ?? "", features: [...new Set((sel.features ?? []).filter(isFeatureId))].sort() });
47942
+ }
47943
+ function hasChosenScope(state, recordKey) {
47944
+ const sel = state.scope?.[recordKey];
47945
+ return Boolean(sel && (sel.wholePreset || Array.isArray(sel.features) && sel.features.some(isFeatureId)));
47946
+ }
47391
47947
  function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ new Date()) {
47392
47948
  const all = { ...state.scope ?? {} };
47393
47949
  if (!scope) {
@@ -47403,16 +47959,26 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
47403
47959
  if (!Object.keys(all).length) delete next.scope;
47404
47960
  return next;
47405
47961
  }
47406
- function documentScopeFor(state, locationId2) {
47407
- const sc = state.scope?.[locationId2];
47408
- if (!sc && Array.isArray(state.assessmentPurchases?.[locationId2])) return { awaitingConfirmation: true, built: [], notBuilt: [] };
47409
- let offered = FEATURE_IDS;
47962
+ function offeredFeaturesFor(state, recordKey) {
47963
+ const locationId2 = recordKey.split("--")[0];
47410
47964
  try {
47411
47965
  const chosen = loadPresetChoice(void 0, presetForIndustry(state.accounts?.[locationId2]?.industry));
47412
- if (chosen.ok) offered = featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
47966
+ if (chosen.ok) return featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
47413
47967
  } catch {
47414
47968
  }
47415
- if (!sc || sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
47969
+ return FEATURE_IDS;
47970
+ }
47971
+ function documentScopeFor(state, locationId2, savedPlan) {
47972
+ const sc = state.scope?.[locationId2];
47973
+ const offered = offeredFeaturesFor(state, locationId2);
47974
+ if (!sc) {
47975
+ const outcome = state.outcomes?.[`${locationId2}:3`];
47976
+ const built = state.clients?.[locationId2]?.stages?.[3] === "done" || outcome?.ok === true;
47977
+ const wholePresetPlan = Boolean(savedPlan && !savedPlan.scope && typeof savedPlan.plan?.preset === "string");
47978
+ if (built && wholePresetPlan) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
47979
+ return { awaitingConfirmation: true, built: [], notBuilt: [] };
47980
+ }
47981
+ if (sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
47416
47982
  return {
47417
47983
  ...sc.package ? { package: sc.package } : {},
47418
47984
  // Built = the confirmed scope, the one record the build follows. The purchase
@@ -47587,6 +48153,12 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
47587
48153
  run.lines.push(`Could not write this run to the cost record: ${e instanceof Error ? e.message : String(e)}. The step itself is unaffected.`);
47588
48154
  }
47589
48155
  for (const line3 of costLines(outcome)) run.lines.push(line3);
48156
+ const scopeRefusal = stage === 3 && !outcome.ok && typeof outcome.error === "string" && SCOPE_REFUSAL.test(outcome.error);
48157
+ if (scopeRefusal) {
48158
+ outcome.summary = outcome.error;
48159
+ outcome.issues = [];
48160
+ outcome.plain = { summary: outcome.error, issues: [] };
48161
+ }
47590
48162
  if (stage === 3 && !outcome.summary && !outcome.issues?.length) {
47591
48163
  try {
47592
48164
  const st = readCockpitState();
@@ -47600,8 +48172,8 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
47600
48172
  const st = readCockpitState();
47601
48173
  writeCockpitState({ ...st, outcomes: { ...st.outcomes ?? {}, [`${rk}:${stage}`]: { ok: outcome.ok, error: outcome.error, summary: outcome.summary, issues: outcome.issues, ...plain && { plain }, at, inputHash: runInputHash, ...stage === 4 ? { buildHash: runBuildHash, verification: verifyRaw?.verification } : {}, hash: contentHash({ inputHash: runInputHash, at, ok: outcome.ok, summary: outcome.summary, issues: outcome.issues }) } } });
47602
48174
  };
47603
- save();
47604
- if (PLAIN_REPORT_STAGES.has(stage)) {
48175
+ save(scopeRefusal ? outcome.plain : void 0);
48176
+ if (PLAIN_REPORT_STAGES.has(stage) && !scopeRefusal) {
47605
48177
  run.lines.push("Writing the plain-English version of what came back\u2026");
47606
48178
  const plain = await rewriteOutcomePlain(STAGES2[stage], verifyRaw ?? outcome, { onEvent: (l) => run.lines.push(l), onCost: (costUsd) => {
47607
48179
  recordRunCost({ at, locationId: locationId2, stage, kind: "plain rewrite", ok: true, costUsd });
@@ -47808,6 +48380,40 @@ function logQuietly(message) {
47808
48380
  } catch {
47809
48381
  }
47810
48382
  }
48383
+ function queueScanFilter(body) {
48384
+ try {
48385
+ const b = body ? JSON.parse(body) : null;
48386
+ if (b && typeof b === "object" && b.only === "pending") {
48387
+ const open2 = b.open;
48388
+ return { only: "pending", ...typeof open2 === "string" && open2 ? { open: open2 } : {} };
48389
+ }
48390
+ } catch {
48391
+ }
48392
+ return void 0;
48393
+ }
48394
+ async function rereadQueueIntakes(opts) {
48395
+ const reread = {};
48396
+ const failed = [];
48397
+ for (const { locationId: locationId2 } of opts.roster) {
48398
+ if (opts.state.clients[locationId2]?.stages?.[0] !== "done") continue;
48399
+ if (opts.filter?.only === "pending" && locationId2 !== opts.filter.open && opts.state.intakeInputs?.[locationId2]?.status === "received") continue;
48400
+ try {
48401
+ const got = await opts.read(locationId2);
48402
+ if (got === null) continue;
48403
+ if (got === "unavailable") {
48404
+ failed.push(locationId2);
48405
+ continue;
48406
+ }
48407
+ reread[locationId2] = got;
48408
+ } catch {
48409
+ failed.push(locationId2);
48410
+ }
48411
+ }
48412
+ return { reread, failed };
48413
+ }
48414
+ function mergeQueueIntakes(latest, reread) {
48415
+ return { ...latest, intakeInputs: { ...latest.intakeInputs ?? {}, ...reread } };
48416
+ }
47811
48417
  function readBody(req, res, max, onDone, tooLarge = "That request is too large.", sensitive = false) {
47812
48418
  let body = "";
47813
48419
  let tooBig = false;
@@ -48041,7 +48647,7 @@ const LABELS=["Intake","Brief","Approve","Build","Verify","Manual tasks","Go-liv
48041
48647
  // What each step does: [the one line on its row, the fuller sentence in the next-step panel].
48042
48648
  const STEP_ABOUT=[["Creates the intake form, its fields and one notification workflow.","Creates or repairs this client's intake form, its custom fields and one published notification workflow."],
48043
48649
  ["Reads the intake into a brief.","Reads this client's intake, from the submitted form or the one completed here, and turns it into the brief the plan is made from."],
48044
- ["You approve the proposed build.","Review the intake and the brief, then approve the proposed build for this client."],
48650
+ ["You choose what they bought, then approve the build.","Choose what the client bought, review the intake and the brief, then approve the build for this client. Only what they bought gets built."],
48045
48651
  ["Builds the approved plan into this account.","Builds the approved plan into this client's account. Anything it cannot build is listed as an issue."],
48046
48652
  ["Checks the build for broken links.","Checks the account for broken links between workflows, pipelines and fields. It only reads. Anything it cannot check is reported as unchecked."],
48047
48653
  ["Jobs only a person can do. You confirm them.","The jobs only a person can do, such as text-message registration, Google sign-in, staff and phone. Confirm them when they are finished."],
@@ -48095,16 +48701,80 @@ function listRows(rows,key,noun){const frag=document.createDocumentFragment();co
48095
48701
  function unnest(el){if(el&&el.classList){el.classList.remove("cds-panel","cds-card");el.classList.add("cds-stack")}return el}
48096
48702
  function typeOf(state,id){const sandboxes=state.sandboxAccounts||Object.fromEntries(SANDBOX.map(id=>[id,true]));if(sandboxes[id])return "sandbox";const t=(state.accounts&&state.accounts[id]&&state.accounts[id].type);return t==="sandbox"?"client":(t||"client")}
48097
48703
  function activityScore(state,id){const c=state.clients[id];if(!c)return 0;return c.stages.some(s=>s!=="pending")?1:0}
48098
- async function load(){if(SCREEN==="new"&&canCreateSubAccounts){const probe=await(await clientPageRequest("/api/agency-connection")).json();agencyReady=probe.ok===true}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:[];snapshotError=sr.ok?"":clientPageError(sr.error,"Check the agency key in GoHighLevel, then refresh the snapshot list.")}catch(e){snapshots=[];snapshotError="Could not load snapshots; check the connection and refresh this page."}}render(s,r);
48099
- 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)}}
48704
+ async function load(quiet,freshQueue){if(SCREEN==="new"&&canCreateSubAccounts){const probe=await(await clientPageRequest("/api/agency-connection")).json();agencyReady=probe.ok===true}/* The queue first: a re-reading POST can take a minute, and state fetched before it would draw stale (review of c4b75c9). */
48705
+ await loadQueue(quiet===true?(freshQueue===true?"pending":"get"):undefined);const s=await(await fetch("/api/state")).json();const r=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();if(SCREEN==="new"&&!snapshots){try{const sr=await(await fetch("/api/snapshots")).json();snapshots=sr.ok?sr.snapshots:[];snapshotError=sr.ok?"":clientPageError(sr.error,"Check the agency key in GoHighLevel, then refresh the snapshot list.")}catch(e){snapshots=[];snapshotError="Could not load snapshots; check the connection and refresh this page."}}/* A background refresh (quiet) draws only when the data changed (2026-09-29: an unchanged redraw closed what Jerry had open). */
48706
+ if(!(quiet===true&&dataSig(s,r)===lastDrawnSig))render(s,r);hideFinishNote();
48707
+ if(r.running&&!polling){polling=true;const t=setInterval(async()=>{let rr;try{rr=await fetchRunStatus()}catch(e){return}
48708
+ if(!rr.running){clearInterval(t);polling=false}
48709
+ if(!shouldRedraw(lastRun,rr))return;
48710
+ try{await loadQueue("get");const ss=await(await fetch("/api/state")).json();if(dataSig(ss,rr)!==lastDrawnSig)render(ss,rr);hideFinishNote()}catch(e){}},1500)}}
48100
48711
  // Heartbeat (2026-08-25): the fast poller above only starts for a run THIS page saw begin. A page that was
48101
48712
  // already open, or opened mid-run, or a run kicked off from another seat/the API, showed "pending" until a
48102
48713
  // manual reload . a client watching would conclude the build died. Every 3s: if a run is live, hand over to
48103
48714
  // the fast poller; every ~15s otherwise, refresh state so stages finished elsewhere flip to done.
48104
- // Never rebuild the page under the operator's hands: while a form field has focus or an intake panel is
48105
- // open, the heartbeat waits. (2026-08-25: the first heartbeat threw Jerry out of the intake field every refresh.)
48106
- function userIsBusy(){if(SCREEN==="new")return true;const a=document.activeElement;if(a&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))return true;try{if(Object.values(iOpen||{}).some(Boolean))return true}catch(e){}return false}
48107
- let hbTick=0;setInterval(async()=>{if(polling||userIsBusy())return;hbTick++;try{const rr=await(await fetch("/api/run/status")).json();if(rr.running||hbTick%5===0)load()}catch{}},3000);
48715
+ // Never rebuild the page under the operator's hands: while a form field has focus the redraw waits,
48716
+ // and happens on focusout. (2026-08-25: the first heartbeat threw Jerry out of the intake field every refresh.)
48717
+ // Stale "Running" fix (2026-09-29 rehearsal): Brief finished on the server while the page showed
48718
+ // "Running now, working 5m" until a manual reload. The old guard also skipped the status FETCH whenever an
48719
+ // intake panel was open, and the clock ticks on the page alone, so a frozen page looked alive. Now:
48720
+ // the status is ALWAYS fetched; only the full redraw waits, and only while an input has focus; an open intake
48721
+ // panel alone never blocks it (its typed answers, pasted material and prepared-form link live in state, so a
48722
+ // redraw keeps them). A running-to-finished change always redraws, or, with an input focused, shows a bar
48723
+ // that says so. Returning to the tab refreshes. A status older than 15s freezes the clock on "checking\u2026".
48724
+ let lastStatusOkAt=0,redrawPending=false,pointerIsDown=false,flushTimer=0;
48725
+ /* Intake work in flight for a client (Fill the intake from this, a document upload, send, prepare, save): a redraw
48726
+ then would re-enable its button and drop its message, so it waits like a focused field. */
48727
+ let iBusy={};
48728
+ function intakeWorkBusy(){try{return Object.values(iBusy).some(n=>n>0)}catch(e){return false}}
48729
+ /* A counter, so two requests for one client overlapping never clear each other. */
48730
+ async function iWork(id,fn){iBusy[id]=(iBusy[id]||0)+1;try{return await fn()}finally{iBusy[id]=Math.max(0,(iBusy[id]||0)-1);if(redrawPending)scheduleFlush()}}
48731
+ function inputFocused(){const a=document.activeElement;return Boolean(a&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))}
48732
+ /* Holds back the full redraw, never the status fetch. */
48733
+ function userIsBusy(){if(SCREEN==="new")return true;return inputFocused()||intakeWorkBusy()}
48734
+ function intakeIsOpen(){try{return Object.values(iOpen||{}).some(Boolean)}catch(e){return false}}
48735
+ async function fetchRunStatus(){const rr=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();return rr}
48736
+ function statusIsStale(){return !lastStatusOkAt||Date.now()-lastStatusOkAt>15000}
48737
+ function runKey(r){return r?[r.running?1:0,r.locationId||"",r.stage==null?"":r.stage,r.capability||"",r.startedAt||"",r.reviewing?1:0,r.ok==null?"":r.ok?1:0].join("|"):""}
48738
+ function runJustFinished(prev,next){return Boolean(prev&&prev.running&&next&&!next.running)}
48739
+ function stepName(r){return r&&!r.capability&&LABELS[r.stage]?LABELS[r.stage]:"The step"}
48740
+ /* Decides whether a fresh status redraws the page now. A finished run always does, unless an input has focus:
48741
+ then the bar tells the operator and the redraw follows on focusout. With an intake panel open the page
48742
+ redraws only when the run itself changed (started, moved stage, finished), not on every tick. */
48743
+ function shouldRedraw(prev,next){const fin=runJustFinished(prev,next);
48744
+ if(userIsBusy()){if(SCREEN==="new")return false;if(fin)showFinishNote(prev);redrawPending=true;return false}
48745
+ if(fin)return true;
48746
+ if(intakeIsOpen())return runKey(prev)!==runKey(next);
48747
+ return true}
48748
+ function showFinishNote(prev){let b=document.getElementById("run-finished-note");
48749
+ if(!b){b=document.createElement("button");b.id="run-finished-note";b.type="button";b.className="cds-callout info";b.setAttribute("role","status");
48750
+ b.style.cssText="position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:50;max-width:calc(100% - 32px);cursor:pointer";
48751
+ b.onclick=()=>{redrawPending=false;load(true).catch(()=>{})};document.body.appendChild(b)}
48752
+ b.textContent=stepName(prev)+" finished. Click to see the result."}
48753
+ function hideFinishNote(){const b=document.getElementById("run-finished-note");if(b)b.remove()}
48754
+ /* One retry timer at a time: focusout, pointer releases and finished intake work all ask, only one waits. */
48755
+ function scheduleFlush(){if(flushTimer)return;flushTimer=setTimeout(()=>{flushTimer=0;flushRedraw()},300)}
48756
+ function flushRedraw(){if(!redrawPending)return;if(SCREEN==="new"){redrawPending=false;return}if(userIsBusy()||pointerIsDown){scheduleFlush();return}redrawPending=false;load(true).catch(()=>{})}
48757
+ document.addEventListener("pointerdown",()=>{pointerIsDown=true},true);
48758
+ document.addEventListener("pointerup",()=>{pointerIsDown=false},true);
48759
+ /* A touch that is cancelled, or a release outside the window, never sends pointerup. */
48760
+ document.addEventListener("pointercancel",()=>{pointerIsDown=false},true);
48761
+ window.addEventListener("blur",()=>{pointerIsDown=false});
48762
+ /* Focus leaving an input: a held redraw happens now (after the field's change event has saved its value). */
48763
+ document.addEventListener("focusout",()=>{if(redrawPending)scheduleFlush()});
48764
+ /* Coming back to the tab or the window: fetch at once, rather than trusting what the page last drew. */
48765
+ function refreshOnReturn(){if(userIsBusy()){if(SCREEN!=="new")redrawPending=true;fetchRunStatus().then(rr=>{if(runJustFinished(lastRun,rr)&&SCREEN!=="new")showFinishNote(lastRun)}).catch(()=>{});return}load(true,true).catch(()=>{})}
48766
+ /* Focus and visibilitychange usually fire together on a return: one refresh, not two. */
48767
+ let returnTimer=0;function onReturn(){if(returnTimer)return;returnTimer=setTimeout(()=>{returnTimer=0;refreshOnReturn()},250)}
48768
+ document.addEventListener("visibilitychange",()=>{if(document.visibilityState==="visible")onReturn()});
48769
+ window.addEventListener("focus",onReturn);
48770
+ /* The GET queue shows intake answers as last read; every 4th heartbeat load (about a minute) and a return to the
48771
+ tab use the POST, which re-reads each client's intake, so a submitted or edited intake shows without a reload. */
48772
+ let hbTick=0,hbLoads=0;setInterval(async()=>{if(polling)return;hbTick++;let rr;try{rr=await fetchRunStatus()}catch(e){return}
48773
+ const changed=runKey(rr)!==runKey(lastRun);
48774
+ if(!(rr.running||changed||hbTick%5===0))return;
48775
+ if(userIsBusy()){if(changed)shouldRedraw(lastRun,rr);return}
48776
+ if(intakeIsOpen()&&!changed&&!(rr.running&&!polling))return;
48777
+ hbLoads++;load(true,hbLoads%4===0).catch(()=>{})},3000);
48108
48778
  let intakeOpen=false,intakeData=null,intakeIndustry="";
48109
48779
  async function loadIntake(){const r=await(await clientPageRequest("/api/intake?industry="+encodeURIComponent(intakeIndustry))).json();intakeData=r;render(lastState,lastRun)}
48110
48780
  async function saveOverlay(o){const r=await(await clientPageRequest("/api/overlay",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();if(!r.ok){showClientError(r.error);return}await loadIntake()}
@@ -48145,6 +48815,25 @@ function intakePanel(){const d=document.createElement("div");d.className="cds-pa
48145
48815
  d.appendChild(reset);
48146
48816
  return d}
48147
48817
  let iOpen={},iData={},iMode={},iAnswers={},iDirty={},iIdx={},iPre={},iSub={},oOpen={},iDocs={},iDocsErrors={},iPreSrc={};
48818
+ /* What the operator typed into the intake's free boxes (pasted transcript, link, recorder note, the send
48819
+ form) and the prepared-form link live here, not only in the DOM, so a redraw while the intake is open
48820
+ keeps them (2026-09-29: an open intake no longer blocks the page from showing a finished step). */
48821
+ let iDraft={},iPrepared={};
48822
+ function preparedFormBlock(box,id){const p=iPrepared[id];
48823
+ const openLink=document.createElement("a");openLink.className="cds-link";openLink.href=p.url;openLink.target="_blank";openLink.rel="noopener";openLink.textContent="Open the prepared form";box.appendChild(openLink);
48824
+ const hint=document.createElement("p");hint.className="muted";
48825
+ const left=p.leftovers||[];
48826
+ hint.textContent=left.length
48827
+ ?"Use Open the prepared form above to review your answers, except "+left.length+" listed below. Handle each as noted there, then click Submit and come back and press Refresh:"
48828
+ :"Use Open the prepared form above, submit it, then return and press Refresh.";
48829
+ box.appendChild(hint);
48830
+ if(left.length){const ul=document.createElement("ul");ul.className="muted";
48831
+ const WHY={"several answers":"pick each of these in the form","contains a plus sign":"type this in the form","no field for this question":"the form has no question for this, so it was not carried. Add it as a note on the contact after you submit"};
48832
+ for(const l of left){const li=document.createElement("li");li.textContent=l.label+": "+l.values.join(", ")+". "+(WHY[l.reason]||l.reason)+".";ul.appendChild(li)}
48833
+ box.appendChild(ul)}
48834
+ const rf=document.createElement("button");rf.className="cds-btn ghost";rf.textContent="Refresh status";
48835
+ rf.onclick=()=>iLoad(id);box.appendChild(rf)}
48836
+ function keepDraft(el,id,name){const k=id+":"+name;if(iDraft[k]!=null)el.value=iDraft[k];el.addEventListener("input",()=>{iDraft[k]=el.value});return el}
48148
48837
  async function iDocsLoad(id){const r=await(await clientPageRequest("/api/intake-documents?locationId="+encodeURIComponent(id))).json();iDocs[id]=(r&&r.files)||[];iDocsErrors[id]=r.ok?"":clientPageError(r.error,"Refresh the page to load this client\u2019s documents again.");render(lastState,lastRun)}
48149
48838
  // Show the plain outcome here. The separate Step progress panel shows member-facing progress.
48150
48839
  function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc)return null;
@@ -48174,7 +48863,7 @@ function iEdit(id,label,value){(iAnswers[id]||(iAnswers[id]={}))[label]=value;(i
48174
48863
  function iApplyProposals(id,proposals){const applied=[];const answers=iAnswers[id]||(iAnswers[id]={});for(const [label,value] of Object.entries(proposals||{})){if(answers[label]&&JSON.stringify(answers[label])!==JSON.stringify(value)){if(!confirm(label+String.fromCharCode(10)+"Current: "+answers[label]+String.fromCharCode(10)+"Proposed: "+value+String.fromCharCode(10)+"Replace this answer?"))continue}iEdit(id,label,value);applied.push(label)}return applied}
48175
48864
  async function iLoad(id){rememberClient(id);const r=await(await clientPageRequest("/api/client-intake?locationId="+id)).json();iData[id]=r;
48176
48865
  // Only unsaved edits override the newest server submission, including edits made before Connect.
48177
- if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits}}render(lastState,lastRun)}
48866
+ if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits};if(r.status==="received")delete iPrepared[id]}render(lastState,lastRun)}
48178
48867
  let iFieldN=0;
48179
48868
  function iField(id,q,val,prefilled){const w=document.createElement("div");w.className="cds-field ifield"+(prefilled?" prefilled":"");
48180
48869
  const fid="ifield-"+(++iFieldN);
@@ -48222,14 +48911,14 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48222
48911
  box.appendChild(ch);
48223
48912
  const mode=iMode[id];
48224
48913
  if(mode==="send"&&hasForm){const w=document.createElement("div");w.className="cds-stack";
48225
- const em=document.createElement("input");em.placeholder="client@business.com";em.className="cds-input";em.setAttribute("aria-label","Client email");
48226
- const fn=document.createElement("input");fn.placeholder="Their first name (optional)";fn.className="cds-input";fn.setAttribute("aria-label","Client first name");
48227
- const ag=document.createElement("input");ag.placeholder="Sign it from\u2026 (your name / agency)";ag.className="cds-input";ag.setAttribute("aria-label","Signed from");
48914
+ const em=document.createElement("input");em.placeholder="client@business.com";em.className="cds-input";em.setAttribute("aria-label","Client email");keepDraft(em,id,"email");
48915
+ const fn=document.createElement("input");fn.placeholder="Their first name (optional)";fn.className="cds-input";fn.setAttribute("aria-label","Client first name");keepDraft(fn,id,"firstName");
48916
+ const ag=document.createElement("input");ag.placeholder="Sign it from\u2026 (your name / agency)";ag.className="cds-input";ag.setAttribute("aria-label","Signed from");keepDraft(ag,id,"signedFrom");
48228
48917
  const b=document.createElement("button");b.className="cds-btn";b.textContent="Send the questionnaire";
48229
48918
  b.onclick=async()=>{if(!em.value.trim())return showClientError("Enter the client\u2019s email.",box);if(!confirm("Creates a contact for "+em.value.trim()+" in "+name+" and sends one email from "+(d.sender||"the sender configured in this client\u2019s GoHighLevel account")+". Sender not verified. Save a manual task to confirm the sender and send in GoHighLevel?"))return;b.disabled=true;b.textContent="Saving the task\u2026";
48230
- const r=await(await clientPageRequest("/api/intake-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,email:em.value.trim(),firstName:fn.value.trim(),agencyName:ag.value.trim(),confirmed:true})})).json();
48919
+ const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,email:em.value.trim(),firstName:fn.value.trim(),agencyName:ag.value.trim(),confirmed:true})})).json());
48231
48920
  if(r.manual){showClientError(r.error,box);const a=document.createElement("a");a.href="https://app.gohighlevel.com/v2/location/"+encodeURIComponent(id)+"/conversations/conversations";a.target="_blank";a.rel="noopener";a.textContent="Open this client\u2019s conversations to confirm the sender and send the questionnaire";box.appendChild(a);b.disabled=false;b.textContent="Send the questionnaire";return}
48232
- if(r.ok){iMode[id]=null;await iLoad(id)}else{showClientError(r.error,box);b.disabled=false;b.textContent="Send the questionnaire"}};
48921
+ if(r.ok){for(const n of ["email","firstName","signedFrom"])delete iDraft[id+":"+n];iMode[id]=null;await iLoad(id)}else{showClientError(r.error,box);b.disabled=false;b.textContent="Send the questionnaire"}};
48233
48922
  w.appendChild(em);w.appendChild(fn);w.appendChild(ag);w.appendChild(b);
48234
48923
  const note=document.createElement("p");note.className="muted";
48235
48924
  note.textContent="Command OS cannot verify this account\u2019s sender. This button records a manual task to confirm the sender and send the questionnaire in GoHighLevel.";w.appendChild(note);box.appendChild(w)}
@@ -48241,9 +48930,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48241
48930
  tb.onclick=()=>{iSub[id]=k;render(lastState,lastRun)};tabs.appendChild(tb)}
48242
48931
  w.appendChild(tabs);
48243
48932
  const ta=document.createElement("textarea");ta.rows=6;ta.placeholder="Paste your discovery-call transcript (Plaud, Zoom, Otter, notes), or drop a .txt/.vtt/.srt file here";
48244
- ta.className="cds-textarea";ta.setAttribute("aria-label","Transcript");
48933
+ ta.className="cds-textarea";ta.setAttribute("aria-label","Transcript");keepDraft(ta,id,"transcript");
48245
48934
  ta.ondragover=(e)=>{e.preventDefault();ta.style.borderColor="var(--focus)"};
48246
- ta.ondrop=async(e)=>{e.preventDefault();ta.style.borderColor="";const f=e.dataTransfer.files[0];if(f){if(!/[.](txt|md|csv|vtt|srt)$/i.test(f.name))return showClientError("Use plain text, .vtt or .srt.",box);ta.value=await f.text()}};
48935
+ ta.ondrop=async(e)=>{e.preventDefault();ta.style.borderColor="";const f=e.dataTransfer.files[0];if(f){if(!/[.](txt|md|csv|vtt|srt)$/i.test(f.name))return showClientError("Use plain text, .vtt or .srt.",box);ta.value=await f.text();iDraft[id+":transcript"]=ta.value}};
48247
48936
  const b=document.createElement("button");b.className="cds-btn";b.textContent="Fill the intake from this";
48248
48937
  const st=document.createElement("p");st.className="muted";
48249
48938
  b.onclick=async()=>{
@@ -48256,10 +48945,10 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48256
48945
  st.textContent=sub==="paste"?"This text goes to your own Claude account to be read, and nowhere else."
48257
48946
  :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."
48258
48947
  :"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.";
48259
- const r=await(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json();
48948
+ const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json());
48260
48949
  b.disabled=false;b.textContent="Fill the intake from this";
48261
48950
  if(!r.ok){st.textContent=clientPageError(r.error,"Check the source and try reading it again.");return}
48262
- if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";
48951
+ if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";for(const n of ["transcript","url","recorder"])delete iDraft[id+":"+n];
48263
48952
  /* Codex F2 (2026-09-06): a HELD answer (the words were said, the value was worked out) is a
48264
48953
  suggestion beside the field, never an answer. It reaches iAnswers only when the operator
48265
48954
  clicks "Use it". Unverified answers (read by their Claude, not pasted here) are answers,
@@ -48270,9 +48959,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48270
48959
  iNote[id]=(heldKeys.length?heldKeys.length+" answer"+(heldKeys.length===1?" was":"s were")+" worked out rather than heard and NOT filled in: "+heldKeys.join(", ")+". Each is shown beside its field with a Use it button. ":"")+(unv.length?unv.length+" could not be checked against the material because your Claude read it rather than you pasting it here: "+unv.join(", ")+". Check them.":"");
48271
48960
  iMode[id]="fill";render(lastState,lastRun)};
48272
48961
  const urlIn=document.createElement("input");urlIn.placeholder="https://\u2026 a shared transcript or recording page";
48273
- urlIn.className="cds-input";urlIn.setAttribute("aria-label","Link to the transcript or recording");
48962
+ urlIn.className="cds-input";urlIn.setAttribute("aria-label","Link to the transcript or recording");keepDraft(urlIn,id,"url");
48274
48963
  const recIn=document.createElement("input");recIn.placeholder='e.g. "grab my Plaud recording from this morning with Radiance Med Spa"';
48275
- recIn.className="cds-input";recIn.setAttribute("aria-label","Which recording to use");
48964
+ recIn.className="cds-input";recIn.setAttribute("aria-label","Which recording to use");keepDraft(recIn,id,"recorder");
48276
48965
  const hint=document.createElement("p");hint.className="muted";
48277
48966
  if(iSub[id]==="paste"){w.appendChild(ta);hint.textContent="Plain text, .vtt and .srt only. The limit is 60,000 characters.";w.appendChild(hint)}
48278
48967
  else if(iSub[id]==="doc"){
@@ -48287,7 +48976,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48287
48976
  const up=async(f)=>{if(!f)return;if(f.size>20*1024*1024){showClientError("That file is over 20 MB.",box,"Split it or export the part that matters.");return}
48288
48977
  drop.textContent="Saving "+f.name+"\u2026";
48289
48978
  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)});
48290
- const r=await(await clientPageRequest("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json();
48979
+ const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json());
48291
48980
  if(!r.ok){showClientError(r.error||"Could not save that file",box);drop.textContent="Drop plain text, .vtt or .srt here, or choose a file. The limit is 60,000 characters.";return}
48292
48981
  iDocs[id]=r.files||[];render(lastState,lastRun)};
48293
48982
  drop.ondragover=(e)=>{e.preventDefault();drop.style.outline="2px solid var(--focus)"};
@@ -48301,7 +48990,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48301
48990
  const nm=document.createElement("span");nm.className="ttl";nm.textContent=f.name;nm.appendChild(mk("span","sub",Math.max(1,Math.round(f.bytes/1024))+" KB"));row.appendChild(nm);
48302
48991
  const rb=document.createElement("button");rb.className="cds-btn ghost";rb.textContent="Fill the intake from this";
48303
48992
  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.";
48304
- const r=await(await clientPageRequest("/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();
48993
+ const r=await iWork(id,async()=>(await clientPageRequest("/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());
48305
48994
  rb.disabled=false;rb.textContent="Fill the intake from this";
48306
48995
  if(!r.ok){st.textContent=clientPageError(r.error,"Check the source and try reading it again.");return}
48307
48996
  if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]="the document";iHeld[id]=r.held||{};iUnv[id]=r.unverified||[];const heldKeys=Object.keys(iHeld[id]),unv=iUnv[id];iNote[id]=(heldKeys.length?heldKeys.length+" answer"+(heldKeys.length===1?" was":"s were")+" worked out rather than heard and NOT filled in: "+heldKeys.join(", ")+". Each is shown beside its field with a Use it button. ":"")+(unv.length?unv.length+" could not be checked against the material because your Claude read it rather than you pasting it here: "+unv.join(", ")+". Check them.":"");iMode[id]="fill";render(lastState,lastRun)};
@@ -48342,60 +49031,67 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48342
49031
  save.textContent="Review and submit the real form";save.disabled=Boolean(d.connectionPending);
48343
49032
  save.title="Opens this client's actual GHL form with your available answers filled in. One click submits it, so it lands as a genuine form submission.";
48344
49033
  save.onclick=async()=>{const missing=d.questions.filter(q=>q.required&&(!(iAnswers[id]||{})[q.label]||(Array.isArray(iAnswers[id][q.label])&&!iAnswers[id][q.label].length)));if(missing.length&&!confirm("Required answers still missing: "+missing.map(q=>q.label).join(", ")+". Continue to the real form?"))return;save.disabled=true;save.textContent="Preparing the form\u2026";
48345
- const r=await(await clientPageRequest("/api/intake-prefill-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:iAnswers[id]||{},contact:iContact(id,d.questions)})})).json();
49034
+ const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:iAnswers[id]||{},contact:iContact(id,d.questions)})})).json());
48346
49035
  save.disabled=false;save.textContent="Review and submit the real form";
48347
49036
  if(!r.ok){showClientError(r.error,box);return}
48348
- const openLink=document.createElement("a");openLink.className="cds-link";openLink.href=r.url;openLink.target="_blank";openLink.rel="noopener";openLink.textContent="Open the prepared form";box.appendChild(openLink);
48349
- const hint=document.createElement("p");hint.className="muted";
48350
- const left=r.leftovers||[];
48351
- hint.textContent=left.length
48352
- ?"Use Open the prepared form above to review your answers, except "+left.length+" listed below. Handle each as noted there, then click Submit and come back and press Refresh:"
48353
- :"Use Open the prepared form above, submit it, then return and press Refresh.";
48354
- box.appendChild(hint);
48355
- if(left.length){const ul=document.createElement("ul");ul.className="muted";
48356
- const WHY={"several answers":"pick each of these in the form","contains a plus sign":"type this in the form","no field for this question":"the form has no question for this, so it was not carried. Add it as a note on the contact after you submit"};
48357
- for(const l of left){const li=document.createElement("li");li.textContent=l.label+": "+l.values.join(", ")+". "+(WHY[l.reason]||l.reason)+".";ul.appendChild(li)}
48358
- box.appendChild(ul)}
48359
- const rf=document.createElement("button");rf.className="cds-btn ghost";rf.textContent="Refresh status";
48360
- rf.onclick=()=>iLoad(id);box.appendChild(rf)};
49037
+ iPrepared[id]={url:r.url,leftovers:r.leftovers||[]};render(lastState,lastRun)};
48361
49038
  box.appendChild(save)}else if(formNote)box.appendChild(formNote);
48362
49039
  const alt=document.createElement("button");alt.className="cds-btn ghost";
48363
49040
 
48364
49041
  alt.textContent="Save draft on this computer";
48365
49042
  alt.title="Saves your answers for this client on this computer. You can return to them later.";
48366
49043
  alt.onclick=async()=>{alt.disabled=true;alt.textContent="Saving\u2026";const savedAnswers={...(iAnswers[id]||{})};
48367
- const r=await(await clientPageRequest("/api/intake-submit",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:savedAnswers,contact:iContact(id,d.questions)})})).json();
49044
+ const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-submit",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:savedAnswers,contact:iContact(id,d.questions)})})).json());
48368
49045
  if(r.ok){for(const label of iDirty[id]||[])if(JSON.stringify(iAnswers[id][label])===JSON.stringify(savedAnswers[label]))iDirty[id].delete(label);iMode[id]=null;iPre[id]=[];await iLoad(id)}else{showClientError(r.error,box);alt.disabled=false;alt.textContent="Save draft on this computer"}};
48369
- box.appendChild(alt)}}
49046
+ box.appendChild(alt);
49047
+ if(hasForm&&iPrepared[id])preparedFormBlock(box,id)}}
48370
49048
  return box}
48371
49049
  let iNote={},iHeld={},iUnv={};
48372
49050
  ${ASSESSMENT_PANEL_JS}
48373
49051
  let rOpen={},rData={},rBusy={},rSeen={};
48374
49052
  let profCache=null,profLoading=false;
48375
49053
  async function profLoad(){if(profLoading)return;profLoading=true;try{profCache=((await(await fetch("/api/agency-profile")).json()).profile)||{}}catch(e){profCache={}}profLoading=false;render(lastState,lastRun)}
48376
- /* What they bought (2026-09-06). Foundation is always built; the ticks decide the features. Nothing saved = the whole preset. */
49054
+ /* What they bought (2026-09-06). Foundation is always built; the ticks decide the features.
49055
+ Jerry, 2026-09-29: nothing saved no longer means the whole preset. Approve and Build wait for a save,
49056
+ and nothing is pre-ticked unless the Assessment recorded purchases. */
49057
+ /* The ticks and package chosen but not yet saved, kept outside the block so a redraw keeps them (review of c09e946). */
49058
+ let scopeDraft={};
49059
+ function scopeChosen(state,id){const s=(state.scope||{})[id];return Boolean(s&&(s.wholePreset||(Array.isArray(s.features)&&s.features.length)))}
49060
+ function openWhatTheyBought(id){rOpen[id]=true;rSeen[id]=true;const go=()=>{render(lastState,lastRun);requestAnimationFrame(()=>{const t=document.getElementById("wtb-"+id);if(t){t.scrollIntoView({behavior:"smooth",block:"start"});const f=t.querySelector("button,input,select");if(f)f.focus({preventScroll:true})}})};if(!rData[id])rLoad(id).then(go,go);else go()}
48377
49061
  function scopeBlock(id,state){
48378
- const sc=document.createElement("div");sc.className="cds-stack";
49062
+ const sc=document.createElement("div");sc.className="cds-stack";sc.id="wtb-"+id;
48379
49063
  sc.innerHTML="<h4 class='cds-panel-label'>What they bought</h4>";
49064
+ const lead=document.createElement("p");lead.textContent="Tick what the client bought. Only these get built.";sc.appendChild(lead);
48380
49065
  if(!profCache){profLoad();const p=document.createElement("p");p.className="muted";p.textContent="Loading your packages\u2026";sc.appendChild(p);return sc}
48381
49066
  const saved=(state.scope||{})[id]||null;const pk=(profCache.packages||[]);
48382
49067
  const sel=document.createElement("select");sel.className="cds-select";sel.setAttribute("aria-label","Package they bought");
48383
- const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"Everything the preset has (no package)":"Everything the preset has (no packages saved yet: add them on Your agency)";sel.appendChild(o0);
49068
+ const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"No package: tick the features below":"No packages saved yet (add them on Your agency): tick the features below";sel.appendChild(o0);
48384
49069
  for(const p of pk){const o=document.createElement("option");o.value=p.id;o.textContent=p.name+(p.price?" \xB7 "+moneyText(p.price):"");sel.appendChild(o)}
48385
- const prefill=(state.scopePrefill||{})[id];let ticked=new Set(saved&&!saved.wholePreset?saved.features:(prefill||Object.keys(FEATS)));if(saved&&saved.package)sel.value=saved.package;
49070
+ /* Only the features this client's plan has are offered (review of 89acc7d); a tick saved earlier outside it stays visible so it can be removed. */
49071
+ const offered=((state.scopeOffered||{})[id])||Object.keys(FEATS);
49072
+ const prefill=(state.scopePrefill||{})[id];let ticked=new Set(saved?(saved.wholePreset?offered:saved.features):(prefill||[]));if(saved&&saved.package)sel.value=saved.package;
49073
+ const base=JSON.stringify(saved);if(scopeDraft[id]&&scopeDraft[id].base!==base)delete scopeDraft[id];const dr=scopeDraft[id];if(dr){ticked=new Set(dr.ticked);sel.value=dr.pkg}
49074
+ const keepScope=()=>{scopeDraft[id]={ticked:[...ticked],pkg:sel.value,base:base};unsaved.hidden=false};
49075
+ const everything=document.createElement("button");everything.className="cds-btn"+(saved&&saved.wholePreset?" ghost":"");everything.textContent="Everything in the plan";
49076
+ everything.title="Builds every feature in the plan for this client's industry.";
49077
+ everything.onclick=async()=>{everything.disabled=true;const r=await postJSON("/api/scope",{locationId:id,features:null});if(r){if(r.approvalWithdrawn)alert(r.message);delete scopeDraft[id];delete assessmentCards[id];load()}else everything.disabled=false};
49078
+ sc.appendChild(everything);
48386
49079
  const boxes=document.createElement("div");boxes.className="cds-stack";
48387
- function drawBoxes(){boxes.innerHTML="";for(const k of Object.keys(FEATS)){const l=document.createElement("label");l.className="cds-check";
48388
- const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(k);c.onchange=()=>{if(c.checked)ticked.add(k);else ticked.delete(k)};l.appendChild(c);
49080
+ function drawBoxes(){boxes.innerHTML="";for(const k of Object.keys(FEATS).filter(k=>offered.includes(k)||ticked.has(k))){const l=document.createElement("label");l.className="cds-check";
49081
+ l.dataset.k=k;const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(k);c.onchange=()=>{if(c.checked)ticked.add(k);else ticked.delete(k);keepScope();drawFigs()};l.appendChild(c);
48389
49082
  const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=FEATS[k].name;t.appendChild(tb);t.appendChild(mk("small","",FEATS[k].what));l.appendChild(t);boxes.appendChild(l)}}
48390
49083
  const figs=((state.assessmentFigures||{})[id])||{};
48391
49084
  function drawFigs(){for(const l of boxes.querySelectorAll("label")){const k=l.dataset.k;const old=l.querySelector("em");if(old)old.remove();if(figs[k]&&!ticked.has(k)){const e=document.createElement("em");e.className="cds-chip warn";e.textContent="Not bought: "+figs[k]+".";l.querySelector("span").appendChild(e)}}}
48392
- sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);ticked=new Set(p?p.features:Object.keys(FEATS));drawBoxes();drawFigs()};drawBoxes();
48393
- for(const l of boxes.querySelectorAll("label")){const c=l.querySelector("input");const k=Object.keys(FEATS)[[...boxes.querySelectorAll("label")].indexOf(l)];l.dataset.k=k;c.addEventListener("change",drawFigs)}drawFigs();
49085
+ sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);if(p)ticked=new Set(p.features);keepScope();drawBoxes();drawFigs()};drawBoxes();drawFigs();
48394
49086
  const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing. Unticked features go in the client document as available next.";
48395
49087
  const sv=document.createElement("button");sv.className="cds-btn ghost";sv.textContent="Save what they bought";
48396
- sv.onclick=async()=>{const all=Object.keys(FEATS).every(k=>ticked.has(k));const body={locationId:id,features:(all&&!sel.value)?null:[...ticked]};if(sel.value)body.package=sel.value;if(await postJSON("/api/scope",body)){delete assessmentCards[id];load()}};
48397
- const status=document.createElement("p");status.className="muted";status.textContent=saved?(saved.wholePreset?"Saved: the whole preset. The build follows this.":"Saved: "+saved.features.length+" of "+Object.keys(FEATS).length+" features"+(saved.package?" from the "+saved.package+" package":"")+". The build follows this."):prefill?"Not confirmed yet: ticked from what they bought. Nothing is built until you save.":"Nothing saved: the whole preset is built.";
48398
- sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(status);
49088
+ const status=document.createElement("p");status.className="muted";status.setAttribute("role","status");
49089
+ sv.onclick=async()=>{if(!ticked.size){status.textContent="Tick at least one feature, or choose Everything in the plan.";return}
49090
+ if(![...ticked].some(k=>offered.includes(k))){status.textContent="None of the ticked features is in this client's plan. Tick features from the list, or choose Everything in the plan.";return}
49091
+ const all=offered.every(k=>ticked.has(k));const body={locationId:id,features:(all&&!sel.value)?null:[...ticked]};if(sel.value)body.package=sel.value;const r=await postJSON("/api/scope",body);if(r){if(r.approvalWithdrawn)alert(r.message);delete scopeDraft[id];delete assessmentCards[id];load()}};
49092
+ status.textContent=saved?(saved.wholePreset?"Saved: everything in the plan. The build follows this.":saved.features.length?"Saved: "+saved.features.filter(k=>offered.includes(k)).length+" of "+offered.length+" features"+(saved.package?" from the "+saved.package+" package":"")+". The build follows this.":"Saved with nothing ticked, so nothing can be built. Tick what they bought, or choose Everything in the plan, and save."):prefill?"Not confirmed yet: ticked from what they bought. Nothing is built until you save.":"Nothing saved yet. Choose and save before approving.";
49093
+ const unsaved=mk("span","cds-chip warn","Not saved yet");unsaved.hidden=!scopeDraft[id];
49094
+ sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(unsaved);sc.appendChild(status);
48399
49095
  return sc}
48400
49096
  async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;render(lastState,lastRun)}
48401
49097
  // Finding 20 (2026-08-26): writing the review takes 30\u201390 s and the page re-renders every few seconds meanwhile,
@@ -48426,7 +49122,9 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
48426
49122
  if(!r||!r.ok){if(!busy){const p=document.createElement("p");if(r&&r.error&&r.error!=="not generated yet"){p.className="cds-callout warn";p.setAttribute("role","alert")}else p.className="muted";
48427
49123
  p.textContent=r&&r.error?r.error:"Turn their intake answers into strategy: who they really sell to, whether the offer holds up, what the account is missing, and what to build first.";
48428
49124
  d.appendChild(p)}
48429
- d.appendChild(gen);return d}
49125
+ d.appendChild(gen);
49126
+ /* What they bought decides the build, so it is here even before the review is written (2026-09-29). */
49127
+ d.appendChild(scopeBlock(id,state));return d}
48430
49128
  if(r.headline){const h=document.createElement("div");h.className="cds-callout info headline";h.textContent=moneyText(r.headline);d.appendChild(h)}
48431
49129
  if(r.ica){const s1=document.createElement("div");s1.className="cds-stack";s1.innerHTML="<h4 class='cds-panel-label'>Who they actually sell to</h4>";
48432
49130
  const p1=document.createElement("p");p1.textContent=moneyText(r.ica.who||"");s1.appendChild(p1);
@@ -48489,7 +49187,7 @@ function clientPageError(raw,next){
48489
49187
  return text+(action?" "+action:"")}
48490
49188
  function showClientError(raw,container,next){const parent=container||document.getElementById("main");let e=parent.querySelector('[role="alert"]');if(!e){e=document.createElement("p");e.setAttribute("role","alert");e.className="cds-callout warn";parent.appendChild(e)}e.textContent=clientPageError(raw,next)}
48491
49189
  async function clientPageRequest(url,options){try{const response=await fetch(url,options);const data=await response.json();if(!response.ok&&data.ok!==false)data.ok=false;return {json:async()=>data}}catch(e){return {json:async()=>({ok:false,error:/^[/]api[/](?:client-)?intake/.test(url)?"GHL Command could not confirm the result. Refresh the intake to check its status.":"GHL Command could not confirm the result; check GoHighLevel before trying again to avoid repeating a change."})}}}
48492
- function pendingCard(c,state,intake=false){const div=document.createElement("section");div.className="cds-panel client";
49190
+ function pendingCard(c,state,intake=false){const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
48493
49191
  const head=document.createElement("div");head.className="cds-panel-head client-head";
48494
49192
  const txt=mk("div","txt");const h=document.createElement("h2");h.className="cds-panel-title";h.textContent=c.name;txt.appendChild(h);head.appendChild(txt);
48495
49193
  const pill=chip("warn","Needs connecting");head.appendChild(pill);
@@ -48636,7 +49334,7 @@ function card(c,state,run){const st=(state.clients[c.locationId]||{stages:STAGES
48636
49334
  // A template or hidden account opens only so its owner can lock or unlock it and change its
48637
49335
  // type: nothing on its card builds into it (review L-1; a template can be the live snapshot source).
48638
49336
  const parkedType=["template","hidden"].includes(typeOf(state,c.locationId));
48639
- const div=document.createElement("section");div.className="cds-panel client";
49337
+ const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
48640
49338
  const head=document.createElement("div");head.className="cds-panel-head client-head";
48641
49339
  const htxt=mk("div","txt");const h=document.createElement("h2");h.className="cds-panel-title";h.textContent=c.name;htxt.appendChild(h);head.appendChild(htxt);
48642
49340
  if(sandbox){const sb=chip("info","");sb.title="Practice accounts skip typing the client name. Review and approve the plan before building.";sb.textContent="Practice account";htxt.appendChild(sb)}
@@ -48679,7 +49377,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48679
49377
  const nextBox=document.createElement("div");nextBox.className="cds-stack snext";div.appendChild(nextBox);
48680
49378
  const row=document.createElement("div");row.className="cds-flow stages";row.setAttribute("role","list");
48681
49379
  let curLive=false;
48682
- STAGES.forEach((name,i)=>{const stale=staleOf(i);const el=document.createElement("div");el.className="cds-node stage "+(stale?"pending":(st[i]||"pending"))+(i===curI?" here hit":"");el.setAttribute("role","listitem");
49380
+ STAGES.forEach((name,i)=>{const stale=staleOf(i);const el=document.createElement("div");el.className="cds-node stage "+(stale?"pending":(st[i]||"pending"))+(i===curI?" here hit":"");el.setAttribute("role","listitem");el.setAttribute("data-key",c.locationId+":"+i);
48683
49381
  const gate=GATES[String(i)];
48684
49382
  const oc0=(state.outcomes||{})[c.locationId+":"+i];const notPassed=(st[i]||"pending")==="pending"&&oc0&&(oc0.issues||[]).length>0;
48685
49383
  if(notPassed)el.className+=" notpassed";
@@ -48713,7 +49411,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48713
49411
  const clock=document.createElement("div");clock.className="cds-num";
48714
49412
 
48715
49413
  const began=run.startedAt?new Date(run.startedAt).getTime():0;
48716
- const tick=()=>{if(!began)return;const sec=Math.max(0,Math.round((Date.now()-began)/1000));
49414
+ /* The clock counts on the page alone, so it only counts while the status is fresh: a page that has
49415
+ not heard from the server for 15s freezes and says so, rather than looking busy (2026-09-29). */
49416
+ const tick=()=>{if(!began)return;if(statusIsStale()){clock.textContent="checking\u2026";return}const sec=Math.max(0,Math.round((Date.now()-began)/1000));
48717
49417
  clock.textContent="working "+(sec<60?sec+"s":Math.floor(sec/60)+"m "+(sec%60)+"s")};
48718
49418
  tick();if(began){const h=setInterval(()=>{if(!clock.isConnected){clearInterval(h);return}tick()},1000)}
48719
49419
  box.appendChild(clock);
@@ -48752,8 +49452,11 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48752
49452
  else if(!live){const note=mkNote();what.appendChild(note||about)}
48753
49453
  if(POWERED.includes(i)&&(st[i]||"pending")!=="done"){el.style.cursor="default";
48754
49454
  el.title="Command OS runs and checks this step. Its result comes from a run, never from a click."}
48755
- const gateLabel=({2:"Approve this plan",5:"Confirm these tasks are finished",6:"Sign this client live"})[i];
48756
- const mkGate=cls=>{const action=document.createElement("button");action.className=cls;action.textContent=gateLabel;action.onclick=()=>{if(i===2)return approveBuilding();return markStage(c.locationId,i,"done")};return action};
49455
+ /* Jerry, 2026-09-29: Approve waits for What they bought. Until a selection is saved the gate
49456
+ is "Choose what the client bought", which opens the Blueprint Review at that block. */
49457
+ const mustChoose=i===2&&!scopeChosen(state,c.locationId);
49458
+ const gateLabel=mustChoose?"Choose what the client bought":({2:"Approve this plan",5:"Confirm these tasks are finished",6:"Sign this client live"})[i];
49459
+ const mkGate=cls=>{const action=document.createElement("button");action.className=cls;action.textContent=gateLabel;action.onclick=()=>{if(mustChoose)return openWhatTheyBought(c.locationId);if(i===2)return approveBuilding();return markStage(c.locationId,i,"done")};return action};
48757
49460
  const canBuild=!parkedType&&(i<=1||sandbox||Boolean((state.buildApproved||{})[c.locationId]));
48758
49461
  const runIt=async(ev)=>{ev.stopPropagation();if(i===0&&!confirm("In "+c.name+", Intake writes custom fields, one form, and one published notification workflow."+String.fromCharCode(10,10)+INTAKE_NOTIFICATION_REVIEW.join(String.fromCharCode(10))+String.fromCharCode(10,10)+"Create or repair these intake items?"))return;if(i===4&&PAGE_PLANS.includes(c.locationId)&&!confirm("Verify submits one test lead to the pages "+c.name+"\u2019s older build made, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once. Run the check?"))return;const res=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,stage:i,intakeConfirmed:i===0})});const j=await res.json();if(!j.ok)alert(j.error);load()};
48759
49462
  const mkRun=(cls,label)=>{const b=document.createElement("button");b.className=cls;
@@ -48765,10 +49468,11 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48765
49468
  A gate the owner has already answered offers nothing. */
48766
49469
  if(gateOpen&&!anyBusy&&!parkedType&&i!==curI)act.appendChild(mkGate("cds-link sactbtn"));
48767
49470
  if(canBuild&&POWERED.includes(i)&&i!==curI)act.appendChild(mkRun("cds-link sactbtn srun",(st[i]||"pending")==="done"?"Run again":"Run this step"));
48768
- if(i===curI){const main=document.createElement("div");
49471
+ if(i===curI){const main=document.createElement("div");main.setAttribute("data-key",c.locationId+":"+i+":next");
48769
49472
  const k=mk("div","cds-panel-label sk",parkedType?"Building is off":"Next step");main.appendChild(k);
48770
49473
  const h3=document.createElement("h3");h3.className="cds-panel-title";h3.textContent=parkedType?"Nothing is built into this account":(i+1)+". "+LABELS[i];main.appendChild(h3);
48771
49474
  const pa=document.createElement("p");pa.textContent=parkedType?"It opens here so you can lock or unlock it, or change its type to Client to build it.":stepAbout(i,c,true);main.appendChild(pa);
49475
+ if(mustChoose&&!parkedType){const pc=document.createElement("p");pc.className="swarn";pc.textContent="First choose what the client bought. Only those parts get built.";main.appendChild(pc)}
48772
49476
  if(live)main.appendChild(mkLive());
48773
49477
  if(stale&&!live){const w=document.createElement("div");w.className="cds-row";w.appendChild(chip(chipOf[1],chipOf[0]));w.appendChild(mk("span","swarn",stale.reason));main.appendChild(w)}
48774
49478
  const pn=mkNote();if(pn){const w=document.createElement("div");w.className="snoteline";w.appendChild(pn);main.appendChild(w)}
@@ -48804,7 +49508,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48804
49508
  if(view.stale.length){const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const stale of view.stale){const p=document.createElement("p");p.textContent=LABELS[stale.stage]+": stale. "+stale.reason;d.appendChild(p)}div.appendChild(d)}
48805
49509
  if(view.approvedAt){const p=document.createElement("p");p.textContent="Building approved on "+niceDate(view.approvedAt,true)+".";div.appendChild(p)}
48806
49510
  for(const repair of view.repairs){const p=document.createElement("p");p.textContent="Live workflow repair: "+repair.name+". "+repair.changes.join(" ");div.appendChild(p)}
48807
- if(view.planHash&&!parkedType){const approve=document.createElement("button");approve.className="cds-btn ghost";approve.textContent="Approve building\u2026";approve.onclick=async()=>{const typed=sandbox?c.name:prompt("Approve building and the listed live workflow repairs for "+c.name+". Type the client name to confirm.");if(typed===null)return;if(await postJSON("/api/build-approval",{locationId:c.locationId,confirmName:typed,repairHashes:view.repairs.map(r=>r.hash)}))load()};div.appendChild(approve)}
49511
+ /* No "Approve building\u2026" while nothing is chosen (review of 89acc7d): it would ask for a name and then refuse. */
49512
+ if(view.planHash&&!parkedType&&!scopeChosen(state,c.locationId)){const ch=document.createElement("button");ch.className="cds-btn ghost";ch.textContent="Choose what the client bought";ch.onclick=()=>openWhatTheyBought(c.locationId);div.appendChild(ch)}
49513
+ else if(view.planHash&&!parkedType){const approve=document.createElement("button");approve.className="cds-btn ghost";approve.textContent="Approve building\u2026";approve.onclick=async()=>{const typed=sandbox?c.name:prompt("Approve building and the listed live workflow repairs for "+c.name+". Type the client name to confirm.");if(typed===null)return;if(await postJSON("/api/build-approval",{locationId:c.locationId,confirmName:typed,repairHashes:view.repairs.map(r=>r.hash)}))load()};div.appendChild(approve)}
48808
49514
  }else{const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));const p=document.createElement("p");p.textContent="The executable plan is prepared when Build runs.";d.appendChild(p);div.appendChild(d)}
48809
49515
  if(view?.handoff)div.appendChild(unnest(handoffPanel(c,view)));
48810
49516
  const _prot=PROTECTED[c.locationId];
@@ -48823,8 +49529,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48823
49529
  ab.appendChild(s1);ab.appendChild(rv);ab.appendChild(protectBtn(c))}
48824
49530
  else if(parkedType){ab.appendChild(protectBtn(c))}
48825
49531
  else{const w=document.createElement("span");w.className="muted";w.textContent="Automated building is off for this client. ";
48826
- const btn=document.createElement("button");btn.className="cds-btn ghost";btn.textContent="Approve building\u2026";
48827
- btn.onclick=approveBuilding;
49532
+ const btn=document.createElement("button");btn.className="cds-btn ghost";
49533
+ if(scopeChosen(lastState,c.locationId)){btn.textContent="Approve building\u2026";btn.onclick=approveBuilding}
49534
+ else{btn.textContent="Choose what the client bought";btn.onclick=()=>openWhatTheyBought(c.locationId)}
48828
49535
  ab.appendChild(w);ab.appendChild(btn);ab.appendChild(protectBtn(c))}
48829
49536
  div.appendChild(ab)}
48830
49537
  // A "live" claim is re-derived from the signature, never trusted from the
@@ -48942,7 +49649,7 @@ async function postJSON(url,body){
48942
49649
  catch(e){alert("Command OS could not confirm the result. Refresh this client to read its saved state before retrying.");return false}
48943
49650
  try{j=await r.json()}catch(e){}
48944
49651
  if(!r.ok||(j&&j.ok===false)){alert((j&&j.error)||"That did not go through, and nothing was changed.");return false}
48945
- return true}
49652
+ return j||true}
48946
49653
  /* Nothing hand-markable that Command OS can check for itself. Steps it runs are marked
48947
49654
  by the run; steps that need a person are marked by that person, on the record. */
48948
49655
  async function markStage(id,i,status){
@@ -48952,7 +49659,24 @@ async function markStage(id,i,status){
48952
49659
  const r=await(await fetch("/api/stage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,stage:i,status:status,by:who})})).json();
48953
49660
  if(!r.ok){alert(r.error);return}
48954
49661
  await loadQueue();load()}
48955
- async function loadQueue(){try{const r=await fetch("/api/queue",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"});queueData=await r.json();if(!r.ok)throw new Error(queueData.error||"The work queue could not be read.");if(window.cdsQueueBadge&&queueData.counts)window.cdsQueueBadge(queueData.counts.you||0)}catch(e){queueData={ok:false,error:e.message||"The work queue could not be read."}}}
49662
+ /* cheap: a background refresh uses GET /api/queue, which derives the queue from the saved board without re-reading
49663
+ every client's intake from GoHighLevel; the POST (which does) stays for the page's own loads. */
49664
+ /* Three ways to read the queue:
49665
+ - undefined: the page's own load and the operator's actions. POST {}: re-reads every started client's intake.
49666
+ - "pending": a background re-read (a return to the tab, every 4th heartbeat load). POST {only:"pending"}: re-reads
49667
+ only clients whose saved intake is not yet "received", plus the one open here. At most one queue POST is in
49668
+ flight, and a background one is skipped (the GET is used) within 30s of the last one finishing.
49669
+ - "get": GET, derived from the saved board with no GoHighLevel reads.
49670
+ A background read that fails keeps the last good queue rather than blanking it. */
49671
+ let queuePostBusy=false,queuePostAt=0;
49672
+ async function loadQueue(mode){
49673
+ if(mode==="pending"&&(queuePostBusy||Date.now()-queuePostAt<30000))mode="get";
49674
+ const background=mode==="get"||mode==="pending";const post=mode!=="get";
49675
+ const init=mode==="get"?undefined:{method:"POST",headers:{"Content-Type":"application/json"},body:mode==="pending"?JSON.stringify({only:"pending",open:activeId||undefined}):"{}"};
49676
+ if(post)queuePostBusy=true;
49677
+ try{const r=await fetch("/api/queue",init);const q=await r.json();if(!r.ok||!q||q.ok===false)throw new Error((q&&q.error)||"The work queue could not be read.");queueData=q;if(window.cdsQueueBadge&&queueData.counts)window.cdsQueueBadge(queueData.counts.you||0)}
49678
+ catch(e){if(!(background&&queueData&&queueData.ok))queueData={ok:false,error:e.message||"The work queue could not be read."}}
49679
+ finally{if(post){queuePostBusy=false;queuePostAt=Date.now()}}}
48956
49680
  /* A whole first sentence, never a line cut mid-thought. */
48957
49681
  function firstSentence(s){const t=String(s||"").split(String.fromCharCode(10)).map(x=>x.trim()).filter(Boolean)[0]||"";const m=/^(.+?[.!?])(\\s|$)/.exec(t);return m?m[1]:t}
48958
49682
  /* Build text an owner cannot read: quoted names, tag and custom value keys (business_phone), plus signs,
@@ -49036,11 +49760,11 @@ function queueBlock(match){const q=queueData;if(!q||!q.ok)return queueReadError(
49036
49760
  frag.appendChild(now);
49037
49761
  /* The rest fold away (Disclosure: cds-more), each in a Panel of its own that says what is inside. */
49038
49762
  if(later.length){const p=panel("Not started yet",later.length+" "+plural(later.length,"account")+" not started");p.classList.add("q-later");
49039
- const d=mk("details","cds-more");d.open=showQueueRest;d.ontoggle=()=>{showQueueRest=d.open};
49763
+ const d=mk("details","cds-more");d.setAttribute("data-key","queue-rest");d.open=showQueueRest;d.ontoggle=()=>{showQueueRest=d.open};
49040
49764
  d.appendChild(mk("summary","","Show the "+later.length+" "+plural(later.length,"step")+" not started"));
49041
49765
  d.appendChild(listRows(later.map(queueRow),"queue-later"));p.appendChild(d);frag.appendChild(p)}
49042
49766
  if(fin.length){const p=panel("Finished",fin.length+" "+plural(fin.length,"account")+" finished");p.classList.add("q-finished");
49043
- const d=mk("details","cds-more");d.open=showLive;d.ontoggle=()=>{showLive=d.open};
49767
+ const d=mk("details","cds-more");d.setAttribute("data-key","queue-live");d.open=showLive;d.ontoggle=()=>{showLive=d.open};
49044
49768
  d.appendChild(mk("summary","","Show the "+fin.length+" finished "+plural(fin.length,"account")));
49045
49769
  for(const signed of [true,false]){const group=fin.filter(f=>Boolean(f.live)===signed);if(!group.length)continue;
49046
49770
  d.appendChild(mk("div","cds-panel-label",signed?"Finished and live ("+group.length+")":"Finished, not signed live ("+group.length+")"));
@@ -49266,7 +49990,7 @@ send.disabled=!published;
49266
49990
  if(!confirm("Send one email from "+c.name+"\u2019s account to "+r.preview.email+" with the handoff at "+r.url+"?"))return;
49267
49991
  const result=await(await fetch("/api/handoff-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,hash:r.preview.hash,confirmed:true})})).json();note.textContent=result.note||result.error;
49268
49992
  }catch(e){note.textContent=e.message||"The result could not be confirmed. Check Conversations before retrying."}finally{send.disabled=false}};box.appendChild(send);return box}
49269
- 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)}
49993
+ function pollCap(){if(capPoll)return;capPoll=setInterval(async()=>{try{const r=await fetchRunStatus();const s=await(await fetch("/api/state")).json();if(!r.running){clearInterval(capPoll);capPoll=null;capRun=null;capReads=null;capReadsFor=""}if(!shouldRedraw(lastRun,r))return;if(!r.running||dataSig(s,r)!==lastDrawnSig){render(s,r);hideFinishNote()}}catch(e){}},2000)}
49270
49994
 
49271
49995
  /* PageHeader: kicker, the title as a sentence, one muted line, at most one primary action. */
49272
49996
  function screenHead(title,sub,kicker,action){const d=mk("div","cds-page-head");const t=mk("div","txt");
@@ -49313,9 +50037,34 @@ function homeBlock(state,run){const wrap=mk("div","board");
49313
50037
  li.onclick=go;li.onkeydown=(e)=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();go()}};return li}),"board-needs"))}
49314
50038
  two.appendChild(needs);
49315
50039
  const priv=panel("Privacy","What stays on this computer");priv.classList.add("privacy");for(const line of PRIVACY_SHORT)priv.appendChild(mk("p","muted small",line));
49316
- {const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const line of PRIVACY_LINES)d.appendChild(mk("p","muted small",line));priv.appendChild(d)}two.appendChild(priv);
50040
+ {const d=mk("details","cds-details");d.setAttribute("data-key","home-privacy");d.appendChild(mk("summary","","Details"));for(const line of PRIVACY_LINES)d.appendChild(mk("p","muted small",line));priv.appendChild(d)}two.appendChild(priv);
49317
50041
  wrap.appendChild(two);return wrap}
49318
- function render(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...state.roster)}syncReviews(state);lastState=state;lastRun=run;
50042
+ /* Every redraw keeps the view (2026-09-29, Jerry: "anything I expand closes itself within 1 to 3 seconds").
50043
+ The page is rebuilt from data, so a disclosure the operator opened, the scroll position and the cursor in
50044
+ a field would all reset. Before drawing: note which <details> are open or closed, keyed by the nearest
50045
+ data-key (client, client:stage) plus the summary words, and where focus and the caret are. After: put
50046
+ them back. Toggles such as See issues already live in state (oOpen), and survive on their own. */
50047
+ let lastDrawnSig="";
50048
+ /* generatedAt is stamped fresh on every queue read (work-queue.ts), so it is left out: with it, no two reads ever matched
50049
+ and every background refresh redrew (review of c09e946). */
50050
+ function dataSig(state,run){try{return JSON.stringify([state,run,typeof queueData==="undefined"?null:queueData],(k,v)=>k==="generatedAt"?undefined:v)}catch(e){return String(Date.now())}}
50051
+ function keyOf(el,root){const a=el.closest("[data-key]");return a&&root.contains(a)?a.getAttribute("data-key"):"main"}
50052
+ function detailsKeys(root){const out=[];const seen={};for(const d of root.querySelectorAll("details")){const sm=d.querySelector("summary");
50053
+ const base=keyOf(d,root)+"|"+String(d.className||"")+"|"+String(sm?sm.textContent:"").replace(/[0-9]+/g,"#").trim();seen[base]=(seen[base]||0)+1;out.push([d,base+"#"+seen[base]])}return out}
50054
+ function fieldKey(el,root){return keyOf(el,root)+"|"+el.tagName+"|"+(el.getAttribute("aria-label")||el.getAttribute("name")||el.getAttribute("placeholder")||el.id||"")}
50055
+ function captureView(){const m=document.getElementById("main");if(!m)return null;const open=new Set(),closed=new Set();
50056
+ for(const [d,k] of detailsKeys(m))(d.open?open:closed).add(k);
50057
+ let focus=null;const a=document.activeElement;
50058
+ if(a&&m.contains(a)&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)){const k=fieldKey(a,m);const same=[...m.querySelectorAll(a.tagName)].filter(x=>fieldKey(x,m)===k);
50059
+ let st=null,en=null;try{st=a.selectionStart;en=a.selectionEnd}catch(e){}focus={key:k,tag:a.tagName,n:same.indexOf(a),st:st,en:en}}
50060
+ return {open:open,closed:closed,focus:focus,x:window.scrollX,y:window.scrollY}}
50061
+ function restoreView(v){const m=document.getElementById("main");if(!v||!m)return;
50062
+ for(const [d,k] of detailsKeys(m)){if(v.open.has(k)&&!d.open)d.open=true;else if(v.closed.has(k)&&d.open)d.open=false}
50063
+ if(v.focus){const el=[...m.querySelectorAll(v.focus.tag)].filter(x=>fieldKey(x,m)===v.focus.key)[v.focus.n];
50064
+ if(el&&document.activeElement!==el){try{el.focus({preventScroll:true})}catch(e){}try{if(v.focus.st!=null)el.setSelectionRange(v.focus.st,v.focus.en)}catch(e){}}}
50065
+ if(window.scrollX!==v.x||window.scrollY!==v.y){try{window.scrollTo(v.x,v.y)}catch(e){}}}
50066
+ function render(state,run){const v=captureView();try{renderPage(state,run)}finally{lastDrawnSig=dataSig(state,run);restoreView(v)}}
50067
+ function renderPage(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...state.roster)}syncReviews(state);lastState=state;lastRun=run;
49319
50068
  if(state&&state.protectedAccounts)PROTECTED=state.protectedAccounts;const m=document.getElementById("main");m.innerHTML="";
49320
50069
  /* Four screens share this page's data and differ only in what they show. */
49321
50070
  if(SCREEN==="new"){m.classList.add("ncflow");const head=screenHead(HEADS.new.title,HEADS.new.sub,HEADS.new.kicker);head.querySelector(".cds-page-sub").classList.add("nc-lead");m.appendChild(head);
@@ -49391,7 +50140,7 @@ function render(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...sta
49391
50140
  const list=panel("Client accounts",actC?"Your other client accounts":"Your client accounts");if(!actC)list.id="clients";
49392
50141
  if(rest.length){list.appendChild(listRows(rest.map(c=>acctRow(c,state,run)),"clients-rest","accounts"));
49393
50142
  list.appendChild(mk("p","switchnote","One account at a time. Opening another puts this one back in the list. Nothing is lost."))}
49394
- if(parkedRest.length){const d=mk("details","cds-more");d.open=showParked;d.ontoggle=()=>{showParked=d.open};
50143
+ if(parkedRest.length){const d=mk("details","cds-more");d.setAttribute("data-key","parked-rest");d.open=showParked;d.ontoggle=()=>{showParked=d.open};
49395
50144
  d.appendChild(mk("summary","","Show the "+parkedRest.length+" template and hidden "+plural(parkedRest.length,"account")));
49396
50145
  d.appendChild(listRows(parkedRest.map(c=>acctRow(c,state,run)),"clients-parked"));list.appendChild(d)}
49397
50146
  m.appendChild(list)}}
@@ -49794,7 +50543,7 @@ async function runDashboard(argv) {
49794
50543
  let ffAdmin;
49795
50544
  try {
49796
50545
  const b = workflowReadConnection(registry2, c);
49797
- stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
50546
+ stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
49798
50547
  ffAdmin = builderAdmin(b);
49799
50548
  } catch {
49800
50549
  }
@@ -50560,6 +51309,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50560
51309
  const p = scopePrefillFor(st, loc);
50561
51310
  if (p) scopePrefill[loc] = p;
50562
51311
  }
51312
+ const scopeOffered = Object.fromEntries(currentRoster().map((c) => [c.locationId, offeredFeaturesFor(st, c.locationId)]));
50563
51313
  const buildViews = Object.fromEntries(currentRoster().map((c) => {
50564
51314
  const saved = readSavedPlan(c.locationId);
50565
51315
  const approval = st.planApprovals?.[c.locationId];
@@ -50592,7 +51342,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50592
51342
  }
50593
51343
  for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || st.manualTasks?.[id]?.length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
50594
51344
  const outcomes = Object.fromEntries(Object.entries(st.outcomes ?? {}).map(([key, value]) => [key, { ...value, ...key.endsWith(":4") && value.verification ? { plain: verifyPanelOutcome(value) } : { ...plainFallbackOutcome(value), plain: plainFallbackOutcome(value.plain ?? value) }, error: value.error ? "The step did not finish. Check the account before retrying." : void 0 }]));
50595
- res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
51345
+ res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, scopeOffered, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
50596
51346
  const records = st.outcomes?.[`${id}:4`]?.verification;
50597
51347
  const allowed = records ? verifyReadback(records).allowPublishing : false;
50598
51348
  return [id, tasks2.map((t) => ({ ...t, text: verifyPublishingText(plainFallback(t.text, { nextStep: true }), allowed) })).filter((t) => t.text)];
@@ -50609,24 +51359,23 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50609
51359
  return;
50610
51360
  }
50611
51361
  if (req.method === "POST" && url === "/api/queue") {
50612
- readBody(req, res, 5e3, async () => {
51362
+ readBody(req, res, 5e3, async (body) => {
50613
51363
  try {
50614
51364
  const roster2 = currentRoster();
50615
51365
  const state = readCockpitState();
50616
- const inputs = { ...state.intakeInputs };
50617
- for (const entry of roster2) {
50618
- if (state.clients[entry.locationId]?.stages?.[0] !== "done") continue;
50619
- const c = clientFor(entry.locationId);
50620
- if (!c) continue;
50621
- const snap = await readIntakeSnapshot(c, entry.locationId);
50622
- if (snap.status === "unavailable") throw new Error("The work queue could not read intake answers. Retry the queue.");
51366
+ const { reread, failed } = await rereadQueueIntakes({ roster: roster2, state, filter: queueScanFilter(body), read: async (locationId2) => {
51367
+ const c = clientFor(locationId2);
51368
+ if (!c) return null;
51369
+ const snap = await readIntakeSnapshot(c, locationId2);
51370
+ if (snap.status === "unavailable") return "unavailable";
50623
51371
  const answers = normalizedIntake(snap);
50624
- inputs[entry.locationId] = { answers, status: snap.status, hash: intakeInput(answers, state.accounts?.[entry.locationId]?.industry, composeQuestionSet({ industry: state.accounts?.[entry.locationId]?.industry, agency: readOverlay() })) };
50625
- }
50626
- const latest = readCockpitState();
50627
- writeCockpitState({ ...latest, intakeInputs: inputs });
51372
+ return { answers, status: snap.status, hash: intakeInput(answers, state.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: state.accounts?.[locationId2]?.industry, agency: readOverlay() })) };
51373
+ } });
51374
+ const latest = mergeQueueIntakes(readCockpitState(), reread);
51375
+ if (Object.keys(reread).length) writeCockpitState(latest);
51376
+ const inputs = latest.intakeInputs ?? {};
50628
51377
  res.writeHead(200, { "Content-Type": "application/json" });
50629
- res.end(JSON.stringify(deriveQueue({ roster: roster2, state: { ...latest, intakeInputs: inputs, staleClients: roster2.filter((c) => stageFreshness(inputs[c.locationId]?.hash, readSavedPlan(c.locationId), latest.planApprovals?.[c.locationId], latest.outcomes?.[`${c.locationId}:3`], latest.outcomes?.[`${c.locationId}:4`], latest.outcomes?.[`${c.locationId}:1`]).length).map((c) => c.locationId) }, run: activeRun2 })));
51378
+ res.end(JSON.stringify({ ...deriveQueue({ roster: roster2, state: { ...latest, intakeInputs: inputs, staleClients: roster2.filter((c) => stageFreshness(inputs[c.locationId]?.hash, readSavedPlan(c.locationId), latest.planApprovals?.[c.locationId], latest.outcomes?.[`${c.locationId}:3`], latest.outcomes?.[`${c.locationId}:4`], latest.outcomes?.[`${c.locationId}:1`]).length).map((c) => c.locationId) }, run: activeRun2 }), ...failed.length ? { intakeUnread: failed } : {} }));
50630
51379
  } catch {
50631
51380
  res.writeHead(503, { "Content-Type": "application/json" });
50632
51381
  res.end(JSON.stringify({ ok: false, error: "The work queue could not be read. Retry the queue." }));
@@ -50652,6 +51401,11 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50652
51401
  let st0 = readCockpitState();
50653
51402
  let inputHash;
50654
51403
  let normalizedAnswers;
51404
+ if (stage === 3) {
51405
+ if (!hasChosenScope(st0, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_BEFORE_BUILD);
51406
+ const ap = st0.planApprovals?.[rk];
51407
+ if (ap && ap.scopeHash !== scopeHashOf(st0.scope?.[rk])) throw new Error(SCOPE_CHANGED_SINCE_APPROVAL);
51408
+ }
50655
51409
  if (who.clientKey && [3, 4].includes(stage)) {
50656
51410
  inputHash = secondClientInputHash(readSavedPlan(rk));
50657
51411
  assertBuildApproval(st0.planApprovals?.[rk], inputHash);
@@ -51212,7 +51966,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51212
51966
  }
51213
51967
  const name = currentRoster().find((r) => r.locationId === locationId2)?.name || locationId2;
51214
51968
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
51215
- res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
51969
+ res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2, readSavedPlan(locationId2)), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
51216
51970
  return;
51217
51971
  }
51218
51972
  if (req.method === "GET" && url === "/api/review") {
@@ -51228,15 +51982,16 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51228
51982
  const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
51229
51983
  const rk = who.recordKey;
51230
51984
  if (PROTECTED_LOCATIONS[who.locationId]) throw new Error(PROTECTED_LOCATIONS[who.locationId] + " can never be approved for building.");
51985
+ const current = readCockpitState();
51986
+ if (!revoke && !hasChosenScope(current, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_WHAT_THEY_BOUGHT);
51231
51987
  if (!revoke && !readAccountRulesSafe().sandboxes[who.locationId] && String(confirmName || "").trim().toLowerCase() !== who.name.trim().toLowerCase()) {
51232
51988
  throw new Error(`Type the client's name exactly ("${who.name}") to approve automated building.`);
51233
51989
  }
51234
- const current = readCockpitState();
51235
51990
  const at = (/* @__PURE__ */ new Date()).toISOString();
51236
51991
  const approvals = { ...current.planApprovals };
51237
51992
  if (revoke) delete approvals[rk];
51238
51993
  else if (who.clientKey) {
51239
- approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]) };
51994
+ approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: scopeHashOf(current.scope?.[rk]) };
51240
51995
  } else {
51241
51996
  const c = clientFor(who.locationId);
51242
51997
  if (!c) throw new Error(INTAKE_UNREADABLE);
@@ -51244,7 +51999,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51244
51999
  if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
51245
52000
  const hash = intakeInput(normalizedIntake(snap), current.accounts?.[who.locationId]?.industry, composeQuestionSet({ industry: current.accounts?.[who.locationId]?.industry, agency: readOverlay() }));
51246
52001
  if (current.outcomes?.[`${rk}:1`]?.inputHash && current.outcomes[`${rk}:1`].inputHash !== hash) throw new Error("The intake changed. Run Brief again before approving building.");
51247
- const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]) };
52002
+ const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: scopeHashOf(current.scope?.[rk]) };
51248
52003
  approvals[rk] = approval;
51249
52004
  }
51250
52005
  const latest = readCockpitState();
@@ -51609,9 +52364,23 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51609
52364
  const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
51610
52365
  if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
51611
52366
  const next = features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) };
51612
- writeCockpitState(setClientScope(readCockpitState(), who.recordKey, next));
52367
+ if (Array.isArray(features)) {
52368
+ const ticked = features.map(String).filter(isFeatureId);
52369
+ const offered = offeredFeaturesFor(readCockpitState(), who.recordKey);
52370
+ if (ticked.length && !ticked.some((f) => offered.includes(f))) throw new Error(NOTHING_TICKED_IN_PLAN);
52371
+ }
52372
+ const rk = who.recordKey;
52373
+ let nextState = setClientScope(readCockpitState(), rk, next);
52374
+ const ap = nextState.planApprovals?.[rk];
52375
+ const withdrawn = Boolean(ap && ap.scopeHash !== scopeHashOf(nextState.scope?.[rk]));
52376
+ if (withdrawn) {
52377
+ const approvals = { ...nextState.planApprovals };
52378
+ delete approvals[rk];
52379
+ nextState = { ...setBuildApproval(nextState, rk, null), planApprovals: approvals };
52380
+ }
52381
+ writeCockpitState(nextState);
51613
52382
  res.writeHead(200, { "Content-Type": "application/json" });
51614
- res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[who.recordKey] ?? null }));
52383
+ res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[rk] ?? null, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_SCOPE } : {} }));
51615
52384
  } catch (e) {
51616
52385
  res.writeHead(400, { "Content-Type": "application/json" });
51617
52386
  res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
@@ -51748,7 +52517,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51748
52517
  process.on("SIGTERM", stop2);
51749
52518
  });
51750
52519
  }
51751
- var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
52520
+ var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, SCOPE_REFUSAL, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
51752
52521
  var init_dashboard = __esm({
51753
52522
  "src/dashboard.ts"() {
51754
52523
  "use strict";
@@ -51870,6 +52639,12 @@ var init_dashboard = __esm({
51870
52639
  ];
51871
52640
  ACCOUNT_TYPES = ["client", "sandbox", "template", "hidden"];
51872
52641
  SECOND_CLIENT_NO_INTAKE = "This sub-account's intake form belongs to its default client, so a second client's intake and Brief are not run from here. Prepare this client's plan with its clinic prefix (the build preview), then approve and build it here.";
52642
+ CHOOSE_WHAT_THEY_BOUGHT = "Choose what the client bought before approving: Blueprint Review, then What they bought, then Save.";
52643
+ CHOOSE_BEFORE_BUILD = "Choose what the client bought before building: Blueprint Review, then What they bought, then Save.";
52644
+ SCOPE_REFUSAL = /Nobody has chosen what|what this client bought|features (are )?selected for this client|No features are selected|features nobody ticked/i;
52645
+ NOTHING_TICKED_IN_PLAN = "None of the ticked features is in the plan for this client's industry, so nothing was saved. Tick features from the list, or choose Everything in the plan.";
52646
+ SCOPE_CHANGED_SINCE_APPROVAL = "What they bought changed after the build was approved, so Build did not start. Approve this plan again, then build.";
52647
+ APPROVAL_WITHDRAWN_FOR_SCOPE = "What they bought changed, so the build approval was withdrawn. Approve this plan again before building.";
51873
52648
  activeRun2 = null;
51874
52649
  handoffEmailPreviews = /* @__PURE__ */ new Map();
51875
52650
  UPGRADE_MSG = "\n The cockpit is not included in this licence, so it did not start.\n Nothing was changed.\n\n";
@@ -52308,7 +53083,9 @@ WARNING: saved under ${canonicalCompanyId}, but switch_location resolves ${args.
52308
53083
  locationId: args.test_location_id,
52309
53084
  userId: foundViaRealPath.userId,
52310
53085
  companyId: resolvedCompanyId,
52311
- registry: null
53086
+ // The registry, so a token rotated by this probe is saved to the
53087
+ // company's own slot instead of being lost (review, 2026-09-29).
53088
+ registry: registry2
52312
53089
  });
52313
53090
  try {
52314
53091
  await probe.listWorkflows(1, 0);
@@ -54927,7 +55704,7 @@ async function collectInstallChecks(installedVersion, client, builderClient, reg
54927
55704
  }
54928
55705
  if (result.ok) {
54929
55706
  const idNote = tokenCompany ? ` Authenticated as company ${tokenCompany}.` : "";
54930
- return { name: "Firebase auth (workflow builder)", status: "pass", detail: `ID token refresh succeeded. Workflow builder tools are usable.${companyNote}${idNote}` };
55707
+ return { name: "Firebase auth (workflow builder)", status: "pass", detail: `Got a valid ID token. Workflow builder tools are usable.${companyNote}${idNote}` };
54931
55708
  }
54932
55709
  return { name: "Firebase auth (workflow builder)", status: "fail", detail: `Token refresh failed: ${result.error}.${companyNote} If you're in a client's account, register its Firebase with register_company_firebase. Otherwise re-capture your Firebase values from GHL DevTools and re-run enable_workflow_builder.` };
54933
55710
  })();
@@ -58120,7 +58897,7 @@ function extractHtml(text2) {
58120
58897
  function claudeGenerate(model = DEFAULT_MODEL, bin = "claude") {
58121
58898
  return (instruction, { cwd }) => new Promise((resolveP) => {
58122
58899
  const t0 = Date.now();
58123
- const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model]);
58900
+ const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model, ...NO_MEMBER_HOOKS]);
58124
58901
  const child = (0, import_node_child_process6.spawn)(cmd.command, cmd.args, {
58125
58902
  cwd,
58126
58903
  env: { ...process.env, CLAUDECODE: "", CLAUDE_CODE_ENTRYPOINT: "" },
@@ -58281,6 +59058,7 @@ var init_run2 = __esm({
58281
59058
  "use strict";
58282
59059
  import_node_child_process6 = require("node:child_process");
58283
59060
  init_claude_locate();
59061
+ init_result_text();
58284
59062
  import_node_fs12 = require("node:fs");
58285
59063
  import_node_path11 = require("node:path");
58286
59064
  init_brief();
@@ -61494,7 +62272,7 @@ function registerSecretShopperTools(server2, client, registry2) {
61494
62272
  if (registry2) {
61495
62273
  try {
61496
62274
  const b = workflowReadConnection(registry2, client);
61497
- stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
62275
+ stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
61498
62276
  } catch {
61499
62277
  }
61500
62278
  }
@@ -65893,6 +66671,18 @@ async function readLocationName(client, locationId2) {
65893
66671
  return locationId2;
65894
66672
  }
65895
66673
  }
66674
+ function cockpitSelectionRequired(board, locationId2, recordKey, env2 = process.env) {
66675
+ if (env2.GHL_COMMAND_STAGE) return true;
66676
+ const has = (field, key) => {
66677
+ const m = board[field];
66678
+ return Boolean(m && typeof m === "object" && m[key]);
66679
+ };
66680
+ const own = ["clients", "planApprovals", "buildApproved", "assessmentPurchases", "scope", "reviews", "intakeInputs", "assessmentAttachments", "clientProfiles"].some((f) => has(f, recordKey));
66681
+ return own || recordKey === locationId2 && has("accounts", locationId2);
66682
+ }
66683
+ function noSelectionMessage(clientKey) {
66684
+ return clientKey ? secondClientChooseMessage(clientKey) : NO_SELECTION_SCOPE;
66685
+ }
65896
66686
  function resolvePlanSource(input) {
65897
66687
  const { plan, useSavedPlan, replaceSavedPlan, saved } = input;
65898
66688
  if (useSavedPlan) {
@@ -66027,9 +66817,10 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66027
66817
  if (!briefArg) {
66028
66818
  return jsonResponse({ ok: false, phase: "compose", error: "fromPreset needs the brief \u2014 pass the \xA74 Brief from normalize_submission_to_brief (or assembled from the cockpit intake answers)." });
66029
66819
  }
66030
- let briefForCompose = briefArg;
66031
- let scopeSource = briefArg.scope ? "brief" : "none";
66032
- const briefScope = briefArg.scope;
66820
+ const briefIn = process.env.GHL_COMMAND_STAGE && briefArg.scope ? (({ scope: _ignored, ...rest }) => rest)(briefArg) : briefArg;
66821
+ let briefForCompose = briefIn;
66822
+ let scopeSource = briefIn.scope ? "brief" : "none";
66823
+ const briefScope = briefIn.scope;
66033
66824
  if (briefScope && Array.isArray(briefScope.features) && briefScope.features.length === 0) {
66034
66825
  return jsonResponse({
66035
66826
  ok: false,
@@ -66038,13 +66829,15 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66038
66829
  error: "The brief's scope names no features, so nothing was built. Choose the features to build (prefilled from what they bought), confirm, and run again."
66039
66830
  });
66040
66831
  }
66041
- if (!briefArg.scope) {
66832
+ if (!briefIn.scope) {
66042
66833
  const board = readBoardState();
66043
66834
  const saved2 = board.scope?.[recordKey];
66044
66835
  if (saved2?.wholePreset) {
66045
66836
  scopeSource = "cockpit";
66046
66837
  } else if (!saved2 && Array.isArray(board.assessmentPurchases?.[recordKey])) {
66047
66838
  return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
66839
+ } else if (!saved2 && cockpitSelectionRequired(board, activeLocation, recordKey)) {
66840
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
66048
66841
  } else if (saved2 && Array.isArray(saved2.features)) {
66049
66842
  const features = saved2.features.filter(isFeatureId);
66050
66843
  if (!features.length) {
@@ -66055,7 +66848,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66055
66848
  error: "No features are selected for this client, so nothing was built. Open the client's Review screen, tick the features to build (it is prefilled from what they bought), confirm, and run the build again."
66056
66849
  });
66057
66850
  }
66058
- briefForCompose = { ...briefArg, scope: { ...saved2.package ? { package: saved2.package } : {}, features } };
66851
+ briefForCompose = { ...briefIn, scope: { ...saved2.package ? { package: saved2.package } : {}, features } };
66059
66852
  scopeSource = "cockpit";
66060
66853
  }
66061
66854
  }
@@ -66108,6 +66901,30 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66108
66901
  if (!current && Array.isArray(board.assessmentPurchases?.[recordKey])) {
66109
66902
  return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
66110
66903
  }
66904
+ if (!current && cockpitSelectionRequired(board, activeLocation, recordKey)) {
66905
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
66906
+ }
66907
+ if (current && !current.wholePreset && Array.isArray(current.features) && !(saved && planSource !== "argument-replaces-saved")) {
66908
+ const chosen = new Set(current.features.filter(isFeatureId));
66909
+ const outside = [];
66910
+ for (const w of plan.workflows ?? []) {
66911
+ let f = null;
66912
+ try {
66913
+ f = featureOfWorkflow(String(w.ref ?? ""), w.feature);
66914
+ } catch {
66915
+ f = String(w.feature);
66916
+ }
66917
+ if (f !== null && !chosen.has(f)) outside.push(`${String(w.name ?? w.ref)} (${isFeatureId(f) ? FEATURES[f].name : f})`);
66918
+ }
66919
+ if (chosen.size && outside.length) {
66920
+ return jsonResponse({
66921
+ ok: false,
66922
+ phase: "scope",
66923
+ scopeSource: "cockpit",
66924
+ error: `This plan builds workflows for features nobody ticked for this client, so nothing was built: ${outside.join(", ")}. Build from the preset (fromPreset), which follows what they bought, or tick those features and save first.`
66925
+ });
66926
+ }
66927
+ }
66111
66928
  if (current && !current.wholePreset && Array.isArray(current.features) && !current.features.some(isFeatureId)) {
66112
66929
  return jsonResponse({
66113
66930
  ok: false,
@@ -66116,7 +66933,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66116
66933
  error: "No features are selected for this client, so nothing was built, not even from the saved plan. Open the client's Review screen, tick the features to build, confirm, and run the build again."
66117
66934
  });
66118
66935
  }
66119
- if (saved && planSource !== "argument-replaces-saved" && !savedPlanMatchesScope(saved, current)) {
66936
+ if (saved && planSource !== "argument-replaces-saved" && (current ? !savedPlanMatchesScope(saved, current) : Boolean(saved.scope))) {
66120
66937
  const planned = saved?.scope ? [...saved.scope.built, ...(saved.scope.omitted ?? []).map((f) => f.id)].join(", ") || "no features" : "everything in the preset";
66121
66938
  const now = current?.wholePreset ? "everything in the preset (confirmed)" : current ? current.features.filter(isFeatureId).join(", ") : "everything in the preset (nothing is saved)";
66122
66939
  return jsonResponse({
@@ -66896,7 +67713,7 @@ function intakeFormMatches(expected, actual) {
66896
67713
  const keys = ["id", "tag", "type", "hiddenFieldQueryKey", "required", "label", "options", "picklistOptions"];
66897
67714
  return expected.every((field, i) => keys.every((key) => field[key] === void 0 || JSON.stringify(field[key]) === JSON.stringify(actual[i]?.[key])));
66898
67715
  }
66899
- var import_zod93, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
67716
+ var import_zod93, customFieldItemSchema, sleep6, NO_SELECTION_SCOPE, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
66900
67717
  var init_intake_to_build = __esm({
66901
67718
  "src/tools/intake-to-build.ts"() {
66902
67719
  "use strict";
@@ -66948,7 +67765,8 @@ var init_intake_to_build = __esm({
66948
67765
  picklistOptions: import_zod93.z.array(import_zod93.z.string()).optional()
66949
67766
  }).passthrough();
66950
67767
  sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
66951
- NOT_CONFIRMED_SCOPE = "What this client bought is recorded, but nobody has confirmed what to build, so nothing was built. Open the client's Review screen: the features are ticked from what they bought. Change them if you need to, save to confirm, and run the build again.";
67768
+ NO_SELECTION_SCOPE = "Nobody has chosen what this client bought, so nothing was built. In the cockpit open Blueprint Review, then What they bought: tick what they bought, or choose Everything in the plan, then save and run the build again.";
67769
+ NOT_CONFIRMED_SCOPE = "What this client bought is recorded, but nobody has confirmed what to build, so nothing was built. In the cockpit open Blueprint Review, then What they bought: the features are ticked from what they bought. Change them if you need to, save to confirm, and run the build again.";
66952
67770
  WHOLE_PRESET_NOT_KEPT = "Note: an earlier build on this client used everything in the preset, and that choice was not kept, so the Review screen ticks only what they bought. Tick everything again if that is still what you want.";
66953
67771
  workflowInventorySchema = import_zod93.z.object({
66954
67772
  workflows: import_zod93.z.array(import_zod93.z.object({ id: import_zod93.z.string().min(1), name: import_zod93.z.string() }).passthrough())