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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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: {} };
@@ -24741,7 +25006,7 @@ var init_question_set = __esm({
24741
25006
  field: { kind: "custom", dataType: "LARGE_TEXT" },
24742
25007
  placeholder: "The problems they come to you with (one per line)",
24743
25008
  briefPath: "audience.painPoints",
24744
- briefKind: "list"
25009
+ briefKind: "textList"
24745
25010
  },
24746
25011
  {
24747
25012
  key: "objections",
@@ -24752,7 +25017,7 @@ var init_question_set = __esm({
24752
25017
  field: { kind: "custom", dataType: "LARGE_TEXT" },
24753
25018
  placeholder: "Why prospects hesitate (one per line)",
24754
25019
  briefPath: "audience.objections",
24755
- briefKind: "list"
25020
+ briefKind: "textList"
24756
25021
  },
24757
25022
  // ── Section D: Goal and sales process ──
24758
25023
  {
@@ -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";
@@ -30078,6 +30420,9 @@ function findingsFor(outcome) {
30078
30420
  const list4 = outcome.plain?.issues?.length ? outcome.plain.issues : outcome.issues;
30079
30421
  return (list4 ?? []).map((s) => String(s).trim()).filter(Boolean);
30080
30422
  }
30423
+ function openTasks(tasks2) {
30424
+ return tasks2.filter((t) => !(t.claimed === true && typeof t.text === "string" && SELF_REPORTED.test(t.text)));
30425
+ }
30081
30426
  function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISOString()) {
30082
30427
  const before = new Map(previous.map((t) => [t.id, t]));
30083
30428
  const seen = /* @__PURE__ */ new Set();
@@ -30087,7 +30432,9 @@ function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISO
30087
30432
  if (seen.has(id)) continue;
30088
30433
  seen.add(id);
30089
30434
  const prior = before.get(id);
30090
- if (prior?.claimed) {
30435
+ if (prior?.claimed && SELF_REPORTED.test(text2)) {
30436
+ out.push(prior);
30437
+ } else if (prior?.claimed) {
30091
30438
  out.push({ id, text: text2, reappeared: true });
30092
30439
  } else {
30093
30440
  out.push({ id, text: text2, ...prior?.reappeared ? { reappeared: true } : {} });
@@ -30105,7 +30452,7 @@ function manualProgress(tasks2, verifyHasRun) {
30105
30452
  const total = tasks2.length;
30106
30453
  const claimed = tasks2.filter((t) => t.claimed).length;
30107
30454
  const reappeared = tasks2.filter((t) => t.reappeared).length;
30108
- const clear = verifyHasRun && total === 0;
30455
+ const clear = verifyHasRun && openTasks(tasks2).length === 0;
30109
30456
  let line3;
30110
30457
  if (!verifyHasRun) {
30111
30458
  line3 = "Run the check first. It writes this list for you.";
@@ -30139,9 +30486,11 @@ function manualSignOffRefusal(tasks2, verifyHasRun) {
30139
30486
  }
30140
30487
  return "Everything is ticked, but the check has not confirmed it yet. Run the check again and this will clear on its own.";
30141
30488
  }
30489
+ var SELF_REPORTED;
30142
30490
  var init_manual_tasks = __esm({
30143
30491
  "src/manual-tasks.ts"() {
30144
30492
  "use strict";
30493
+ SELF_REPORTED = /^[^:]+: Bought, but not built automatically yet\./;
30145
30494
  }
30146
30495
  });
30147
30496
 
@@ -30323,7 +30672,7 @@ function deriveQueue(input) {
30323
30672
  if (stages.every((s) => s === "done")) {
30324
30673
  const proof2 = lastProof(state, locationId2, QUEUE_STEPS.length);
30325
30674
  const claim = liveClaim(true, state.goLive?.[locationId2], state.outcomes?.[`${locationId2}:3`]?.at);
30326
- const signedLive = claim.live && state.outcomes?.[`${locationId2}:4`]?.ok === true && !state.outcomes?.[`${locationId2}:4`]?.issues?.length && !state.manualTasks?.[locationId2]?.length && !state.staleClients?.includes(locationId2);
30675
+ const signedLive = claim.live && state.outcomes?.[`${locationId2}:4`]?.ok === true && !state.outcomes?.[`${locationId2}:4`]?.issues?.length && !openTasks(state.manualTasks?.[locationId2] ?? []).length && !state.staleClients?.includes(locationId2);
30327
30676
  finished.push({
30328
30677
  locationId: locationId2,
30329
30678
  clientName: name,
@@ -30457,6 +30806,7 @@ var init_work_queue = __esm({
30457
30806
  "use strict";
30458
30807
  init_stage_runner();
30459
30808
  init_plain_outcome();
30809
+ init_manual_tasks();
30460
30810
  init_go_live();
30461
30811
  QUEUE_STEPS = [
30462
30812
  { label: "Intake", todo: "Set up the intake questions for {client}", why: "Command OS puts the questionnaire into their account." },
@@ -32152,7 +32502,7 @@ var require_package = __commonJS({
32152
32502
  "package.json"(exports2, module2) {
32153
32503
  module2.exports = {
32154
32504
  name: "@elitedcs/ghl-mcp",
32155
- version: "3.89.0-beta.1",
32505
+ version: "3.89.0-beta.3",
32156
32506
  mcpName: "io.github.drjerryrelth/ghl-command",
32157
32507
  description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
32158
32508
  main: "dist/index.js",
@@ -33376,10 +33726,12 @@ function judgeSteps(rawRows, contactId, workflowNames) {
33376
33726
  }
33377
33727
  };
33378
33728
  }
33379
- function liveStepLogReader(locationId2, headers, fetchFn = fetch) {
33729
+ function liveStepLogReader(locationId2, headers, fetchFn = fetch, onUnauthorized) {
33380
33730
  return async (workflowId, fromMs, toMs) => {
33381
33731
  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() });
33732
+ const used = await headers();
33733
+ const res = await fetchFn(`https://backend.leadconnectorhq.com/workflows/logs/v2?${q3}`, { headers: used });
33734
+ if (res.status === 401) onUnauthorized?.(used);
33383
33735
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
33384
33736
  const body = await res.json();
33385
33737
  if (!Array.isArray(body)) throw new Error("the log came back in a shape this version does not know");
@@ -35892,7 +36244,7 @@ function workflowReadConnection(registry2, client, companyId) {
35892
36244
  const home = companyId && companyId === process.env.GHL_COMPANY_ID ? registry2.getFirebase() : void 0;
35893
36245
  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
36246
  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 });
36247
+ return new WorkflowBuilderClient({ firebaseApiKey: config3.apiKey, refreshToken: config3.refreshToken, userId: config3.userId, apiKey: client.getApiKey(), locationId: locationId2, companyId, registry: registry2 });
35896
36248
  }
35897
36249
  var BUILDER_REPAIR_MESSAGE;
35898
36250
  var init_workflow_read_connection = __esm({
@@ -38194,6 +38546,13 @@ function saveBuildObligations(id, items) {
38194
38546
  fs30.writeFileSync(file2 + ".tmp", JSON.stringify([...merged.values()], null, 2), { mode: 384 });
38195
38547
  fs30.renameSync(file2 + ".tmp", file2);
38196
38548
  }
38549
+ function replaceBuildObligationsOfType(id, type, items) {
38550
+ const kept = readBuildObligations(id).filter((m) => m.type !== type);
38551
+ const file2 = fileFor(id);
38552
+ fs30.mkdirSync(path28.dirname(file2), { recursive: true });
38553
+ fs30.writeFileSync(file2 + ".tmp", JSON.stringify([...kept, ...items.map((i) => ({ ...i, type }))], null, 2), { mode: 384 });
38554
+ fs30.renameSync(file2 + ".tmp", file2);
38555
+ }
38197
38556
  function planObjectRefs(plan) {
38198
38557
  const refs2 = /* @__PURE__ */ new Set();
38199
38558
  const visit = (value) => {
@@ -38212,7 +38571,7 @@ function planObjectRefs(plan) {
38212
38571
  function reconcileBuildObligations(id, checks, currentPlan) {
38213
38572
  const refs2 = currentPlan && typeof currentPlan === "object" ? planObjectRefs(currentPlan) : void 0;
38214
38573
  const workflows = currentPlan?.workflows;
38215
- const stillPlanned = (item) => !refs2 || refs2.has(item.ref) || // The producer keys each manual job by workflow name and local job index.
38574
+ const stillPlanned = (item) => !refs2 || refs2.has(item.ref) || NOT_FROM_PLAN.has(item.type) || // The producer keys each manual job by workflow name and local job index.
38216
38575
  // Its index can change or disappear from a later preview without completion.
38217
38576
  item.type === "workflow-manual" && Array.isArray(workflows) && workflows.some((workflow) => item.ref.startsWith(`${workflow.name}:`) && /^\d+$/.test(item.ref.slice(workflow.name.length + 1)));
38218
38577
  const remaining = readBuildObligations(id).filter((item) => stillPlanned(item) && !checks.some((c) => c.ref === item.ref && c.type === item.type && c.ok === true && typeof c.evidence === "string" && c.evidence.trim()));
@@ -38221,13 +38580,14 @@ function reconcileBuildObligations(id, checks, currentPlan) {
38221
38580
  fs30.writeFileSync(file2 + ".tmp", JSON.stringify(remaining, null, 2), { mode: 384 });
38222
38581
  fs30.renameSync(file2 + ".tmp", file2);
38223
38582
  }
38224
- var fs30, path28;
38583
+ var fs30, path28, NOT_FROM_PLAN;
38225
38584
  var init_build_obligations = __esm({
38226
38585
  "src/build-obligations.ts"() {
38227
38586
  "use strict";
38228
38587
  fs30 = __toESM(require("node:fs"));
38229
38588
  path28 = __toESM(require("node:path"));
38230
38589
  init_credentials_store();
38590
+ NOT_FROM_PLAN = /* @__PURE__ */ new Set(["bought-not-built"]);
38231
38591
  }
38232
38592
  });
38233
38593
 
@@ -38873,12 +39233,12 @@ var init_run_cost = __esm({
38873
39233
  function callClaudeRecorded(bin, argv, context, onCost = () => {
38874
39234
  }) {
38875
39235
  return new Promise((resolve13, reject) => {
38876
- let text2 = "";
39236
+ const run = new RunTexts();
38877
39237
  let buffer = "";
38878
39238
  let stderr = "";
38879
39239
  let facts = {};
38880
39240
  let settled = false;
38881
- const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose"]);
39241
+ const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose", ...NO_MEMBER_HOOKS]);
38882
39242
  const child = (0, import_node_child_process5.spawn)(cmd.command, cmd.args, { stdio: ["ignore", "pipe", "pipe"] });
38883
39243
  const timer = setTimeout(() => child.kill("SIGKILL"), 6 * 60 * 1e3);
38884
39244
  const finish3 = (ok) => {
@@ -38891,17 +39251,14 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
38891
39251
  onCost("The cost record could not be saved.");
38892
39252
  }
38893
39253
  onCost(`${context.kind}: ${facts.costUsd === void 0 ? "cost not reported" : formatUsd(facts.costUsd)}.`);
38894
- if (ok) resolve13(text2);
39254
+ if (ok) resolve13(run.pick());
38895
39255
  else reject(new Error("Check your Claude authentication in Your agency before reading this source."));
38896
39256
  };
38897
39257
  const consume = (line3) => {
38898
39258
  try {
38899
39259
  const ev = JSON.parse(line3);
38900
39260
  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;
39261
+ run.event(ev);
38905
39262
  } catch {
38906
39263
  }
38907
39264
  };
@@ -38919,7 +39276,7 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
38919
39276
  child.on("error", () => finish3(false));
38920
39277
  child.on("close", (code) => {
38921
39278
  if (buffer.trim()) consume(buffer);
38922
- finish3(code === 0 && Boolean(text2));
39279
+ finish3(code === 0 && !run.empty);
38923
39280
  });
38924
39281
  });
38925
39282
  }
@@ -38931,6 +39288,7 @@ var init_claude_call = __esm({
38931
39288
  init_run_cost();
38932
39289
  init_claude_cli();
38933
39290
  init_claude_locate();
39291
+ init_result_text();
38934
39292
  }
38935
39293
  });
38936
39294
 
@@ -39151,10 +39509,40 @@ function briefWarnings(brief) {
39151
39509
  }
39152
39510
  return out;
39153
39511
  }
39512
+ function liftGoalCalendars(input) {
39513
+ if (!input || typeof input !== "object" || Array.isArray(input)) return { input, notes: [] };
39514
+ const brief = input;
39515
+ const goal = brief.goal;
39516
+ if (!goal || typeof goal !== "object" || Array.isArray(goal)) return { input, notes: [] };
39517
+ const misplaced = goal.calendars;
39518
+ if (!Array.isArray(misplaced)) return { input, notes: [] };
39519
+ const top = brief.calendars;
39520
+ if (Array.isArray(top) && top.length > 0) {
39521
+ if (misplaced.length === 0) return { input, notes: [] };
39522
+ const names = misplaced.map((c, i) => {
39523
+ const n = c?.name;
39524
+ return typeof n === "string" && n.trim() ? `"${n.trim()}"` : `entry ${i + 1}`;
39525
+ });
39526
+ return {
39527
+ input,
39528
+ 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.`]
39529
+ };
39530
+ }
39531
+ const lifted = [];
39532
+ const notes = [];
39533
+ misplaced.forEach((c, i) => {
39534
+ const r = briefCalendarSchema.safeParse(c);
39535
+ if (r.success) lifted.push(r.data);
39536
+ 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.`);
39537
+ });
39538
+ const { calendars: _moved, ...restGoal } = goal;
39539
+ return { input: { ...brief, goal: restGoal, ...lifted.length ? { calendars: lifted } : {} }, notes };
39540
+ }
39154
39541
  function validateBrief(input) {
39155
- const parsed2 = briefSchema.safeParse(input);
39542
+ const lifted = liftGoalCalendars(input);
39543
+ const parsed2 = briefSchema.safeParse(lifted.input);
39156
39544
  if (parsed2.success) {
39157
- return { valid: true, errors: [], warnings: briefWarnings(parsed2.data), brief: parsed2.data };
39545
+ return { valid: true, errors: [], warnings: [...lifted.notes, ...briefWarnings(parsed2.data)], brief: parsed2.data };
39158
39546
  }
39159
39547
  return {
39160
39548
  valid: false,
@@ -39302,8 +39690,12 @@ function firstString(v) {
39302
39690
  }
39303
39691
  return void 0;
39304
39692
  }
39305
- function splitList(text2) {
39306
- return text2.split(/\r?\n|;|•/).flatMap((line3) => line3.includes(",") && !/\d,\d/.test(line3) ? line3.split(",") : [line3]).map((s) => s.replace(/^[\s\-*\d.)]+/, "").trim()).filter(Boolean);
39693
+ function splitList(text2, opts = {}) {
39694
+ let lines = text2.split(/\r?\n/).filter((l) => l.trim());
39695
+ if (lines.length > 1 && /:\s*$/.test(lines[0])) lines = lines.slice(1);
39696
+ const oneLine = (line3) => line3.split(/;|•/).flatMap((part) => opts.commas !== false && part.includes(",") && !/\d,\d/.test(part) ? part.split(",") : [part]);
39697
+ const items = lines.length > 1 ? lines : lines.flatMap(oneLine);
39698
+ return items.map((s) => s.replace(/^[\s\-*•\d.)]+/, "").replace(/[\s,;]+$/, "").trim()).filter(Boolean);
39307
39699
  }
39308
39700
  function parsePricePoints(text2) {
39309
39701
  const out = [];
@@ -39316,10 +39708,13 @@ function parsePricePoints(text2) {
39316
39708
  const t = segment.trim().replace(/\.$/, "");
39317
39709
  if (!t) continue;
39318
39710
  const m = t.match(ENTRY);
39711
+ const withTerms = m ? null : t.match(/^(.*?)\s*[-–—:|]?\s*\$\s*([\d,]+(?:\.\d+)?)\s*(.+)$/);
39319
39712
  if (m && m[2]) {
39320
39713
  const name = clean3(m[1]);
39321
39714
  const price = (m[3] ? `${num3(m[2])}-${num3(m[3])}` : num3(m[2])) + recur(m[4]);
39322
39715
  out.push({ name: name || t, price });
39716
+ } else if (withTerms && clean3(withTerms[1])) {
39717
+ out.push({ name: clean3(withTerms[1]), price: num3(withTerms[2]) + (withTerms[3].trim().startsWith("/") ? "" : " ") + withTerms[3].trim() });
39323
39718
  } else {
39324
39719
  out.push({ name: t, price: "" });
39325
39720
  }
@@ -39335,7 +39730,16 @@ function cleanLines(text2) {
39335
39730
  function parseStaffLines(text2) {
39336
39731
  const staff = [];
39337
39732
  const warnings = [];
39338
- cleanLines(text2).forEach((line3, i) => {
39733
+ const emailsIn = (t) => splitFields(t).filter((f) => EMAIL_RE2.test(f.replace(/^e-?mail\s*[:=]\s*/i, ""))).length;
39734
+ const lines = cleanLines(text2).flatMap((line3) => {
39735
+ if (!line3.includes(";") || emailsIn(line3) < 2) return [line3];
39736
+ const pieces = line3.split(";").map((p) => p.trim()).filter(Boolean);
39737
+ return pieces.every((p) => emailsIn(p) === 1) ? pieces : [line3];
39738
+ });
39739
+ lines.forEach((line3, i) => {
39740
+ if (emailsIn(line3) > 1) {
39741
+ warnings.push(`Staff line ${i + 1} ("${line3}") lists more than one email address, so only the first person was read. Put one person per line.`);
39742
+ }
39339
39743
  const fields = splitFields(line3);
39340
39744
  let email;
39341
39745
  let mobile;
@@ -39473,12 +39877,29 @@ function cleanPersonCandidate(s) {
39473
39877
  if (durationMinutesFromWords(t) !== void 0) return void 0;
39474
39878
  return t;
39475
39879
  }
39476
- function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames) {
39477
- const fields = splitFields(entry);
39880
+ function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames, sharedPeople = []) {
39881
+ let bracketMinutes;
39882
+ const bracketNotes = [];
39883
+ const bracketPeople = [];
39884
+ const staffIn = (part) => knownStaffNames.length ? matchesStaffName(part.replace(/^(?:with|by)\s+/i, ""), knownStaffNames) : void 0;
39885
+ const unbracketed = entry.replace(/\s*\(([^()]*)\)/g, (whole, inner) => {
39886
+ const hasLength = countDurations(inner) > 0;
39887
+ if (!hasLength && !/[,$;]/.test(inner) && !staffIn(inner)) return whole;
39888
+ const parts = inner.split(/[,;]/).map((x) => x.trim()).filter(Boolean);
39889
+ for (const part of parts) {
39890
+ const d = durationInProse(part);
39891
+ const person = staffIn(part);
39892
+ if (d && bracketMinutes === void 0 && !d.rest.replace(/[\s.,;:-]+/g, "")) bracketMinutes = d.minutes;
39893
+ else if (person) bracketPeople.push(person);
39894
+ else bracketNotes.push(part);
39895
+ }
39896
+ return " ";
39897
+ }).replace(/\s{2,}/g, " ").trim();
39898
+ const fields = splitFields(unbracketed);
39478
39899
  let nameField = fields.shift() ?? "";
39479
39900
  let type;
39480
- let durationMinutes;
39481
- const candidates = [];
39901
+ let durationMinutes = bracketMinutes;
39902
+ const candidates = [...sharedPeople, ...bracketPeople];
39482
39903
  let proseName = false;
39483
39904
  const inName = durationInProse(nameField);
39484
39905
  if (inName) {
@@ -39539,6 +39960,9 @@ function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames)
39539
39960
  warnings.push(`Calendar line ${index + 1} ("${entry}") was skipped: no calendar name found.`);
39540
39961
  return;
39541
39962
  }
39963
+ if (bracketNotes.length) {
39964
+ warnings.push(`Calendar "${name}": Review the details "${bracketNotes.join(", ")}". They were kept as a note, not as calendar settings.`);
39965
+ }
39542
39966
  const people = [];
39543
39967
  for (const c of candidates) {
39544
39968
  const cleaned = cleanPersonCandidate(c);
@@ -39581,10 +40005,18 @@ function parseCalendarLines(text2, knownStaffNames = []) {
39581
40005
  const calendars = [];
39582
40006
  const warnings = [];
39583
40007
  const entries = [];
40008
+ const sharedPeople = [];
40009
+ const SHARED = /(?:^|[.;]\s*)(?:both|all|each)(?:\s+(?:of\s+them|calendars?|are|is))*\s+(?:with|by|for)\s+([^.;\n]+)[.;]?\s*$/i;
39584
40010
  for (const rawLine of text2.split(/\r?\n/)) {
39585
40011
  let line3 = rawLine.replace(/^[\s\-*•]+/, "").trim();
39586
40012
  if (!line3) continue;
39587
- line3 = line3.replace(LEADING_COUNT_WITH_NOUN, "").replace(LEADING_COUNT_BEFORE_MARKER, "");
40013
+ const shared = SHARED.exec(line3);
40014
+ if (shared) {
40015
+ sharedPeople.push(...splitNames(shared[1]));
40016
+ line3 = line3.slice(0, shared.index).trim();
40017
+ if (!line3) continue;
40018
+ }
40019
+ line3 = line3.replace(LEADING_COUNT_WITH_NOUN, "").replace(LEADING_COUNT_BEFORE_MARKER, "").replace(LEADING_BARE_COUNT, "");
39588
40020
  for (const marked3 of splitOnMarkers(line3)) {
39589
40021
  for (const clause of splitOnAnd(marked3)) {
39590
40022
  const cleaned = clause.replace(/^\(?\d{1,2}[).]\s+/, "").replace(/^[\s\-*•]+/, "").trim();
@@ -39592,7 +40024,7 @@ function parseCalendarLines(text2, knownStaffNames = []) {
39592
40024
  }
39593
40025
  }
39594
40026
  }
39595
- entries.forEach((entry, i) => parseCalendarEntry(entry, i, calendars, warnings, knownStaffNames));
40027
+ entries.forEach((entry, i) => parseCalendarEntry(entry, i, calendars, warnings, knownStaffNames, sharedPeople));
39596
40028
  return { calendars, warnings };
39597
40029
  }
39598
40030
  function coerceTriState(v) {
@@ -39620,6 +40052,10 @@ function coerceValue(raw, kind, warnings) {
39620
40052
  const s = firstString(raw);
39621
40053
  return s ? splitList(s) : void 0;
39622
40054
  }
40055
+ case "textList": {
40056
+ const s = firstString(raw);
40057
+ return s ? splitList(s, { commas: false }) : void 0;
40058
+ }
39623
40059
  case "pricePoints": {
39624
40060
  const s = firstString(raw);
39625
40061
  return s ? parsePricePoints(s) : void 0;
@@ -39807,7 +40243,7 @@ function normalizeSubmissionToBrief(opts) {
39807
40243
  brief.flags = deriveFlags(brief);
39808
40244
  return brief;
39809
40245
  }
39810
- var EMAIL_RE2, PHONE_RE, CALENDAR_TYPE_WORDS, DURATION_IN_PROSE, COUNT_WORD, LEADING_COUNT_BEFORE_MARKER, LEADING_COUNT_WITH_NOUN, ASSIGNMENT_SLOTS;
40246
+ var EMAIL_RE2, PHONE_RE, CALENDAR_TYPE_WORDS, DURATION_IN_PROSE, COUNT_WORD, LEADING_COUNT_BEFORE_MARKER, LEADING_COUNT_WITH_NOUN, LEADING_BARE_COUNT, ASSIGNMENT_SLOTS;
39811
40247
  var init_normalizer = __esm({
39812
40248
  "src/intake-to-build/normalizer.ts"() {
39813
40249
  "use strict";
@@ -39826,6 +40262,7 @@ var init_normalizer = __esm({
39826
40262
  `^\\s*(?:(?:we|i)(?:'d| would| will)?\\s*(?:need|want|like|use)\\s+)?${COUNT_WORD}\\s+(?:booking\\s+)?calendars?\\s*[.:;,\u2013\u2014-]+\\s*`,
39827
40263
  "i"
39828
40264
  );
40265
+ LEADING_BARE_COUNT = new RegExp(`^\\s*${COUNT_WORD}\\s*:\\s+`, "i");
39829
40266
  ASSIGNMENT_SLOTS = [
39830
40267
  { key: "notifyName", label: "who should be notified about new leads" },
39831
40268
  { key: "callsName", label: "who takes booking and follow-up calls" }
@@ -40034,18 +40471,33 @@ function keptByCondition(items, ctx) {
40034
40471
  function isFillBlock(v) {
40035
40472
  return !!v && typeof v === "object" && !Array.isArray(v) && ("fillFrom" in v || "transform" in v);
40036
40473
  }
40037
- function composeStages(block, ctx, referencedStageRefs) {
40474
+ function slugHasPhrase(slug4, phrase) {
40475
+ return `_${slug4}_`.includes(`_${slugify2(phrase)}_`);
40476
+ }
40477
+ function isTerminalStage(name) {
40478
+ const s = slugify2(name);
40479
+ return TERMINAL_STAGE_WORDS.some((w) => slugHasPhrase(s, w));
40480
+ }
40481
+ function stringList(v) {
40482
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
40483
+ }
40484
+ function composeStages(block, ctx, uses, remaps) {
40038
40485
  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) }));
40486
+ const defaultList = defaults.map((d) => ({
40487
+ ref: String(d.ref),
40488
+ name: fillString(String(d.name), ctx),
40489
+ aliases: stringList(d.aliases)
40490
+ }));
40040
40491
  const briefStages = (ctx.brief.goal.salesStages ?? []).map((s) => s.trim()).filter(Boolean);
40041
40492
  if (!isFillBlock(block) || briefStages.length === 0) {
40042
- return defaultList.map((d, i) => ({ ...d, position: i }));
40493
+ return defaultList.map((d, i) => ({ ref: d.ref, name: d.name, position: i }));
40043
40494
  }
40044
40495
  const bySlug = /* @__PURE__ */ new Map();
40045
40496
  for (const d of defaultList) {
40046
40497
  bySlug.set(slugify2(d.name), d);
40047
40498
  bySlug.set(d.ref.replace(/^stage\./, ""), d);
40048
40499
  }
40500
+ const defaultRefs = new Set(defaultList.map((d) => d.ref));
40049
40501
  const out = [];
40050
40502
  const usedRefs = /* @__PURE__ */ new Set();
40051
40503
  for (const name of briefStages) {
@@ -40056,39 +40508,160 @@ function composeStages(block, ctx, referencedStageRefs) {
40056
40508
  } else {
40057
40509
  let ref = `stage.${slugify2(name)}`;
40058
40510
  let n = 2;
40059
- while (usedRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
40511
+ while (usedRefs.has(ref) || defaultRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
40060
40512
  usedRefs.add(ref);
40061
40513
  out.push({ ref, name });
40062
40514
  }
40063
40515
  }
40064
- for (const d of defaultList) {
40065
- if (usedRefs.has(d.ref)) continue;
40066
- if (!referencedStageRefs.has(d.ref)) continue;
40516
+ const findClientStage = (phrases, allowTerminal, skipOwned) => {
40517
+ for (const phrase of phrases) {
40518
+ for (const s of out) {
40519
+ if (!allowTerminal && isTerminalStage(s.name)) continue;
40520
+ if (skipOwned && defaultRefs.has(s.ref)) continue;
40521
+ if (slugHasPhrase(slugify2(s.name), phrase)) return s;
40522
+ }
40523
+ }
40524
+ return void 0;
40525
+ };
40526
+ const defaultName = (ref) => defaultList.find((d) => d.ref === ref)?.name ?? ref.replace(/^stage\./, "");
40527
+ const stillReferenced = /* @__PURE__ */ new Set();
40528
+ const preferred = /* @__PURE__ */ new Set();
40529
+ for (const u of uses) {
40530
+ const key = `${u.workflowRef}\0${u.ref}`;
40531
+ if (preferred.has(key)) continue;
40532
+ const target = u.prefer?.length ? findClientStage(u.prefer, true, false) : void 0;
40533
+ if (!target) {
40534
+ stillReferenced.add(u.ref);
40535
+ continue;
40536
+ }
40537
+ preferred.add(key);
40538
+ if (target.ref === u.ref) continue;
40539
+ let m = remaps.byWorkflow.get(u.workflowRef);
40540
+ if (!m) {
40541
+ m = /* @__PURE__ */ new Map();
40542
+ remaps.byWorkflow.set(u.workflowRef, m);
40543
+ }
40544
+ m.set(u.ref, target.ref);
40545
+ ctx.warnings.push(
40546
+ `Mapped "${defaultName(u.ref)}" automations in ${u.workflowName} onto your "${target.name}" stage.`
40547
+ );
40548
+ }
40549
+ const pending = defaultList.filter((d) => !usedRefs.has(d.ref) && stillReferenced.has(d.ref));
40550
+ const candidates = [];
40551
+ for (const d of pending) {
40552
+ const terminalRef = isTerminalStage(d.name);
40553
+ const phrases = [d.name, ...d.aliases];
40554
+ for (const [sIdx, s] of out.entries()) {
40555
+ if (defaultRefs.has(s.ref)) continue;
40556
+ if (!terminalRef && isTerminalStage(s.name)) continue;
40557
+ const slug4 = slugify2(s.name);
40558
+ const aliasIdx = phrases.findIndex((p) => slugHasPhrase(slug4, p));
40559
+ if (aliasIdx < 0) continue;
40560
+ const best = phrases.filter((p) => slugHasPhrase(slug4, p)).map(slugify2).sort((a, b) => b.split("_").length - a.split("_").length || b.length - a.length)[0];
40561
+ candidates.push({ d, s, words: best.split("_").length, len: best.length, aliasIdx, dIdx: defaultList.indexOf(d), sIdx });
40562
+ }
40563
+ }
40564
+ candidates.sort((a, b) => b.words - a.words || b.len - a.len || a.aliasIdx - b.aliasIdx || a.dIdx - b.dIdx || a.sIdx - b.sIdx);
40565
+ const taken = /* @__PURE__ */ new Set();
40566
+ const mapped = /* @__PURE__ */ new Set();
40567
+ for (const c of candidates) {
40568
+ if (mapped.has(c.d.ref) || taken.has(c.s.ref)) continue;
40569
+ mapped.add(c.d.ref);
40570
+ taken.add(c.s.ref);
40571
+ remaps.global.set(c.d.ref, c.s.ref);
40572
+ }
40573
+ const first2 = defaultList[0];
40574
+ 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)) {
40575
+ mapped.add(first2.ref);
40576
+ taken.add(out[0].ref);
40577
+ remaps.global.set(first2.ref, out[0].ref);
40578
+ }
40579
+ for (const d of pending) {
40580
+ const to = remaps.global.get(d.ref);
40581
+ const s = to ? out.find((x) => x.ref === to) : void 0;
40582
+ if (s) ctx.warnings.push(`Mapped "${d.name}" automations onto your "${s.name}" stage.`);
40583
+ }
40584
+ const placedAt = (ref) => {
40585
+ const target = remaps.global.get(ref) ?? ref;
40586
+ return out.findIndex((x) => x.ref === target);
40587
+ };
40588
+ for (const d of pending) {
40589
+ if (mapped.has(d.ref)) continue;
40590
+ const i = defaultList.indexOf(d);
40591
+ const firstTerminal = out.findIndex((s) => isTerminalStage(s.name));
40592
+ let at = -1;
40593
+ let where = "";
40594
+ for (let j = i - 1; j >= 0 && at < 0; j--) {
40595
+ const k = placedAt(defaultList[j].ref);
40596
+ if (k >= 0 && !isTerminalStage(out[k].name)) {
40597
+ at = k + 1;
40598
+ where = `just after your "${out[k].name}" stage`;
40599
+ }
40600
+ }
40601
+ for (let j = i + 1; j < defaultList.length && at < 0; j++) {
40602
+ const k = placedAt(defaultList[j].ref);
40603
+ if (k >= 0 && !isTerminalStage(out[k].name)) {
40604
+ at = k;
40605
+ where = `just before your "${out[k].name}" stage`;
40606
+ }
40607
+ }
40608
+ if (at >= 0 && firstTerminal >= 0 && at > firstTerminal) at = -1;
40609
+ if (at < 0 && firstTerminal >= 0) {
40610
+ at = firstTerminal;
40611
+ where = `just before your "${out[firstTerminal].name}" stage`;
40612
+ }
40613
+ if (at < 0) {
40614
+ at = out.length;
40615
+ where = "at the end of your stages";
40616
+ }
40617
+ out.splice(at, 0, { ref: d.ref, name: d.name });
40067
40618
  usedRefs.add(d.ref);
40068
- out.push(d);
40069
40619
  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.`
40620
+ `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
40621
  );
40072
40622
  }
40073
40623
  return out.map((s, i) => ({ ...s, position: i }));
40074
40624
  }
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
- };
40625
+ function collectStageUses(workflows, ctx) {
40626
+ const uses = [];
40086
40627
  for (const w of workflows) {
40628
+ const workflowRef = String(w.ref ?? "");
40629
+ const workflowName = typeof w.name === "string" ? fillString(w.name, ctx) : workflowRef;
40630
+ const pref = w.stagePreference && typeof w.stagePreference === "object" ? w.stagePreference : {};
40631
+ const add2 = (ref) => {
40632
+ const prefer = stringList(pref[ref]);
40633
+ uses.push({ workflowRef, workflowName, ref, ...prefer.length ? { prefer } : {} });
40634
+ };
40635
+ const scanActions = (actions) => {
40636
+ for (const a of keptByCondition(actions, ctx)) {
40637
+ if (typeof a.stageRef === "string") add2(a.stageRef);
40638
+ if (a.type === "find_opportunity") {
40639
+ scanActions(a.found);
40640
+ scanActions(a.notFound);
40641
+ }
40642
+ }
40643
+ };
40087
40644
  const trig = w.trigger;
40088
- if (trig && typeof trig.stageRef === "string") refs2.add(trig.stageRef);
40645
+ if (trig && typeof trig.stageRef === "string") add2(trig.stageRef);
40089
40646
  scanActions(w.actions);
40090
40647
  }
40091
- return refs2;
40648
+ return uses;
40649
+ }
40650
+ function remapWorkflowStages(w, remaps) {
40651
+ const own = remaps.byWorkflow.get(String(w.ref));
40652
+ const to = (ref) => own?.get(ref) ?? remaps.global.get(ref) ?? ref;
40653
+ const walk2 = (actions) => {
40654
+ for (const a of actions ?? []) {
40655
+ if (typeof a.stageRef === "string") a.stageRef = to(a.stageRef);
40656
+ if (a.type === "find_opportunity") {
40657
+ walk2(a.found);
40658
+ walk2(a.notFound);
40659
+ }
40660
+ }
40661
+ };
40662
+ const trig = w.trigger;
40663
+ if (trig && typeof trig.stageRef === "string") trig.stageRef = to(trig.stageRef);
40664
+ walk2(w.actions);
40092
40665
  }
40093
40666
  function calendarTypeForBrief(briefType, calName, teamSize) {
40094
40667
  const mapped = BRIEF_CALENDAR_TYPE_MAP[briefType] ?? "event";
@@ -40246,12 +40819,13 @@ function planFromPreset(input) {
40246
40819
  ...omitted.length ? { omitted } : {}
40247
40820
  };
40248
40821
  })() : void 0;
40249
- const referencedStageRefs = collectReferencedStageRefs(keptWorkflowSkeletons, ctx);
40822
+ const stageUses = collectStageUses(keptWorkflowSkeletons, ctx);
40823
+ const stageRemaps = { global: /* @__PURE__ */ new Map(), byWorkflow: /* @__PURE__ */ new Map() };
40250
40824
  const users = ctx.users;
40251
40825
  const pipelines = keptByCondition(sk.pipelines, ctx).map((pl) => ({
40252
40826
  ref: String(pl.ref),
40253
40827
  name: fillString(String(pl.name), ctx),
40254
- stages: composeStages(pl.stages, ctx, referencedStageRefs)
40828
+ stages: composeStages(pl.stages, ctx, stageUses, stageRemaps)
40255
40829
  }));
40256
40830
  const customFields = keptByCondition(sk.customFields, ctx).map((f) => fillNode(f, ctx));
40257
40831
  const tags = keptByCondition(sk.tags, ctx).map((t) => fillNode(t, ctx));
@@ -40303,7 +40877,9 @@ function planFromPreset(input) {
40303
40877
  const workflows = keptWorkflowSkeletons.map((raw) => {
40304
40878
  const { actions, ...rest } = raw;
40305
40879
  const w = fillNode(rest, ctx);
40880
+ delete w.stagePreference;
40306
40881
  w.actions = composeActions(actions, ctx, templateRefByAsset);
40882
+ remapWorkflowStages(w, stageRemaps);
40307
40883
  return w;
40308
40884
  });
40309
40885
  if (scope) {
@@ -40362,6 +40938,8 @@ function planFromPreset(input) {
40362
40938
  }
40363
40939
  const handoffs = keptByCondition(sk.handoffs, ctx).map((raw) => {
40364
40940
  const h = fillNode(raw, ctx);
40941
+ if (typeof h.produces === "string") h.produces = stageRemaps.global.get(h.produces) ?? h.produces;
40942
+ if (Array.isArray(h.blocks)) h.blocks = h.blocks.map((b) => stageRemaps.global.get(b) ?? b);
40365
40943
  if (typeof h.produces === "string" && !definedRefs.has(h.produces)) delete h.produces;
40366
40944
  if (Array.isArray(h.blocks)) {
40367
40945
  h.blocks = h.blocks.filter((b) => b.endsWith(".*") || definedRefs.has(b));
@@ -40554,7 +41132,7 @@ function loadPresetChoice(requested, briefPreset) {
40554
41132
  if (generic) return { ok: true, preset: generic, file: "generic-client.preset.json", note: "Using the generic preset." };
40555
41133
  return { ok: false, error: "No usable preset file was found in the library." };
40556
41134
  }
40557
- var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, BRIEF_CALENDAR_TYPE_MAP;
41135
+ var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, TERMINAL_STAGE_WORDS, BRIEF_CALENDAR_TYPE_MAP;
40558
41136
  var init_plan_from_preset = __esm({
40559
41137
  "src/intake-to-build/plan-from-preset.ts"() {
40560
41138
  "use strict";
@@ -40571,6 +41149,28 @@ var init_plan_from_preset = __esm({
40571
41149
  };
40572
41150
  INLINE_TOKEN_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+|\[\d+\])*)\s*(?:\|\|([^}]*))?\}\}/g;
40573
41151
  ADMIN_ROLE_RE = /\b(owner|founder|admin|administrator|ceo|principal|director)\b/i;
41152
+ TERMINAL_STAGE_WORDS = [
41153
+ "lost",
41154
+ "closed",
41155
+ "won",
41156
+ "no_show",
41157
+ "noshow",
41158
+ "no_sale",
41159
+ "not_interested",
41160
+ "not_a_fit",
41161
+ "no_fit",
41162
+ "disqualified",
41163
+ "unqualified",
41164
+ "dead",
41165
+ "cancelled",
41166
+ "canceled",
41167
+ "declined",
41168
+ "dnd",
41169
+ "do_not_disturb",
41170
+ "churned",
41171
+ "lapsed",
41172
+ "archived"
41173
+ ];
40574
41174
  BRIEF_CALENDAR_TYPE_MAP = {
40575
41175
  one_on_one: "event",
40576
41176
  round_robin: "round_robin",
@@ -41349,6 +41949,193 @@ var init_attachment = __esm({
41349
41949
  }
41350
41950
  });
41351
41951
 
41952
+ // src/bought-list.ts
41953
+ function isBoughtId(x) {
41954
+ return typeof x === "string" && BOUGHT_IDS.includes(x);
41955
+ }
41956
+ function boughtItemsFor(offered) {
41957
+ return ASSESSMENT_FEATURES.map((f) => {
41958
+ const id = f.id;
41959
+ const base = { id, name: f.name, means: BOUGHT_MEANS[id] };
41960
+ const feature = FEATURE_FOR_BOUGHT[id];
41961
+ if (feature) {
41962
+ const next = id === "missed" ? { next: CALL_ANSWERING_NEXT } : {};
41963
+ if (!offered.includes(feature)) {
41964
+ return { ...base, kind: "not-in-plan", feature, ...next, delivery: `Not built for this client: ${FEATURES[feature].name.toLowerCase()} is not in the plan for this industry.` };
41965
+ }
41966
+ return { ...base, kind: "built", feature, ...next, delivery: BUILT_LABEL[id] };
41967
+ }
41968
+ const other = OTHER_DELIVERY[id];
41969
+ return { ...base, kind: other.kind, delivery: other.text };
41970
+ });
41971
+ }
41972
+ function featuresFromBought(ticks, offered) {
41973
+ return FEATURE_IDS.filter((f) => ticks.includes(BUILD_ASSESSMENT_FEATURE[f]) && offered.includes(f));
41974
+ }
41975
+ function scopeFromBought(ticks, offered, pkg2) {
41976
+ const features = featuresFromBought(ticks, offered);
41977
+ if (!pkg2 && offered.length && offered.every((f) => features.includes(f))) return { wholePreset: true };
41978
+ return { ...pkg2 ? { package: pkg2 } : {}, features };
41979
+ }
41980
+ function isAssessmentChoice(x) {
41981
+ return typeof x === "string" && ASSESSMENT_CHOICES.includes(x);
41982
+ }
41983
+ function ticksFor(state, rk) {
41984
+ const saved = state.bought?.[rk];
41985
+ if (Array.isArray(saved)) return { ticks: BOUGHT_IDS.filter((id) => saved.includes(id)), source: "saved" };
41986
+ const sc = state.scope?.[rk];
41987
+ if (sc && !sc.wholePreset && Array.isArray(sc.features)) {
41988
+ const chosen = sc.features.filter(isFeatureId).map((f) => BUILD_ASSESSMENT_FEATURE[f]);
41989
+ return { ticks: BOUGHT_IDS.filter((id) => !FEATURE_FOR_BOUGHT[id] || chosen.includes(id)), source: "scope" };
41990
+ }
41991
+ if (sc) return { ticks: [...BOUGHT_IDS], source: "scope" };
41992
+ const purchases = state.assessmentPurchases?.[rk];
41993
+ if (Array.isArray(purchases)) return { ticks: BOUGHT_IDS.filter((id) => purchases.includes(id)), source: "assessment" };
41994
+ return { ticks: [...BOUGHT_IDS], source: "all" };
41995
+ }
41996
+ function assessmentAnswerOf(state, rk) {
41997
+ if (state.assessmentAttachments?.[rk]) return "attached";
41998
+ const c = state.assessmentChoice?.[rk]?.choice;
41999
+ return isAssessmentChoice(c) ? c : null;
42000
+ }
42001
+ function secondClinicAssessmentMessage(clientKey, name) {
42002
+ return `Nobody has answered the Assessment question for ${name ? name : `the second clinic "${clientKey}"`}, so nothing was approved or built. For a second clinic in a sub-account the only answer available for now is Build without an Assessment. Send the cockpit POST /api/assessment-choice with this sub-account's locationId, clientKey "${clientKey}" and choice "without". Then approve and build again.`;
42003
+ }
42004
+ function assessmentGateRefusal(state, rk, clientKey, name) {
42005
+ const answer2 = assessmentAnswerOf(state, rk);
42006
+ if (state.assessmentAttachments?.[rk] || answer2 === "without") return null;
42007
+ if (clientKey) return secondClinicAssessmentMessage(clientKey, name);
42008
+ if (answer2 === "attached") return ATTACH_THE_ASSESSMENT;
42009
+ if (answer2 === "run-first") return RUN_THE_ASSESSMENT_FIRST;
42010
+ return ANSWER_THE_ASSESSMENT_QUESTION;
42011
+ }
42012
+ function approvalBindingHash(scopeHash, state, rk) {
42013
+ const saved = state.bought?.[rk];
42014
+ const answer2 = assessmentAnswerOf(state, rk);
42015
+ if (!Array.isArray(saved) && !answer2) return scopeHash;
42016
+ const snapshot = state.assessmentAttachments?.[rk];
42017
+ return contentHash({ scope: scopeHash, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null, assessment: answer2, attached: snapshot ? contentHash(snapshot) : null });
42018
+ }
42019
+ function boughtBindingHash(scopeHash, state, rk) {
42020
+ const saved = state.bought?.[rk];
42021
+ return contentHash({ scope: scopeHash, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null });
42022
+ }
42023
+ function boughtJobs(ticks, offered) {
42024
+ return boughtItemsFor(offered).filter((item) => ticks.includes(item.id) && (item.kind === "go-live" || item.kind === "separate" || item.kind === "not-in-plan")).map((item) => ({
42025
+ ref: `bought:${item.id}`,
42026
+ type: BOUGHT_JOB_TYPE,
42027
+ name: item.name,
42028
+ reason: item.kind === "not-in-plan" ? "Bought, but not built automatically yet. It is not in the plan for this client's industry." : `Bought, but not built automatically yet. ${OTHER_DELIVERY[item.id].job}`
42029
+ }));
42030
+ }
42031
+ function boughtJobLines(ticks, offered) {
42032
+ return boughtJobs(ticks, offered).map((j) => `${j.name}: ${j.reason}`);
42033
+ }
42034
+ function claudeSuggestions(ticks) {
42035
+ const out = [];
42036
+ if (ticks.includes("missed")) out.push("Call answering: next step with Claude, an AI voice agent to answer live.");
42037
+ if (ticks.includes("reporting")) out.push("Reporting: from Claude, a weekly client report. No account setup.");
42038
+ return out;
42039
+ }
42040
+ function assessmentChoiceLine(answer2, attached = answer2 === "attached") {
42041
+ if (attached) return "The Assessment was run and is attached.";
42042
+ if (answer2 === "attached") return "The Assessment was run. It is not attached yet.";
42043
+ if (answer2 === "without") return "No Assessment was run for this client.";
42044
+ if (answer2 === "run-first") return "The Assessment will be run first.";
42045
+ return "Not answered yet: has the Assessment been run for this client?";
42046
+ }
42047
+ function boughtViewFor(state, rk, offered) {
42048
+ const { ticks, source } = ticksFor(state, rk);
42049
+ return {
42050
+ items: boughtItemsFor(offered),
42051
+ ticks,
42052
+ source,
42053
+ answer: assessmentAnswerOf(state, rk),
42054
+ attached: Boolean(state.assessmentAttachments?.[rk]),
42055
+ ready: assessmentGateRefusal(state, rk) === null,
42056
+ suggestions: source === "saved" ? claudeSuggestions(ticks) : []
42057
+ };
42058
+ }
42059
+ function boughtDocumentFor(state, rk, offered) {
42060
+ const { ticks, source } = ticksFor(state, rk);
42061
+ const items = boughtItemsFor(offered);
42062
+ const confirmed = source === "saved" || source === "scope";
42063
+ const ticked = items.filter((i) => ticks.includes(i.id));
42064
+ return {
42065
+ confirmed,
42066
+ assessment: assessmentChoiceLine(assessmentAnswerOf(state, rk), Boolean(state.assessmentAttachments?.[rk])),
42067
+ bought: ticked.map((i) => ({ name: i.name, means: i.means, delivery: clientDelivery(i) })),
42068
+ // The client reads this: never the agency's job text, and never "with Claude" (review of ea3526f).
42069
+ stillToDo: ticked.flatMap((i) => {
42070
+ if (i.kind === "go-live") return [`${i.name}: being set up in your account.`];
42071
+ if (i.kind === "separate") return [`${i.name}: the form is ready. The pages are a separate piece of work.`];
42072
+ if (i.kind === "not-in-plan") return [`${i.name}: being set up separately from this build.`];
42073
+ if (i.kind === "claude") return [`${i.name}: a weekly report on what is working, sent to you.`];
42074
+ return [];
42075
+ }).concat(ticks.includes("missed") ? ["Call answering: live call answering by an AI voice agent is a possible next step."] : [])
42076
+ };
42077
+ }
42078
+ function clientDelivery(i) {
42079
+ if (i.kind === "go-live") return "Being set up in your account";
42080
+ if (i.kind === "separate") return "The form is built; the pages are separate work";
42081
+ if (i.kind === "not-in-plan") return "Not part of this build";
42082
+ if (i.kind === "claude") return "A weekly report on what is working";
42083
+ return i.delivery;
42084
+ }
42085
+ var BOUGHT_IDS, BOUGHT_MEANS, FEATURE_FOR_BOUGHT, BUILT_LABEL, OTHER_DELIVERY, CALL_ANSWERING_NEXT, ASSESSMENT_CHOICES, ANSWER_THE_ASSESSMENT_QUESTION, ATTACH_THE_ASSESSMENT, RUN_THE_ASSESSMENT_FIRST, BOUGHT_JOB_TYPE;
42086
+ var init_bought_list = __esm({
42087
+ "src/bought-list.ts"() {
42088
+ "use strict";
42089
+ init_attachment();
42090
+ init_features();
42091
+ init_build_contract();
42092
+ BOUGHT_IDS = ASSESSMENT_FEATURES.map((f) => f.id);
42093
+ BOUGHT_MEANS = {
42094
+ missed: "No call goes unanswered",
42095
+ waited: "Every new lead hears back in seconds",
42096
+ quotes: "No quote goes cold",
42097
+ noshow: "Bookings confirmed, reminded and rescued",
42098
+ reactivation: "Old leads and past clients brought back",
42099
+ nurture: "Leads kept warm until they are ready",
42100
+ recall: "Clients come back on schedule",
42101
+ deposits: "Fewer no-shows, cash up front",
42102
+ reviews: "More five-star reviews",
42103
+ referrals: "Happy clients send friends",
42104
+ lead_magnet: "A reason to give you their details",
42105
+ sms: "Texts that actually get delivered",
42106
+ pipeline: "Every lead tracked to a sale",
42107
+ reporting: "The owner sees what is working"
42108
+ };
42109
+ FEATURE_FOR_BOUGHT = Object.fromEntries(
42110
+ FEATURE_IDS.map((f) => [BUILD_ASSESSMENT_FEATURE[f], f])
42111
+ );
42112
+ BUILT_LABEL = {
42113
+ missed: "Built: missed-call text-back",
42114
+ waited: "Built: Speed to Lead",
42115
+ quotes: "Built: Quote follow-up",
42116
+ noshow: "Built: booking, reminders and no-show rescue",
42117
+ reactivation: "Built: Win-back",
42118
+ nurture: "Built: 30-day lead nurture",
42119
+ reviews: "Built: Review requests"
42120
+ };
42121
+ OTHER_DELIVERY = {
42122
+ pipeline: { kind: "always", text: "Always built: Sales pipeline" },
42123
+ recall: { kind: "go-live", text: "Go-live job for now: Rebooking and recall", job: "Set up rebooking and recall in the account." },
42124
+ referrals: { kind: "go-live", text: "Go-live job for now: Referral requests", job: "Set up referral requests in the account." },
42125
+ deposits: { kind: "go-live", text: "Go-live job: Deposits and prepayment (payment setup is done in the account)", job: "Payment setup is done in the account." },
42126
+ sms: { kind: "go-live", text: "Go-live job: SMS capability (phone number and text registration)", job: "Get the phone number and finish text registration." },
42127
+ lead_magnet: { kind: "separate", text: "Separate job: Lead magnet (the form is built; pages are separate work)", job: "The form is built. The pages are separate work." },
42128
+ reporting: { kind: "claude", text: "From Claude: Reporting (weekly client report, no account setup)" }
42129
+ };
42130
+ CALL_ANSWERING_NEXT = "Next step with Claude: an AI voice agent to answer live";
42131
+ ASSESSMENT_CHOICES = ["attached", "run-first", "without"];
42132
+ ANSWER_THE_ASSESSMENT_QUESTION = "Answer the Assessment question before approving: Blueprint Review, then Have you run the Assessment for this client.";
42133
+ ATTACH_THE_ASSESSMENT = "Attach the Assessment before approving, or choose Build without an Assessment.";
42134
+ RUN_THE_ASSESSMENT_FIRST = "You chose to run the Assessment first. Attach it when it is done, or choose Build without an Assessment.";
42135
+ BOUGHT_JOB_TYPE = "bought-not-built";
42136
+ }
42137
+ });
42138
+
41352
42139
  // src/intake-contact.ts
41353
42140
  async function pickIntakeContact(client, locationId2, fieldIdToLabel, knownContactId, formName) {
41354
42141
  const ids = /* @__PURE__ */ new Set();
@@ -41747,6 +42534,21 @@ function prefillFormUrl(formUrl, queryKeys, answers, contact = {}, altQueryKeys
41747
42534
  const qs = p.toString();
41748
42535
  return { url: qs ? `${formUrl}?${qs}` : formUrl, leftovers };
41749
42536
  }
42537
+ function isAuthFailure(error) {
42538
+ const text2 = error instanceof Error ? error.message : String(error ?? "");
42539
+ return /GHL API Error (?:401|403)\b/.test(text2);
42540
+ }
42541
+ function isRequestFailure(error) {
42542
+ if (isGHLRequestError(error)) return true;
42543
+ const text2 = error instanceof Error ? error.message : String(error ?? "");
42544
+ return /GHL API Error \d{3}|Request timeout|timed? ?out|fetch failed|ECONN|ETIMEDOUT|socket hang up|network/i.test(text2);
42545
+ }
42546
+ function unavailable(empty, reason3) {
42547
+ return { ...empty, status: "unavailable", unavailableReason: reason3 };
42548
+ }
42549
+ function unavailableFrom(empty, error) {
42550
+ return unavailable(empty, isAuthFailure(error) ? "auth" : isRequestFailure(error) ? "transient" : "error");
42551
+ }
41750
42552
  async function readIntakeSnapshot(client, locationId2, sentMarker) {
41751
42553
  const empty = { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
41752
42554
  try {
@@ -41798,16 +42600,16 @@ async function readIntakeSnapshot(client, locationId2, sentMarker) {
41798
42600
  contactId = hit2.id;
41799
42601
  channel = isFormSubmission(hit2) ? "form" : "cockpit";
41800
42602
  }
41801
- } catch {
41802
- return { ...empty, status: "unavailable" };
42603
+ } catch (e) {
42604
+ return unavailableFrom(empty, e);
41803
42605
  }
41804
42606
  }
41805
42607
  const hit = await pickIntakeContact(client, locationId2, Object.fromEntries(Object.entries(fields).map(([label2, id]) => [id, label2])), contactId, INTAKE_FORM_NAME2);
41806
- if (submissions && !hit) return { ...empty, status: "unavailable" };
42608
+ if (submissions && !hit) return unavailable(empty, "missing");
41807
42609
  const status = submissions > 0 ? "received" : sentMarker ? "sent" : "installed";
41808
42610
  return { status, answers: hit?.answers ?? {}, formId: form.id, formUrl: publicFormUrl(form.id), fields, queryKeys, altQueryKeys, multiFields, submissions, answeredAt, channel, contactId };
41809
- } catch {
41810
- return { ...empty, status: "unavailable" };
42611
+ } catch (e) {
42612
+ return unavailableFrom(empty, e);
41811
42613
  }
41812
42614
  }
41813
42615
  function recorderAllowPatterns(mcpListOutput) {
@@ -41937,6 +42739,7 @@ var init_client_intake = __esm({
41937
42739
  "src/client-intake.ts"() {
41938
42740
  "use strict";
41939
42741
  init_intake_contact();
42742
+ init_ghl_client();
41940
42743
  init_question_set();
41941
42744
  init_form_template();
41942
42745
  init_transcript();
@@ -41958,8 +42761,8 @@ function savePlanRecord(rec2, base) {
41958
42761
  return file2;
41959
42762
  }
41960
42763
  function savedPlanMatchesScope(saved, current) {
41961
- if (!saved) return false;
41962
- if (!current || current.wholePreset) return !saved.scope;
42764
+ if (!saved || !current) return false;
42765
+ if (current.wholePreset) return !saved.scope;
41963
42766
  if (!saved.scope) return false;
41964
42767
  const omitted = (saved.scope.omitted ?? []).map((f) => f.id);
41965
42768
  const offered = /* @__PURE__ */ new Set([...saved.scope.built, ...saved.scope.notBuilt.map((f) => f.id), ...omitted]);
@@ -42764,7 +43567,7 @@ async function assessmentAttachmentAction(args, deps) {
42764
43567
  if (!Array.isArray(chosen) || chosen.some((id2) => !ASSESSMENT_FEATURES.some((f) => f.id === id2))) throw new Error("Choose features from the list.");
42765
43568
  const state2 = deps.read();
42766
43569
  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.";
43570
+ 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
43571
  break;
42769
43572
  }
42770
43573
  default:
@@ -42803,23 +43606,17 @@ async function assessmentRequest(id,action,extra={}){
42803
43606
  const response=await fetch('/api/client-assessment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientLocationId:id,action,...extra})});
42804
43607
  const result=await response.json();if(!response.ok||!result.ok)throw new Error(result.error||'The assessment action did not finish.');
42805
43608
  if(action==='list'){p.list=result.assessments;p.listed=true;p.selected='';if(!p.list.length)p.notice='There are no assessments in this account’s Assessments pipeline.'}
42806
- else{p.data=result;p.choices=result.purchasedFeatureIds||[];p.notice=result.message||'';if(action==='fill'){p.fill=result;delete iData[id];if(typeof iOpen!=='undefined'&&iOpen[id])await iLoad(id)}}
42807
- if(action==='purchases')await load();
43609
+ else{p.data=result;p.notice=result.message||'';if(action==='fill'){p.fill=result;delete iData[id];if(typeof iOpen!=='undefined'&&iOpen[id])await iLoad(id)}}
43610
+ if(action==='purchases'||action==='attach'||action==='detach')await load();
42808
43611
  }catch(e){p.notice=e.message||'The assessment could not be read. Try again.'}
42809
43612
  p.busy=false;render(lastState,lastRun);
42810
43613
  }
42811
43614
  function assessmentPanel(id){
42812
- const fresh=!assessmentCards[id];const p=assessmentCards[id]||(assessmentCards[id]={busy:false,data:null,list:[],source:'',selected:'',choices:[]});
43615
+ const fresh=!assessmentCards[id];const p=assessmentCards[id]||(assessmentCards[id]={busy:false,data:null,list:[],source:'',selected:''});
42813
43616
  const box=document.createElement('section');box.className='cds-stack';
42814
43617
  const text=(tag,value)=>{const el=document.createElement(tag);if(tag==='h4')el.className='cds-panel-label';el.textContent=value;box.appendChild(el);return el};
42815
43618
  const button=(label,run,disabled=false)=>{const b=text('button',label);b.className='cds-btn ghost';b.disabled=p.busy||disabled;b.onclick=run;return b};
42816
- text('h4','What they bought');
42817
- text('p','Record the features they bought for their Blueprint document.');
42818
- if(p.data){
42819
- 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.');
42822
- }
43619
+ /* What they bought is ticked on the one list below this panel (2026-09-29); this panel attaches the Assessment. */
42823
43620
  text('h4','Assessment');
42824
43621
  text('p','The numbers from your sales conversation follow the client into the intake, the Review and their Blueprint.');
42825
43622
  const select=text('select','');select.className='cds-select';select.setAttribute('aria-label','Your account where the assessment was saved');select.disabled=p.busy;
@@ -44713,7 +45510,7 @@ function errLine(msg3) {
44713
45510
  function preflightWritable() {
44714
45511
  try {
44715
45512
  const dir = ensureAppDataDir();
44716
- const probe = path36.join(dir, `.write-probe.${process.pid}.${(0, import_crypto2.randomBytes)(4).toString("hex")}`);
45513
+ const probe = path36.join(dir, `.write-probe.${process.pid}.${(0, import_crypto3.randomBytes)(4).toString("hex")}`);
44717
45514
  fs38.writeFileSync(probe, "ok");
44718
45515
  fs38.unlinkSync(probe);
44719
45516
  return true;
@@ -44997,14 +45794,14 @@ async function runCli(subcommand, argv) {
44997
45794
  return EXIT_USAGE2;
44998
45795
  }
44999
45796
  }
45000
- var import_node_util3, fs38, path36, import_crypto2, EXIT_OK2, EXIT_USAGE2, EXIT_VALIDATION, EXIT_FS, USAGE;
45797
+ var import_node_util3, fs38, path36, import_crypto3, EXIT_OK2, EXIT_USAGE2, EXIT_VALIDATION, EXIT_FS, USAGE;
45001
45798
  var init_cli = __esm({
45002
45799
  "src/cli.ts"() {
45003
45800
  "use strict";
45004
45801
  import_node_util3 = require("node:util");
45005
45802
  fs38 = __toESM(require("fs"));
45006
45803
  path36 = __toESM(require("path"));
45007
- import_crypto2 = require("crypto");
45804
+ import_crypto3 = require("crypto");
45008
45805
  init_ghl_client();
45009
45806
  init_token_registry();
45010
45807
  init_credentials_store();
@@ -45662,10 +46459,14 @@ function reviewDocumentHtml(review, opts) {
45662
46459
  ${review.gaps?.length ? `<div class="head">What's leaking today (${review.gaps.length})</div>${review.gaps.map(gap).join("")}` : ""}
45663
46460
  ${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
46461
  ${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>
46462
+ <p>Not confirmed yet. The build waits until the features to build are confirmed.</p>
45666
46463
  ` : opts.scope ? `<div class="head">What this build includes</div>
45667
46464
  <p>${opts.scope.package ? `Built to your <b>${esc14(opts.scope.package)}</b> package: ` : ""}${opts.scope.built.length ? esc14(opts.scope.built.map((b) => b.name).join(", ")) : "the foundation only"}. Always included: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing.</p>
45668
46465
  ` : ""}
46466
+ ${opts.bought ? `<div class="head">What you bought</div>
46467
+ <p>${esc14(opts.bought.assessment)}</p>
46468
+ ${opts.bought.confirmed ? opts.bought.bought.map((b) => `<div class="build"><b>${esc14(b.name)}</b><span>${esc14(b.means)}. ${esc14(b.delivery)}.</span></div>`).join("") : "<p>Not confirmed yet. The build waits until what you bought is saved.</p>"}
46469
+ ${opts.bought.confirmed && opts.bought.stillToDo.length ? `<div class="head">Still to do</div><div class="ask"><ul>${opts.bought.stillToDo.map((line3) => `<li>${esc14(line3)}</li>`).join("")}</ul></div>` : ""}` : ""}
45669
46470
  ${review.unknowns?.length ? `<div class="head">What we still need from you</div><div class="ask"><ul>${review.unknowns.map((u) => `<li>${esc14(u)}</li>`).join("")}</ul></div>` : ""}
45670
46471
  ${opts.assessment ? `<div class="head">Assessment</div><p>Snapshot attached on ${esc14(opts.assessment.attachedAt.slice(0, 10))}.</p>${assessmentFeatureLines(opts.assessment, [], false).filter((line3) => line3.includes("by your own numbers")).map((line3) => `<p>${esc14(line3)}</p>`).join("")}` : ""}
45671
46472
  ${available ? `<div class="head">Available next</div>${available}` : opts.assessment ? `<div class="head">Available next</div><p>${purchased ? "Every listed feature is included in what you bought." : "What you bought has not been recorded yet."}</p>` : ""}
@@ -47136,7 +47937,7 @@ function prefixReservations(locationId2, base) {
47136
47937
  function refusal(prefix, owner) {
47137
47938
  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
47939
  }
47139
- function sleepSync(ms) {
47940
+ function sleepSync2(ms) {
47140
47941
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
47141
47942
  }
47142
47943
  function locationLockPath(dir, locationId2) {
@@ -47165,7 +47966,7 @@ function withLocationLock(dir, locationId2, fn, opts = {}) {
47165
47966
  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
47967
  );
47167
47968
  }
47168
- sleepSync(15);
47969
+ sleepSync2(15);
47169
47970
  }
47170
47971
  }
47171
47972
  try {
@@ -47244,13 +48045,27 @@ var init_clinic_prefix_registry = __esm({
47244
48045
  var dashboard_exports = {};
47245
48046
  __export(dashboard_exports, {
47246
48047
  ACCOUNT_TYPES: () => ACCOUNT_TYPES,
48048
+ APPROVAL_WITHDRAWN_FOR_ASSESSMENT: () => APPROVAL_WITHDRAWN_FOR_ASSESSMENT,
48049
+ APPROVAL_WITHDRAWN_FOR_SCOPE: () => APPROVAL_WITHDRAWN_FOR_SCOPE,
48050
+ ASSESSMENT_CHANGED_SINCE_APPROVAL: () => ASSESSMENT_CHANGED_SINCE_APPROVAL,
48051
+ CHOOSE_BEFORE_BUILD: () => CHOOSE_BEFORE_BUILD,
48052
+ CHOOSE_WHAT_THEY_BOUGHT: () => CHOOSE_WHAT_THEY_BOUGHT,
47247
48053
  CLAUDE_REPAIR_MESSAGES: () => CLAUDE_REPAIR_MESSAGES,
48054
+ INTAKE_ANSWERS_MISSING: () => INTAKE_ANSWERS_MISSING,
47248
48055
  INTAKE_CONNECT_REQUIRED: () => INTAKE_CONNECT_REQUIRED,
47249
48056
  INTAKE_FORM_REQUIRED: () => INTAKE_FORM_REQUIRED,
47250
48057
  INTAKE_OWN_MESSAGES: () => INTAKE_OWN_MESSAGES,
48058
+ INTAKE_READ_FAILED: () => INTAKE_READ_FAILED,
48059
+ INTAKE_READ_TRANSIENT: () => INTAKE_READ_TRANSIENT,
48060
+ INTAKE_REFRESH_RETRYING: () => INTAKE_REFRESH_RETRYING,
47251
48061
  INTAKE_UNREADABLE: () => INTAKE_UNREADABLE,
48062
+ NOTHING_BOUGHT: () => NOTHING_BOUGHT,
48063
+ NOTHING_BUILT_FROM_TICKS: () => NOTHING_BUILT_FROM_TICKS,
48064
+ NOTHING_TICKED_IN_PLAN: () => NOTHING_TICKED_IN_PLAN,
47252
48065
  NOT_SIGNED_IN_MSG: () => NOT_SIGNED_IN_MSG,
47253
48066
  PARKED_NOT_BUILT: () => PARKED_NOT_BUILT,
48067
+ SCOPE_CHANGED_SINCE_APPROVAL: () => SCOPE_CHANGED_SINCE_APPROVAL,
48068
+ SCOPE_REFUSAL: () => SCOPE_REFUSAL,
47254
48069
  SCREEN_HEADS: () => SCREEN_HEADS,
47255
48070
  SEAT_GUARDED_READS: () => SEAT_GUARDED_READS,
47256
48071
  SEAT_TOKEN_HEADER: () => SEAT_TOKEN_HEADER,
@@ -47259,8 +48074,11 @@ __export(dashboard_exports, {
47259
48074
  STAGES: () => STAGES2,
47260
48075
  accountTypeOf: () => accountTypeOf,
47261
48076
  agencyConnectionReady: () => agencyConnectionReady,
48077
+ approvalBoughtHash: () => approvalBoughtHash,
48078
+ approvalScopeHash: () => approvalScopeHash,
47262
48079
  assessmentAgencyTag: () => assessmentAgencyTag,
47263
48080
  assessmentFiguresFor: () => assessmentFiguresFor,
48081
+ boughtUnchangedSinceApproval: () => boughtUnchangedSinceApproval,
47264
48082
  callClaudeRecorded: () => callClaudeRecorded,
47265
48083
  canonicalIndustry: () => canonicalIndustry,
47266
48084
  checkMutationGuard: () => checkMutationGuard,
@@ -47278,9 +48096,11 @@ __export(dashboard_exports, {
47278
48096
  ensureEngineReady: () => ensureEngineReady,
47279
48097
  forgetAnthropicKey: () => forgetAnthropicKey,
47280
48098
  guarded: () => guarded,
48099
+ hasChosenScope: () => hasChosenScope,
47281
48100
  insideDir: () => insideDir,
47282
48101
  intakeInput: () => intakeInput,
47283
48102
  intakeRequestError: () => intakeRequestError,
48103
+ intakeUnavailableError: () => intakeUnavailableError,
47284
48104
  isAllowedCockpitHost: () => isAllowedCockpitHost,
47285
48105
  isLoopback: () => isLoopback,
47286
48106
  isUpstreamFailure: () => isUpstreamFailure,
@@ -47289,10 +48109,13 @@ __export(dashboard_exports, {
47289
48109
  loadingMainHtml: () => loadingMainHtml,
47290
48110
  mayServeSeat: () => mayServeSeat,
47291
48111
  memberFacingError: () => memberFacingError,
48112
+ mergeQueueIntakes: () => mergeQueueIntakes,
47292
48113
  newClientFailure: () => newClientFailure,
47293
48114
  normalizedIntake: () => normalizedIntake,
48115
+ offeredFeaturesFor: () => offeredFeaturesFor,
47294
48116
  overlayPath: () => overlayPath,
47295
48117
  prefillTeamAnswers: () => prefillTeamAnswers,
48118
+ queueScanFilter: () => queueScanFilter,
47296
48119
  readBody: () => readBody,
47297
48120
  readClientStages: () => readClientStages,
47298
48121
  readCockpitState: () => readCockpitState,
@@ -47304,9 +48127,11 @@ __export(dashboard_exports, {
47304
48127
  recoverStuckStages: () => recoverStuckStages,
47305
48128
  renderCockpitHtml: () => renderCockpitHtml,
47306
48129
  reportSeatAllowed: () => reportSeatAllowed,
48130
+ rereadQueueIntakes: () => rereadQueueIntakes,
47307
48131
  resolvePlan: () => resolvePlan2,
47308
48132
  runDashboard: () => runDashboard,
47309
48133
  safeDocName: () => safeDocName,
48134
+ scopeHashOf: () => scopeHashOf,
47310
48135
  scopePrefillFor: () => scopePrefillFor,
47311
48136
  secondClientInputHash: () => secondClientInputHash,
47312
48137
  setAccountIndustry: () => setAccountIndustry,
@@ -47318,7 +48143,9 @@ __export(dashboard_exports, {
47318
48143
  uniqueDocName: () => uniqueDocName,
47319
48144
  unknownCapabilityHtml: () => unknownCapabilityHtml,
47320
48145
  verifyPanelOutcome: () => verifyPanelOutcome,
48146
+ withBoughtJobs: () => withBoughtJobs,
47321
48147
  withCockpitShell: () => withCockpitShell,
48148
+ withdrawIfBindingChanged: () => withdrawIfBindingChanged,
47322
48149
  writeCockpitState: () => writeCockpitState,
47323
48150
  writeDocAtomically: () => writeDocAtomically,
47324
48151
  writeOverlay: () => writeOverlay
@@ -47388,6 +48215,37 @@ function setAccountIndustry(state, locationId2, industry) {
47388
48215
  if (!next.industry) delete next.industry;
47389
48216
  return { ...state, accounts: { ...state.accounts ?? {}, [locationId2]: next } };
47390
48217
  }
48218
+ function scopeHashOf(sel) {
48219
+ if (!sel) return contentHash({ none: true });
48220
+ if (sel.wholePreset) return contentHash({ wholePreset: true });
48221
+ return contentHash({ package: sel.package ?? "", features: [...new Set((sel.features ?? []).filter(isFeatureId))].sort() });
48222
+ }
48223
+ function approvalScopeHash(state, recordKey) {
48224
+ return approvalBindingHash(scopeHashOf(state.scope?.[recordKey]), state, recordKey);
48225
+ }
48226
+ function withdrawIfBindingChanged(state, recordKey) {
48227
+ const ap = state.planApprovals?.[recordKey];
48228
+ if (!ap || ap.scopeHash === approvalScopeHash(state, recordKey)) return { state, withdrawn: false };
48229
+ const approvals = { ...state.planApprovals };
48230
+ delete approvals[recordKey];
48231
+ return { state: { ...setBuildApproval(state, recordKey, null), planApprovals: approvals }, withdrawn: true };
48232
+ }
48233
+ function withBoughtJobs(tasks2, lines) {
48234
+ const kept = tasks2.filter((t) => !SELF_REPORTED.test(t.text));
48235
+ const prior = new Map(tasks2.map((t) => [t.id, t]));
48236
+ return [...kept, ...lines.map((text2) => prior.get(taskId(text2)) ?? { id: taskId(text2), text: text2 })];
48237
+ }
48238
+ function approvalBoughtHash(state, recordKey) {
48239
+ return boughtBindingHash(scopeHashOf(state.scope?.[recordKey]), state, recordKey);
48240
+ }
48241
+ function boughtUnchangedSinceApproval(ap, state, recordKey) {
48242
+ if (ap.boughtHash) return ap.boughtHash === approvalBoughtHash(state, recordKey);
48243
+ return ap.scopeHash === scopeHashOf(state.scope?.[recordKey]) && !Array.isArray(state.bought?.[recordKey]);
48244
+ }
48245
+ function hasChosenScope(state, recordKey) {
48246
+ const sel = state.scope?.[recordKey];
48247
+ return Boolean(sel && (sel.wholePreset || Array.isArray(sel.features) && sel.features.some(isFeatureId)));
48248
+ }
47391
48249
  function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ new Date()) {
47392
48250
  const all = { ...state.scope ?? {} };
47393
48251
  if (!scope) {
@@ -47403,16 +48261,26 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
47403
48261
  if (!Object.keys(all).length) delete next.scope;
47404
48262
  return next;
47405
48263
  }
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;
48264
+ function offeredFeaturesFor(state, recordKey) {
48265
+ const locationId2 = recordKey.split("--")[0];
47410
48266
  try {
47411
48267
  const chosen = loadPresetChoice(void 0, presetForIndustry(state.accounts?.[locationId2]?.industry));
47412
- if (chosen.ok) offered = featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
48268
+ if (chosen.ok) return featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
47413
48269
  } catch {
47414
48270
  }
47415
- if (!sc || sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
48271
+ return FEATURE_IDS;
48272
+ }
48273
+ function documentScopeFor(state, locationId2, savedPlan) {
48274
+ const sc = state.scope?.[locationId2];
48275
+ const offered = offeredFeaturesFor(state, locationId2);
48276
+ if (!sc) {
48277
+ const outcome = state.outcomes?.[`${locationId2}:3`];
48278
+ const built = state.clients?.[locationId2]?.stages?.[3] === "done" || outcome?.ok === true;
48279
+ const wholePresetPlan = Boolean(savedPlan && !savedPlan.scope && typeof savedPlan.plan?.preset === "string");
48280
+ if (built && wholePresetPlan) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
48281
+ return { awaitingConfirmation: true, built: [], notBuilt: [] };
48282
+ }
48283
+ if (sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
47416
48284
  return {
47417
48285
  ...sc.package ? { package: sc.package } : {},
47418
48286
  // Built = the confirmed scope, the one record the build follows. The purchase
@@ -47510,7 +48378,10 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
47510
48378
  normalizedAnswers: meta2.normalizedAnswers,
47511
48379
  dependencies: verificationPlan ? verificationPlan.dependencies ?? planDependencies(verificationPlan.plan) : readSavedPlan(rk)?.dependencies,
47512
48380
  industry,
47513
- manualObligations: stage === 4 ? readBuildObligations(rk) : void 0,
48381
+ // Bought-but-not-built jobs are the member's to confirm: no check can read them, so
48382
+ // Verify never reports them as findings (review of ea3526f). They reach the card's
48383
+ // jobs from the obligations file directly.
48384
+ manualObligations: stage === 4 ? readBuildObligations(rk).filter((o) => o.type !== BOUGHT_JOB_TYPE) : void 0,
47514
48385
  intakeSummary: describeComposition({ industry, agency }),
47515
48386
  customLabels: composed.filter((q3) => {
47516
48387
  const base = INTAKE_QUESTIONS.find((b) => b.key === q3.key);
@@ -47587,6 +48458,12 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
47587
48458
  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
48459
  }
47589
48460
  for (const line3 of costLines(outcome)) run.lines.push(line3);
48461
+ const scopeRefusal = stage === 3 && !outcome.ok && typeof outcome.error === "string" && SCOPE_REFUSAL.test(outcome.error);
48462
+ if (scopeRefusal) {
48463
+ outcome.summary = outcome.error;
48464
+ outcome.issues = [];
48465
+ outcome.plain = { summary: outcome.error, issues: [] };
48466
+ }
47590
48467
  if (stage === 3 && !outcome.summary && !outcome.issues?.length) {
47591
48468
  try {
47592
48469
  const st = readCockpitState();
@@ -47600,8 +48477,8 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
47600
48477
  const st = readCockpitState();
47601
48478
  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
48479
  };
47603
- save();
47604
- if (PLAIN_REPORT_STAGES.has(stage)) {
48480
+ save(scopeRefusal ? outcome.plain : void 0);
48481
+ if (PLAIN_REPORT_STAGES.has(stage) && !scopeRefusal) {
47605
48482
  run.lines.push("Writing the plain-English version of what came back\u2026");
47606
48483
  const plain = await rewriteOutcomePlain(STAGES2[stage], verifyRaw ?? outcome, { onEvent: (l) => run.lines.push(l), onCost: (costUsd) => {
47607
48484
  recordRunCost({ at, locationId: locationId2, stage, kind: "plain rewrite", ok: true, costUsd });
@@ -47808,6 +48685,40 @@ function logQuietly(message) {
47808
48685
  } catch {
47809
48686
  }
47810
48687
  }
48688
+ function queueScanFilter(body) {
48689
+ try {
48690
+ const b = body ? JSON.parse(body) : null;
48691
+ if (b && typeof b === "object" && b.only === "pending") {
48692
+ const open2 = b.open;
48693
+ return { only: "pending", ...typeof open2 === "string" && open2 ? { open: open2 } : {} };
48694
+ }
48695
+ } catch {
48696
+ }
48697
+ return void 0;
48698
+ }
48699
+ async function rereadQueueIntakes(opts) {
48700
+ const reread = {};
48701
+ const failed = [];
48702
+ for (const { locationId: locationId2 } of opts.roster) {
48703
+ if (opts.state.clients[locationId2]?.stages?.[0] !== "done") continue;
48704
+ if (opts.filter?.only === "pending" && locationId2 !== opts.filter.open && opts.state.intakeInputs?.[locationId2]?.status === "received") continue;
48705
+ try {
48706
+ const got = await opts.read(locationId2);
48707
+ if (got === null) continue;
48708
+ if (got === "unavailable") {
48709
+ failed.push(locationId2);
48710
+ continue;
48711
+ }
48712
+ reread[locationId2] = got;
48713
+ } catch {
48714
+ failed.push(locationId2);
48715
+ }
48716
+ }
48717
+ return { reread, failed };
48718
+ }
48719
+ function mergeQueueIntakes(latest, reread) {
48720
+ return { ...latest, intakeInputs: { ...latest.intakeInputs ?? {}, ...reread } };
48721
+ }
47811
48722
  function readBody(req, res, max, onDone, tooLarge = "That request is too large.", sensitive = false) {
47812
48723
  let body = "";
47813
48724
  let tooBig = false;
@@ -47917,6 +48828,10 @@ function readableAccount(locationId2, roster = currentRosterRef(), state = readC
47917
48828
  const t = state.accounts?.[locationId2]?.type;
47918
48829
  return t !== "hidden" && t !== "template";
47919
48830
  }
48831
+ function intakeUnavailableError(snap) {
48832
+ const reason3 = snap.unavailableReason;
48833
+ return new Error(reason3 === "auth" ? INTAKE_UNREADABLE : reason3 === "missing" ? INTAKE_ANSWERS_MISSING : reason3 === "error" ? INTAKE_READ_FAILED : INTAKE_READ_TRANSIENT);
48834
+ }
47920
48835
  function intakeRequestError(e) {
47921
48836
  const text2 = e instanceof Error ? e.message : "";
47922
48837
  return INTAKE_OWN_MESSAGES.includes(text2) ? text2 : "The intake request did not finish. Retry this step.";
@@ -47957,6 +48872,9 @@ function html(roster, seatToken, screen = "board", cap) {
47957
48872
  /* Board: four doors in one row (DoorTile), then the two-column grid */
47958
48873
  .board{display:grid;gap:var(--space-6)}
47959
48874
  .txt > .cds-chip{justify-self:start}
48875
+ /* A class that sets display (.cds-chip is inline-flex) beats the browser's own [hidden] rule, so
48876
+ "Not saved yet" stayed on screen after a save (rehearsal 2, finding 12). Hidden means hidden. */
48877
+ [hidden]{display:none!important}
47960
48878
  .cds-door > .cds-callout{display:grid;gap:var(--space-2)}
47961
48879
 
47962
48880
  /* Toolbars and rows of controls */
@@ -48041,7 +48959,7 @@ const LABELS=["Intake","Brief","Approve","Build","Verify","Manual tasks","Go-liv
48041
48959
  // What each step does: [the one line on its row, the fuller sentence in the next-step panel].
48042
48960
  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
48961
  ["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."],
48962
+ ["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
48963
  ["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
48964
  ["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
48965
  ["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."],
@@ -48057,7 +48975,6 @@ const SCREEN=document.body.getAttribute("data-screen")||"board";
48057
48975
  const CAP=${scriptJson(capForPage)};const HUB=${scriptJson(HUB_URL)};const HEADS=${scriptJson(SCREEN_HEADS)};
48058
48976
  const INDUSTRIES=${scriptJson([...INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })), { slug: "other", label: "Other" }])};
48059
48977
  const INDUSTRIES2=${scriptJson(INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })))};
48060
- const FEATS=${scriptJson(FEATURES)};
48061
48978
  /* One account at a time (Jerry, 2026-08-26: "way too busy\u2026 tempting to work on multiple
48062
48979
  accounts at once"). One card is ever expanded; every other account is a one-line row. */
48063
48980
  let newClientId=new URLSearchParams(location.hash.slice(1)).get("client"),existingClientsOpen=false;
@@ -48066,7 +48983,7 @@ let registerNotice="",registerFailed=false,registerBusy=false;
48066
48983
  let activeId=null;try{activeId=localStorage.getItem("cmdos-active")||null}catch(e){}
48067
48984
  {const linkedClient=new URLSearchParams(location.hash.slice(1)).get("client");if(linkedClient)rememberClient(linkedClient)}
48068
48985
  function rememberClient(id){activeId=id;try{localStorage.setItem("cmdos-active",id)}catch(e){};document.querySelectorAll(".cds-side a[href]").forEach(a=>{const href=a.getAttribute("href");if(href&&href.startsWith("/")&&!href.startsWith("/api/")&&!href.includes("?")){a.href=href.split("#")[0]+"#client="+encodeURIComponent(id)}})}
48069
- function setActive(id){rememberClient(id);render(lastState,lastRun)}
48986
+ function setActive(id){if(typeof iRetryStopExcept==="function")iRetryStopExcept(id);rememberClient(id);render(lastState,lastRun)}
48070
48987
  function initials(name){const p=String(name||"").trim().split(/\\s+/);return ((p[0]||"?")[0]+(p[1]?p[1][0]:"")).toUpperCase()}
48071
48988
  /* Command Design System pieces, built as text (never markup from data). */
48072
48989
  function mk(tag,cls,text){const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}
@@ -48095,16 +49012,83 @@ function listRows(rows,key,noun){const frag=document.createDocumentFragment();co
48095
49012
  function unnest(el){if(el&&el.classList){el.classList.remove("cds-panel","cds-card");el.classList.add("cds-stack")}return el}
48096
49013
  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
49014
  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)}}
49015
+ 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). */
49016
+ 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). */
49017
+ if(!(quiet===true&&dataSig(s,r)===lastDrawnSig))render(s,r);hideFinishNote();
49018
+ if(r.running&&!polling){polling=true;const t=setInterval(async()=>{let rr;try{rr=await fetchRunStatus()}catch(e){return}
49019
+ if(!rr.running){clearInterval(t);polling=false}
49020
+ if(!shouldRedraw(lastRun,rr))return;
49021
+ 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
49022
  // Heartbeat (2026-08-25): the fast poller above only starts for a run THIS page saw begin. A page that was
48101
49023
  // already open, or opened mid-run, or a run kicked off from another seat/the API, showed "pending" until a
48102
49024
  // manual reload . a client watching would conclude the build died. Every 3s: if a run is live, hand over to
48103
49025
  // 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);
49026
+ // Never rebuild the page under the operator's hands: while a form field has focus the redraw waits,
49027
+ // and happens on focusout. (2026-08-25: the first heartbeat threw Jerry out of the intake field every refresh.)
49028
+ // Stale "Running" fix (2026-09-29 rehearsal): Brief finished on the server while the page showed
49029
+ // "Running now, working 5m" until a manual reload. The old guard also skipped the status FETCH whenever an
49030
+ // intake panel was open, and the clock ticks on the page alone, so a frozen page looked alive. Now:
49031
+ // the status is ALWAYS fetched; only the full redraw waits, and only while an input has focus; an open intake
49032
+ // panel alone never blocks it (its typed answers, pasted material and prepared-form link live in state, so a
49033
+ // redraw keeps them). A running-to-finished change always redraws, or, with an input focused, shows a bar
49034
+ // that says so. Returning to the tab refreshes. A status older than 15s freezes the clock on "checking\u2026".
49035
+ let lastStatusOkAt=0,redrawPending=false,pointerIsDown=false,flushTimer=0;
49036
+ /* Intake work in flight for a client (Fill the intake from this, a document upload, send, prepare, save): a redraw
49037
+ then would re-enable its button and drop its message, so it waits like a focused field. */
49038
+ let iBusy={};
49039
+ function intakeWorkBusy(){try{return Object.values(iBusy).some(n=>n>0)}catch(e){return false}}
49040
+ /* Inline confirm panels (see icPanel): one waiting on the server holds the redraw like intake work. */
49041
+ let icOpen={};
49042
+ function icBusy(){try{return Object.values(icOpen).some(x=>x&&x.busy)}catch(e){return false}}
49043
+ /* A counter, so two requests for one client overlapping never clear each other. */
49044
+ async function iWork(id,fn){iBusy[id]=(iBusy[id]||0)+1;try{return await fn()}finally{iBusy[id]=Math.max(0,(iBusy[id]||0)-1);if(redrawPending)scheduleFlush()}}
49045
+ function inputFocused(){const a=document.activeElement;return Boolean(a&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))}
49046
+ /* Holds back the full redraw, never the status fetch. */
49047
+ function userIsBusy(){if(SCREEN==="new")return true;return inputFocused()||intakeWorkBusy()||icBusy()}
49048
+ function intakeIsOpen(){try{return Object.values(iOpen||{}).some(Boolean)}catch(e){return false}}
49049
+ async function fetchRunStatus(){const rr=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();return rr}
49050
+ function statusIsStale(){return !lastStatusOkAt||Date.now()-lastStatusOkAt>15000}
49051
+ 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("|"):""}
49052
+ function runJustFinished(prev,next){return Boolean(prev&&prev.running&&next&&!next.running)}
49053
+ function stepName(r){return r&&!r.capability&&LABELS[r.stage]?LABELS[r.stage]:"The step"}
49054
+ /* Decides whether a fresh status redraws the page now. A finished run always does, unless an input has focus:
49055
+ then the bar tells the operator and the redraw follows on focusout. With an intake panel open the page
49056
+ redraws only when the run itself changed (started, moved stage, finished), not on every tick. */
49057
+ function shouldRedraw(prev,next){const fin=runJustFinished(prev,next);
49058
+ if(userIsBusy()){if(SCREEN==="new")return false;if(fin)showFinishNote(prev);redrawPending=true;return false}
49059
+ if(fin)return true;
49060
+ if(intakeIsOpen())return runKey(prev)!==runKey(next);
49061
+ return true}
49062
+ function showFinishNote(prev){let b=document.getElementById("run-finished-note");
49063
+ if(!b){b=document.createElement("button");b.id="run-finished-note";b.type="button";b.className="cds-callout info";b.setAttribute("role","status");
49064
+ b.style.cssText="position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:50;max-width:calc(100% - 32px);cursor:pointer";
49065
+ b.onclick=()=>{redrawPending=false;load(true).catch(()=>{})};document.body.appendChild(b)}
49066
+ b.textContent=stepName(prev)+" finished. Click to see the result."}
49067
+ function hideFinishNote(){const b=document.getElementById("run-finished-note");if(b)b.remove()}
49068
+ /* One retry timer at a time: focusout, pointer releases and finished intake work all ask, only one waits. */
49069
+ function scheduleFlush(){if(flushTimer)return;flushTimer=setTimeout(()=>{flushTimer=0;flushRedraw()},300)}
49070
+ function flushRedraw(){if(!redrawPending)return;if(SCREEN==="new"){redrawPending=false;return}if(userIsBusy()||pointerIsDown){scheduleFlush();return}redrawPending=false;load(true).catch(()=>{})}
49071
+ document.addEventListener("pointerdown",()=>{pointerIsDown=true},true);
49072
+ document.addEventListener("pointerup",()=>{pointerIsDown=false},true);
49073
+ /* A touch that is cancelled, or a release outside the window, never sends pointerup. */
49074
+ document.addEventListener("pointercancel",()=>{pointerIsDown=false},true);
49075
+ window.addEventListener("blur",()=>{pointerIsDown=false});
49076
+ /* Focus leaving an input: a held redraw happens now (after the field's change event has saved its value). */
49077
+ document.addEventListener("focusout",()=>{if(redrawPending)scheduleFlush()});
49078
+ /* Coming back to the tab or the window: fetch at once, rather than trusting what the page last drew. */
49079
+ 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(()=>{})}
49080
+ /* Focus and visibilitychange usually fire together on a return: one refresh, not two. */
49081
+ let returnTimer=0;function onReturn(){if(returnTimer)return;returnTimer=setTimeout(()=>{returnTimer=0;refreshOnReturn()},250)}
49082
+ document.addEventListener("visibilitychange",()=>{if(document.visibilityState==="visible")onReturn()});
49083
+ window.addEventListener("focus",onReturn);
49084
+ /* The GET queue shows intake answers as last read; every 4th heartbeat load (about a minute) and a return to the
49085
+ tab use the POST, which re-reads each client's intake, so a submitted or edited intake shows without a reload. */
49086
+ let hbTick=0,hbLoads=0;setInterval(async()=>{if(polling)return;hbTick++;let rr;try{rr=await fetchRunStatus()}catch(e){return}
49087
+ const changed=runKey(rr)!==runKey(lastRun);
49088
+ if(!(rr.running||changed||hbTick%5===0))return;
49089
+ if(userIsBusy()){if(changed)shouldRedraw(lastRun,rr);return}
49090
+ if(intakeIsOpen()&&!changed&&!(rr.running&&!polling))return;
49091
+ hbLoads++;load(true,hbLoads%4===0).catch(()=>{})},3000);
48108
49092
  let intakeOpen=false,intakeData=null,intakeIndustry="";
48109
49093
  async function loadIntake(){const r=await(await clientPageRequest("/api/intake?industry="+encodeURIComponent(intakeIndustry))).json();intakeData=r;render(lastState,lastRun)}
48110
49094
  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 +49129,25 @@ function intakePanel(){const d=document.createElement("div");d.className="cds-pa
48145
49129
  d.appendChild(reset);
48146
49130
  return d}
48147
49131
  let iOpen={},iData={},iMode={},iAnswers={},iDirty={},iIdx={},iPre={},iSub={},oOpen={},iDocs={},iDocsErrors={},iPreSrc={};
49132
+ /* What the operator typed into the intake's free boxes (pasted transcript, link, recorder note, the send
49133
+ form) and the prepared-form link live here, not only in the DOM, so a redraw while the intake is open
49134
+ keeps them (2026-09-29: an open intake no longer blocks the page from showing a finished step). */
49135
+ let iDraft={},iPrepared={};
49136
+ function preparedFormBlock(box,id){const p=iPrepared[id];
49137
+ 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);
49138
+ const hint=document.createElement("p");hint.className="muted";
49139
+ const left=p.leftovers||[];
49140
+ hint.textContent=left.length
49141
+ ?"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:"
49142
+ :"Use Open the prepared form above, submit it, then return and press Refresh.";
49143
+ box.appendChild(hint);
49144
+ if(left.length){const ul=document.createElement("ul");ul.className="muted";
49145
+ 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"};
49146
+ 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)}
49147
+ box.appendChild(ul)}
49148
+ const rf=document.createElement("button");rf.className="cds-btn ghost";rf.textContent="Refresh status";
49149
+ rf.onclick=()=>iLoad(id);box.appendChild(rf)}
49150
+ 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
49151
  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
49152
  // Show the plain outcome here. The separate Step progress panel shows member-facing progress.
48150
49153
  function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc)return null;
@@ -48172,9 +49175,35 @@ function costLabel(kind){return ({stage:"Stage",review:"Client review","plain re
48172
49175
  function iContact(id,questions){const out={};const keys={first_name:"firstName",last_name:"lastName",email:"email",phone:"phone"};for(const q of questions){if(keys[q.contactTag])out[keys[q.contactTag]]=String((iAnswers[id]||{})[q.label]||"")}return out}
48173
49176
  function iEdit(id,label,value){(iAnswers[id]||(iAnswers[id]={}))[label]=value;(iDirty[id]||(iDirty[id]=new Set())).add(label)}
48174
49177
  function iApplyProposals(id,proposals){const applied=[];const answers=iAnswers[id]||(iAnswers[id]={});for(const [label,value] of Object.entries(proposals||{})){if(answers[label]&&JSON.stringify(answers[label])!==JSON.stringify(value)){if(!confirm(label+String.fromCharCode(10)+"Current: "+answers[label]+String.fromCharCode(10)+"Proposed: "+value+String.fromCharCode(10)+"Replace this answer?"))continue}iEdit(id,label,value);applied.push(label)}return applied}
48175
- async function iLoad(id){rememberClient(id);const r=await(await clientPageRequest("/api/client-intake?locationId="+id)).json();iData[id]=r;
49178
+ /* Rehearsal 2 (2026-09-29): a read that failed for a passing reason (a timeout, a rate limit) is not a broken
49179
+ key. The panel keeps what it last read, says so quietly, and retries; the next good read clears the note.
49180
+ Only a real refusal of the key (401/403) still says to paste it again. */
49181
+ /* Review of d407f04: the retry is a refresh only. It never changes the client on screen (iLoad does, for a
49182
+ click), it stops when the member moves to another client or closes the intake, a throw schedules the next
49183
+ try instead of ending them, and after I_RETRY_MAX tries it stops and says so. */
49184
+ let iRetry={};const I_RETRY_MAX=10;
49185
+ const I_RETRY_NOTE="Could not refresh just now; retrying.",I_RETRY_GAVE_UP="Could not refresh. Refresh status to try again.";
49186
+ function iRetryNote(id){const n=iRetry[id];if(!n)return null;const p=mk("p","muted iretry",n.gaveUp?I_RETRY_GAVE_UP:(n.note||I_RETRY_NOTE));p.setAttribute("role","status");
49187
+ if(n.gaveUp){const b=mk("button","cds-link","Refresh status");b.type="button";b.onclick=()=>iLoad(id);p.appendChild(document.createTextNode(" "));p.appendChild(b)}return p}
49188
+ function iRetryStop(id){const n=iRetry[id];if(n){if(n.t)clearTimeout(n.t);delete iRetry[id]}}
49189
+ function iRetryStopExcept(id){for(const k of Object.keys(iRetry))if(k!==id)iRetryStop(k)}
49190
+ /* Still worth refreshing: the client is the one on screen. */
49191
+ function iShown(id){return activeId===id}
49192
+ function iScheduleRetry(id,note){const n=((iRetry[id]&&iRetry[id].n)||0)+1;if(iRetry[id]&&iRetry[id].t)clearTimeout(iRetry[id].t);
49193
+ if(n>I_RETRY_MAX){iRetry[id]={n:n,gaveUp:true,t:0};return}
49194
+ iRetry[id]={n:n,note:note||I_RETRY_NOTE,t:setTimeout(()=>{const cur=iRetry[id];if(!cur||cur.gaveUp)return;cur.t=0;if(!iShown(id)){iRetryStop(id);return}iRefresh(id)},Math.min(30000,3000*n))}}
49195
+ /* The background read: no rememberClient, no change of client. */
49196
+ async function iRefresh(id){try{const res=await fetch("/api/client-intake?locationId="+encodeURIComponent(id));const r=await res.json();if(!res.ok&&r&&r.ok!==false)r.ok=false;iApply(id,r)}
49197
+ catch(e){if(!iShown(id)){iRetryStop(id);return}iScheduleRetry(id,I_RETRY_NOTE);try{render(lastState,lastRun)}catch(_){}}}
49198
+ async function iLoad(id){rememberClient(id);iRetryStop(id);const r=await(await clientPageRequest("/api/client-intake?locationId="+id)).json();iApply(id,r)}
49199
+ function iApply(id,r){
49200
+ if(r&&r.ok===false&&r.transient){iScheduleRetry(id,r.error);
49201
+ if(!iData[id]||!iData[id].ok)iData[id]={ok:false,transient:true,error:r.error};
49202
+ render(lastState,lastRun);return}
49203
+ iRetryStop(id);
49204
+ iData[id]=r;
48176
49205
  // 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)}
49206
+ if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits};if(r.status==="received")delete iPrepared[id]}render(lastState,lastRun)}
48178
49207
  let iFieldN=0;
48179
49208
  function iField(id,q,val,prefilled){const w=document.createElement("div");w.className="cds-field ifield"+(prefilled?" prefilled":"");
48180
49209
  const fid="ifield-"+(++iFieldN);
@@ -48199,8 +49228,10 @@ function iField(id,q,val,prefilled){const w=document.createElement("div");w.clas
48199
49228
  w.appendChild(inp);return w}
48200
49229
  function intakeWorkspace(id,name){const d=iData[id];const box=document.createElement("div");box.className="cds-panel ipanel";
48201
49230
  if(!d){box.textContent="Loading intake\u2026";return box}
49231
+ if(!d.ok&&d.transient){box.innerHTML="<h3 class='cds-panel-title'>Intake</h3>";box.appendChild(iRetryNote(id)||mk("p","muted",d.error||"Could not refresh just now; retrying."));return box}
48202
49232
  if(!d.ok){box.innerHTML="<h3 class='cds-panel-title'>Intake</h3><p role='alert'>"+escT(clientPageError(d.error))+"</p>";const a=document.createElement("a");a.className="cds-link";a.href="/new";a.textContent="Reconnect this client";box.appendChild(a);return box}
48203
49233
  box.innerHTML="<h3 class='cds-panel-title'>Intake for "+escT(name)+"</h3>";
49234
+ {const rn=iRetryNote(id);if(rn)box.appendChild(rn)}
48204
49235
  if(!d.industry){const label=document.createElement("p");label.textContent="Choose this client\u2019s industry before preparing the intake.";box.appendChild(label);const select=document.createElement("select");select.className="cds-select";select.setAttribute("aria-label","Client industry");for(const item of [{slug:"",label:"Choose the client industry"},...INDUSTRIES]){const o=document.createElement("option");o.value=item.slug;o.textContent=item.label;select.appendChild(o)}box.appendChild(select);const save=document.createElement("button");save.className="cds-btn";save.textContent="Confirm industry";save.onclick=async()=>{if(!select.value)return;if(await postJSON("/api/industry",{locationId:id,industry:select.value}))await iLoad(id)};box.appendChild(save);return box}
48205
49236
  if(d.status==="received"&&d.savedAt){const note=document.createElement("p");note.textContent="Submitted answers are loaded. A newer saved draft may include changes that have not been submitted.";box.appendChild(note)}
48206
49237
  const sub=document.createElement("p");sub.className="muted";
@@ -48222,14 +49253,14 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48222
49253
  box.appendChild(ch);
48223
49254
  const mode=iMode[id];
48224
49255
  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");
49256
+ const em=document.createElement("input");em.placeholder="client@business.com";em.className="cds-input";em.setAttribute("aria-label","Client email");keepDraft(em,id,"email");
49257
+ 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");
49258
+ 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
49259
  const b=document.createElement("button");b.className="cds-btn";b.textContent="Send the questionnaire";
48229
49260
  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();
49261
+ 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
49262
  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"}};
49263
+ 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
49264
  w.appendChild(em);w.appendChild(fn);w.appendChild(ag);w.appendChild(b);
48234
49265
  const note=document.createElement("p");note.className="muted";
48235
49266
  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 +49272,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48241
49272
  tb.onclick=()=>{iSub[id]=k;render(lastState,lastRun)};tabs.appendChild(tb)}
48242
49273
  w.appendChild(tabs);
48243
49274
  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");
49275
+ ta.className="cds-textarea";ta.setAttribute("aria-label","Transcript");keepDraft(ta,id,"transcript");
48245
49276
  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()}};
49277
+ 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
49278
  const b=document.createElement("button");b.className="cds-btn";b.textContent="Fill the intake from this";
48248
49279
  const st=document.createElement("p");st.className="muted";
48249
49280
  b.onclick=async()=>{
@@ -48256,10 +49287,10 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48256
49287
  st.textContent=sub==="paste"?"This text goes to your own Claude account to be read, and nowhere else."
48257
49288
  :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
49289
  :"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();
49290
+ const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json());
48260
49291
  b.disabled=false;b.textContent="Fill the intake from this";
48261
49292
  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";
49293
+ 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
49294
  /* Codex F2 (2026-09-06): a HELD answer (the words were said, the value was worked out) is a
48264
49295
  suggestion beside the field, never an answer. It reaches iAnswers only when the operator
48265
49296
  clicks "Use it". Unverified answers (read by their Claude, not pasted here) are answers,
@@ -48270,9 +49301,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48270
49301
  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
49302
  iMode[id]="fill";render(lastState,lastRun)};
48272
49303
  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");
49304
+ urlIn.className="cds-input";urlIn.setAttribute("aria-label","Link to the transcript or recording");keepDraft(urlIn,id,"url");
48274
49305
  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");
49306
+ recIn.className="cds-input";recIn.setAttribute("aria-label","Which recording to use");keepDraft(recIn,id,"recorder");
48276
49307
  const hint=document.createElement("p");hint.className="muted";
48277
49308
  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
49309
  else if(iSub[id]==="doc"){
@@ -48287,7 +49318,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48287
49318
  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
49319
  drop.textContent="Saving "+f.name+"\u2026";
48289
49320
  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();
49321
+ 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
49322
  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
49323
  iDocs[id]=r.files||[];render(lastState,lastRun)};
48293
49324
  drop.ondragover=(e)=>{e.preventDefault();drop.style.outline="2px solid var(--focus)"};
@@ -48301,7 +49332,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48301
49332
  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
49333
  const rb=document.createElement("button");rb.className="cds-btn ghost";rb.textContent="Fill the intake from this";
48303
49334
  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();
49335
+ 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
49336
  rb.disabled=false;rb.textContent="Fill the intake from this";
48306
49337
  if(!r.ok){st.textContent=clientPageError(r.error,"Check the source and try reading it again.");return}
48307
49338
  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 +49373,88 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
48342
49373
  save.textContent="Review and submit the real form";save.disabled=Boolean(d.connectionPending);
48343
49374
  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
49375
  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();
49376
+ 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
49377
  save.disabled=false;save.textContent="Review and submit the real form";
48347
49378
  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)};
49379
+ iPrepared[id]={url:r.url,leftovers:r.leftovers||[]};render(lastState,lastRun)};
48361
49380
  box.appendChild(save)}else if(formNote)box.appendChild(formNote);
48362
49381
  const alt=document.createElement("button");alt.className="cds-btn ghost";
48363
49382
 
48364
49383
  alt.textContent="Save draft on this computer";
48365
49384
  alt.title="Saves your answers for this client on this computer. You can return to them later.";
48366
49385
  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();
49386
+ 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
49387
  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)}}
49388
+ box.appendChild(alt);
49389
+ if(hasForm&&iPrepared[id])preparedFormBlock(box,id)}}
48370
49390
  return box}
48371
49391
  let iNote={},iHeld={},iUnv={};
48372
49392
  ${ASSESSMENT_PANEL_JS}
48373
49393
  let rOpen={},rData={},rBusy={},rSeen={};
48374
49394
  let profCache=null,profLoading=false;
48375
49395
  async function profLoad(){if(profLoading)return;profLoading=true;try{profCache=((await(await fetch("/api/agency-profile")).json()).profile)||{}}catch(e){profCache={}}profLoading=false;render(lastState,lastRun)}
48376
- /* What they bought (2026-09-06). Foundation is always built; the ticks decide the features. Nothing saved = the whole preset. */
49396
+ /* What they bought (2026-09-06). Foundation is always built; the ticks decide the features.
49397
+ One list since 2026-09-29 (Jerry's approved design): the fourteen Assessment items, each with
49398
+ what it means for the client and what ticking it does for this client's preset. The server
49399
+ derives the build scope from the ticks, so the build reads what it always read. Approve also
49400
+ waits for the Assessment question above the list. */
49401
+ /* The ticks and package chosen but not yet saved, kept outside the block so a redraw keeps them (review of c09e946). */
49402
+ let scopeDraft={},boughtNotice={};
49403
+ const BOUGHT_ITEMS=${scriptJson(boughtItemsFor(FEATURE_IDS))};
49404
+ function scopeChosen(state,id){const s=(state.scope||{})[id];const v=(state.boughtViews||{})[id];return Boolean(s&&(s.wholePreset||(Array.isArray(s.features)&&s.features.length)))&&!(v&&v.ready===false)}
49405
+ function openWhatTheyBought(id){rOpen[id]=true;rSeen[id]=true;const go=()=>{render(lastState,lastRun);requestAnimationFrame(()=>{const t=document.getElementById("wtb-"+id);if(t){t.scrollIntoView({behavior:"smooth",block:"start"});const f=t.querySelector("button,input,select");if(f)f.focus({preventScroll:true})}})};if(!rData[id])rLoad(id).then(go,go);else go()}
49406
+ function boughtView(state,id){return (state.boughtViews||{})[id]||{items:BOUGHT_ITEMS,ticks:BOUGHT_ITEMS.map(i=>i.id),source:"all",answer:null,attached:false,ready:false,suggestions:[]}}
49407
+ /* "Have you run the Assessment for <client>?" One click records the answer; Yes opens the attach flow. */
49408
+ function assessmentQuestion(id,v){
49409
+ const q=mk("div","cds-stack");const who=((typeof ROSTER!=="undefined"&&ROSTER.find(r=>r.locationId===id))||{}).name||"this client";
49410
+ q.appendChild(mk("h4","cds-panel-label","Have you run the Assessment for "+who+"?"));
49411
+ const row=mk("div","cds-row");
49412
+ const opt=(choice,label)=>{const on=v.answer===choice;const b=mk("button","cds-btn"+(on?"":" ghost"),label);b.setAttribute("aria-pressed",String(on));
49413
+ b.onclick=async()=>{b.disabled=true;const r=await postJSON("/api/assessment-choice",{locationId:id,choice});if(r){if(r.approvalWithdrawn)boughtNotice[id]=r.message;load()}else b.disabled=false};row.appendChild(b)};
49414
+ opt("attached","Yes, attach it");opt("run-first","Run it first (recommended)");opt("without","Build without an Assessment");q.appendChild(row);
49415
+ if(v.attached||v.answer==="attached")q.appendChild(assessmentPanel(id));
49416
+ else if(v.answer==="run-first"){const p=mk("p","");const a=document.createElement("a");a.className="cds-link";a.href="/assessment";a.target="_blank";a.rel="noopener";a.textContent="Run the Assessment";p.appendChild(a);p.appendChild(document.createTextNode(". When it is saved, come back and attach it here."));q.appendChild(p)}
49417
+ else if(v.answer==="without")q.appendChild(mk("p","muted","Recorded: no Assessment for this client. The client document says so."));
49418
+ else q.appendChild(mk("p","muted","Approve waits for an answer."));
49419
+ return q}
48377
49420
  function scopeBlock(id,state){
48378
- const sc=document.createElement("div");sc.className="cds-stack";
48379
- sc.innerHTML="<h4 class='cds-panel-label'>What they bought</h4>";
49421
+ const sc=document.createElement("div");sc.className="cds-stack";sc.id="wtb-"+id;
49422
+ const v=boughtView(state,id);
49423
+ sc.appendChild(assessmentQuestion(id,v));
49424
+ sc.appendChild(mk("h4","cds-panel-label","What they bought"));
49425
+ sc.appendChild(mk("p","",v.source==="all"?"Everything starts ticked. Untick what this client did not buy.":v.source==="assessment"?"Ticked from what the Assessment recorded as bought. Untick anything they did not buy.":"Tick what the client bought. Only the items marked Built get built."));
48380
49426
  if(!profCache){profLoad();const p=document.createElement("p");p.className="muted";p.textContent="Loading your packages\u2026";sc.appendChild(p);return sc}
48381
49427
  const saved=(state.scope||{})[id]||null;const pk=(profCache.packages||[]);
48382
49428
  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);
49429
+ const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"No package: tick the items below":"No packages saved yet (add them on Your agency): tick the items below";sel.appendChild(o0);
48384
49430
  for(const p of pk){const o=document.createElement("option");o.value=p.id;o.textContent=p.name+(p.price?" \xB7 "+moneyText(p.price):"");sel.appendChild(o)}
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;
49431
+ let ticked=new Set(v.ticks);if(saved&&saved.package)sel.value=saved.package;
49432
+ const base=JSON.stringify([saved,v.ticks]);if(scopeDraft[id]&&scopeDraft[id].base!==base)delete scopeDraft[id];const dr=scopeDraft[id];if(dr){ticked=new Set(dr.ticked);sel.value=dr.pkg}
49433
+ const keepScope=()=>{scopeDraft[id]={ticked:[...ticked],pkg:sel.value,base:base};unsaved.hidden=false};
49434
+ const everything=document.createElement("button");everything.className="cds-btn ghost";everything.textContent="Tick everything";
49435
+ everything.title="Ticks all fourteen. Nothing is saved until you press Save what they bought.";
49436
+ everything.onclick=()=>{ticked=new Set(v.items.map(i=>i.id));keepScope();drawBoxes();drawFigs()};
49437
+ sc.appendChild(everything);
48386
49438
  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);
48389
- const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=FEATS[k].name;t.appendChild(tb);t.appendChild(mk("small","",FEATS[k].what));l.appendChild(t);boxes.appendChild(l)}}
49439
+ function drawBoxes(){boxes.innerHTML="";for(const it of v.items){const l=document.createElement("label");l.className="cds-check";
49440
+ l.dataset.k=it.id;const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(it.id);c.onchange=()=>{if(c.checked)ticked.add(it.id);else ticked.delete(it.id);keepScope();drawFigs()};l.appendChild(c);
49441
+ const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=it.name;t.appendChild(tb);t.appendChild(mk("small","",it.means));
49442
+ t.appendChild(mk("small","bought-delivery",it.delivery));if(it.next)t.appendChild(mk("small","bought-next",it.next));l.appendChild(t);boxes.appendChild(l)}}
48390
49443
  const figs=((state.assessmentFigures||{})[id])||{};
48391
- 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();
48394
- const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing. Unticked features go in the client document as available next.";
49444
+ function drawFigs(){for(const l of boxes.querySelectorAll("label")){const it=v.items.find(i=>i.id===l.dataset.k);const old=l.querySelector("em");if(old)old.remove();if(it&&it.feature&&figs[it.feature]){const e=document.createElement("em");e.className="cds-chip"+(ticked.has(it.id)?"":" warn");e.textContent="Assessment: "+figs[it.feature]+(ticked.has(it.id)?".":". Not ticked.");l.querySelector("span").appendChild(e)}}}
49445
+ /* A package names build features: it ticks their items and unticks the other built ones; the rest stay as they are. */
49446
+ sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);if(p)for(const it of v.items){if(!it.feature)continue;if((p.features||[]).includes(it.feature))ticked.add(it.id);else ticked.delete(it.id)}keepScope();drawBoxes();drawFigs()};drawBoxes();drawFigs();
49447
+ const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing. A ticked item that is not built automatically becomes one of your jobs in the account.";
48395
49448
  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);
49449
+ const status=document.createElement("p");status.className="muted";status.setAttribute("role","status");
49450
+ sv.onclick=async()=>{if(!ticked.size){status.textContent="Tick at least one item they bought, then save.";return}
49451
+ if(!v.items.some(it=>it.kind==="built"&&ticked.has(it.id))){status.textContent="None of the ticked items is built for this client's industry. Tick at least one item marked Built.";return}
49452
+ const body={locationId:id,bought:[...ticked]};if(sel.value)body.package=sel.value;const r=await postJSON("/api/scope",body);if(r){unsaved.hidden=true;if(r.approvalWithdrawn)boughtNotice[id]=r.message;else delete boughtNotice[id];delete scopeDraft[id];delete assessmentCards[id];load()}};
49453
+ const nBought=v.ticks.length,nBuilt=v.items.filter(it=>it.kind==="built"&&v.ticks.includes(it.id)).length;
49454
+ status.textContent=saved&&v.source==="saved"?"Saved: "+nBought+" of "+v.items.length+" bought, "+nBuilt+" of them built automatically"+(saved.package?", from the "+saved.package+" package":"")+". The build follows this.":saved?(saved.wholePreset?"Saved: everything in the plan. The build follows this.":"Saved before this list: "+saved.features.length+" features. Save again to record what they bought."):"Not saved yet. Save what they bought before approving.";
49455
+ const unsaved=mk("span","cds-chip warn","Not saved yet");unsaved.hidden=!scopeDraft[id];
49456
+ sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(unsaved);sc.appendChild(status);
49457
+ if(boughtNotice[id]){const n=mk("p","cds-callout warn",boughtNotice[id]);n.setAttribute("role","alert");sc.appendChild(n)}
48399
49458
  return sc}
48400
49459
  async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;render(lastState,lastRun)}
48401
49460
  // Finding 20 (2026-08-26): writing the review takes 30\u201390 s and the page re-renders every few seconds meanwhile,
@@ -48413,7 +49472,6 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
48413
49472
  {const sc=(state.staffChecks||{})[id];if(!sc||sc.status!=="ready"){const w=document.createElement("p");w.className="cds-callout warn";w.setAttribute("role","alert");w.textContent=sc?STAFF_REVIEW_WARNING:STAFF_REVIEW_UNCHECKED;d.appendChild(w)}}
48414
49473
  /* Codex F1 (2026-09-06): the review is the model's output and the names are other businesses' records; both are TEXT. */
48415
49474
  const bt=(el,label,text)=>{const b=document.createElement("b");b.textContent=label;el.appendChild(b);el.appendChild(document.createTextNode(moneyText(text)))};
48416
- d.appendChild(assessmentPanel(id));
48417
49475
  const busy=reviewBusy(id,run);
48418
49476
  const gen=document.createElement("button");gen.className="cds-btn ghost";
48419
49477
  gen.textContent=busy?"Thinking it through\u2026":(r&&r.ok)?"Regenerate":"Generate the review";gen.disabled=busy;
@@ -48426,7 +49484,9 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
48426
49484
  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
49485
  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
49486
  d.appendChild(p)}
48429
- d.appendChild(gen);return d}
49487
+ d.appendChild(gen);
49488
+ /* What they bought decides the build, so it is here even before the review is written (2026-09-29). */
49489
+ d.appendChild(scopeBlock(id,state));return d}
48430
49490
  if(r.headline){const h=document.createElement("div");h.className="cds-callout info headline";h.textContent=moneyText(r.headline);d.appendChild(h)}
48431
49491
  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
49492
  const p1=document.createElement("p");p1.textContent=moneyText(r.ica.who||"");s1.appendChild(p1);
@@ -48466,17 +49526,13 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
48466
49526
  const a=document.createElement("a");a.className="cds-link";a.href="/blueprint?locationId="+encodeURIComponent(id);a.target="_blank";a.rel="noopener";a.textContent="Open the prepared client document";brand.appendChild(a)};
48467
49527
  brand.appendChild(an);brand.appendChild(ac);brand.appendChild(dl);d.appendChild(brand);
48468
49528
  const row=document.createElement("div");row.className="cds-row";
48469
- const ap=document.createElement("button");ap.className="cds-btn ghost";ap.textContent="Approve: unlock the build";
48470
-
48471
- const stg=(state.clients[id]||{stages:[]}).stages||[];
48472
- if(stg[2]==="done"){ap.disabled=true;ap.textContent="Approved"}
48473
- /* Finding 35: this button used to POST to /api/stage on its own, with no operator name and
48474
- without reading the reply. When the work queue made a hand mark record WHO made it, this
48475
- button started failing the check and saying nothing at all . it just looked dead. Every
48476
- hand mark goes through markStage(), which asks for the name once, keeps it, and shows any
48477
- refusal instead of swallowing it. */
48478
- ap.onclick=async()=>{await markStage(id,2,"done")};
48479
- row.appendChild(ap);row.appendChild(gen);d.appendChild(row);
49529
+ /* Rehearsal 2, finding 15: this was a third approve button (labelled unlock the build) that only
49530
+ ticked the step, so Build then refused with "Approve building before running Build." It is now the
49531
+ same "Approve this plan" as the next step, and it appears only when approving can go through:
49532
+ once what they bought is saved, and not after approval. */
49533
+ if((state.buildApproved||{})[id]){const ap=document.createElement("button");ap.className="cds-btn ghost";ap.textContent="Approved";ap.disabled=true;row.appendChild(ap)}
49534
+ else if(scopeChosen(state,id)&&!["template","hidden"].includes(typeOf(state,id))&&!PROTECTED[id])row.appendChild(approveButton(id,name,state,"review","cds-btn"));
49535
+ row.appendChild(gen);d.appendChild(row);
48480
49536
  return d}
48481
49537
  function clientPageError(raw,next){
48482
49538
  const sentence=value=>{const t=String(value||"").trim().replace(/\\s*\\u2014\\s*/g,". ").replace(/[.!?;:]+$/,"");return t?t.charAt(0).toUpperCase()+t.slice(1)+".":""};
@@ -48489,7 +49545,7 @@ function clientPageError(raw,next){
48489
49545
  return text+(action?" "+action:"")}
48490
49546
  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
49547
  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";
49548
+ function pendingCard(c,state,intake=false){const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
48493
49549
  const head=document.createElement("div");head.className="cds-panel-head client-head";
48494
49550
  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
49551
  const pill=chip("warn","Needs connecting");head.appendChild(pill);
@@ -48636,7 +49692,7 @@ function card(c,state,run){const st=(state.clients[c.locationId]||{stages:STAGES
48636
49692
  // A template or hidden account opens only so its owner can lock or unlock it and change its
48637
49693
  // type: nothing on its card builds into it (review L-1; a template can be the live snapshot source).
48638
49694
  const parkedType=["template","hidden"].includes(typeOf(state,c.locationId));
48639
- const div=document.createElement("section");div.className="cds-panel client";
49695
+ const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
48640
49696
  const head=document.createElement("div");head.className="cds-panel-head client-head";
48641
49697
  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
49698
  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)}
@@ -48657,11 +49713,6 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48657
49713
  else if(!st.some(s=>s==="done"))div.appendChild(unnest(preflightCard(c)));
48658
49714
  /* Kept reachable after a finished Build while a re-run is held for staff (the gate saves its check). */
48659
49715
  {const sc=(state.staffChecks||{})[c.locationId];if(!parkedType&&((st[3]||"pending")!=="done"||(sc&&sc.status!=="ready")))div.appendChild(unnest(staffCard(c,state)))}
48660
- const approveBuilding=async()=>{const typed=prompt('Approve the Blueprint Review for "'+c.name+'" and let Command OS build in this account.'+String.fromCharCode(10,10)+'Type the client name exactly to confirm:');if(typed===null)return;
48661
- try{const response=await fetch("/api/build-approval",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,confirmName:typed})});const r=await response.json();
48662
- if(!response.ok||r.ok!==true){r.ok=false;r.error=r.error||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
48663
- if(!r.ok)alert(r.error);else load()
48664
- }catch(e){alert("Command OS could not confirm the result. Refresh this client to read its saved state before retrying.")}};
48665
49716
  // The steps, laid out as the owner approved on 2026-09-25: a progress bar, the next step in
48666
49717
  // full, then one row per step. Only the layout changed. Every button below keeps the condition
48667
49718
  // and the handler it had in the seven-column layout; the next-step panel draws the SAME
@@ -48679,7 +49730,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48679
49730
  const nextBox=document.createElement("div");nextBox.className="cds-stack snext";div.appendChild(nextBox);
48680
49731
  const row=document.createElement("div");row.className="cds-flow stages";row.setAttribute("role","list");
48681
49732
  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");
49733
+ 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
49734
  const gate=GATES[String(i)];
48684
49735
  const oc0=(state.outcomes||{})[c.locationId+":"+i];const notPassed=(st[i]||"pending")==="pending"&&oc0&&(oc0.issues||[]).length>0;
48685
49736
  if(notPassed)el.className+=" notpassed";
@@ -48713,7 +49764,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48713
49764
  const clock=document.createElement("div");clock.className="cds-num";
48714
49765
 
48715
49766
  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));
49767
+ /* The clock counts on the page alone, so it only counts while the status is fresh: a page that has
49768
+ not heard from the server for 15s freezes and says so, rather than looking busy (2026-09-29). */
49769
+ const tick=()=>{if(!began)return;if(statusIsStale()){clock.textContent="checking\u2026";return}const sec=Math.max(0,Math.round((Date.now()-began)/1000));
48717
49770
  clock.textContent="working "+(sec<60?sec+"s":Math.floor(sec/60)+"m "+(sec%60)+"s")};
48718
49771
  tick();if(began){const h=setInterval(()=>{if(!clock.isConnected){clearInterval(h);return}tick()},1000)}
48719
49772
  box.appendChild(clock);
@@ -48752,23 +49805,37 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48752
49805
  else if(!live){const note=mkNote();what.appendChild(note||about)}
48753
49806
  if(POWERED.includes(i)&&(st[i]||"pending")!=="done"){el.style.cursor="default";
48754
49807
  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};
49808
+ /* Jerry, 2026-09-29: Approve waits for What they bought. Until a selection is saved the gate
49809
+ is "Choose what the client bought", which opens the Blueprint Review at that block. */
49810
+ const mustChoose=i===2&&!scopeChosen(state,c.locationId);
49811
+ const gateLabel=mustChoose?"Choose what the client bought":({2:"Approve this plan",5:"Confirm these tasks are finished",6:"Sign this client live"})[i];
49812
+ /* Approve opens the one approval panel; the other gates open a hand-mark panel with the name on it (rehearsal 2). */
49813
+ const mkGate=cls=>{if(i===2&&!mustChoose)return approveButton(c.locationId,c.name,state,"gate",cls);
49814
+ const action=document.createElement("button");action.className=cls;action.textContent=gateLabel;const key="mark:"+c.locationId+":"+i;
49815
+ action.onclick=()=>{if(mustChoose)return openWhatTheyBought(c.locationId);icShow(action,key,markSpec(c.locationId,i,gateLabel))};return icAfter(action,key)};
48757
49816
  const canBuild=!parkedType&&(i<=1||sandbox||Boolean((state.buildApproved||{})[c.locationId]));
48758
- const runIt=async(ev)=>{ev.stopPropagation();if(i===0&&!confirm("In "+c.name+", Intake writes custom fields, one form, and one published notification workflow."+String.fromCharCode(10,10)+INTAKE_NOTIFICATION_REVIEW.join(String.fromCharCode(10))+String.fromCharCode(10,10)+"Create or repair these intake items?"))return;if(i===4&&PAGE_PLANS.includes(c.locationId)&&!confirm("Verify submits one test lead to the pages "+c.name+"\u2019s older build made, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once. Run the check?"))return;const res=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,stage:i,intakeConfirmed:i===0})});const j=await res.json();if(!j.ok)alert(j.error);load()};
49817
+ /* Starts the run; the answer is an error sentence, or "" when it started. */
49818
+ const startIt=async()=>{let j=null;try{const res=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,stage:i,intakeConfirmed:i===0})});j=await res.json()}catch(e){}
49819
+ return (j&&j.ok)?"":((j&&j.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.")};
49820
+ /* Intake, and Verify on an older page build, ask first: in a panel under the button, not a confirm() (rehearsal 2). */
49821
+ const runAsk=i===0?{title:"Create or repair the intake in "+c.name,text:["Intake writes custom fields, one form, and one published notification workflow."].concat(INTAKE_NOTIFICATION_REVIEW),ok:"Create or repair these intake items",busyLabel:"Starting\u2026",run:startIt}
49822
+ :(i===4&&PAGE_PLANS.includes(c.locationId))?{title:"Run the check on "+c.name,text:["Verify submits one test lead to the pages "+c.name+"\u2019s older build made, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once."],ok:"Run the check",busyLabel:"Starting\u2026",run:startIt}:null;
49823
+ const runKeyOf="run:"+c.locationId+":"+i;
49824
+ const runIt=async(ev,btn)=>{ev.stopPropagation();if(runAsk)return icShow(btn,runKeyOf,runAsk);const err=await startIt();if(err)alert(err);load()};
48759
49825
  const mkRun=(cls,label)=>{const b=document.createElement("button");b.className=cls;
48760
49826
  const busy=run&&run.running;b.textContent=busy&&run.locationId===c.locationId&&run.stage===i?"Running\u2026":label;
48761
49827
  b.disabled=Boolean(busy)||(!st.some(s=>s==="done")&&!preflights[c.locationId]?.ok);
48762
- b.onclick=runIt;return b};
49828
+ b.onclick=ev=>runIt(ev,b);return runAsk?icAfter(b,runKeyOf):b};
48763
49829
  if(!live&&nIss){const si=document.createElement("button");si.className="cds-link sactbtn";si.textContent=nIss>1?"See issues":"See issue";si.onclick=toggleIssues;act.appendChild(si)}
48764
49830
  /* One way to act on a step: the current step's buttons live in the next-step block only; other rows keep their links.
48765
49831
  A gate the owner has already answered offers nothing. */
48766
49832
  if(gateOpen&&!anyBusy&&!parkedType&&i!==curI)act.appendChild(mkGate("cds-link sactbtn"));
48767
49833
  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");
49834
+ if(i===curI){const main=document.createElement("div");main.setAttribute("data-key",c.locationId+":"+i+":next");
48769
49835
  const k=mk("div","cds-panel-label sk",parkedType?"Building is off":"Next step");main.appendChild(k);
48770
49836
  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
49837
  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);
49838
+ 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
49839
  if(live)main.appendChild(mkLive());
48773
49840
  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
49841
  const pn=mkNote();if(pn){const w=document.createElement("div");w.className="snoteline";w.appendChild(pn);main.appendChild(w)}
@@ -48777,7 +49844,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48777
49844
  if(canBuild&&POWERED.includes(i))btns.appendChild(mkRun("cds-btn runbtn sbig","Run "+LABELS[i]));
48778
49845
  if(!parkedType&&canBuild&&POWERED.includes(i)&&run&&run.running&&run.locationId!==c.locationId){const why=document.createElement("p");why.className="muted swhy";why.textContent="A step is running for another client. This one can start when it finishes.";btns.appendChild(why)}
48779
49846
  if(!btns.children.length&&!live&&!parkedType){const why=document.createElement("p");why.className="muted swhy";
48780
- why.textContent=anyBusy?"Another step is running for this client. This one opens when it finishes.":POWERED.includes(i)?"Approve building for this client below, and this step can run.":"";
49847
+ why.textContent=anyBusy?"Another step is running for this client. This one opens when it finishes.":POWERED.includes(i)?"Approve this plan in step 3, and this step can run.":"";
48781
49848
  if(why.textContent)btns.appendChild(why)}
48782
49849
  nextBox.title=el.title||"";nextBox.appendChild(main);nextBox.appendChild(btns)}
48783
49850
  row.appendChild(el)});
@@ -48804,7 +49871,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48804
49871
  if(view.stale.length){const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const stale of view.stale){const p=document.createElement("p");p.textContent=LABELS[stale.stage]+": stale. "+stale.reason;d.appendChild(p)}div.appendChild(d)}
48805
49872
  if(view.approvedAt){const p=document.createElement("p");p.textContent="Building approved on "+niceDate(view.approvedAt,true)+".";div.appendChild(p)}
48806
49873
  for(const repair of view.repairs){const p=document.createElement("p");p.textContent="Live workflow repair: "+repair.name+". "+repair.changes.join(" ");div.appendChild(p)}
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)}
49874
+ /* Rehearsal 2, finding 15: the second building-approval button that was here is gone. The one "Approve this plan"
49875
+ (step 3) approves the listed live workflow repairs too, and says so in its panel. */
49876
+ if(view.planHash&&!parkedType&&!scopeChosen(state,c.locationId)){const ch=document.createElement("button");ch.className="cds-btn ghost";ch.textContent="Choose what the client bought";ch.onclick=()=>openWhatTheyBought(c.locationId);div.appendChild(ch)}
48808
49877
  }else{const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));const p=document.createElement("p");p.textContent="The executable plan is prepared when Build runs.";d.appendChild(p);div.appendChild(d)}
48809
49878
  if(view?.handoff)div.appendChild(unnest(handoffPanel(c,view)));
48810
49879
  const _prot=PROTECTED[c.locationId];
@@ -48822,10 +49891,11 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48822
49891
  rv.onclick=async()=>{await postJSON("/api/build-approval",{locationId:c.locationId,revoke:true});load()};
48823
49892
  ab.appendChild(s1);ab.appendChild(rv);ab.appendChild(protectBtn(c))}
48824
49893
  else if(parkedType){ab.appendChild(protectBtn(c))}
48825
- 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;
48828
- ab.appendChild(w);ab.appendChild(btn);ab.appendChild(protectBtn(c))}
49894
+ else{/* No second approve button here (rehearsal 2, finding 15): step 3's "Approve this plan" is the one. */
49895
+ const w=document.createElement("span");w.className="muted";w.textContent="Automated building is off for this client until you approve this plan in step 3. ";
49896
+ ab.appendChild(w);
49897
+ if(!scopeChosen(lastState,c.locationId)){const btn=document.createElement("button");btn.className="cds-btn ghost";btn.textContent="Choose what the client bought";btn.onclick=()=>openWhatTheyBought(c.locationId);ab.appendChild(btn)}
49898
+ ab.appendChild(protectBtn(c))}
48829
49899
  div.appendChild(ab)}
48830
49900
  // A "live" claim is re-derived from the signature, never trusted from the
48831
49901
  // stage array: the gate runs on the write path, and this file can be restored
@@ -48867,6 +49937,13 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48867
49937
  txt.textContent=t.text;if(t.reappeared){txt.textContent="";txt.appendChild(chip("warn","Came back after you ticked it"));txt.appendChild(document.createTextNode(" "+t.text))}
48868
49938
  row.appendChild(cb);row.appendChild(txt);box.appendChild(row)});
48869
49939
  div.appendChild(box)}}
49940
+ // Suggested next steps with Claude (2026-09-29): from what they bought, shown beside the
49941
+ // jobs once Verify has run, never a job to tick and never something go-live waits on.
49942
+ {const sg=((lastState.boughtViews||{})[c.locationId]||{}).suggestions||[];
49943
+ if(sg.length&&(Boolean((lastState.outcomes||{})[c.locationId+":4"])||((lastState.manualTasks||{})[c.locationId]||[]).length)){
49944
+ const box=mk("div","cds-stack");box.appendChild(mk("h3","cds-panel-title","Suggested next steps with Claude"));
49945
+ box.appendChild(mk("p","muted","From what they bought. These are not jobs in the account, and nothing waits on them."));
49946
+ const ul=mk("ul","cds-list");for(const s of sg)ul.appendChild(mk("li","",s));box.appendChild(ul);div.appendChild(box)}}
48870
49947
  const _intakeIn=(iData[c.locationId]&&iData[c.locationId].status==="received");
48871
49948
  const _writing=reviewBusy(c.locationId,run);
48872
49949
  { // Review is accessible before intake so an assessment can fill it.
@@ -48878,7 +49955,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
48878
49955
  if(rOpen[c.locationId])div.appendChild(unnest(reviewPanel(c.locationId,c.name,state,run)))}
48879
49956
  if(st[0]==="done"){const ir=document.createElement("div");ir.className="cds-row";
48880
49957
  const ib2=document.createElement("button");ib2.className="cds-btn ghost";ib2.textContent=iOpen[c.locationId]?"Close the intake":"Complete the intake";ib2.setAttribute("aria-expanded",String(Boolean(iOpen[c.locationId])));
48881
- ib2.onclick=async()=>{iOpen[c.locationId]=!iOpen[c.locationId];if(iOpen[c.locationId]&&!iData[c.locationId])await iLoad(c.locationId);else render(lastState,lastRun)};
49958
+ ib2.onclick=async()=>{iOpen[c.locationId]=!iOpen[c.locationId];if(!iOpen[c.locationId])iRetryStop(c.locationId);if(iOpen[c.locationId]&&!iData[c.locationId])await iLoad(c.locationId);else render(lastState,lastRun)};
48882
49959
  ir.appendChild(ib2);
48883
49960
  const dd=iData[c.locationId];
48884
49961
  if(dd&&dd.ok){const ic=document.createElement("span");ic.className="cds-chip istatus "+(dd.status==="received"?"ok":dd.status==="sent"?"warn":"");
@@ -48929,10 +50006,77 @@ function acctRow(c,state,run){const row=document.createElement("li");row.classNa
48929
50006
  Derived server-side from the same board the cards show (/api/queue): no second
48930
50007
  list to fall out of step with. Clicking a row opens that account's card. \u2500\u2500 */
48931
50008
  let queueData=null,showQueueRest=false,showLive=false;
48932
- function opName(force){let n="";try{n=localStorage.getItem("cmdos-operator")||""}catch(e){}
48933
- if(!n||force){const t=prompt("Your name. Command OS records who marked a step done by hand, so nobody has to take it on trust:",n||"");
48934
- if(t===null)return n||null;n=t.trim();if(!n)return null;try{localStorage.setItem("cmdos-operator",n)}catch(e){}}
48935
- return n}
50009
+ /* \u2500\u2500 Inline confirm (rehearsal 2, 2026-09-29) \u2500\u2500
50010
+ A panel under the button instead of prompt(), confirm() or alert(): one stray keystroke dismissed a
50011
+ prompt, and an alert sent the member off the page to read why. The panel keeps its fields, says what
50012
+ went wrong in place, and is drawn again after a redraw with what was typed (its state lives here). */
50013
+ function icPanel(key){const st=icOpen[key],sp=st.spec;const p=document.createElement("div");p.className="cds-callout info cds-stack ic-panel";p.dataset.icp=key;p.setAttribute("role","group");
50014
+ if(sp.title)p.setAttribute("aria-label",sp.title);
50015
+ /* Clicks and keys stay in the panel: a queue row behind it opens a client on click. */
50016
+ p.onclick=e=>e.stopPropagation();
50017
+ p.onkeydown=e=>{e.stopPropagation();if(e.key==="Escape"&&!st.busy){delete icOpen[key];p.remove()}};
50018
+ if(sp.title)p.appendChild(mk("b","",sp.title));
50019
+ for(const line of [].concat(sp.text||[]))p.appendChild(mk("p","",line));
50020
+ const inputs={};let ok;
50021
+ for(const f of sp.fields||[]){const l=document.createElement("label");l.className="cds-field";l.appendChild(mk("span","",f.label));
50022
+ const inp=document.createElement("input");inp.className="cds-input";inp.autocomplete="off";inp.name="ic:"+key+":"+f.id;inp.value=st.values[f.id]!=null?st.values[f.id]:(f.value||"");
50023
+ inp.oninput=()=>{st.values[f.id]=inp.value};inp.onkeydown=e=>{if(e.key==="Enter"){e.preventDefault();ok.click()}};
50024
+ l.appendChild(inp);p.appendChild(l);inputs[f.id]=inp}
50025
+ const err=mk("p","cds-callout warn",st.error||"");err.setAttribute("role","alert");err.hidden=!st.error;p.appendChild(err);
50026
+ const row=document.createElement("div");row.className="cds-row";
50027
+ ok=document.createElement("button");ok.type="button";ok.className="cds-btn";ok.textContent=st.busy?(sp.busyLabel||"Working\u2026"):sp.ok;ok.disabled=Boolean(st.busy);
50028
+ const no=document.createElement("button");no.type="button";no.className="cds-btn ghost";no.textContent="Cancel";no.disabled=Boolean(st.busy);
50029
+ no.onclick=()=>{delete icOpen[key];p.remove()};
50030
+ ok.onclick=async()=>{if(st.busy)return;const vals={};for(const f of sp.fields||[])vals[f.id]=String(inputs[f.id].value||"").trim();
50031
+ st.busy=true;st.error="";err.hidden=true;ok.disabled=true;no.disabled=true;ok.textContent=sp.busyLabel||"Working\u2026";
50032
+ let msg="";try{msg=(await sp.run(vals))||""}catch(e){msg="Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
50033
+ st.busy=false;
50034
+ /* A redraw during the request drew a new panel from the state (showing "Approving\u2026"); this one may
50035
+ be detached. Every copy of the panel is replaced with a fresh one, or removed on success (review of d407f04). */
50036
+ const copies=new Set([p,...icLive(key)]);
50037
+ if(msg){st.error=msg;for(const x of copies)if(x.parentNode)x.replaceWith(icPanel(key));if(redrawPending)scheduleFlush();return}
50038
+ delete icOpen[key];for(const x of copies)x.remove();if(sp.after)await sp.after();else load()};
50039
+ row.appendChild(ok);row.appendChild(no);p.appendChild(row);return p}
50040
+ /* Opens the panel under the button (or focuses it when it is already open). */
50041
+ function icShow(btn,key,spec){const nx=btn.nextElementSibling;if(icOpen[key]&&nx&&nx.dataset&&nx.dataset.icp===key){const f=nx.querySelector("input")||nx.querySelector("button");if(f)f.focus();return}
50042
+ icOpen[key]={spec:spec,values:{},error:"",busy:false};const p=icPanel(key);btn.insertAdjacentElement("afterend",p);
50043
+ const f=p.querySelector("input")||p.querySelector("button");if(f)f.focus()}
50044
+ /* Marks a button as the owner of a panel, so a redraw puts an open panel back under it. */
50045
+ /* Put back synchronously by render() before it restores focus, so a field typed in keeps the cursor;
50046
+ the microtask covers a card drawn outside render(). */
50047
+ let icQueue=[];
50048
+ function icAfter(btn,key){if(icOpen[key]){icQueue.push([btn,key]);queueMicrotask(icFlush)}return btn}
50049
+ function icFlush(){const q=icQueue;icQueue=[];for(const [btn,key] of q){const nx=btn.nextElementSibling;if(btn.parentNode&&icOpen[key]&&!(nx&&nx.dataset&&nx.dataset.icp===key))btn.insertAdjacentElement("afterend",icPanel(key))}}
50050
+ /* Every panel for this key on the page now. */
50051
+ function icLive(key){return [...document.querySelectorAll(".ic-panel")].filter(x=>x.dataset.icp===key)}
50052
+ /* A panel whose question the state has answered elsewhere is dropped, so a later revoke or reset never
50053
+ reopens it with old typing (review of d407f04). */
50054
+ function icPrune(state){for(const k of Object.keys(icOpen)){const st=icOpen[k];if(!st||st.busy)continue;const [kind,id,step]=k.split(":");
50055
+ const stages=((state.clients||{})[id]||{}).stages||[];
50056
+ if(kind==="approve"&&(state.buildApproved||{})[id])delete icOpen[k];
50057
+ else if((kind==="mark"||kind==="qmark")&&stages[Number(step)]==="done")delete icOpen[k]}}
50058
+ /* The name Command OS records on a hand mark, as last saved on this computer. */
50059
+ function opName(){let n="";try{n=localStorage.getItem("cmdos-operator")||""}catch(e){}return n}
50060
+ function saveOpName(n){try{localStorage.setItem("cmdos-operator",n)}catch(e){}}
50061
+ const NAME_FIELD=()=>({id:"who",label:"Your name",value:opName()});
50062
+ function needName(v){if(!v.who)return "Type your name. Command OS records who did this, so nobody has to take it on trust.";saveOpName(v.who);return ""}
50063
+ /* The ONE approval (rehearsal 2, finding 15). The next-step button and the Blueprint Review both open this,
50064
+ with the same label and the same request; nothing else approves, and nothing refuses and points elsewhere. */
50065
+ function approveSpec(id,name,state){const sandbox=typeOf(state,id)==="sandbox";
50066
+ const view=(state.buildViews||{})[id];const repairs=(view&&view.planHash&&view.repairs)||[];
50067
+ return {title:"Approve this plan for "+name,
50068
+ text:["Command OS then builds in this account what the Blueprint Review lists"+(repairs.length?", and makes the "+repairs.length+" live workflow "+plural(repairs.length,"repair")+" listed with the plan":"")+"."],
50069
+ fields:[NAME_FIELD()].concat(sandbox?[]:[{id:"client",label:"Type the client name to confirm: "+name,value:""}]),
50070
+ ok:"Approve",busyLabel:"Approving\u2026",
50071
+ run:async v=>{const bad=needName(v);if(bad)return bad;
50072
+ if(!sandbox&&v.client.toLowerCase()!==String(name).trim().toLowerCase())return 'Type the client name exactly as shown: "'+name+'".';
50073
+ let res,j=null;try{res=await fetch("/api/build-approval",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,confirmName:sandbox?name:v.client,by:v.who,repairHashes:repairs.map(r=>r.hash)})})}
50074
+ catch(e){return "Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
50075
+ try{j=await res.json()}catch(e){}
50076
+ if(!res.ok||!j||j.ok!==true)return (j&&j.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.";
50077
+ return ""}}}
50078
+ function approveButton(id,name,state,place,cls){const b=document.createElement("button");b.className=cls;b.textContent="Approve this plan";
50079
+ const key="approve:"+id+":"+place;b.onclick=()=>icShow(b,key,approveSpec(id,name,lastState||state));return icAfter(b,key)}
48936
50080
  /* Finding 35: a write whose reply nobody reads is a button that can start failing in
48937
50081
  silence. Every cockpit write goes through here, so a refusal is shown rather than
48938
50082
  swallowed. */
@@ -48942,17 +50086,35 @@ async function postJSON(url,body){
48942
50086
  catch(e){alert("Command OS could not confirm the result. Refresh this client to read its saved state before retrying.");return false}
48943
50087
  try{j=await r.json()}catch(e){}
48944
50088
  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}
50089
+ return j||true}
48946
50090
  /* Nothing hand-markable that Command OS can check for itself. Steps it runs are marked
48947
50091
  by the run; steps that need a person are marked by that person, on the record. */
48948
- async function markStage(id,i,status){
48949
- if(POWERED.includes(i)&&status!=="pending"){
48950
- alert(LABELS[i]+" is a step Command OS runs and checks for you, so it cannot be ticked off by hand. Use the Run this step button on the card. If it went wrong, set it back to not started and run it again.");return}
48951
- const who=opName(false);if(!who)return;
48952
- 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
- if(!r.ok){alert(r.error);return}
48954
- 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."}}}
50092
+ async function markStage(id,i,status,who){
50093
+ if(POWERED.includes(i)&&status!=="pending")return LABELS[i]+" is a step Command OS runs and checks for you, so it cannot be ticked off by hand. Use the Run this step button on the card. If it went wrong, set it back to not started and run it again.";
50094
+ let r=null;try{r=await(await fetch("/api/stage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,stage:i,status:status,by:who})})).json()}catch(e){}
50095
+ if(!r||!r.ok)return (r&&r.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.";
50096
+ return ""}
50097
+ /* A hand mark opens a panel with the name that goes on it (prefilled from last time) instead of a prompt. */
50098
+ function markSpec(id,i,label,text){return {title:label,text:[].concat(text||[],"Marking this done puts your name and the time on it."),fields:[NAME_FIELD()],ok:label,busyLabel:"Saving\u2026",
50099
+ run:async v=>{const bad=needName(v);if(bad)return bad;return markStage(id,i,"done",v.who)},after:async()=>{await loadQueue();load()}}}
50100
+ /* cheap: a background refresh uses GET /api/queue, which derives the queue from the saved board without re-reading
50101
+ every client's intake from GoHighLevel; the POST (which does) stays for the page's own loads. */
50102
+ /* Three ways to read the queue:
50103
+ - undefined: the page's own load and the operator's actions. POST {}: re-reads every started client's intake.
50104
+ - "pending": a background re-read (a return to the tab, every 4th heartbeat load). POST {only:"pending"}: re-reads
50105
+ only clients whose saved intake is not yet "received", plus the one open here. At most one queue POST is in
50106
+ flight, and a background one is skipped (the GET is used) within 30s of the last one finishing.
50107
+ - "get": GET, derived from the saved board with no GoHighLevel reads.
50108
+ A background read that fails keeps the last good queue rather than blanking it. */
50109
+ let queuePostBusy=false,queuePostAt=0;
50110
+ async function loadQueue(mode){
50111
+ if(mode==="pending"&&(queuePostBusy||Date.now()-queuePostAt<30000))mode="get";
50112
+ const background=mode==="get"||mode==="pending";const post=mode!=="get";
50113
+ const init=mode==="get"?undefined:{method:"POST",headers:{"Content-Type":"application/json"},body:mode==="pending"?JSON.stringify({only:"pending",open:activeId||undefined}):"{}"};
50114
+ if(post)queuePostBusy=true;
50115
+ 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)}
50116
+ catch(e){if(!(background&&queueData&&queueData.ok))queueData={ok:false,error:e.message||"The work queue could not be read."}}
50117
+ finally{if(post){queuePostBusy=false;queuePostAt=Date.now()}}}
48956
50118
  /* A whole first sentence, never a line cut mid-thought. */
48957
50119
  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
50120
  /* Build text an owner cannot read: quoted names, tag and custom value keys (business_phone), plus signs,
@@ -48994,10 +50156,9 @@ function queueRow(it){const row=document.createElement("li");row.className="q-ro
48994
50156
  /* State in words: running is info; anything unfinished is warn. */
48995
50157
  side.appendChild(it.running?chip("info","Running now"):it.needs==="you"?chip("warn","Your call"):(it.proof&&it.proof.issues&&it.proof.issues.length)?chip("warn","To sort out"):chip("warn","Not run yet"));
48996
50158
  if(it.action==="mark"){const mb=document.createElement("button");mb.className="cds-btn ghost";mb.textContent="Mark done";
48997
- mb.onclick=async(e)=>{e.stopPropagation();
48998
- if(!confirm(it.title+String.fromCharCode(10,10)+"Marking this done puts your name and the time on it."))return;
48999
- await markStage(it.locationId,it.stageIndex,"done")};
49000
- side.appendChild(mb)}
50159
+ const mkey="qmark:"+it.locationId+":"+it.stageIndex;
50160
+ mb.onclick=(e)=>{e.stopPropagation();icShow(mb,mkey,markSpec(it.locationId,it.stageIndex,"Mark done",it.title))};
50161
+ side.appendChild(icAfter(mb,mkey))}
49001
50162
  else side.appendChild(mk("span","cds-link acct-open","Open"));
49002
50163
  row.appendChild(side);
49003
50164
  row.onclick=()=>{if(it.href){rememberClient(it.locationId);location.href=it.href}else queueOpen(it.locationId)};
@@ -49021,7 +50182,9 @@ function operatorLine(){const who=document.createElement("span");who.className="
49021
50182
  let nm="";try{nm=localStorage.getItem("cmdos-operator")||""}catch(e){}
49022
50183
  who.appendChild(document.createTextNode(nm?("Marking as "+nm):"Nobody set to mark steps"));
49023
50184
  const ch=document.createElement("button");ch.className="cds-link";ch.textContent=nm?"Change":"Set your name";
49024
- ch.onclick=()=>{opName(true);render(lastState,lastRun)};who.appendChild(ch);return who}
50185
+ /* A panel with the name field, not a prompt (rehearsal 2). */
50186
+ ch.onclick=()=>icShow(ch,"opname",{title:"Who marks steps done",fields:[NAME_FIELD()],ok:"Save",busyLabel:"Saving\u2026",run:async v=>needName(v),after:async()=>render(lastState,lastRun)});
50187
+ who.appendChild(icAfter(ch,"opname"));return who}
49025
50188
  function queueBlock(match){const q=queueData;if(!q||!q.ok)return queueReadError();
49026
50189
  const frag=document.createDocumentFragment();
49027
50190
  const items=(q.items||[]).filter(i=>match({name:i.clientName,locationId:i.locationId}));
@@ -49036,11 +50199,11 @@ function queueBlock(match){const q=queueData;if(!q||!q.ok)return queueReadError(
49036
50199
  frag.appendChild(now);
49037
50200
  /* The rest fold away (Disclosure: cds-more), each in a Panel of its own that says what is inside. */
49038
50201
  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};
50202
+ const d=mk("details","cds-more");d.setAttribute("data-key","queue-rest");d.open=showQueueRest;d.ontoggle=()=>{showQueueRest=d.open};
49040
50203
  d.appendChild(mk("summary","","Show the "+later.length+" "+plural(later.length,"step")+" not started"));
49041
50204
  d.appendChild(listRows(later.map(queueRow),"queue-later"));p.appendChild(d);frag.appendChild(p)}
49042
50205
  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};
50206
+ const d=mk("details","cds-more");d.setAttribute("data-key","queue-live");d.open=showLive;d.ontoggle=()=>{showLive=d.open};
49044
50207
  d.appendChild(mk("summary","","Show the "+fin.length+" finished "+plural(fin.length,"account")));
49045
50208
  for(const signed of [true,false]){const group=fin.filter(f=>Boolean(f.live)===signed);if(!group.length)continue;
49046
50209
  d.appendChild(mk("div","cds-panel-label",signed?"Finished and live ("+group.length+")":"Finished, not signed live ("+group.length+")"));
@@ -49215,7 +50378,8 @@ const KEY_FACTS=[/website/i,/primary goal/i,/primary services|services you sell/
49215
50378
  function capAnswer(v){return Array.isArray(v)?v.join(", "):String(v==null?"":v).trim()}
49216
50379
  function renderIntakeCapability(m,c){const id=c.locationId;const data=iData[id];
49217
50380
  if(!data){iLoad(id);const p=panel("Intake","The client\u2019s answers");p.appendChild(mk("p","muted","Reading the intake\u2026"));m.appendChild(p);return}
49218
- if(data.ok===false){const w=mk("div","cds-callout warn");w.setAttribute("role","alert");w.appendChild(mk("span","ttl","Could not read the intake"));w.appendChild(mk("span","",data.error||"Refresh the page to try again."));m.appendChild(w)}
50381
+ if(data.ok===false&&data.transient){const p=mk("p","muted",data.error||"Could not refresh just now; retrying.");p.setAttribute("role","status");m.appendChild(p)}
50382
+ else if(data.ok===false){const w=mk("div","cds-callout warn");w.setAttribute("role","alert");w.appendChild(mk("span","ttl","Could not read the intake"));w.appendChild(mk("span","",data.error||"Refresh the page to try again."));m.appendChild(w)}
49219
50383
  const answers=data.answers||{};const questions=data.questions||[];
49220
50384
  const answerOf=q=>capAnswer(answers[q.label]!=null?answers[q.label]:answers[q.key]);
49221
50385
  const answered=questions.length?questions.filter(q=>answerOf(q)).map(q=>[q.label,answerOf(q)]):Object.entries(answers).map(([k,v])=>[k,capAnswer(v)]).filter(x=>x[1]);
@@ -49266,7 +50430,7 @@ send.disabled=!published;
49266
50430
  if(!confirm("Send one email from "+c.name+"\u2019s account to "+r.preview.email+" with the handoff at "+r.url+"?"))return;
49267
50431
  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
50432
  }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)}
50433
+ 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
50434
 
49271
50435
  /* PageHeader: kicker, the title as a sentence, one muted line, at most one primary action. */
49272
50436
  function screenHead(title,sub,kicker,action){const d=mk("div","cds-page-head");const t=mk("div","txt");
@@ -49313,9 +50477,34 @@ function homeBlock(state,run){const wrap=mk("div","board");
49313
50477
  li.onclick=go;li.onkeydown=(e)=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();go()}};return li}),"board-needs"))}
49314
50478
  two.appendChild(needs);
49315
50479
  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);
50480
+ {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
50481
  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;
50482
+ /* Every redraw keeps the view (2026-09-29, Jerry: "anything I expand closes itself within 1 to 3 seconds").
50483
+ The page is rebuilt from data, so a disclosure the operator opened, the scroll position and the cursor in
50484
+ a field would all reset. Before drawing: note which <details> are open or closed, keyed by the nearest
50485
+ data-key (client, client:stage) plus the summary words, and where focus and the caret are. After: put
50486
+ them back. Toggles such as See issues already live in state (oOpen), and survive on their own. */
50487
+ let lastDrawnSig="";
50488
+ /* generatedAt is stamped fresh on every queue read (work-queue.ts), so it is left out: with it, no two reads ever matched
50489
+ and every background refresh redrew (review of c09e946). */
50490
+ 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())}}
50491
+ function keyOf(el,root){const a=el.closest("[data-key]");return a&&root.contains(a)?a.getAttribute("data-key"):"main"}
50492
+ function detailsKeys(root){const out=[];const seen={};for(const d of root.querySelectorAll("details")){const sm=d.querySelector("summary");
50493
+ 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}
50494
+ function fieldKey(el,root){return keyOf(el,root)+"|"+el.tagName+"|"+(el.getAttribute("aria-label")||el.getAttribute("name")||el.getAttribute("placeholder")||el.id||"")}
50495
+ function captureView(){const m=document.getElementById("main");if(!m)return null;const open=new Set(),closed=new Set();
50496
+ for(const [d,k] of detailsKeys(m))(d.open?open:closed).add(k);
50497
+ let focus=null;const a=document.activeElement;
50498
+ 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);
50499
+ 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}}
50500
+ return {open:open,closed:closed,focus:focus,x:window.scrollX,y:window.scrollY}}
50501
+ function restoreView(v){const m=document.getElementById("main");if(!v||!m)return;
50502
+ 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}
50503
+ if(v.focus){const el=[...m.querySelectorAll(v.focus.tag)].filter(x=>fieldKey(x,m)===v.focus.key)[v.focus.n];
50504
+ if(el&&document.activeElement!==el){try{el.focus({preventScroll:true})}catch(e){}try{if(v.focus.st!=null)el.setSelectionRange(v.focus.st,v.focus.en)}catch(e){}}}
50505
+ if(window.scrollX!==v.x||window.scrollY!==v.y){try{window.scrollTo(v.x,v.y)}catch(e){}}}
50506
+ function render(state,run){const v=captureView();try{if(state)icPrune(state);renderPage(state,run);icFlush()}finally{lastDrawnSig=dataSig(state,run);restoreView(v)}}
50507
+ function renderPage(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...state.roster)}syncReviews(state);lastState=state;lastRun=run;
49319
50508
  if(state&&state.protectedAccounts)PROTECTED=state.protectedAccounts;const m=document.getElementById("main");m.innerHTML="";
49320
50509
  /* Four screens share this page's data and differ only in what they show. */
49321
50510
  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 +50580,7 @@ function render(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...sta
49391
50580
  const list=panel("Client accounts",actC?"Your other client accounts":"Your client accounts");if(!actC)list.id="clients";
49392
50581
  if(rest.length){list.appendChild(listRows(rest.map(c=>acctRow(c,state,run)),"clients-rest","accounts"));
49393
50582
  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};
50583
+ if(parkedRest.length){const d=mk("details","cds-more");d.setAttribute("data-key","parked-rest");d.open=showParked;d.ontoggle=()=>{showParked=d.open};
49395
50584
  d.appendChild(mk("summary","","Show the "+parkedRest.length+" template and hidden "+plural(parkedRest.length,"account")));
49396
50585
  d.appendChild(listRows(parkedRest.map(c=>acctRow(c,state,run)),"clients-parked"));list.appendChild(d)}
49397
50586
  m.appendChild(list)}}
@@ -49794,7 +50983,7 @@ async function runDashboard(argv) {
49794
50983
  let ffAdmin;
49795
50984
  try {
49796
50985
  const b = workflowReadConnection(registry2, c);
49797
- stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
50986
+ stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
49798
50987
  ffAdmin = builderAdmin(b);
49799
50988
  } catch {
49800
50989
  }
@@ -50168,9 +51357,18 @@ async function runDashboard(argv) {
50168
51357
  if (req.method === "POST" && url === "/api/client-assessment") {
50169
51358
  readBody(req, res, 2e4, async (body) => {
50170
51359
  try {
50171
- const result = await assessmentAttachmentAction(JSON.parse(body), attachmentDeps);
51360
+ const request2 = JSON.parse(body);
51361
+ const result = await assessmentAttachmentAction(request2, attachmentDeps);
51362
+ let withdrawn = false;
51363
+ if (typeof request2?.clientLocationId === "string") {
51364
+ const after = withdrawIfBindingChanged(readCockpitState(), request2.clientLocationId);
51365
+ if (after.withdrawn) {
51366
+ writeCockpitState(after.state);
51367
+ withdrawn = true;
51368
+ }
51369
+ }
50172
51370
  res.writeHead(200, { "Content-Type": "application/json" });
50173
- res.end(JSON.stringify(result));
51371
+ res.end(JSON.stringify({ ...result, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_ASSESSMENT } : {} }));
50174
51372
  } catch (e) {
50175
51373
  res.writeHead(400, { "Content-Type": "application/json" });
50176
51374
  logQuietly(`client assessment: ${(0, import_node_util5.inspect)(e)}`);
@@ -50555,11 +51753,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50555
51753
  }
50556
51754
  const assessmentFigures = {};
50557
51755
  for (const loc of Object.keys(st.assessmentAttachments ?? {})) assessmentFigures[loc] = assessmentFiguresFor(st, loc);
50558
- const scopePrefill = {};
50559
- for (const loc of Object.keys(st.assessmentPurchases ?? {})) {
50560
- const p = scopePrefillFor(st, loc);
50561
- if (p) scopePrefill[loc] = p;
50562
- }
51756
+ const scopeOffered = Object.fromEntries(currentRoster().map((c) => [c.locationId, offeredFeaturesFor(st, c.locationId)]));
51757
+ const boughtViews = Object.fromEntries(currentRoster().map((c) => [c.locationId, boughtViewFor(st, c.locationId, scopeOffered[c.locationId])]));
50563
51758
  const buildViews = Object.fromEntries(currentRoster().map((c) => {
50564
51759
  const saved = readSavedPlan(c.locationId);
50565
51760
  const approval = st.planApprovals?.[c.locationId];
@@ -50590,9 +51785,9 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50590
51785
  delivery: void 0
50591
51786
  };
50592
51787
  }
50593
- for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || st.manualTasks?.[id]?.length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
51788
+ for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || openTasks(st.manualTasks?.[id] ?? []).length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
50594
51789
  const outcomes = Object.fromEntries(Object.entries(st.outcomes ?? {}).map(([key, value]) => [key, { ...value, ...key.endsWith(":4") && value.verification ? { plain: verifyPanelOutcome(value) } : { ...plainFallbackOutcome(value), plain: plainFallbackOutcome(value.plain ?? value) }, error: value.error ? "The step did not finish. Check the account before retrying." : void 0 }]));
50595
- res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
51790
+ res.end(JSON.stringify({ ...st, assessmentFigures, boughtViews, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
50596
51791
  const records = st.outcomes?.[`${id}:4`]?.verification;
50597
51792
  const allowed = records ? verifyReadback(records).allowPublishing : false;
50598
51793
  return [id, tasks2.map((t) => ({ ...t, text: verifyPublishingText(plainFallback(t.text, { nextStep: true }), allowed) })).filter((t) => t.text)];
@@ -50609,24 +51804,23 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50609
51804
  return;
50610
51805
  }
50611
51806
  if (req.method === "POST" && url === "/api/queue") {
50612
- readBody(req, res, 5e3, async () => {
51807
+ readBody(req, res, 5e3, async (body) => {
50613
51808
  try {
50614
51809
  const roster2 = currentRoster();
50615
51810
  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.");
51811
+ const { reread, failed } = await rereadQueueIntakes({ roster: roster2, state, filter: queueScanFilter(body), read: async (locationId2) => {
51812
+ const c = clientFor(locationId2);
51813
+ if (!c) return null;
51814
+ const snap = await readIntakeSnapshot(c, locationId2);
51815
+ if (snap.status === "unavailable") return "unavailable";
50623
51816
  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 });
51817
+ return { answers, status: snap.status, hash: intakeInput(answers, state.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: state.accounts?.[locationId2]?.industry, agency: readOverlay() })) };
51818
+ } });
51819
+ const latest = mergeQueueIntakes(readCockpitState(), reread);
51820
+ if (Object.keys(reread).length) writeCockpitState(latest);
51821
+ const inputs = latest.intakeInputs ?? {};
50628
51822
  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 })));
51823
+ 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
51824
  } catch {
50631
51825
  res.writeHead(503, { "Content-Type": "application/json" });
50632
51826
  res.end(JSON.stringify({ ok: false, error: "The work queue could not be read. Retry the queue." }));
@@ -50652,6 +51846,13 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50652
51846
  let st0 = readCockpitState();
50653
51847
  let inputHash;
50654
51848
  let normalizedAnswers;
51849
+ if (stage === 3) {
51850
+ if (!hasChosenScope(st0, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_BEFORE_BUILD);
51851
+ const ap = st0.planApprovals?.[rk];
51852
+ const assessmentWait = assessmentGateRefusal(st0, rk, who.clientKey, who.name);
51853
+ if (assessmentWait) throw new Error(assessmentWait);
51854
+ if (ap && ap.scopeHash !== approvalScopeHash(st0, rk)) throw new Error(boughtUnchangedSinceApproval(ap, st0, rk) ? ASSESSMENT_CHANGED_SINCE_APPROVAL : SCOPE_CHANGED_SINCE_APPROVAL);
51855
+ }
50655
51856
  if (who.clientKey && [3, 4].includes(stage)) {
50656
51857
  inputHash = secondClientInputHash(readSavedPlan(rk));
50657
51858
  assertBuildApproval(st0.planApprovals?.[rk], inputHash);
@@ -50669,7 +51870,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50669
51870
  const client = clientFor(locationId2);
50670
51871
  if (!client) throw new Error(INTAKE_UNREADABLE);
50671
51872
  const snap = await readIntakeSnapshot(client, locationId2);
50672
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
51873
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
50673
51874
  normalizedAnswers = normalizedIntake(snap);
50674
51875
  inputHash = intakeInput(normalizedAnswers, st0.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: st0.accounts?.[locationId2]?.industry, agency: readOverlay() }));
50675
51876
  st0 = { ...st0, intakeInputs: { ...st0.intakeInputs, [locationId2]: { hash: inputHash, answers: normalizedAnswers, status: snap.status } } };
@@ -50690,7 +51891,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50690
51891
  const c = clientFor(locationId2);
50691
51892
  if (!c) throw new Error(INTAKE_UNREADABLE);
50692
51893
  const snap = await readIntakeSnapshot(c, locationId2);
50693
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
51894
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
50694
51895
  if (snap.status !== "received") throw new Error("Open the real form, submit it, then refresh.");
50695
51896
  const required = composeQuestionSet({ industry: st0.accounts?.[locationId2]?.industry, agency: readOverlay() }).filter((q3) => q3.required && !String(normalizedAnswers?.[q3.label] ?? normalizedAnswers?.[q3.key] ?? "").trim());
50696
51897
  if (required.length) throw new Error("Complete these required intake answers: " + required.map((q3) => q3.label).join(", ") + ".");
@@ -50776,7 +51977,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50776
51977
  const st = readCockpitState();
50777
51978
  if (!c && !st.pending?.[locationId2]) throw new Error(INTAKE_CONNECT_REQUIRED);
50778
51979
  const snap = c ? await readIntakeSnapshot(c, locationId2, Boolean(st.intakeSent?.[locationId2])) : { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
50779
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
51980
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
50780
51981
  const industry = st.accounts?.[locationId2]?.industry;
50781
51982
  const questions = composeQuestionSet({ industry, agency: readOverlay() }).map((q3) => ({ key: q3.key, contactTag: q3.field.kind === "standard" ? q3.field.tag : void 0, label: q3.label, sectionLabel: q3.sectionLabel, required: q3.required, options: q3.options ?? null, placeholder: q3.placeholder ?? "", multi: q3.field.kind === "custom" && q3.field.dataType === "MULTIPLE_OPTIONS" }));
50782
51983
  const answers = prefillTeamAnswers(normalizedIntake(snap, st.intakeDrafts?.[locationId2]), st.staffRoster?.[locationId2], questions);
@@ -50789,7 +51990,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
50789
51990
  } catch (e) {
50790
51991
  res.writeHead(400, { "Content-Type": "application/json" });
50791
51992
  logQuietly(`client-intake ${new URL(req.url || "/", "http://x").searchParams.get("locationId") || ""}: ${e instanceof Error ? e.message : String(e)}`);
50792
- res.end(JSON.stringify({ ok: false, error: intakeRequestError(e) }));
51993
+ if (e instanceof Error && e.message === INTAKE_READ_TRANSIENT) res.end(JSON.stringify({ ok: false, transient: true, error: INTAKE_REFRESH_RETRYING }));
51994
+ else res.end(JSON.stringify({ ok: false, error: intakeRequestError(e) }));
50793
51995
  }
50794
51996
  })();
50795
51997
  return;
@@ -51212,7 +52414,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51212
52414
  }
51213
52415
  const name = currentRoster().find((r) => r.locationId === locationId2)?.name || locationId2;
51214
52416
  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 }));
52417
+ res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2, readSavedPlan(locationId2)), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), bought: boughtDocumentFor(st, locationId2, offeredFeaturesFor(st, locationId2)), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
51216
52418
  return;
51217
52419
  }
51218
52420
  if (req.method === "GET" && url === "/api/review") {
@@ -51224,27 +52426,31 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51224
52426
  if (req.method === "POST" && url === "/api/build-approval") {
51225
52427
  readBody(req, res, 5e3, async (body) => {
51226
52428
  try {
51227
- const { locationId: locationId2, clientKey, confirmName, revoke, repairHashes } = JSON.parse(body);
52429
+ const { locationId: locationId2, clientKey, confirmName, revoke, repairHashes, by } = JSON.parse(body);
52430
+ const approvedBy = typeof by === "string" && by.trim() ? { by: by.trim().slice(0, 80) } : {};
51228
52431
  const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
51229
52432
  const rk = who.recordKey;
51230
52433
  if (PROTECTED_LOCATIONS[who.locationId]) throw new Error(PROTECTED_LOCATIONS[who.locationId] + " can never be approved for building.");
52434
+ const current = readCockpitState();
52435
+ if (!revoke && !hasChosenScope(current, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_WHAT_THEY_BOUGHT);
52436
+ const assessmentWait = revoke ? null : assessmentGateRefusal(current, rk, who.clientKey, who.name);
52437
+ if (assessmentWait) throw new Error(assessmentWait);
51231
52438
  if (!revoke && !readAccountRulesSafe().sandboxes[who.locationId] && String(confirmName || "").trim().toLowerCase() !== who.name.trim().toLowerCase()) {
51232
52439
  throw new Error(`Type the client's name exactly ("${who.name}") to approve automated building.`);
51233
52440
  }
51234
- const current = readCockpitState();
51235
52441
  const at = (/* @__PURE__ */ new Date()).toISOString();
51236
52442
  const approvals = { ...current.planApprovals };
51237
52443
  if (revoke) delete approvals[rk];
51238
52444
  else if (who.clientKey) {
51239
- approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]) };
52445
+ approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: approvalScopeHash(current, rk), boughtHash: approvalBoughtHash(current, rk), ...approvedBy };
51240
52446
  } else {
51241
52447
  const c = clientFor(who.locationId);
51242
52448
  if (!c) throw new Error(INTAKE_UNREADABLE);
51243
52449
  const snap = await readIntakeSnapshot(c, who.locationId);
51244
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
52450
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
51245
52451
  const hash = intakeInput(normalizedIntake(snap), current.accounts?.[who.locationId]?.industry, composeQuestionSet({ industry: current.accounts?.[who.locationId]?.industry, agency: readOverlay() }));
51246
52452
  if (current.outcomes?.[`${rk}:1`]?.inputHash && current.outcomes[`${rk}:1`].inputHash !== hash) throw new Error("The intake changed. Run Brief again before approving building.");
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]) };
52453
+ const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: approvalScopeHash(current, rk), boughtHash: approvalBoughtHash(current, rk), ...approvedBy };
51248
52454
  approvals[rk] = approval;
51249
52455
  }
51250
52456
  const latest = readCockpitState();
@@ -51602,16 +52808,63 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51602
52808
  });
51603
52809
  return;
51604
52810
  }
52811
+ if (req.method === "POST" && url === "/api/assessment-choice") {
52812
+ readBody(req, res, 5e3, (body) => {
52813
+ try {
52814
+ const { locationId: locationId2, clientKey, choice } = JSON.parse(body);
52815
+ const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
52816
+ if (!isAssessmentChoice(choice)) throw new Error("Choose one of the three answers.");
52817
+ const rk = who.recordKey;
52818
+ const current = readCockpitState();
52819
+ const { state: nextState, withdrawn } = withdrawIfBindingChanged({ ...current, assessmentChoice: { ...current.assessmentChoice, [rk]: { choice, at: (/* @__PURE__ */ new Date()).toISOString() } } }, rk);
52820
+ writeCockpitState(nextState);
52821
+ res.writeHead(200, { "Content-Type": "application/json" });
52822
+ res.end(JSON.stringify({ ok: true, choice, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_ASSESSMENT } : {} }));
52823
+ } catch (e) {
52824
+ res.writeHead(400, { "Content-Type": "application/json" });
52825
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
52826
+ }
52827
+ });
52828
+ return;
52829
+ }
51605
52830
  if (req.method === "POST" && url === "/api/scope") {
51606
52831
  readBody(req, res, 1e4, (body) => {
51607
52832
  try {
51608
- const { locationId: locationId2, clientKey, package: pkg2, features } = JSON.parse(body);
52833
+ const { locationId: locationId2, clientKey, package: pkg2, features, bought } = JSON.parse(body);
51609
52834
  const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
51610
- 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
- const next = features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) };
51612
- writeCockpitState(setClientScope(readCockpitState(), who.recordKey, next));
52835
+ let ticks;
52836
+ let derived;
52837
+ if (bought !== void 0) {
52838
+ if (!Array.isArray(bought) || bought.some((b) => !isBoughtId(b))) throw new Error("Tick items from the list.");
52839
+ ticks = [...new Set(bought.map(String))];
52840
+ if (!ticks.length) throw new Error(NOTHING_BOUGHT);
52841
+ derived = scopeFromBought(ticks, offeredFeaturesFor(readCockpitState(), who.recordKey), typeof pkg2 === "string" && pkg2.trim() ? pkg2 : void 0);
52842
+ if (!("wholePreset" in derived) && !derived.features.length) throw new Error(NOTHING_BUILT_FROM_TICKS);
52843
+ } else if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
52844
+ const next = derived ?? (features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) });
52845
+ if (!derived && Array.isArray(features)) {
52846
+ const ticked = features.map(String).filter(isFeatureId);
52847
+ const offered = offeredFeaturesFor(readCockpitState(), who.recordKey);
52848
+ if (ticked.length && !ticked.some((f) => offered.includes(f))) throw new Error(NOTHING_TICKED_IN_PLAN);
52849
+ }
52850
+ const rk = who.recordKey;
52851
+ let nextState = setClientScope(readCockpitState(), rk, next);
52852
+ const savedTicks = { ...nextState.bought ?? {} };
52853
+ if (ticks) savedTicks[rk] = ticks;
52854
+ else delete savedTicks[rk];
52855
+ nextState = { ...nextState, bought: savedTicks };
52856
+ if (!Object.keys(savedTicks).length) delete nextState.bought;
52857
+ const { state: bound, withdrawn } = withdrawIfBindingChanged(nextState, rk);
52858
+ nextState = bound;
52859
+ {
52860
+ const offered = offeredFeaturesFor(nextState, rk);
52861
+ replaceBuildObligationsOfType(rk, BOUGHT_JOB_TYPE, ticks ? boughtJobs(ticks, offered) : []);
52862
+ const tasks2 = withBoughtJobs(nextState.manualTasks?.[rk] ?? [], ticks ? boughtJobLines(ticks, offered) : []);
52863
+ if (tasks2.length || nextState.manualTasks?.[rk]) nextState = { ...nextState, manualTasks: { ...nextState.manualTasks, [rk]: tasks2 } };
52864
+ }
52865
+ writeCockpitState(nextState);
51613
52866
  res.writeHead(200, { "Content-Type": "application/json" });
51614
- res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[who.recordKey] ?? null }));
52867
+ res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[rk] ?? null, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_SCOPE } : {} }));
51615
52868
  } catch (e) {
51616
52869
  res.writeHead(400, { "Content-Type": "application/json" });
51617
52870
  res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
@@ -51666,7 +52919,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51666
52919
  const client = clientFor(locationId2);
51667
52920
  if (!client) throw new Error(INTAKE_UNREADABLE);
51668
52921
  const snap = await readIntakeSnapshot(client, locationId2);
51669
- if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
52922
+ if (snap.status === "unavailable") throw intakeUnavailableError(snap);
51670
52923
  const answers = normalizedIntake(snap);
51671
52924
  hash = intakeInput(answers, state.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: state.accounts?.[locationId2]?.industry, agency: readOverlay() }));
51672
52925
  state = { ...state, intakeInputs: { ...state.intakeInputs, [locationId2]: { answers, hash, status: snap.status } } };
@@ -51748,7 +53001,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
51748
53001
  process.on("SIGTERM", stop2);
51749
53002
  });
51750
53003
  }
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;
53004
+ var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, SCOPE_REFUSAL, NOTHING_BOUGHT, NOTHING_BUILT_FROM_TICKS, ASSESSMENT_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_ASSESSMENT, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_READ_TRANSIENT, INTAKE_REFRESH_RETRYING, INTAKE_ANSWERS_MISSING, INTAKE_READ_FAILED, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
51752
53005
  var init_dashboard = __esm({
51753
53006
  "src/dashboard.ts"() {
51754
53007
  "use strict";
@@ -51816,6 +53069,7 @@ var init_dashboard = __esm({
51816
53069
  init_plan_from_preset();
51817
53070
  init_plan_guide();
51818
53071
  init_attachment();
53072
+ init_bought_list();
51819
53073
  init_intake_contact();
51820
53074
  init_client_intake();
51821
53075
  init_client_intake();
@@ -51870,6 +53124,16 @@ var init_dashboard = __esm({
51870
53124
  ];
51871
53125
  ACCOUNT_TYPES = ["client", "sandbox", "template", "hidden"];
51872
53126
  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.";
53127
+ CHOOSE_WHAT_THEY_BOUGHT = "Choose what the client bought before approving: Blueprint Review, then What they bought, then Save.";
53128
+ CHOOSE_BEFORE_BUILD = "Choose what the client bought before building: Blueprint Review, then What they bought, then Save.";
53129
+ SCOPE_REFUSAL = /Nobody has chosen what|what this client bought|features (are )?selected for this client|No features are selected|features nobody ticked/i;
53130
+ NOTHING_BOUGHT = "Tick at least one item they bought, then save.";
53131
+ NOTHING_BUILT_FROM_TICKS = "None of the ticked items is built for this client's industry, so nothing was saved. Tick at least one item marked Built.";
53132
+ ASSESSMENT_CHANGED_SINCE_APPROVAL = "The Assessment answer or the attached Assessment changed after the build was approved, so Build did not start. Approve this plan again, then build.";
53133
+ APPROVAL_WITHDRAWN_FOR_ASSESSMENT = "The Assessment answer changed, so the build approval was withdrawn. Approve this plan again before building.";
53134
+ NOTHING_TICKED_IN_PLAN = "None of the ticked features is in the plan for this client's industry, so nothing was saved. Tick features from the list, or choose Everything in the plan.";
53135
+ SCOPE_CHANGED_SINCE_APPROVAL = "What they bought changed after the build was approved, so Build did not start. Approve this plan again, then build.";
53136
+ APPROVAL_WITHDRAWN_FOR_SCOPE = "What they bought changed, so the build approval was withdrawn. Approve this plan again before building.";
51873
53137
  activeRun2 = null;
51874
53138
  handoffEmailPreviews = /* @__PURE__ */ new Map();
51875
53139
  UPGRADE_MSG = "\n The cockpit is not included in this licence, so it did not start.\n Nothing was changed.\n\n";
@@ -51878,10 +53142,17 @@ var init_dashboard = __esm({
51878
53142
  PARKED_NOT_BUILT = "Template and hidden accounts are not built from Command OS. Change the account's type to Client first.";
51879
53143
  NEEDS_AGENCY_KEY = "No agency key on file. Run register_agency_key in GHL Command first.";
51880
53144
  INTAKE_UNREADABLE = "Command OS could not read this client's account. Paste its key again in Build a client account.";
53145
+ INTAKE_READ_TRANSIENT = "Command OS could not reach this client's account just now. Try again in a moment.";
53146
+ INTAKE_REFRESH_RETRYING = "Could not refresh just now; retrying.";
53147
+ INTAKE_ANSWERS_MISSING = "The intake form shows a submission, but no contact in this client's account has the answers yet. If the client has not submitted the form, send it again. If they have, press Refresh status in a minute.";
53148
+ INTAKE_READ_FAILED = "The intake could not be read. Press Refresh status to try again.";
51881
53149
  INTAKE_FORM_REQUIRED = "Run the intake step in Client builds to create the form, then come back and submit.";
51882
53150
  INTAKE_CONNECT_REQUIRED = "Connect this sub-account by pasting its Private Integration key in Command OS.";
51883
53151
  INTAKE_OWN_MESSAGES = [
51884
53152
  INTAKE_UNREADABLE,
53153
+ INTAKE_READ_TRANSIENT,
53154
+ INTAKE_ANSWERS_MISSING,
53155
+ INTAKE_READ_FAILED,
51885
53156
  ...CLAUDE_REPAIR_MESSAGES,
51886
53157
  "The limit is 60,000 characters. Paste an excerpt or shorten this text file.",
51887
53158
  "That account is not on the board.",
@@ -52308,7 +53579,9 @@ WARNING: saved under ${canonicalCompanyId}, but switch_location resolves ${args.
52308
53579
  locationId: args.test_location_id,
52309
53580
  userId: foundViaRealPath.userId,
52310
53581
  companyId: resolvedCompanyId,
52311
- registry: null
53582
+ // The registry, so a token rotated by this probe is saved to the
53583
+ // company's own slot instead of being lost (review, 2026-09-29).
53584
+ registry: registry2
52312
53585
  });
52313
53586
  try {
52314
53587
  await probe.listWorkflows(1, 0);
@@ -53162,11 +54435,11 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
53162
54435
  };
53163
54436
  const dataA = await fetchWithOwnKey(locationA);
53164
54437
  const dataB = await fetchWithOwnKey(locationB);
53165
- const { comparison, unavailable } = buildLocationComparison(dataA, dataB, SECTIONS);
54438
+ const { comparison, unavailable: unavailable2 } = buildLocationComparison(dataA, dataB, SECTIONS);
53166
54439
  diff.comparison = comparison;
53167
54440
  if (usedRegisteredKey.length) diff.usedRegisteredKeyFor = usedRegisteredKey;
53168
- if (unavailable.length) {
53169
- diff.unavailable = unavailable;
54441
+ if (unavailable2.length) {
54442
+ diff.unavailable = unavailable2;
53170
54443
  diff.warning = "Some sections could not be read. Those sections are marked unavailable \u2014 do NOT read them as empty. A Private Integration key only works on the sub-account it was created in, so register each account's own key (register_location) before comparing them.";
53171
54444
  }
53172
54445
  return jsonResponse(diff);
@@ -53435,13 +54708,13 @@ function discoverAssets(input) {
53435
54708
  return assets;
53436
54709
  }
53437
54710
  function buildLocationComparison(dataA, dataB, sections) {
53438
- const unavailable = [];
54711
+ const unavailable2 = [];
53439
54712
  const comparison = {};
53440
54713
  for (const key of sections) {
53441
54714
  const a = dataA[key] ?? { error: "not read" };
53442
54715
  const b = dataB[key] ?? { error: "not read" };
53443
- if ("error" in a) unavailable.push(`locationA.${key}: ${a.error}`);
53444
- if ("error" in b) unavailable.push(`locationB.${key}: ${b.error}`);
54716
+ if ("error" in a) unavailable2.push(`locationA.${key}: ${a.error}`);
54717
+ if ("error" in b) unavailable2.push(`locationB.${key}: ${b.error}`);
53445
54718
  if ("error" in a || "error" in b) {
53446
54719
  comparison[key] = {
53447
54720
  A: "error" in a ? "unavailable" : a.names.length,
@@ -53461,7 +54734,7 @@ function buildLocationComparison(dataA, dataB, sections) {
53461
54734
  onlyInB: b.names.filter((n) => !setA.has(n))
53462
54735
  };
53463
54736
  }
53464
- return { comparison, unavailable };
54737
+ return { comparison, unavailable: unavailable2 };
53465
54738
  }
53466
54739
  var import_zod70, OFF_PLATFORM_ASSETS;
53467
54740
  var init_account_export = __esm({
@@ -54927,7 +56200,7 @@ async function collectInstallChecks(installedVersion, client, builderClient, reg
54927
56200
  }
54928
56201
  if (result.ok) {
54929
56202
  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}` };
56203
+ return { name: "Firebase auth (workflow builder)", status: "pass", detail: `Got a valid ID token. Workflow builder tools are usable.${companyNote}${idNote}` };
54931
56204
  }
54932
56205
  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
56206
  })();
@@ -58120,7 +59393,7 @@ function extractHtml(text2) {
58120
59393
  function claudeGenerate(model = DEFAULT_MODEL, bin = "claude") {
58121
59394
  return (instruction, { cwd }) => new Promise((resolveP) => {
58122
59395
  const t0 = Date.now();
58123
- const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model]);
59396
+ const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model, ...NO_MEMBER_HOOKS]);
58124
59397
  const child = (0, import_node_child_process6.spawn)(cmd.command, cmd.args, {
58125
59398
  cwd,
58126
59399
  env: { ...process.env, CLAUDECODE: "", CLAUDE_CODE_ENTRYPOINT: "" },
@@ -58281,6 +59554,7 @@ var init_run2 = __esm({
58281
59554
  "use strict";
58282
59555
  import_node_child_process6 = require("node:child_process");
58283
59556
  init_claude_locate();
59557
+ init_result_text();
58284
59558
  import_node_fs12 = require("node:fs");
58285
59559
  import_node_path11 = require("node:path");
58286
59560
  init_brief();
@@ -61494,7 +62768,7 @@ function registerSecretShopperTools(server2, client, registry2) {
61494
62768
  if (registry2) {
61495
62769
  try {
61496
62770
  const b = workflowReadConnection(registry2, client);
61497
- stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
62771
+ stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
61498
62772
  } catch {
61499
62773
  }
61500
62774
  }
@@ -63639,8 +64913,8 @@ function registerAccountHealthTools(server2, client) {
63639
64913
  conversations,
63640
64914
  phoneNumbers
63641
64915
  };
63642
- const unavailable = Object.entries(sections).filter(([, v]) => v.status === "unavailable").map(([k]) => k);
63643
- return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable };
64916
+ const unavailable2 = Object.entries(sections).filter(([, v]) => v.status === "unavailable").map(([k]) => k);
64917
+ return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable: unavailable2 };
63644
64918
  }
63645
64919
  );
63646
64920
  }
@@ -65893,6 +67167,18 @@ async function readLocationName(client, locationId2) {
65893
67167
  return locationId2;
65894
67168
  }
65895
67169
  }
67170
+ function cockpitSelectionRequired(board, locationId2, recordKey, env2 = process.env) {
67171
+ if (env2.GHL_COMMAND_STAGE) return true;
67172
+ const has = (field, key) => {
67173
+ const m = board[field];
67174
+ return Boolean(m && typeof m === "object" && m[key]);
67175
+ };
67176
+ const own = ["clients", "planApprovals", "buildApproved", "assessmentPurchases", "scope", "reviews", "intakeInputs", "assessmentAttachments", "clientProfiles"].some((f) => has(f, recordKey));
67177
+ return own || recordKey === locationId2 && has("accounts", locationId2);
67178
+ }
67179
+ function noSelectionMessage(clientKey) {
67180
+ return clientKey ? secondClientChooseMessage(clientKey) : NO_SELECTION_SCOPE;
67181
+ }
65896
67182
  function resolvePlanSource(input) {
65897
67183
  const { plan, useSavedPlan, replaceSavedPlan, saved } = input;
65898
67184
  if (useSavedPlan) {
@@ -66027,9 +67313,10 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66027
67313
  if (!briefArg) {
66028
67314
  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
67315
  }
66030
- let briefForCompose = briefArg;
66031
- let scopeSource = briefArg.scope ? "brief" : "none";
66032
- const briefScope = briefArg.scope;
67316
+ const briefIn = process.env.GHL_COMMAND_STAGE && briefArg.scope ? (({ scope: _ignored, ...rest }) => rest)(briefArg) : briefArg;
67317
+ let briefForCompose = briefIn;
67318
+ let scopeSource = briefIn.scope ? "brief" : "none";
67319
+ const briefScope = briefIn.scope;
66033
67320
  if (briefScope && Array.isArray(briefScope.features) && briefScope.features.length === 0) {
66034
67321
  return jsonResponse({
66035
67322
  ok: false,
@@ -66038,13 +67325,15 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66038
67325
  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
67326
  });
66040
67327
  }
66041
- if (!briefArg.scope) {
67328
+ if (!briefIn.scope) {
66042
67329
  const board = readBoardState();
66043
67330
  const saved2 = board.scope?.[recordKey];
66044
67331
  if (saved2?.wholePreset) {
66045
67332
  scopeSource = "cockpit";
66046
67333
  } else if (!saved2 && Array.isArray(board.assessmentPurchases?.[recordKey])) {
66047
67334
  return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
67335
+ } else if (!saved2 && cockpitSelectionRequired(board, activeLocation, recordKey)) {
67336
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
66048
67337
  } else if (saved2 && Array.isArray(saved2.features)) {
66049
67338
  const features = saved2.features.filter(isFeatureId);
66050
67339
  if (!features.length) {
@@ -66055,7 +67344,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66055
67344
  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
67345
  });
66057
67346
  }
66058
- briefForCompose = { ...briefArg, scope: { ...saved2.package ? { package: saved2.package } : {}, features } };
67347
+ briefForCompose = { ...briefIn, scope: { ...saved2.package ? { package: saved2.package } : {}, features } };
66059
67348
  scopeSource = "cockpit";
66060
67349
  }
66061
67350
  }
@@ -66108,6 +67397,30 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66108
67397
  if (!current && Array.isArray(board.assessmentPurchases?.[recordKey])) {
66109
67398
  return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
66110
67399
  }
67400
+ if (!current && cockpitSelectionRequired(board, activeLocation, recordKey)) {
67401
+ return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
67402
+ }
67403
+ if (current && !current.wholePreset && Array.isArray(current.features) && !(saved && planSource !== "argument-replaces-saved")) {
67404
+ const chosen = new Set(current.features.filter(isFeatureId));
67405
+ const outside = [];
67406
+ for (const w of plan.workflows ?? []) {
67407
+ let f = null;
67408
+ try {
67409
+ f = featureOfWorkflow(String(w.ref ?? ""), w.feature);
67410
+ } catch {
67411
+ f = String(w.feature);
67412
+ }
67413
+ if (f !== null && !chosen.has(f)) outside.push(`${String(w.name ?? w.ref)} (${isFeatureId(f) ? FEATURES[f].name : f})`);
67414
+ }
67415
+ if (chosen.size && outside.length) {
67416
+ return jsonResponse({
67417
+ ok: false,
67418
+ phase: "scope",
67419
+ scopeSource: "cockpit",
67420
+ 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.`
67421
+ });
67422
+ }
67423
+ }
66111
67424
  if (current && !current.wholePreset && Array.isArray(current.features) && !current.features.some(isFeatureId)) {
66112
67425
  return jsonResponse({
66113
67426
  ok: false,
@@ -66116,7 +67429,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
66116
67429
  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
67430
  });
66118
67431
  }
66119
- if (saved && planSource !== "argument-replaces-saved" && !savedPlanMatchesScope(saved, current)) {
67432
+ if (saved && planSource !== "argument-replaces-saved" && (current ? !savedPlanMatchesScope(saved, current) : Boolean(saved.scope))) {
66120
67433
  const planned = saved?.scope ? [...saved.scope.built, ...(saved.scope.omitted ?? []).map((f) => f.id)].join(", ") || "no features" : "everything in the preset";
66121
67434
  const now = current?.wholePreset ? "everything in the preset (confirmed)" : current ? current.features.filter(isFeatureId).join(", ") : "everything in the preset (nothing is saved)";
66122
67435
  return jsonResponse({
@@ -66896,7 +68209,7 @@ function intakeFormMatches(expected, actual) {
66896
68209
  const keys = ["id", "tag", "type", "hiddenFieldQueryKey", "required", "label", "options", "picklistOptions"];
66897
68210
  return expected.every((field, i) => keys.every((key) => field[key] === void 0 || JSON.stringify(field[key]) === JSON.stringify(actual[i]?.[key])));
66898
68211
  }
66899
- var import_zod93, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
68212
+ var import_zod93, customFieldItemSchema, sleep6, NO_SELECTION_SCOPE, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
66900
68213
  var init_intake_to_build = __esm({
66901
68214
  "src/tools/intake-to-build.ts"() {
66902
68215
  "use strict";
@@ -66948,7 +68261,8 @@ var init_intake_to_build = __esm({
66948
68261
  picklistOptions: import_zod93.z.array(import_zod93.z.string()).optional()
66949
68262
  }).passthrough();
66950
68263
  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.";
68264
+ 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.";
68265
+ 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
68266
  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
68267
  workflowInventorySchema = import_zod93.z.object({
66954
68268
  workflows: import_zod93.z.array(import_zod93.z.object({ id: import_zod93.z.string().min(1), name: import_zod93.z.string() }).passthrough())