@elitedcs/ghl-mcp 3.89.0-beta.1 → 3.89.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/README.md +1 -1
- package/dist/index.js +1042 -224
- package/package.json +1 -1
- package/skills/blueprint/SKILL.md +1 -1
- package/skills/blueprint/presets/clinic.preset.json +8 -0
- package/skills/blueprint/presets/coach.preset.json +9 -0
- package/skills/blueprint/presets/ecommerce.preset.json +6 -0
- package/skills/blueprint/presets/generic-client.preset.json +9 -0
- package/skills/blueprint/presets/local-service.preset.json +9 -0
- package/skills/blueprint/presets/med-spa.preset.json +8 -0
- package/skills/blueprint/references/preset-format.md +1 -1
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
|
-
|
|
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
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
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
|
|
2816
|
-
*
|
|
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
|
-
|
|
2986
|
+
const refresh = this.obtainIdToken();
|
|
2987
|
+
this.tokenRefreshPromise = refresh;
|
|
2826
2988
|
try {
|
|
2827
|
-
return await
|
|
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
|
|
2833
|
-
const
|
|
2834
|
-
const
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
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
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
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
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
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
|
|
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
|
|
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" &&
|
|
24393
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
24394
|
+
return { ...raw, clients: raw.clients && typeof raw.clients === "object" ? raw.clients : {} };
|
|
24395
|
+
}
|
|
24131
24396
|
} catch {
|
|
24132
24397
|
}
|
|
24133
24398
|
return { clients: {} };
|
|
@@ -27949,6 +28214,77 @@ var init_claude_locate = __esm({
|
|
|
27949
28214
|
}
|
|
27950
28215
|
});
|
|
27951
28216
|
|
|
28217
|
+
// src/result-text.ts
|
|
28218
|
+
function hasParseableResult(text2) {
|
|
28219
|
+
const m = RESULT_RE.exec(text2);
|
|
28220
|
+
if (!m) return false;
|
|
28221
|
+
try {
|
|
28222
|
+
JSON.parse(m[1]);
|
|
28223
|
+
return true;
|
|
28224
|
+
} catch {
|
|
28225
|
+
return false;
|
|
28226
|
+
}
|
|
28227
|
+
}
|
|
28228
|
+
function pickResultText(texts, firstEligible = 0) {
|
|
28229
|
+
for (let i = texts.length - 1; i >= firstEligible; i--) if (hasParseableResult(texts[i])) return texts[i];
|
|
28230
|
+
for (let i = texts.length - 1; i >= firstEligible; i--) if (/RESULT:\s*\{/.test(texts[i])) return texts[i];
|
|
28231
|
+
if (!texts.length) return "";
|
|
28232
|
+
if (texts.length <= firstEligible) return texts[texts.length - 1].replace(/RESULT:\s*\{[\s\S]*\}/, "").trim();
|
|
28233
|
+
return texts[texts.length - 1];
|
|
28234
|
+
}
|
|
28235
|
+
function resultEventFailure(ev) {
|
|
28236
|
+
if (ev?.type !== "result") return void 0;
|
|
28237
|
+
if (typeof ev.subtype === "string" && ev.subtype !== "success") return ev.subtype;
|
|
28238
|
+
if (ev.is_error === true) return "error";
|
|
28239
|
+
return void 0;
|
|
28240
|
+
}
|
|
28241
|
+
var NO_MEMBER_HOOKS, RESULT_RE, RunTexts;
|
|
28242
|
+
var init_result_text = __esm({
|
|
28243
|
+
"src/result-text.ts"() {
|
|
28244
|
+
"use strict";
|
|
28245
|
+
NO_MEMBER_HOOKS = ["--settings", '{"disableAllHooks":true}'];
|
|
28246
|
+
RESULT_RE = /RESULT:\s*(\{[\s\S]*\})/;
|
|
28247
|
+
RunTexts = class {
|
|
28248
|
+
texts = [];
|
|
28249
|
+
afterLastTool = 0;
|
|
28250
|
+
/** Set when the result event reported the run did not finish (e.g. error_max_turns). */
|
|
28251
|
+
failure;
|
|
28252
|
+
/** Record an assistant text block or the result string (blank ones are ignored). */
|
|
28253
|
+
text(t) {
|
|
28254
|
+
if (typeof t === "string" && t.trim()) this.texts.push(t);
|
|
28255
|
+
}
|
|
28256
|
+
/** Record a tool_use block, in stream order. */
|
|
28257
|
+
tool() {
|
|
28258
|
+
this.afterLastTool = this.texts.length;
|
|
28259
|
+
}
|
|
28260
|
+
/** Record every block of one stream-json event. */
|
|
28261
|
+
event(ev) {
|
|
28262
|
+
if (ev?.type === "assistant") {
|
|
28263
|
+
for (const b of ev.message?.content ?? []) {
|
|
28264
|
+
if (b?.type === "tool_use") this.tool();
|
|
28265
|
+
else if (b?.type === "text") this.text(b.text);
|
|
28266
|
+
}
|
|
28267
|
+
} else if (ev?.type === "result") this.result(ev);
|
|
28268
|
+
}
|
|
28269
|
+
/**
|
|
28270
|
+
* Record the result event: its failure, and its text only when that text was
|
|
28271
|
+
* not already said (it normally repeats the last assistant text, and a repeat
|
|
28272
|
+
* of a RESULT line said before a tool call must not become eligible).
|
|
28273
|
+
*/
|
|
28274
|
+
result(ev) {
|
|
28275
|
+
this.failure = resultEventFailure(ev) ?? this.failure;
|
|
28276
|
+
if (typeof ev.result === "string" && !this.texts.includes(ev.result)) this.text(ev.result);
|
|
28277
|
+
}
|
|
28278
|
+
get empty() {
|
|
28279
|
+
return this.texts.length === 0;
|
|
28280
|
+
}
|
|
28281
|
+
pick() {
|
|
28282
|
+
return pickResultText(this.texts, this.afterLastTool);
|
|
28283
|
+
}
|
|
28284
|
+
};
|
|
28285
|
+
}
|
|
28286
|
+
});
|
|
28287
|
+
|
|
27952
28288
|
// src/claude-cli.ts
|
|
27953
28289
|
function parseVersion(text2) {
|
|
27954
28290
|
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text2 || "");
|
|
@@ -28028,6 +28364,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
|
|
|
28028
28364
|
const cmd = claudeCommand(bin, [
|
|
28029
28365
|
"-p",
|
|
28030
28366
|
SMOKE_PROMPT,
|
|
28367
|
+
...NO_MEMBER_HOOKS,
|
|
28031
28368
|
"--output-format",
|
|
28032
28369
|
"stream-json",
|
|
28033
28370
|
"--verbose",
|
|
@@ -28045,7 +28382,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
|
|
|
28045
28382
|
done({ ok: false, error: "the engine check did not finish in time" });
|
|
28046
28383
|
}, timeoutMs);
|
|
28047
28384
|
let buffered = "";
|
|
28048
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
30010
|
+
resolve13(texts.pick());
|
|
29670
30011
|
});
|
|
29671
30012
|
child.on("close", () => {
|
|
29672
30013
|
clearTimeout(timeout);
|
|
29673
30014
|
report3();
|
|
29674
|
-
resolve13(
|
|
30015
|
+
resolve13(texts.pick());
|
|
29675
30016
|
});
|
|
29676
30017
|
});
|
|
29677
30018
|
}
|
|
@@ -29682,6 +30023,7 @@ var init_handoff_run = __esm({
|
|
|
29682
30023
|
import_node_child_process2 = require("node:child_process");
|
|
29683
30024
|
init_stage_runner();
|
|
29684
30025
|
init_claude_locate();
|
|
30026
|
+
init_result_text();
|
|
29685
30027
|
init_handoff_pack();
|
|
29686
30028
|
CLIENT_SENTENCE_RULES = "Write for the business owner, not a developer. Complete sentences that end with a period; never trail off. One or two sentences per item, no more. Everyday words: say 'text messaging registration (A2P)' not 'A2P'; 'the calendar is not connected to Google or Outlook yet' not 'OAuth'; 'nothing was wrong' not 'status ok'. No tool names, no ids, no field names in code style, no JSON, no arrows. Keep the names of workflows, pipelines and forms because the client will recognise them. If a list is long, name the first few and say how many in total. State what is in place, or what is missing and why, plainly. An item marked 'done but not confirmed' has NOT been confirmed: never describe it as having happened or been observed; say what is still to be confirmed and how (for example, 'this is confirmed on the handoff call, when you complete one task in the account without help').";
|
|
29687
30029
|
NO_TOOLS_DENY = "Bash,Write,Edit,MultiEdit,NotebookEdit,WebFetch,WebSearch,Task,TodoWrite,Read,Glob,Grep";
|
|
@@ -32152,7 +32494,7 @@ var require_package = __commonJS({
|
|
|
32152
32494
|
"package.json"(exports2, module2) {
|
|
32153
32495
|
module2.exports = {
|
|
32154
32496
|
name: "@elitedcs/ghl-mcp",
|
|
32155
|
-
version: "3.89.0-beta.
|
|
32497
|
+
version: "3.89.0-beta.2",
|
|
32156
32498
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
32157
32499
|
description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
32158
32500
|
main: "dist/index.js",
|
|
@@ -33376,10 +33718,12 @@ function judgeSteps(rawRows, contactId, workflowNames) {
|
|
|
33376
33718
|
}
|
|
33377
33719
|
};
|
|
33378
33720
|
}
|
|
33379
|
-
function liveStepLogReader(locationId2, headers, fetchFn = fetch) {
|
|
33721
|
+
function liveStepLogReader(locationId2, headers, fetchFn = fetch, onUnauthorized) {
|
|
33380
33722
|
return async (workflowId, fromMs, toMs) => {
|
|
33381
33723
|
const q3 = new URLSearchParams({ action: "first", dateType: "custom", fromDate: String(Math.floor(fromMs)), toDate: String(Math.ceil(toMs)), limit: String(STEP_LOG_LIMIT), locationId: locationId2, workflowId });
|
|
33382
|
-
const
|
|
33724
|
+
const used = await headers();
|
|
33725
|
+
const res = await fetchFn(`https://backend.leadconnectorhq.com/workflows/logs/v2?${q3}`, { headers: used });
|
|
33726
|
+
if (res.status === 401) onUnauthorized?.(used);
|
|
33383
33727
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
33384
33728
|
const body = await res.json();
|
|
33385
33729
|
if (!Array.isArray(body)) throw new Error("the log came back in a shape this version does not know");
|
|
@@ -35892,7 +36236,7 @@ function workflowReadConnection(registry2, client, companyId) {
|
|
|
35892
36236
|
const home = companyId && companyId === process.env.GHL_COMPANY_ID ? registry2.getFirebase() : void 0;
|
|
35893
36237
|
const config3 = fb ?? home ?? (companyId && companyId === process.env.GHL_COMPANY_ID && process.env.GHL_FIREBASE_REFRESH_TOKEN ? { apiKey: process.env.GHL_FIREBASE_API_KEY, refreshToken: process.env.GHL_FIREBASE_REFRESH_TOKEN, userId: process.env.GHL_USER_ID } : void 0);
|
|
35894
36238
|
if (!config3?.apiKey || !config3.userId) throw new Error(BUILDER_REPAIR_MESSAGE);
|
|
35895
|
-
return new WorkflowBuilderClient({ firebaseApiKey: config3.apiKey, refreshToken: config3.refreshToken, userId: config3.userId, apiKey: client.getApiKey(), locationId: locationId2, companyId });
|
|
36239
|
+
return new WorkflowBuilderClient({ firebaseApiKey: config3.apiKey, refreshToken: config3.refreshToken, userId: config3.userId, apiKey: client.getApiKey(), locationId: locationId2, companyId, registry: registry2 });
|
|
35896
36240
|
}
|
|
35897
36241
|
var BUILDER_REPAIR_MESSAGE;
|
|
35898
36242
|
var init_workflow_read_connection = __esm({
|
|
@@ -38873,12 +39217,12 @@ var init_run_cost = __esm({
|
|
|
38873
39217
|
function callClaudeRecorded(bin, argv, context, onCost = () => {
|
|
38874
39218
|
}) {
|
|
38875
39219
|
return new Promise((resolve13, reject) => {
|
|
38876
|
-
|
|
39220
|
+
const run = new RunTexts();
|
|
38877
39221
|
let buffer = "";
|
|
38878
39222
|
let stderr = "";
|
|
38879
39223
|
let facts = {};
|
|
38880
39224
|
let settled = false;
|
|
38881
|
-
const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose"]);
|
|
39225
|
+
const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose", ...NO_MEMBER_HOOKS]);
|
|
38882
39226
|
const child = (0, import_node_child_process5.spawn)(cmd.command, cmd.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
38883
39227
|
const timer = setTimeout(() => child.kill("SIGKILL"), 6 * 60 * 1e3);
|
|
38884
39228
|
const finish3 = (ok) => {
|
|
@@ -38891,17 +39235,14 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
|
|
|
38891
39235
|
onCost("The cost record could not be saved.");
|
|
38892
39236
|
}
|
|
38893
39237
|
onCost(`${context.kind}: ${facts.costUsd === void 0 ? "cost not reported" : formatUsd(facts.costUsd)}.`);
|
|
38894
|
-
if (ok) resolve13(
|
|
39238
|
+
if (ok) resolve13(run.pick());
|
|
38895
39239
|
else reject(new Error("Check your Claude authentication in Your agency before reading this source."));
|
|
38896
39240
|
};
|
|
38897
39241
|
const consume = (line3) => {
|
|
38898
39242
|
try {
|
|
38899
39243
|
const ev = JSON.parse(line3);
|
|
38900
39244
|
facts = { ...facts, ...readEngineFacts(ev) };
|
|
38901
|
-
|
|
38902
|
-
for (const block of ev.message?.content ?? []) if (block.type === "text") text2 = block.text;
|
|
38903
|
-
}
|
|
38904
|
-
if (ev.type === "result" && typeof ev.result === "string") text2 = ev.result;
|
|
39245
|
+
run.event(ev);
|
|
38905
39246
|
} catch {
|
|
38906
39247
|
}
|
|
38907
39248
|
};
|
|
@@ -38919,7 +39260,7 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
|
|
|
38919
39260
|
child.on("error", () => finish3(false));
|
|
38920
39261
|
child.on("close", (code) => {
|
|
38921
39262
|
if (buffer.trim()) consume(buffer);
|
|
38922
|
-
finish3(code === 0 &&
|
|
39263
|
+
finish3(code === 0 && !run.empty);
|
|
38923
39264
|
});
|
|
38924
39265
|
});
|
|
38925
39266
|
}
|
|
@@ -38931,6 +39272,7 @@ var init_claude_call = __esm({
|
|
|
38931
39272
|
init_run_cost();
|
|
38932
39273
|
init_claude_cli();
|
|
38933
39274
|
init_claude_locate();
|
|
39275
|
+
init_result_text();
|
|
38934
39276
|
}
|
|
38935
39277
|
});
|
|
38936
39278
|
|
|
@@ -39151,10 +39493,40 @@ function briefWarnings(brief) {
|
|
|
39151
39493
|
}
|
|
39152
39494
|
return out;
|
|
39153
39495
|
}
|
|
39496
|
+
function liftGoalCalendars(input) {
|
|
39497
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return { input, notes: [] };
|
|
39498
|
+
const brief = input;
|
|
39499
|
+
const goal = brief.goal;
|
|
39500
|
+
if (!goal || typeof goal !== "object" || Array.isArray(goal)) return { input, notes: [] };
|
|
39501
|
+
const misplaced = goal.calendars;
|
|
39502
|
+
if (!Array.isArray(misplaced)) return { input, notes: [] };
|
|
39503
|
+
const top = brief.calendars;
|
|
39504
|
+
if (Array.isArray(top) && top.length > 0) {
|
|
39505
|
+
if (misplaced.length === 0) return { input, notes: [] };
|
|
39506
|
+
const names = misplaced.map((c, i) => {
|
|
39507
|
+
const n = c?.name;
|
|
39508
|
+
return typeof n === "string" && n.trim() ? `"${n.trim()}"` : `entry ${i + 1}`;
|
|
39509
|
+
});
|
|
39510
|
+
return {
|
|
39511
|
+
input,
|
|
39512
|
+
notes: [`The brief lists calendars at the top level, so the ${misplaced.length === 1 ? "calendar" : "calendars"} under goal.calendars (${names.join(", ")}) ${misplaced.length === 1 ? "was" : "were"} ignored; move any that are needed into the top-level calendars list.`]
|
|
39513
|
+
};
|
|
39514
|
+
}
|
|
39515
|
+
const lifted = [];
|
|
39516
|
+
const notes = [];
|
|
39517
|
+
misplaced.forEach((c, i) => {
|
|
39518
|
+
const r = briefCalendarSchema.safeParse(c);
|
|
39519
|
+
if (r.success) lifted.push(r.data);
|
|
39520
|
+
else notes.push(`Calendar ${i + 1} under goal.calendars could not be read (${r.error.issues.map((x) => `${x.path.join(".") || "entry"}: ${x.message}`).join("; ")}); give it a name, a type (${BRIEF_CALENDAR_TYPES.join(", ")}) and staffNames.`);
|
|
39521
|
+
});
|
|
39522
|
+
const { calendars: _moved, ...restGoal } = goal;
|
|
39523
|
+
return { input: { ...brief, goal: restGoal, ...lifted.length ? { calendars: lifted } : {} }, notes };
|
|
39524
|
+
}
|
|
39154
39525
|
function validateBrief(input) {
|
|
39155
|
-
const
|
|
39526
|
+
const lifted = liftGoalCalendars(input);
|
|
39527
|
+
const parsed2 = briefSchema.safeParse(lifted.input);
|
|
39156
39528
|
if (parsed2.success) {
|
|
39157
|
-
return { valid: true, errors: [], warnings: briefWarnings(parsed2.data), brief: parsed2.data };
|
|
39529
|
+
return { valid: true, errors: [], warnings: [...lifted.notes, ...briefWarnings(parsed2.data)], brief: parsed2.data };
|
|
39158
39530
|
}
|
|
39159
39531
|
return {
|
|
39160
39532
|
valid: false,
|
|
@@ -40034,18 +40406,33 @@ function keptByCondition(items, ctx) {
|
|
|
40034
40406
|
function isFillBlock(v) {
|
|
40035
40407
|
return !!v && typeof v === "object" && !Array.isArray(v) && ("fillFrom" in v || "transform" in v);
|
|
40036
40408
|
}
|
|
40037
|
-
function
|
|
40409
|
+
function slugHasPhrase(slug4, phrase) {
|
|
40410
|
+
return `_${slug4}_`.includes(`_${slugify2(phrase)}_`);
|
|
40411
|
+
}
|
|
40412
|
+
function isTerminalStage(name) {
|
|
40413
|
+
const s = slugify2(name);
|
|
40414
|
+
return TERMINAL_STAGE_WORDS.some((w) => slugHasPhrase(s, w));
|
|
40415
|
+
}
|
|
40416
|
+
function stringList(v) {
|
|
40417
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
40418
|
+
}
|
|
40419
|
+
function composeStages(block, ctx, uses, remaps) {
|
|
40038
40420
|
const defaults = isFillBlock(block) ? keptByCondition(block.default ?? [], ctx) : keptByCondition(block, ctx);
|
|
40039
|
-
const defaultList = defaults.map((d) => ({
|
|
40421
|
+
const defaultList = defaults.map((d) => ({
|
|
40422
|
+
ref: String(d.ref),
|
|
40423
|
+
name: fillString(String(d.name), ctx),
|
|
40424
|
+
aliases: stringList(d.aliases)
|
|
40425
|
+
}));
|
|
40040
40426
|
const briefStages = (ctx.brief.goal.salesStages ?? []).map((s) => s.trim()).filter(Boolean);
|
|
40041
40427
|
if (!isFillBlock(block) || briefStages.length === 0) {
|
|
40042
|
-
return defaultList.map((d, i) => ({
|
|
40428
|
+
return defaultList.map((d, i) => ({ ref: d.ref, name: d.name, position: i }));
|
|
40043
40429
|
}
|
|
40044
40430
|
const bySlug = /* @__PURE__ */ new Map();
|
|
40045
40431
|
for (const d of defaultList) {
|
|
40046
40432
|
bySlug.set(slugify2(d.name), d);
|
|
40047
40433
|
bySlug.set(d.ref.replace(/^stage\./, ""), d);
|
|
40048
40434
|
}
|
|
40435
|
+
const defaultRefs = new Set(defaultList.map((d) => d.ref));
|
|
40049
40436
|
const out = [];
|
|
40050
40437
|
const usedRefs = /* @__PURE__ */ new Set();
|
|
40051
40438
|
for (const name of briefStages) {
|
|
@@ -40056,39 +40443,160 @@ function composeStages(block, ctx, referencedStageRefs) {
|
|
|
40056
40443
|
} else {
|
|
40057
40444
|
let ref = `stage.${slugify2(name)}`;
|
|
40058
40445
|
let n = 2;
|
|
40059
|
-
while (usedRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
|
|
40446
|
+
while (usedRefs.has(ref) || defaultRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
|
|
40060
40447
|
usedRefs.add(ref);
|
|
40061
40448
|
out.push({ ref, name });
|
|
40062
40449
|
}
|
|
40063
40450
|
}
|
|
40064
|
-
|
|
40065
|
-
|
|
40066
|
-
|
|
40451
|
+
const findClientStage = (phrases, allowTerminal, skipOwned) => {
|
|
40452
|
+
for (const phrase of phrases) {
|
|
40453
|
+
for (const s of out) {
|
|
40454
|
+
if (!allowTerminal && isTerminalStage(s.name)) continue;
|
|
40455
|
+
if (skipOwned && defaultRefs.has(s.ref)) continue;
|
|
40456
|
+
if (slugHasPhrase(slugify2(s.name), phrase)) return s;
|
|
40457
|
+
}
|
|
40458
|
+
}
|
|
40459
|
+
return void 0;
|
|
40460
|
+
};
|
|
40461
|
+
const defaultName = (ref) => defaultList.find((d) => d.ref === ref)?.name ?? ref.replace(/^stage\./, "");
|
|
40462
|
+
const stillReferenced = /* @__PURE__ */ new Set();
|
|
40463
|
+
const preferred = /* @__PURE__ */ new Set();
|
|
40464
|
+
for (const u of uses) {
|
|
40465
|
+
const key = `${u.workflowRef}\0${u.ref}`;
|
|
40466
|
+
if (preferred.has(key)) continue;
|
|
40467
|
+
const target = u.prefer?.length ? findClientStage(u.prefer, true, false) : void 0;
|
|
40468
|
+
if (!target) {
|
|
40469
|
+
stillReferenced.add(u.ref);
|
|
40470
|
+
continue;
|
|
40471
|
+
}
|
|
40472
|
+
preferred.add(key);
|
|
40473
|
+
if (target.ref === u.ref) continue;
|
|
40474
|
+
let m = remaps.byWorkflow.get(u.workflowRef);
|
|
40475
|
+
if (!m) {
|
|
40476
|
+
m = /* @__PURE__ */ new Map();
|
|
40477
|
+
remaps.byWorkflow.set(u.workflowRef, m);
|
|
40478
|
+
}
|
|
40479
|
+
m.set(u.ref, target.ref);
|
|
40480
|
+
ctx.warnings.push(
|
|
40481
|
+
`Mapped "${defaultName(u.ref)}" automations in ${u.workflowName} onto your "${target.name}" stage.`
|
|
40482
|
+
);
|
|
40483
|
+
}
|
|
40484
|
+
const pending = defaultList.filter((d) => !usedRefs.has(d.ref) && stillReferenced.has(d.ref));
|
|
40485
|
+
const candidates = [];
|
|
40486
|
+
for (const d of pending) {
|
|
40487
|
+
const terminalRef = isTerminalStage(d.name);
|
|
40488
|
+
const phrases = [d.name, ...d.aliases];
|
|
40489
|
+
for (const [sIdx, s] of out.entries()) {
|
|
40490
|
+
if (defaultRefs.has(s.ref)) continue;
|
|
40491
|
+
if (!terminalRef && isTerminalStage(s.name)) continue;
|
|
40492
|
+
const slug4 = slugify2(s.name);
|
|
40493
|
+
const aliasIdx = phrases.findIndex((p) => slugHasPhrase(slug4, p));
|
|
40494
|
+
if (aliasIdx < 0) continue;
|
|
40495
|
+
const best = phrases.filter((p) => slugHasPhrase(slug4, p)).map(slugify2).sort((a, b) => b.split("_").length - a.split("_").length || b.length - a.length)[0];
|
|
40496
|
+
candidates.push({ d, s, words: best.split("_").length, len: best.length, aliasIdx, dIdx: defaultList.indexOf(d), sIdx });
|
|
40497
|
+
}
|
|
40498
|
+
}
|
|
40499
|
+
candidates.sort((a, b) => b.words - a.words || b.len - a.len || a.aliasIdx - b.aliasIdx || a.dIdx - b.dIdx || a.sIdx - b.sIdx);
|
|
40500
|
+
const taken = /* @__PURE__ */ new Set();
|
|
40501
|
+
const mapped = /* @__PURE__ */ new Set();
|
|
40502
|
+
for (const c of candidates) {
|
|
40503
|
+
if (mapped.has(c.d.ref) || taken.has(c.s.ref)) continue;
|
|
40504
|
+
mapped.add(c.d.ref);
|
|
40505
|
+
taken.add(c.s.ref);
|
|
40506
|
+
remaps.global.set(c.d.ref, c.s.ref);
|
|
40507
|
+
}
|
|
40508
|
+
const first2 = defaultList[0];
|
|
40509
|
+
if (first2 && pending.includes(first2) && !mapped.has(first2.ref) && out[0] && !defaultRefs.has(out[0].ref) && !taken.has(out[0].ref) && !isTerminalStage(out[0].name)) {
|
|
40510
|
+
mapped.add(first2.ref);
|
|
40511
|
+
taken.add(out[0].ref);
|
|
40512
|
+
remaps.global.set(first2.ref, out[0].ref);
|
|
40513
|
+
}
|
|
40514
|
+
for (const d of pending) {
|
|
40515
|
+
const to = remaps.global.get(d.ref);
|
|
40516
|
+
const s = to ? out.find((x) => x.ref === to) : void 0;
|
|
40517
|
+
if (s) ctx.warnings.push(`Mapped "${d.name}" automations onto your "${s.name}" stage.`);
|
|
40518
|
+
}
|
|
40519
|
+
const placedAt = (ref) => {
|
|
40520
|
+
const target = remaps.global.get(ref) ?? ref;
|
|
40521
|
+
return out.findIndex((x) => x.ref === target);
|
|
40522
|
+
};
|
|
40523
|
+
for (const d of pending) {
|
|
40524
|
+
if (mapped.has(d.ref)) continue;
|
|
40525
|
+
const i = defaultList.indexOf(d);
|
|
40526
|
+
const firstTerminal = out.findIndex((s) => isTerminalStage(s.name));
|
|
40527
|
+
let at = -1;
|
|
40528
|
+
let where = "";
|
|
40529
|
+
for (let j = i - 1; j >= 0 && at < 0; j--) {
|
|
40530
|
+
const k = placedAt(defaultList[j].ref);
|
|
40531
|
+
if (k >= 0 && !isTerminalStage(out[k].name)) {
|
|
40532
|
+
at = k + 1;
|
|
40533
|
+
where = `just after your "${out[k].name}" stage`;
|
|
40534
|
+
}
|
|
40535
|
+
}
|
|
40536
|
+
for (let j = i + 1; j < defaultList.length && at < 0; j++) {
|
|
40537
|
+
const k = placedAt(defaultList[j].ref);
|
|
40538
|
+
if (k >= 0 && !isTerminalStage(out[k].name)) {
|
|
40539
|
+
at = k;
|
|
40540
|
+
where = `just before your "${out[k].name}" stage`;
|
|
40541
|
+
}
|
|
40542
|
+
}
|
|
40543
|
+
if (at >= 0 && firstTerminal >= 0 && at > firstTerminal) at = -1;
|
|
40544
|
+
if (at < 0 && firstTerminal >= 0) {
|
|
40545
|
+
at = firstTerminal;
|
|
40546
|
+
where = `just before your "${out[firstTerminal].name}" stage`;
|
|
40547
|
+
}
|
|
40548
|
+
if (at < 0) {
|
|
40549
|
+
at = out.length;
|
|
40550
|
+
where = "at the end of your stages";
|
|
40551
|
+
}
|
|
40552
|
+
out.splice(at, 0, { ref: d.ref, name: d.name });
|
|
40067
40553
|
usedRefs.add(d.ref);
|
|
40068
|
-
out.push(d);
|
|
40069
40554
|
ctx.warnings.push(
|
|
40070
|
-
`Kept the "${d.name}" pipeline stage even though the intake's stage list did not mention it
|
|
40555
|
+
`Kept the "${d.name}" pipeline stage even though the intake's stage list did not mention it and none of its stages matches it; the automations move deals into it. It sits ${where}.`
|
|
40071
40556
|
);
|
|
40072
40557
|
}
|
|
40073
40558
|
return out.map((s, i) => ({ ...s, position: i }));
|
|
40074
40559
|
}
|
|
40075
|
-
function
|
|
40076
|
-
const
|
|
40077
|
-
const scanActions = (actions) => {
|
|
40078
|
-
for (const a of keptByCondition(actions, ctx)) {
|
|
40079
|
-
if (typeof a.stageRef === "string") refs2.add(a.stageRef);
|
|
40080
|
-
if (a.type === "find_opportunity") {
|
|
40081
|
-
scanActions(a.found);
|
|
40082
|
-
scanActions(a.notFound);
|
|
40083
|
-
}
|
|
40084
|
-
}
|
|
40085
|
-
};
|
|
40560
|
+
function collectStageUses(workflows, ctx) {
|
|
40561
|
+
const uses = [];
|
|
40086
40562
|
for (const w of workflows) {
|
|
40563
|
+
const workflowRef = String(w.ref ?? "");
|
|
40564
|
+
const workflowName = typeof w.name === "string" ? fillString(w.name, ctx) : workflowRef;
|
|
40565
|
+
const pref = w.stagePreference && typeof w.stagePreference === "object" ? w.stagePreference : {};
|
|
40566
|
+
const add2 = (ref) => {
|
|
40567
|
+
const prefer = stringList(pref[ref]);
|
|
40568
|
+
uses.push({ workflowRef, workflowName, ref, ...prefer.length ? { prefer } : {} });
|
|
40569
|
+
};
|
|
40570
|
+
const scanActions = (actions) => {
|
|
40571
|
+
for (const a of keptByCondition(actions, ctx)) {
|
|
40572
|
+
if (typeof a.stageRef === "string") add2(a.stageRef);
|
|
40573
|
+
if (a.type === "find_opportunity") {
|
|
40574
|
+
scanActions(a.found);
|
|
40575
|
+
scanActions(a.notFound);
|
|
40576
|
+
}
|
|
40577
|
+
}
|
|
40578
|
+
};
|
|
40087
40579
|
const trig = w.trigger;
|
|
40088
|
-
if (trig && typeof trig.stageRef === "string")
|
|
40580
|
+
if (trig && typeof trig.stageRef === "string") add2(trig.stageRef);
|
|
40089
40581
|
scanActions(w.actions);
|
|
40090
40582
|
}
|
|
40091
|
-
return
|
|
40583
|
+
return uses;
|
|
40584
|
+
}
|
|
40585
|
+
function remapWorkflowStages(w, remaps) {
|
|
40586
|
+
const own = remaps.byWorkflow.get(String(w.ref));
|
|
40587
|
+
const to = (ref) => own?.get(ref) ?? remaps.global.get(ref) ?? ref;
|
|
40588
|
+
const walk2 = (actions) => {
|
|
40589
|
+
for (const a of actions ?? []) {
|
|
40590
|
+
if (typeof a.stageRef === "string") a.stageRef = to(a.stageRef);
|
|
40591
|
+
if (a.type === "find_opportunity") {
|
|
40592
|
+
walk2(a.found);
|
|
40593
|
+
walk2(a.notFound);
|
|
40594
|
+
}
|
|
40595
|
+
}
|
|
40596
|
+
};
|
|
40597
|
+
const trig = w.trigger;
|
|
40598
|
+
if (trig && typeof trig.stageRef === "string") trig.stageRef = to(trig.stageRef);
|
|
40599
|
+
walk2(w.actions);
|
|
40092
40600
|
}
|
|
40093
40601
|
function calendarTypeForBrief(briefType, calName, teamSize) {
|
|
40094
40602
|
const mapped = BRIEF_CALENDAR_TYPE_MAP[briefType] ?? "event";
|
|
@@ -40246,12 +40754,13 @@ function planFromPreset(input) {
|
|
|
40246
40754
|
...omitted.length ? { omitted } : {}
|
|
40247
40755
|
};
|
|
40248
40756
|
})() : void 0;
|
|
40249
|
-
const
|
|
40757
|
+
const stageUses = collectStageUses(keptWorkflowSkeletons, ctx);
|
|
40758
|
+
const stageRemaps = { global: /* @__PURE__ */ new Map(), byWorkflow: /* @__PURE__ */ new Map() };
|
|
40250
40759
|
const users = ctx.users;
|
|
40251
40760
|
const pipelines = keptByCondition(sk.pipelines, ctx).map((pl) => ({
|
|
40252
40761
|
ref: String(pl.ref),
|
|
40253
40762
|
name: fillString(String(pl.name), ctx),
|
|
40254
|
-
stages: composeStages(pl.stages, ctx,
|
|
40763
|
+
stages: composeStages(pl.stages, ctx, stageUses, stageRemaps)
|
|
40255
40764
|
}));
|
|
40256
40765
|
const customFields = keptByCondition(sk.customFields, ctx).map((f) => fillNode(f, ctx));
|
|
40257
40766
|
const tags = keptByCondition(sk.tags, ctx).map((t) => fillNode(t, ctx));
|
|
@@ -40303,7 +40812,9 @@ function planFromPreset(input) {
|
|
|
40303
40812
|
const workflows = keptWorkflowSkeletons.map((raw) => {
|
|
40304
40813
|
const { actions, ...rest } = raw;
|
|
40305
40814
|
const w = fillNode(rest, ctx);
|
|
40815
|
+
delete w.stagePreference;
|
|
40306
40816
|
w.actions = composeActions(actions, ctx, templateRefByAsset);
|
|
40817
|
+
remapWorkflowStages(w, stageRemaps);
|
|
40307
40818
|
return w;
|
|
40308
40819
|
});
|
|
40309
40820
|
if (scope) {
|
|
@@ -40362,6 +40873,8 @@ function planFromPreset(input) {
|
|
|
40362
40873
|
}
|
|
40363
40874
|
const handoffs = keptByCondition(sk.handoffs, ctx).map((raw) => {
|
|
40364
40875
|
const h = fillNode(raw, ctx);
|
|
40876
|
+
if (typeof h.produces === "string") h.produces = stageRemaps.global.get(h.produces) ?? h.produces;
|
|
40877
|
+
if (Array.isArray(h.blocks)) h.blocks = h.blocks.map((b) => stageRemaps.global.get(b) ?? b);
|
|
40365
40878
|
if (typeof h.produces === "string" && !definedRefs.has(h.produces)) delete h.produces;
|
|
40366
40879
|
if (Array.isArray(h.blocks)) {
|
|
40367
40880
|
h.blocks = h.blocks.filter((b) => b.endsWith(".*") || definedRefs.has(b));
|
|
@@ -40554,7 +41067,7 @@ function loadPresetChoice(requested, briefPreset) {
|
|
|
40554
41067
|
if (generic) return { ok: true, preset: generic, file: "generic-client.preset.json", note: "Using the generic preset." };
|
|
40555
41068
|
return { ok: false, error: "No usable preset file was found in the library." };
|
|
40556
41069
|
}
|
|
40557
|
-
var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, BRIEF_CALENDAR_TYPE_MAP;
|
|
41070
|
+
var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, TERMINAL_STAGE_WORDS, BRIEF_CALENDAR_TYPE_MAP;
|
|
40558
41071
|
var init_plan_from_preset = __esm({
|
|
40559
41072
|
"src/intake-to-build/plan-from-preset.ts"() {
|
|
40560
41073
|
"use strict";
|
|
@@ -40571,6 +41084,28 @@ var init_plan_from_preset = __esm({
|
|
|
40571
41084
|
};
|
|
40572
41085
|
INLINE_TOKEN_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+|\[\d+\])*)\s*(?:\|\|([^}]*))?\}\}/g;
|
|
40573
41086
|
ADMIN_ROLE_RE = /\b(owner|founder|admin|administrator|ceo|principal|director)\b/i;
|
|
41087
|
+
TERMINAL_STAGE_WORDS = [
|
|
41088
|
+
"lost",
|
|
41089
|
+
"closed",
|
|
41090
|
+
"won",
|
|
41091
|
+
"no_show",
|
|
41092
|
+
"noshow",
|
|
41093
|
+
"no_sale",
|
|
41094
|
+
"not_interested",
|
|
41095
|
+
"not_a_fit",
|
|
41096
|
+
"no_fit",
|
|
41097
|
+
"disqualified",
|
|
41098
|
+
"unqualified",
|
|
41099
|
+
"dead",
|
|
41100
|
+
"cancelled",
|
|
41101
|
+
"canceled",
|
|
41102
|
+
"declined",
|
|
41103
|
+
"dnd",
|
|
41104
|
+
"do_not_disturb",
|
|
41105
|
+
"churned",
|
|
41106
|
+
"lapsed",
|
|
41107
|
+
"archived"
|
|
41108
|
+
];
|
|
40574
41109
|
BRIEF_CALENDAR_TYPE_MAP = {
|
|
40575
41110
|
one_on_one: "event",
|
|
40576
41111
|
round_robin: "round_robin",
|
|
@@ -41958,8 +42493,8 @@ function savePlanRecord(rec2, base) {
|
|
|
41958
42493
|
return file2;
|
|
41959
42494
|
}
|
|
41960
42495
|
function savedPlanMatchesScope(saved, current) {
|
|
41961
|
-
if (!saved) return false;
|
|
41962
|
-
if (
|
|
42496
|
+
if (!saved || !current) return false;
|
|
42497
|
+
if (current.wholePreset) return !saved.scope;
|
|
41963
42498
|
if (!saved.scope) return false;
|
|
41964
42499
|
const omitted = (saved.scope.omitted ?? []).map((f) => f.id);
|
|
41965
42500
|
const offered = /* @__PURE__ */ new Set([...saved.scope.built, ...saved.scope.notBuilt.map((f) => f.id), ...omitted]);
|
|
@@ -42764,7 +43299,7 @@ async function assessmentAttachmentAction(args, deps) {
|
|
|
42764
43299
|
if (!Array.isArray(chosen) || chosen.some((id2) => !ASSESSMENT_FEATURES.some((f) => f.id === id2))) throw new Error("Choose features from the list.");
|
|
42765
43300
|
const state2 = deps.read();
|
|
42766
43301
|
deps.write({ ...state2, assessmentPurchases: { ...state2.assessmentPurchases, [id]: [...new Set(chosen)] } });
|
|
42767
|
-
detail.message = state2.scope?.[id] ? "
|
|
43302
|
+
detail.message = state2.scope?.[id] ? "The Assessment record is saved. The build still follows the What they bought list saved on the Blueprint Review." : "The Assessment record is saved. Nothing is built until you save What they bought on the Blueprint Review, which is ticked from this.";
|
|
42768
43303
|
break;
|
|
42769
43304
|
}
|
|
42770
43305
|
default:
|
|
@@ -42813,12 +43348,12 @@ function assessmentPanel(id){
|
|
|
42813
43348
|
const box=document.createElement('section');box.className='cds-stack';
|
|
42814
43349
|
const text=(tag,value)=>{const el=document.createElement(tag);if(tag==='h4')el.className='cds-panel-label';el.textContent=value;box.appendChild(el);return el};
|
|
42815
43350
|
const button=(label,run,disabled=false)=>{const b=text('button',label);b.className='cds-btn ghost';b.disabled=p.busy||disabled;b.onclick=run;return b};
|
|
42816
|
-
text('h4','
|
|
42817
|
-
text('p','Record
|
|
43351
|
+
text('h4','Bought in the Assessment');
|
|
43352
|
+
text('p','Record what they bought in your sales conversation. The What they bought list further down is ticked from this, and only that list decides what gets built.');
|
|
42818
43353
|
if(p.data){
|
|
42819
43354
|
for(const feature of p.data.features){const label=text('label','');label.className='cds-check';const cb=document.createElement('input');cb.type='checkbox';cb.checked=p.choices.includes(feature.id);cb.disabled=p.busy;cb.onchange=()=>{p.choices=cb.checked?[...p.choices,feature.id]:p.choices.filter(v=>v!==feature.id)};label.appendChild(cb);const span=document.createElement('span');span.textContent=feature.name;label.appendChild(span)}
|
|
42820
|
-
button('Save
|
|
42821
|
-
if(p.data.purchasedFeatureIds===null)text('p','
|
|
43355
|
+
button('Save the Assessment record',()=>assessmentRequest(id,'purchases',{purchasedFeatureIds:p.choices}));
|
|
43356
|
+
if(p.data.purchasedFeatureIds===null)text('p','Nothing is recorded from the Assessment yet.');
|
|
42822
43357
|
}
|
|
42823
43358
|
text('h4','Assessment');
|
|
42824
43359
|
text('p','The numbers from your sales conversation follow the client into the intake, the Review and their Blueprint.');
|
|
@@ -44713,7 +45248,7 @@ function errLine(msg3) {
|
|
|
44713
45248
|
function preflightWritable() {
|
|
44714
45249
|
try {
|
|
44715
45250
|
const dir = ensureAppDataDir();
|
|
44716
|
-
const probe = path36.join(dir, `.write-probe.${process.pid}.${(0,
|
|
45251
|
+
const probe = path36.join(dir, `.write-probe.${process.pid}.${(0, import_crypto3.randomBytes)(4).toString("hex")}`);
|
|
44717
45252
|
fs38.writeFileSync(probe, "ok");
|
|
44718
45253
|
fs38.unlinkSync(probe);
|
|
44719
45254
|
return true;
|
|
@@ -44997,14 +45532,14 @@ async function runCli(subcommand, argv) {
|
|
|
44997
45532
|
return EXIT_USAGE2;
|
|
44998
45533
|
}
|
|
44999
45534
|
}
|
|
45000
|
-
var import_node_util3, fs38, path36,
|
|
45535
|
+
var import_node_util3, fs38, path36, import_crypto3, EXIT_OK2, EXIT_USAGE2, EXIT_VALIDATION, EXIT_FS, USAGE;
|
|
45001
45536
|
var init_cli = __esm({
|
|
45002
45537
|
"src/cli.ts"() {
|
|
45003
45538
|
"use strict";
|
|
45004
45539
|
import_node_util3 = require("node:util");
|
|
45005
45540
|
fs38 = __toESM(require("fs"));
|
|
45006
45541
|
path36 = __toESM(require("path"));
|
|
45007
|
-
|
|
45542
|
+
import_crypto3 = require("crypto");
|
|
45008
45543
|
init_ghl_client();
|
|
45009
45544
|
init_token_registry();
|
|
45010
45545
|
init_credentials_store();
|
|
@@ -45662,7 +46197,7 @@ function reviewDocumentHtml(review, opts) {
|
|
|
45662
46197
|
${review.gaps?.length ? `<div class="head">What's leaking today (${review.gaps.length})</div>${review.gaps.map(gap).join("")}` : ""}
|
|
45663
46198
|
${review.build?.length ? `<div class="head">What we're building</div>${review.build.map((b) => `<div class="build"><b>${esc14(b.item)}</b><span>${esc14(b.because)}</span></div>`).join("")}` : ""}
|
|
45664
46199
|
${opts.scope?.awaitingConfirmation ? `<div class="head">What this build includes</div>
|
|
45665
|
-
<p>Not confirmed yet.
|
|
46200
|
+
<p>Not confirmed yet. The build waits until the features to build are confirmed.</p>
|
|
45666
46201
|
` : opts.scope ? `<div class="head">What this build includes</div>
|
|
45667
46202
|
<p>${opts.scope.package ? `Built to your <b>${esc14(opts.scope.package)}</b> package: ` : ""}${opts.scope.built.length ? esc14(opts.scope.built.map((b) => b.name).join(", ")) : "the foundation only"}. Always included: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing.</p>
|
|
45668
46203
|
` : ""}
|
|
@@ -47136,7 +47671,7 @@ function prefixReservations(locationId2, base) {
|
|
|
47136
47671
|
function refusal(prefix, owner) {
|
|
47137
47672
|
return `The clinic prefix "${prefix}" already belongs to another client in this sub-account (client key "${owner}"). Two clients sharing a prefix would reuse each other's workflows, tags and fields, so nothing was built. Choose a different prefix for this client.`;
|
|
47138
47673
|
}
|
|
47139
|
-
function
|
|
47674
|
+
function sleepSync2(ms) {
|
|
47140
47675
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
47141
47676
|
}
|
|
47142
47677
|
function locationLockPath(dir, locationId2) {
|
|
@@ -47165,7 +47700,7 @@ function withLocationLock(dir, locationId2, fn, opts = {}) {
|
|
|
47165
47700
|
age > STALE_LOCK_MS ? `A clinic prefix could not be reserved because an earlier build left this sub-account's prefix lock behind (it may have crashed), so nothing was built. If no build is running for this sub-account, delete the file ${lock} and run it again.` : "Another build in this sub-account is reserving a clinic prefix right now, so nothing was built. Run it again in a moment."
|
|
47166
47701
|
);
|
|
47167
47702
|
}
|
|
47168
|
-
|
|
47703
|
+
sleepSync2(15);
|
|
47169
47704
|
}
|
|
47170
47705
|
}
|
|
47171
47706
|
try {
|
|
@@ -47244,13 +47779,19 @@ var init_clinic_prefix_registry = __esm({
|
|
|
47244
47779
|
var dashboard_exports = {};
|
|
47245
47780
|
__export(dashboard_exports, {
|
|
47246
47781
|
ACCOUNT_TYPES: () => ACCOUNT_TYPES,
|
|
47782
|
+
APPROVAL_WITHDRAWN_FOR_SCOPE: () => APPROVAL_WITHDRAWN_FOR_SCOPE,
|
|
47783
|
+
CHOOSE_BEFORE_BUILD: () => CHOOSE_BEFORE_BUILD,
|
|
47784
|
+
CHOOSE_WHAT_THEY_BOUGHT: () => CHOOSE_WHAT_THEY_BOUGHT,
|
|
47247
47785
|
CLAUDE_REPAIR_MESSAGES: () => CLAUDE_REPAIR_MESSAGES,
|
|
47248
47786
|
INTAKE_CONNECT_REQUIRED: () => INTAKE_CONNECT_REQUIRED,
|
|
47249
47787
|
INTAKE_FORM_REQUIRED: () => INTAKE_FORM_REQUIRED,
|
|
47250
47788
|
INTAKE_OWN_MESSAGES: () => INTAKE_OWN_MESSAGES,
|
|
47251
47789
|
INTAKE_UNREADABLE: () => INTAKE_UNREADABLE,
|
|
47790
|
+
NOTHING_TICKED_IN_PLAN: () => NOTHING_TICKED_IN_PLAN,
|
|
47252
47791
|
NOT_SIGNED_IN_MSG: () => NOT_SIGNED_IN_MSG,
|
|
47253
47792
|
PARKED_NOT_BUILT: () => PARKED_NOT_BUILT,
|
|
47793
|
+
SCOPE_CHANGED_SINCE_APPROVAL: () => SCOPE_CHANGED_SINCE_APPROVAL,
|
|
47794
|
+
SCOPE_REFUSAL: () => SCOPE_REFUSAL,
|
|
47254
47795
|
SCREEN_HEADS: () => SCREEN_HEADS,
|
|
47255
47796
|
SEAT_GUARDED_READS: () => SEAT_GUARDED_READS,
|
|
47256
47797
|
SEAT_TOKEN_HEADER: () => SEAT_TOKEN_HEADER,
|
|
@@ -47278,6 +47819,7 @@ __export(dashboard_exports, {
|
|
|
47278
47819
|
ensureEngineReady: () => ensureEngineReady,
|
|
47279
47820
|
forgetAnthropicKey: () => forgetAnthropicKey,
|
|
47280
47821
|
guarded: () => guarded,
|
|
47822
|
+
hasChosenScope: () => hasChosenScope,
|
|
47281
47823
|
insideDir: () => insideDir,
|
|
47282
47824
|
intakeInput: () => intakeInput,
|
|
47283
47825
|
intakeRequestError: () => intakeRequestError,
|
|
@@ -47289,10 +47831,13 @@ __export(dashboard_exports, {
|
|
|
47289
47831
|
loadingMainHtml: () => loadingMainHtml,
|
|
47290
47832
|
mayServeSeat: () => mayServeSeat,
|
|
47291
47833
|
memberFacingError: () => memberFacingError,
|
|
47834
|
+
mergeQueueIntakes: () => mergeQueueIntakes,
|
|
47292
47835
|
newClientFailure: () => newClientFailure,
|
|
47293
47836
|
normalizedIntake: () => normalizedIntake,
|
|
47837
|
+
offeredFeaturesFor: () => offeredFeaturesFor,
|
|
47294
47838
|
overlayPath: () => overlayPath,
|
|
47295
47839
|
prefillTeamAnswers: () => prefillTeamAnswers,
|
|
47840
|
+
queueScanFilter: () => queueScanFilter,
|
|
47296
47841
|
readBody: () => readBody,
|
|
47297
47842
|
readClientStages: () => readClientStages,
|
|
47298
47843
|
readCockpitState: () => readCockpitState,
|
|
@@ -47304,9 +47849,11 @@ __export(dashboard_exports, {
|
|
|
47304
47849
|
recoverStuckStages: () => recoverStuckStages,
|
|
47305
47850
|
renderCockpitHtml: () => renderCockpitHtml,
|
|
47306
47851
|
reportSeatAllowed: () => reportSeatAllowed,
|
|
47852
|
+
rereadQueueIntakes: () => rereadQueueIntakes,
|
|
47307
47853
|
resolvePlan: () => resolvePlan2,
|
|
47308
47854
|
runDashboard: () => runDashboard,
|
|
47309
47855
|
safeDocName: () => safeDocName,
|
|
47856
|
+
scopeHashOf: () => scopeHashOf,
|
|
47310
47857
|
scopePrefillFor: () => scopePrefillFor,
|
|
47311
47858
|
secondClientInputHash: () => secondClientInputHash,
|
|
47312
47859
|
setAccountIndustry: () => setAccountIndustry,
|
|
@@ -47388,6 +47935,15 @@ function setAccountIndustry(state, locationId2, industry) {
|
|
|
47388
47935
|
if (!next.industry) delete next.industry;
|
|
47389
47936
|
return { ...state, accounts: { ...state.accounts ?? {}, [locationId2]: next } };
|
|
47390
47937
|
}
|
|
47938
|
+
function scopeHashOf(sel) {
|
|
47939
|
+
if (!sel) return contentHash({ none: true });
|
|
47940
|
+
if (sel.wholePreset) return contentHash({ wholePreset: true });
|
|
47941
|
+
return contentHash({ package: sel.package ?? "", features: [...new Set((sel.features ?? []).filter(isFeatureId))].sort() });
|
|
47942
|
+
}
|
|
47943
|
+
function hasChosenScope(state, recordKey) {
|
|
47944
|
+
const sel = state.scope?.[recordKey];
|
|
47945
|
+
return Boolean(sel && (sel.wholePreset || Array.isArray(sel.features) && sel.features.some(isFeatureId)));
|
|
47946
|
+
}
|
|
47391
47947
|
function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ new Date()) {
|
|
47392
47948
|
const all = { ...state.scope ?? {} };
|
|
47393
47949
|
if (!scope) {
|
|
@@ -47403,16 +47959,26 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
|
|
|
47403
47959
|
if (!Object.keys(all).length) delete next.scope;
|
|
47404
47960
|
return next;
|
|
47405
47961
|
}
|
|
47406
|
-
function
|
|
47407
|
-
const
|
|
47408
|
-
if (!sc && Array.isArray(state.assessmentPurchases?.[locationId2])) return { awaitingConfirmation: true, built: [], notBuilt: [] };
|
|
47409
|
-
let offered = FEATURE_IDS;
|
|
47962
|
+
function offeredFeaturesFor(state, recordKey) {
|
|
47963
|
+
const locationId2 = recordKey.split("--")[0];
|
|
47410
47964
|
try {
|
|
47411
47965
|
const chosen = loadPresetChoice(void 0, presetForIndustry(state.accounts?.[locationId2]?.industry));
|
|
47412
|
-
if (chosen.ok)
|
|
47966
|
+
if (chosen.ok) return featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
|
|
47413
47967
|
} catch {
|
|
47414
47968
|
}
|
|
47415
|
-
|
|
47969
|
+
return FEATURE_IDS;
|
|
47970
|
+
}
|
|
47971
|
+
function documentScopeFor(state, locationId2, savedPlan) {
|
|
47972
|
+
const sc = state.scope?.[locationId2];
|
|
47973
|
+
const offered = offeredFeaturesFor(state, locationId2);
|
|
47974
|
+
if (!sc) {
|
|
47975
|
+
const outcome = state.outcomes?.[`${locationId2}:3`];
|
|
47976
|
+
const built = state.clients?.[locationId2]?.stages?.[3] === "done" || outcome?.ok === true;
|
|
47977
|
+
const wholePresetPlan = Boolean(savedPlan && !savedPlan.scope && typeof savedPlan.plan?.preset === "string");
|
|
47978
|
+
if (built && wholePresetPlan) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
|
|
47979
|
+
return { awaitingConfirmation: true, built: [], notBuilt: [] };
|
|
47980
|
+
}
|
|
47981
|
+
if (sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
|
|
47416
47982
|
return {
|
|
47417
47983
|
...sc.package ? { package: sc.package } : {},
|
|
47418
47984
|
// Built = the confirmed scope, the one record the build follows. The purchase
|
|
@@ -47587,6 +48153,12 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
|
|
|
47587
48153
|
run.lines.push(`Could not write this run to the cost record: ${e instanceof Error ? e.message : String(e)}. The step itself is unaffected.`);
|
|
47588
48154
|
}
|
|
47589
48155
|
for (const line3 of costLines(outcome)) run.lines.push(line3);
|
|
48156
|
+
const scopeRefusal = stage === 3 && !outcome.ok && typeof outcome.error === "string" && SCOPE_REFUSAL.test(outcome.error);
|
|
48157
|
+
if (scopeRefusal) {
|
|
48158
|
+
outcome.summary = outcome.error;
|
|
48159
|
+
outcome.issues = [];
|
|
48160
|
+
outcome.plain = { summary: outcome.error, issues: [] };
|
|
48161
|
+
}
|
|
47590
48162
|
if (stage === 3 && !outcome.summary && !outcome.issues?.length) {
|
|
47591
48163
|
try {
|
|
47592
48164
|
const st = readCockpitState();
|
|
@@ -47600,8 +48172,8 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
|
|
|
47600
48172
|
const st = readCockpitState();
|
|
47601
48173
|
writeCockpitState({ ...st, outcomes: { ...st.outcomes ?? {}, [`${rk}:${stage}`]: { ok: outcome.ok, error: outcome.error, summary: outcome.summary, issues: outcome.issues, ...plain && { plain }, at, inputHash: runInputHash, ...stage === 4 ? { buildHash: runBuildHash, verification: verifyRaw?.verification } : {}, hash: contentHash({ inputHash: runInputHash, at, ok: outcome.ok, summary: outcome.summary, issues: outcome.issues }) } } });
|
|
47602
48174
|
};
|
|
47603
|
-
save();
|
|
47604
|
-
if (PLAIN_REPORT_STAGES.has(stage)) {
|
|
48175
|
+
save(scopeRefusal ? outcome.plain : void 0);
|
|
48176
|
+
if (PLAIN_REPORT_STAGES.has(stage) && !scopeRefusal) {
|
|
47605
48177
|
run.lines.push("Writing the plain-English version of what came back\u2026");
|
|
47606
48178
|
const plain = await rewriteOutcomePlain(STAGES2[stage], verifyRaw ?? outcome, { onEvent: (l) => run.lines.push(l), onCost: (costUsd) => {
|
|
47607
48179
|
recordRunCost({ at, locationId: locationId2, stage, kind: "plain rewrite", ok: true, costUsd });
|
|
@@ -47808,6 +48380,40 @@ function logQuietly(message) {
|
|
|
47808
48380
|
} catch {
|
|
47809
48381
|
}
|
|
47810
48382
|
}
|
|
48383
|
+
function queueScanFilter(body) {
|
|
48384
|
+
try {
|
|
48385
|
+
const b = body ? JSON.parse(body) : null;
|
|
48386
|
+
if (b && typeof b === "object" && b.only === "pending") {
|
|
48387
|
+
const open2 = b.open;
|
|
48388
|
+
return { only: "pending", ...typeof open2 === "string" && open2 ? { open: open2 } : {} };
|
|
48389
|
+
}
|
|
48390
|
+
} catch {
|
|
48391
|
+
}
|
|
48392
|
+
return void 0;
|
|
48393
|
+
}
|
|
48394
|
+
async function rereadQueueIntakes(opts) {
|
|
48395
|
+
const reread = {};
|
|
48396
|
+
const failed = [];
|
|
48397
|
+
for (const { locationId: locationId2 } of opts.roster) {
|
|
48398
|
+
if (opts.state.clients[locationId2]?.stages?.[0] !== "done") continue;
|
|
48399
|
+
if (opts.filter?.only === "pending" && locationId2 !== opts.filter.open && opts.state.intakeInputs?.[locationId2]?.status === "received") continue;
|
|
48400
|
+
try {
|
|
48401
|
+
const got = await opts.read(locationId2);
|
|
48402
|
+
if (got === null) continue;
|
|
48403
|
+
if (got === "unavailable") {
|
|
48404
|
+
failed.push(locationId2);
|
|
48405
|
+
continue;
|
|
48406
|
+
}
|
|
48407
|
+
reread[locationId2] = got;
|
|
48408
|
+
} catch {
|
|
48409
|
+
failed.push(locationId2);
|
|
48410
|
+
}
|
|
48411
|
+
}
|
|
48412
|
+
return { reread, failed };
|
|
48413
|
+
}
|
|
48414
|
+
function mergeQueueIntakes(latest, reread) {
|
|
48415
|
+
return { ...latest, intakeInputs: { ...latest.intakeInputs ?? {}, ...reread } };
|
|
48416
|
+
}
|
|
47811
48417
|
function readBody(req, res, max, onDone, tooLarge = "That request is too large.", sensitive = false) {
|
|
47812
48418
|
let body = "";
|
|
47813
48419
|
let tooBig = false;
|
|
@@ -48041,7 +48647,7 @@ const LABELS=["Intake","Brief","Approve","Build","Verify","Manual tasks","Go-liv
|
|
|
48041
48647
|
// What each step does: [the one line on its row, the fuller sentence in the next-step panel].
|
|
48042
48648
|
const STEP_ABOUT=[["Creates the intake form, its fields and one notification workflow.","Creates or repairs this client's intake form, its custom fields and one published notification workflow."],
|
|
48043
48649
|
["Reads the intake into a brief.","Reads this client's intake, from the submitted form or the one completed here, and turns it into the brief the plan is made from."],
|
|
48044
|
-
["You approve the
|
|
48650
|
+
["You choose what they bought, then approve the build.","Choose what the client bought, review the intake and the brief, then approve the build for this client. Only what they bought gets built."],
|
|
48045
48651
|
["Builds the approved plan into this account.","Builds the approved plan into this client's account. Anything it cannot build is listed as an issue."],
|
|
48046
48652
|
["Checks the build for broken links.","Checks the account for broken links between workflows, pipelines and fields. It only reads. Anything it cannot check is reported as unchecked."],
|
|
48047
48653
|
["Jobs only a person can do. You confirm them.","The jobs only a person can do, such as text-message registration, Google sign-in, staff and phone. Confirm them when they are finished."],
|
|
@@ -48095,16 +48701,80 @@ function listRows(rows,key,noun){const frag=document.createDocumentFragment();co
|
|
|
48095
48701
|
function unnest(el){if(el&&el.classList){el.classList.remove("cds-panel","cds-card");el.classList.add("cds-stack")}return el}
|
|
48096
48702
|
function typeOf(state,id){const sandboxes=state.sandboxAccounts||Object.fromEntries(SANDBOX.map(id=>[id,true]));if(sandboxes[id])return "sandbox";const t=(state.accounts&&state.accounts[id]&&state.accounts[id].type);return t==="sandbox"?"client":(t||"client")}
|
|
48097
48703
|
function activityScore(state,id){const c=state.clients[id];if(!c)return 0;return c.stages.some(s=>s!=="pending")?1:0}
|
|
48098
|
-
async function load(){if(SCREEN==="new"&&canCreateSubAccounts){const probe=await(await clientPageRequest("/api/agency-connection")).json();agencyReady=probe.ok===true}
|
|
48099
|
-
|
|
48704
|
+
async function load(quiet,freshQueue){if(SCREEN==="new"&&canCreateSubAccounts){const probe=await(await clientPageRequest("/api/agency-connection")).json();agencyReady=probe.ok===true}/* The queue first: a re-reading POST can take a minute, and state fetched before it would draw stale (review of c4b75c9). */
|
|
48705
|
+
await loadQueue(quiet===true?(freshQueue===true?"pending":"get"):undefined);const s=await(await fetch("/api/state")).json();const r=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();if(SCREEN==="new"&&!snapshots){try{const sr=await(await fetch("/api/snapshots")).json();snapshots=sr.ok?sr.snapshots:[];snapshotError=sr.ok?"":clientPageError(sr.error,"Check the agency key in GoHighLevel, then refresh the snapshot list.")}catch(e){snapshots=[];snapshotError="Could not load snapshots; check the connection and refresh this page."}}/* A background refresh (quiet) draws only when the data changed (2026-09-29: an unchanged redraw closed what Jerry had open). */
|
|
48706
|
+
if(!(quiet===true&&dataSig(s,r)===lastDrawnSig))render(s,r);hideFinishNote();
|
|
48707
|
+
if(r.running&&!polling){polling=true;const t=setInterval(async()=>{let rr;try{rr=await fetchRunStatus()}catch(e){return}
|
|
48708
|
+
if(!rr.running){clearInterval(t);polling=false}
|
|
48709
|
+
if(!shouldRedraw(lastRun,rr))return;
|
|
48710
|
+
try{await loadQueue("get");const ss=await(await fetch("/api/state")).json();if(dataSig(ss,rr)!==lastDrawnSig)render(ss,rr);hideFinishNote()}catch(e){}},1500)}}
|
|
48100
48711
|
// Heartbeat (2026-08-25): the fast poller above only starts for a run THIS page saw begin. A page that was
|
|
48101
48712
|
// already open, or opened mid-run, or a run kicked off from another seat/the API, showed "pending" until a
|
|
48102
48713
|
// manual reload . a client watching would conclude the build died. Every 3s: if a run is live, hand over to
|
|
48103
48714
|
// the fast poller; every ~15s otherwise, refresh state so stages finished elsewhere flip to done.
|
|
48104
|
-
// Never rebuild the page under the operator's hands: while a form field has focus
|
|
48105
|
-
//
|
|
48106
|
-
|
|
48107
|
-
|
|
48715
|
+
// Never rebuild the page under the operator's hands: while a form field has focus the redraw waits,
|
|
48716
|
+
// and happens on focusout. (2026-08-25: the first heartbeat threw Jerry out of the intake field every refresh.)
|
|
48717
|
+
// Stale "Running" fix (2026-09-29 rehearsal): Brief finished on the server while the page showed
|
|
48718
|
+
// "Running now, working 5m" until a manual reload. The old guard also skipped the status FETCH whenever an
|
|
48719
|
+
// intake panel was open, and the clock ticks on the page alone, so a frozen page looked alive. Now:
|
|
48720
|
+
// the status is ALWAYS fetched; only the full redraw waits, and only while an input has focus; an open intake
|
|
48721
|
+
// panel alone never blocks it (its typed answers, pasted material and prepared-form link live in state, so a
|
|
48722
|
+
// redraw keeps them). A running-to-finished change always redraws, or, with an input focused, shows a bar
|
|
48723
|
+
// that says so. Returning to the tab refreshes. A status older than 15s freezes the clock on "checking\u2026".
|
|
48724
|
+
let lastStatusOkAt=0,redrawPending=false,pointerIsDown=false,flushTimer=0;
|
|
48725
|
+
/* Intake work in flight for a client (Fill the intake from this, a document upload, send, prepare, save): a redraw
|
|
48726
|
+
then would re-enable its button and drop its message, so it waits like a focused field. */
|
|
48727
|
+
let iBusy={};
|
|
48728
|
+
function intakeWorkBusy(){try{return Object.values(iBusy).some(n=>n>0)}catch(e){return false}}
|
|
48729
|
+
/* A counter, so two requests for one client overlapping never clear each other. */
|
|
48730
|
+
async function iWork(id,fn){iBusy[id]=(iBusy[id]||0)+1;try{return await fn()}finally{iBusy[id]=Math.max(0,(iBusy[id]||0)-1);if(redrawPending)scheduleFlush()}}
|
|
48731
|
+
function inputFocused(){const a=document.activeElement;return Boolean(a&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))}
|
|
48732
|
+
/* Holds back the full redraw, never the status fetch. */
|
|
48733
|
+
function userIsBusy(){if(SCREEN==="new")return true;return inputFocused()||intakeWorkBusy()}
|
|
48734
|
+
function intakeIsOpen(){try{return Object.values(iOpen||{}).some(Boolean)}catch(e){return false}}
|
|
48735
|
+
async function fetchRunStatus(){const rr=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();return rr}
|
|
48736
|
+
function statusIsStale(){return !lastStatusOkAt||Date.now()-lastStatusOkAt>15000}
|
|
48737
|
+
function runKey(r){return r?[r.running?1:0,r.locationId||"",r.stage==null?"":r.stage,r.capability||"",r.startedAt||"",r.reviewing?1:0,r.ok==null?"":r.ok?1:0].join("|"):""}
|
|
48738
|
+
function runJustFinished(prev,next){return Boolean(prev&&prev.running&&next&&!next.running)}
|
|
48739
|
+
function stepName(r){return r&&!r.capability&&LABELS[r.stage]?LABELS[r.stage]:"The step"}
|
|
48740
|
+
/* Decides whether a fresh status redraws the page now. A finished run always does, unless an input has focus:
|
|
48741
|
+
then the bar tells the operator and the redraw follows on focusout. With an intake panel open the page
|
|
48742
|
+
redraws only when the run itself changed (started, moved stage, finished), not on every tick. */
|
|
48743
|
+
function shouldRedraw(prev,next){const fin=runJustFinished(prev,next);
|
|
48744
|
+
if(userIsBusy()){if(SCREEN==="new")return false;if(fin)showFinishNote(prev);redrawPending=true;return false}
|
|
48745
|
+
if(fin)return true;
|
|
48746
|
+
if(intakeIsOpen())return runKey(prev)!==runKey(next);
|
|
48747
|
+
return true}
|
|
48748
|
+
function showFinishNote(prev){let b=document.getElementById("run-finished-note");
|
|
48749
|
+
if(!b){b=document.createElement("button");b.id="run-finished-note";b.type="button";b.className="cds-callout info";b.setAttribute("role","status");
|
|
48750
|
+
b.style.cssText="position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:50;max-width:calc(100% - 32px);cursor:pointer";
|
|
48751
|
+
b.onclick=()=>{redrawPending=false;load(true).catch(()=>{})};document.body.appendChild(b)}
|
|
48752
|
+
b.textContent=stepName(prev)+" finished. Click to see the result."}
|
|
48753
|
+
function hideFinishNote(){const b=document.getElementById("run-finished-note");if(b)b.remove()}
|
|
48754
|
+
/* One retry timer at a time: focusout, pointer releases and finished intake work all ask, only one waits. */
|
|
48755
|
+
function scheduleFlush(){if(flushTimer)return;flushTimer=setTimeout(()=>{flushTimer=0;flushRedraw()},300)}
|
|
48756
|
+
function flushRedraw(){if(!redrawPending)return;if(SCREEN==="new"){redrawPending=false;return}if(userIsBusy()||pointerIsDown){scheduleFlush();return}redrawPending=false;load(true).catch(()=>{})}
|
|
48757
|
+
document.addEventListener("pointerdown",()=>{pointerIsDown=true},true);
|
|
48758
|
+
document.addEventListener("pointerup",()=>{pointerIsDown=false},true);
|
|
48759
|
+
/* A touch that is cancelled, or a release outside the window, never sends pointerup. */
|
|
48760
|
+
document.addEventListener("pointercancel",()=>{pointerIsDown=false},true);
|
|
48761
|
+
window.addEventListener("blur",()=>{pointerIsDown=false});
|
|
48762
|
+
/* Focus leaving an input: a held redraw happens now (after the field's change event has saved its value). */
|
|
48763
|
+
document.addEventListener("focusout",()=>{if(redrawPending)scheduleFlush()});
|
|
48764
|
+
/* Coming back to the tab or the window: fetch at once, rather than trusting what the page last drew. */
|
|
48765
|
+
function refreshOnReturn(){if(userIsBusy()){if(SCREEN!=="new")redrawPending=true;fetchRunStatus().then(rr=>{if(runJustFinished(lastRun,rr)&&SCREEN!=="new")showFinishNote(lastRun)}).catch(()=>{});return}load(true,true).catch(()=>{})}
|
|
48766
|
+
/* Focus and visibilitychange usually fire together on a return: one refresh, not two. */
|
|
48767
|
+
let returnTimer=0;function onReturn(){if(returnTimer)return;returnTimer=setTimeout(()=>{returnTimer=0;refreshOnReturn()},250)}
|
|
48768
|
+
document.addEventListener("visibilitychange",()=>{if(document.visibilityState==="visible")onReturn()});
|
|
48769
|
+
window.addEventListener("focus",onReturn);
|
|
48770
|
+
/* The GET queue shows intake answers as last read; every 4th heartbeat load (about a minute) and a return to the
|
|
48771
|
+
tab use the POST, which re-reads each client's intake, so a submitted or edited intake shows without a reload. */
|
|
48772
|
+
let hbTick=0,hbLoads=0;setInterval(async()=>{if(polling)return;hbTick++;let rr;try{rr=await fetchRunStatus()}catch(e){return}
|
|
48773
|
+
const changed=runKey(rr)!==runKey(lastRun);
|
|
48774
|
+
if(!(rr.running||changed||hbTick%5===0))return;
|
|
48775
|
+
if(userIsBusy()){if(changed)shouldRedraw(lastRun,rr);return}
|
|
48776
|
+
if(intakeIsOpen()&&!changed&&!(rr.running&&!polling))return;
|
|
48777
|
+
hbLoads++;load(true,hbLoads%4===0).catch(()=>{})},3000);
|
|
48108
48778
|
let intakeOpen=false,intakeData=null,intakeIndustry="";
|
|
48109
48779
|
async function loadIntake(){const r=await(await clientPageRequest("/api/intake?industry="+encodeURIComponent(intakeIndustry))).json();intakeData=r;render(lastState,lastRun)}
|
|
48110
48780
|
async function saveOverlay(o){const r=await(await clientPageRequest("/api/overlay",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();if(!r.ok){showClientError(r.error);return}await loadIntake()}
|
|
@@ -48145,6 +48815,25 @@ function intakePanel(){const d=document.createElement("div");d.className="cds-pa
|
|
|
48145
48815
|
d.appendChild(reset);
|
|
48146
48816
|
return d}
|
|
48147
48817
|
let iOpen={},iData={},iMode={},iAnswers={},iDirty={},iIdx={},iPre={},iSub={},oOpen={},iDocs={},iDocsErrors={},iPreSrc={};
|
|
48818
|
+
/* What the operator typed into the intake's free boxes (pasted transcript, link, recorder note, the send
|
|
48819
|
+
form) and the prepared-form link live here, not only in the DOM, so a redraw while the intake is open
|
|
48820
|
+
keeps them (2026-09-29: an open intake no longer blocks the page from showing a finished step). */
|
|
48821
|
+
let iDraft={},iPrepared={};
|
|
48822
|
+
function preparedFormBlock(box,id){const p=iPrepared[id];
|
|
48823
|
+
const openLink=document.createElement("a");openLink.className="cds-link";openLink.href=p.url;openLink.target="_blank";openLink.rel="noopener";openLink.textContent="Open the prepared form";box.appendChild(openLink);
|
|
48824
|
+
const hint=document.createElement("p");hint.className="muted";
|
|
48825
|
+
const left=p.leftovers||[];
|
|
48826
|
+
hint.textContent=left.length
|
|
48827
|
+
?"Use Open the prepared form above to review your answers, except "+left.length+" listed below. Handle each as noted there, then click Submit and come back and press Refresh:"
|
|
48828
|
+
:"Use Open the prepared form above, submit it, then return and press Refresh.";
|
|
48829
|
+
box.appendChild(hint);
|
|
48830
|
+
if(left.length){const ul=document.createElement("ul");ul.className="muted";
|
|
48831
|
+
const WHY={"several answers":"pick each of these in the form","contains a plus sign":"type this in the form","no field for this question":"the form has no question for this, so it was not carried. Add it as a note on the contact after you submit"};
|
|
48832
|
+
for(const l of left){const li=document.createElement("li");li.textContent=l.label+": "+l.values.join(", ")+". "+(WHY[l.reason]||l.reason)+".";ul.appendChild(li)}
|
|
48833
|
+
box.appendChild(ul)}
|
|
48834
|
+
const rf=document.createElement("button");rf.className="cds-btn ghost";rf.textContent="Refresh status";
|
|
48835
|
+
rf.onclick=()=>iLoad(id);box.appendChild(rf)}
|
|
48836
|
+
function keepDraft(el,id,name){const k=id+":"+name;if(iDraft[k]!=null)el.value=iDraft[k];el.addEventListener("input",()=>{iDraft[k]=el.value});return el}
|
|
48148
48837
|
async function iDocsLoad(id){const r=await(await clientPageRequest("/api/intake-documents?locationId="+encodeURIComponent(id))).json();iDocs[id]=(r&&r.files)||[];iDocsErrors[id]=r.ok?"":clientPageError(r.error,"Refresh the page to load this client\u2019s documents again.");render(lastState,lastRun)}
|
|
48149
48838
|
// Show the plain outcome here. The separate Step progress panel shows member-facing progress.
|
|
48150
48839
|
function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc)return null;
|
|
@@ -48174,7 +48863,7 @@ function iEdit(id,label,value){(iAnswers[id]||(iAnswers[id]={}))[label]=value;(i
|
|
|
48174
48863
|
function iApplyProposals(id,proposals){const applied=[];const answers=iAnswers[id]||(iAnswers[id]={});for(const [label,value] of Object.entries(proposals||{})){if(answers[label]&&JSON.stringify(answers[label])!==JSON.stringify(value)){if(!confirm(label+String.fromCharCode(10)+"Current: "+answers[label]+String.fromCharCode(10)+"Proposed: "+value+String.fromCharCode(10)+"Replace this answer?"))continue}iEdit(id,label,value);applied.push(label)}return applied}
|
|
48175
48864
|
async function iLoad(id){rememberClient(id);const r=await(await clientPageRequest("/api/client-intake?locationId="+id)).json();iData[id]=r;
|
|
48176
48865
|
// Only unsaved edits override the newest server submission, including edits made before Connect.
|
|
48177
|
-
if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits}}render(lastState,lastRun)}
|
|
48866
|
+
if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits};if(r.status==="received")delete iPrepared[id]}render(lastState,lastRun)}
|
|
48178
48867
|
let iFieldN=0;
|
|
48179
48868
|
function iField(id,q,val,prefilled){const w=document.createElement("div");w.className="cds-field ifield"+(prefilled?" prefilled":"");
|
|
48180
48869
|
const fid="ifield-"+(++iFieldN);
|
|
@@ -48222,14 +48911,14 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48222
48911
|
box.appendChild(ch);
|
|
48223
48912
|
const mode=iMode[id];
|
|
48224
48913
|
if(mode==="send"&&hasForm){const w=document.createElement("div");w.className="cds-stack";
|
|
48225
|
-
const em=document.createElement("input");em.placeholder="client@business.com";em.className="cds-input";em.setAttribute("aria-label","Client email");
|
|
48226
|
-
const fn=document.createElement("input");fn.placeholder="Their first name (optional)";fn.className="cds-input";fn.setAttribute("aria-label","Client first name");
|
|
48227
|
-
const ag=document.createElement("input");ag.placeholder="Sign it from\u2026 (your name / agency)";ag.className="cds-input";ag.setAttribute("aria-label","Signed from");
|
|
48914
|
+
const em=document.createElement("input");em.placeholder="client@business.com";em.className="cds-input";em.setAttribute("aria-label","Client email");keepDraft(em,id,"email");
|
|
48915
|
+
const fn=document.createElement("input");fn.placeholder="Their first name (optional)";fn.className="cds-input";fn.setAttribute("aria-label","Client first name");keepDraft(fn,id,"firstName");
|
|
48916
|
+
const ag=document.createElement("input");ag.placeholder="Sign it from\u2026 (your name / agency)";ag.className="cds-input";ag.setAttribute("aria-label","Signed from");keepDraft(ag,id,"signedFrom");
|
|
48228
48917
|
const b=document.createElement("button");b.className="cds-btn";b.textContent="Send the questionnaire";
|
|
48229
48918
|
b.onclick=async()=>{if(!em.value.trim())return showClientError("Enter the client\u2019s email.",box);if(!confirm("Creates a contact for "+em.value.trim()+" in "+name+" and sends one email from "+(d.sender||"the sender configured in this client\u2019s GoHighLevel account")+". Sender not verified. Save a manual task to confirm the sender and send in GoHighLevel?"))return;b.disabled=true;b.textContent="Saving the task\u2026";
|
|
48230
|
-
const r=await(await clientPageRequest("/api/intake-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,email:em.value.trim(),firstName:fn.value.trim(),agencyName:ag.value.trim(),confirmed:true})})).json();
|
|
48919
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,email:em.value.trim(),firstName:fn.value.trim(),agencyName:ag.value.trim(),confirmed:true})})).json());
|
|
48231
48920
|
if(r.manual){showClientError(r.error,box);const a=document.createElement("a");a.href="https://app.gohighlevel.com/v2/location/"+encodeURIComponent(id)+"/conversations/conversations";a.target="_blank";a.rel="noopener";a.textContent="Open this client\u2019s conversations to confirm the sender and send the questionnaire";box.appendChild(a);b.disabled=false;b.textContent="Send the questionnaire";return}
|
|
48232
|
-
if(r.ok){iMode[id]=null;await iLoad(id)}else{showClientError(r.error,box);b.disabled=false;b.textContent="Send the questionnaire"}};
|
|
48921
|
+
if(r.ok){for(const n of ["email","firstName","signedFrom"])delete iDraft[id+":"+n];iMode[id]=null;await iLoad(id)}else{showClientError(r.error,box);b.disabled=false;b.textContent="Send the questionnaire"}};
|
|
48233
48922
|
w.appendChild(em);w.appendChild(fn);w.appendChild(ag);w.appendChild(b);
|
|
48234
48923
|
const note=document.createElement("p");note.className="muted";
|
|
48235
48924
|
note.textContent="Command OS cannot verify this account\u2019s sender. This button records a manual task to confirm the sender and send the questionnaire in GoHighLevel.";w.appendChild(note);box.appendChild(w)}
|
|
@@ -48241,9 +48930,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48241
48930
|
tb.onclick=()=>{iSub[id]=k;render(lastState,lastRun)};tabs.appendChild(tb)}
|
|
48242
48931
|
w.appendChild(tabs);
|
|
48243
48932
|
const ta=document.createElement("textarea");ta.rows=6;ta.placeholder="Paste your discovery-call transcript (Plaud, Zoom, Otter, notes), or drop a .txt/.vtt/.srt file here";
|
|
48244
|
-
ta.className="cds-textarea";ta.setAttribute("aria-label","Transcript");
|
|
48933
|
+
ta.className="cds-textarea";ta.setAttribute("aria-label","Transcript");keepDraft(ta,id,"transcript");
|
|
48245
48934
|
ta.ondragover=(e)=>{e.preventDefault();ta.style.borderColor="var(--focus)"};
|
|
48246
|
-
ta.ondrop=async(e)=>{e.preventDefault();ta.style.borderColor="";const f=e.dataTransfer.files[0];if(f){if(!/[.](txt|md|csv|vtt|srt)$/i.test(f.name))return showClientError("Use plain text, .vtt or .srt.",box);ta.value=await f.text()}};
|
|
48935
|
+
ta.ondrop=async(e)=>{e.preventDefault();ta.style.borderColor="";const f=e.dataTransfer.files[0];if(f){if(!/[.](txt|md|csv|vtt|srt)$/i.test(f.name))return showClientError("Use plain text, .vtt or .srt.",box);ta.value=await f.text();iDraft[id+":transcript"]=ta.value}};
|
|
48247
48936
|
const b=document.createElement("button");b.className="cds-btn";b.textContent="Fill the intake from this";
|
|
48248
48937
|
const st=document.createElement("p");st.className="muted";
|
|
48249
48938
|
b.onclick=async()=>{
|
|
@@ -48256,10 +48945,10 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48256
48945
|
st.textContent=sub==="paste"?"This text goes to your own Claude account to be read, and nowhere else."
|
|
48257
48946
|
:sub==="url"?"Your own Claude fetches that link, so that site sees the request; the page text then goes to your Claude account and nowhere else."
|
|
48258
48947
|
:"Your own Claude asks the recorder you connected (Plaud, Otter, Fireflies\u2026) for that call; the transcript then goes to your Claude account and nowhere else.";
|
|
48259
|
-
const r=await(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json();
|
|
48948
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json());
|
|
48260
48949
|
b.disabled=false;b.textContent="Fill the intake from this";
|
|
48261
48950
|
if(!r.ok){st.textContent=clientPageError(r.error,"Check the source and try reading it again.");return}
|
|
48262
|
-
if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";
|
|
48951
|
+
if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";for(const n of ["transcript","url","recorder"])delete iDraft[id+":"+n];
|
|
48263
48952
|
/* Codex F2 (2026-09-06): a HELD answer (the words were said, the value was worked out) is a
|
|
48264
48953
|
suggestion beside the field, never an answer. It reaches iAnswers only when the operator
|
|
48265
48954
|
clicks "Use it". Unverified answers (read by their Claude, not pasted here) are answers,
|
|
@@ -48270,9 +48959,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48270
48959
|
iNote[id]=(heldKeys.length?heldKeys.length+" answer"+(heldKeys.length===1?" was":"s were")+" worked out rather than heard and NOT filled in: "+heldKeys.join(", ")+". Each is shown beside its field with a Use it button. ":"")+(unv.length?unv.length+" could not be checked against the material because your Claude read it rather than you pasting it here: "+unv.join(", ")+". Check them.":"");
|
|
48271
48960
|
iMode[id]="fill";render(lastState,lastRun)};
|
|
48272
48961
|
const urlIn=document.createElement("input");urlIn.placeholder="https://\u2026 a shared transcript or recording page";
|
|
48273
|
-
urlIn.className="cds-input";urlIn.setAttribute("aria-label","Link to the transcript or recording");
|
|
48962
|
+
urlIn.className="cds-input";urlIn.setAttribute("aria-label","Link to the transcript or recording");keepDraft(urlIn,id,"url");
|
|
48274
48963
|
const recIn=document.createElement("input");recIn.placeholder='e.g. "grab my Plaud recording from this morning with Radiance Med Spa"';
|
|
48275
|
-
recIn.className="cds-input";recIn.setAttribute("aria-label","Which recording to use");
|
|
48964
|
+
recIn.className="cds-input";recIn.setAttribute("aria-label","Which recording to use");keepDraft(recIn,id,"recorder");
|
|
48276
48965
|
const hint=document.createElement("p");hint.className="muted";
|
|
48277
48966
|
if(iSub[id]==="paste"){w.appendChild(ta);hint.textContent="Plain text, .vtt and .srt only. The limit is 60,000 characters.";w.appendChild(hint)}
|
|
48278
48967
|
else if(iSub[id]==="doc"){
|
|
@@ -48287,7 +48976,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48287
48976
|
const up=async(f)=>{if(!f)return;if(f.size>20*1024*1024){showClientError("That file is over 20 MB.",box,"Split it or export the part that matters.");return}
|
|
48288
48977
|
drop.textContent="Saving "+f.name+"\u2026";
|
|
48289
48978
|
const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(String(rd.result).split(",")[1]||"");rd.onerror=rej;rd.readAsDataURL(f)});
|
|
48290
|
-
const r=await(await clientPageRequest("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json();
|
|
48979
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json());
|
|
48291
48980
|
if(!r.ok){showClientError(r.error||"Could not save that file",box);drop.textContent="Drop plain text, .vtt or .srt here, or choose a file. The limit is 60,000 characters.";return}
|
|
48292
48981
|
iDocs[id]=r.files||[];render(lastState,lastRun)};
|
|
48293
48982
|
drop.ondragover=(e)=>{e.preventDefault();drop.style.outline="2px solid var(--focus)"};
|
|
@@ -48301,7 +48990,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48301
48990
|
const nm=document.createElement("span");nm.className="ttl";nm.textContent=f.name;nm.appendChild(mk("span","sub",Math.max(1,Math.round(f.bytes/1024))+" KB"));row.appendChild(nm);
|
|
48302
48991
|
const rb=document.createElement("button");rb.className="cds-btn ghost";rb.textContent="Fill the intake from this";
|
|
48303
48992
|
rb.onclick=async()=>{rb.disabled=true;rb.textContent="Reading it\u2026";st.textContent="The text of "+f.name+" goes to your own Claude account to be read, and nowhere else.";
|
|
48304
|
-
const r=await(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,labels:d.questions.map(q=>q.label),file:f.name})})).json();
|
|
48993
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,labels:d.questions.map(q=>q.label),file:f.name})})).json());
|
|
48305
48994
|
rb.disabled=false;rb.textContent="Fill the intake from this";
|
|
48306
48995
|
if(!r.ok){st.textContent=clientPageError(r.error,"Check the source and try reading it again.");return}
|
|
48307
48996
|
if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]="the document";iHeld[id]=r.held||{};iUnv[id]=r.unverified||[];const heldKeys=Object.keys(iHeld[id]),unv=iUnv[id];iNote[id]=(heldKeys.length?heldKeys.length+" answer"+(heldKeys.length===1?" was":"s were")+" worked out rather than heard and NOT filled in: "+heldKeys.join(", ")+". Each is shown beside its field with a Use it button. ":"")+(unv.length?unv.length+" could not be checked against the material because your Claude read it rather than you pasting it here: "+unv.join(", ")+". Check them.":"");iMode[id]="fill";render(lastState,lastRun)};
|
|
@@ -48342,60 +49031,67 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48342
49031
|
save.textContent="Review and submit the real form";save.disabled=Boolean(d.connectionPending);
|
|
48343
49032
|
save.title="Opens this client's actual GHL form with your available answers filled in. One click submits it, so it lands as a genuine form submission.";
|
|
48344
49033
|
save.onclick=async()=>{const missing=d.questions.filter(q=>q.required&&(!(iAnswers[id]||{})[q.label]||(Array.isArray(iAnswers[id][q.label])&&!iAnswers[id][q.label].length)));if(missing.length&&!confirm("Required answers still missing: "+missing.map(q=>q.label).join(", ")+". Continue to the real form?"))return;save.disabled=true;save.textContent="Preparing the form\u2026";
|
|
48345
|
-
const r=await(await clientPageRequest("/api/intake-prefill-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:iAnswers[id]||{},contact:iContact(id,d.questions)})})).json();
|
|
49034
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:iAnswers[id]||{},contact:iContact(id,d.questions)})})).json());
|
|
48346
49035
|
save.disabled=false;save.textContent="Review and submit the real form";
|
|
48347
49036
|
if(!r.ok){showClientError(r.error,box);return}
|
|
48348
|
-
|
|
48349
|
-
const hint=document.createElement("p");hint.className="muted";
|
|
48350
|
-
const left=r.leftovers||[];
|
|
48351
|
-
hint.textContent=left.length
|
|
48352
|
-
?"Use Open the prepared form above to review your answers, except "+left.length+" listed below. Handle each as noted there, then click Submit and come back and press Refresh:"
|
|
48353
|
-
:"Use Open the prepared form above, submit it, then return and press Refresh.";
|
|
48354
|
-
box.appendChild(hint);
|
|
48355
|
-
if(left.length){const ul=document.createElement("ul");ul.className="muted";
|
|
48356
|
-
const WHY={"several answers":"pick each of these in the form","contains a plus sign":"type this in the form","no field for this question":"the form has no question for this, so it was not carried. Add it as a note on the contact after you submit"};
|
|
48357
|
-
for(const l of left){const li=document.createElement("li");li.textContent=l.label+": "+l.values.join(", ")+". "+(WHY[l.reason]||l.reason)+".";ul.appendChild(li)}
|
|
48358
|
-
box.appendChild(ul)}
|
|
48359
|
-
const rf=document.createElement("button");rf.className="cds-btn ghost";rf.textContent="Refresh status";
|
|
48360
|
-
rf.onclick=()=>iLoad(id);box.appendChild(rf)};
|
|
49037
|
+
iPrepared[id]={url:r.url,leftovers:r.leftovers||[]};render(lastState,lastRun)};
|
|
48361
49038
|
box.appendChild(save)}else if(formNote)box.appendChild(formNote);
|
|
48362
49039
|
const alt=document.createElement("button");alt.className="cds-btn ghost";
|
|
48363
49040
|
|
|
48364
49041
|
alt.textContent="Save draft on this computer";
|
|
48365
49042
|
alt.title="Saves your answers for this client on this computer. You can return to them later.";
|
|
48366
49043
|
alt.onclick=async()=>{alt.disabled=true;alt.textContent="Saving\u2026";const savedAnswers={...(iAnswers[id]||{})};
|
|
48367
|
-
const r=await(await clientPageRequest("/api/intake-submit",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:savedAnswers,contact:iContact(id,d.questions)})})).json();
|
|
49044
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-submit",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:savedAnswers,contact:iContact(id,d.questions)})})).json());
|
|
48368
49045
|
if(r.ok){for(const label of iDirty[id]||[])if(JSON.stringify(iAnswers[id][label])===JSON.stringify(savedAnswers[label]))iDirty[id].delete(label);iMode[id]=null;iPre[id]=[];await iLoad(id)}else{showClientError(r.error,box);alt.disabled=false;alt.textContent="Save draft on this computer"}};
|
|
48369
|
-
box.appendChild(alt)
|
|
49046
|
+
box.appendChild(alt);
|
|
49047
|
+
if(hasForm&&iPrepared[id])preparedFormBlock(box,id)}}
|
|
48370
49048
|
return box}
|
|
48371
49049
|
let iNote={},iHeld={},iUnv={};
|
|
48372
49050
|
${ASSESSMENT_PANEL_JS}
|
|
48373
49051
|
let rOpen={},rData={},rBusy={},rSeen={};
|
|
48374
49052
|
let profCache=null,profLoading=false;
|
|
48375
49053
|
async function profLoad(){if(profLoading)return;profLoading=true;try{profCache=((await(await fetch("/api/agency-profile")).json()).profile)||{}}catch(e){profCache={}}profLoading=false;render(lastState,lastRun)}
|
|
48376
|
-
/* What they bought (2026-09-06). Foundation is always built; the ticks decide the features.
|
|
49054
|
+
/* What they bought (2026-09-06). Foundation is always built; the ticks decide the features.
|
|
49055
|
+
Jerry, 2026-09-29: nothing saved no longer means the whole preset. Approve and Build wait for a save,
|
|
49056
|
+
and nothing is pre-ticked unless the Assessment recorded purchases. */
|
|
49057
|
+
/* The ticks and package chosen but not yet saved, kept outside the block so a redraw keeps them (review of c09e946). */
|
|
49058
|
+
let scopeDraft={};
|
|
49059
|
+
function scopeChosen(state,id){const s=(state.scope||{})[id];return Boolean(s&&(s.wholePreset||(Array.isArray(s.features)&&s.features.length)))}
|
|
49060
|
+
function openWhatTheyBought(id){rOpen[id]=true;rSeen[id]=true;const go=()=>{render(lastState,lastRun);requestAnimationFrame(()=>{const t=document.getElementById("wtb-"+id);if(t){t.scrollIntoView({behavior:"smooth",block:"start"});const f=t.querySelector("button,input,select");if(f)f.focus({preventScroll:true})}})};if(!rData[id])rLoad(id).then(go,go);else go()}
|
|
48377
49061
|
function scopeBlock(id,state){
|
|
48378
|
-
const sc=document.createElement("div");sc.className="cds-stack";
|
|
49062
|
+
const sc=document.createElement("div");sc.className="cds-stack";sc.id="wtb-"+id;
|
|
48379
49063
|
sc.innerHTML="<h4 class='cds-panel-label'>What they bought</h4>";
|
|
49064
|
+
const lead=document.createElement("p");lead.textContent="Tick what the client bought. Only these get built.";sc.appendChild(lead);
|
|
48380
49065
|
if(!profCache){profLoad();const p=document.createElement("p");p.className="muted";p.textContent="Loading your packages\u2026";sc.appendChild(p);return sc}
|
|
48381
49066
|
const saved=(state.scope||{})[id]||null;const pk=(profCache.packages||[]);
|
|
48382
49067
|
const sel=document.createElement("select");sel.className="cds-select";sel.setAttribute("aria-label","Package they bought");
|
|
48383
|
-
const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"
|
|
49068
|
+
const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"No package: tick the features below":"No packages saved yet (add them on Your agency): tick the features below";sel.appendChild(o0);
|
|
48384
49069
|
for(const p of pk){const o=document.createElement("option");o.value=p.id;o.textContent=p.name+(p.price?" \xB7 "+moneyText(p.price):"");sel.appendChild(o)}
|
|
48385
|
-
|
|
49070
|
+
/* Only the features this client's plan has are offered (review of 89acc7d); a tick saved earlier outside it stays visible so it can be removed. */
|
|
49071
|
+
const offered=((state.scopeOffered||{})[id])||Object.keys(FEATS);
|
|
49072
|
+
const prefill=(state.scopePrefill||{})[id];let ticked=new Set(saved?(saved.wholePreset?offered:saved.features):(prefill||[]));if(saved&&saved.package)sel.value=saved.package;
|
|
49073
|
+
const base=JSON.stringify(saved);if(scopeDraft[id]&&scopeDraft[id].base!==base)delete scopeDraft[id];const dr=scopeDraft[id];if(dr){ticked=new Set(dr.ticked);sel.value=dr.pkg}
|
|
49074
|
+
const keepScope=()=>{scopeDraft[id]={ticked:[...ticked],pkg:sel.value,base:base};unsaved.hidden=false};
|
|
49075
|
+
const everything=document.createElement("button");everything.className="cds-btn"+(saved&&saved.wholePreset?" ghost":"");everything.textContent="Everything in the plan";
|
|
49076
|
+
everything.title="Builds every feature in the plan for this client's industry.";
|
|
49077
|
+
everything.onclick=async()=>{everything.disabled=true;const r=await postJSON("/api/scope",{locationId:id,features:null});if(r){if(r.approvalWithdrawn)alert(r.message);delete scopeDraft[id];delete assessmentCards[id];load()}else everything.disabled=false};
|
|
49078
|
+
sc.appendChild(everything);
|
|
48386
49079
|
const boxes=document.createElement("div");boxes.className="cds-stack";
|
|
48387
|
-
function drawBoxes(){boxes.innerHTML="";for(const k of Object.keys(FEATS)){const l=document.createElement("label");l.className="cds-check";
|
|
48388
|
-
const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(k);c.onchange=()=>{if(c.checked)ticked.add(k);else ticked.delete(k)};l.appendChild(c);
|
|
49080
|
+
function drawBoxes(){boxes.innerHTML="";for(const k of Object.keys(FEATS).filter(k=>offered.includes(k)||ticked.has(k))){const l=document.createElement("label");l.className="cds-check";
|
|
49081
|
+
l.dataset.k=k;const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(k);c.onchange=()=>{if(c.checked)ticked.add(k);else ticked.delete(k);keepScope();drawFigs()};l.appendChild(c);
|
|
48389
49082
|
const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=FEATS[k].name;t.appendChild(tb);t.appendChild(mk("small","",FEATS[k].what));l.appendChild(t);boxes.appendChild(l)}}
|
|
48390
49083
|
const figs=((state.assessmentFigures||{})[id])||{};
|
|
48391
49084
|
function drawFigs(){for(const l of boxes.querySelectorAll("label")){const k=l.dataset.k;const old=l.querySelector("em");if(old)old.remove();if(figs[k]&&!ticked.has(k)){const e=document.createElement("em");e.className="cds-chip warn";e.textContent="Not bought: "+figs[k]+".";l.querySelector("span").appendChild(e)}}}
|
|
48392
|
-
sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);ticked=new Set(p
|
|
48393
|
-
for(const l of boxes.querySelectorAll("label")){const c=l.querySelector("input");const k=Object.keys(FEATS)[[...boxes.querySelectorAll("label")].indexOf(l)];l.dataset.k=k;c.addEventListener("change",drawFigs)}drawFigs();
|
|
49085
|
+
sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);if(p)ticked=new Set(p.features);keepScope();drawBoxes();drawFigs()};drawBoxes();drawFigs();
|
|
48394
49086
|
const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing. Unticked features go in the client document as available next.";
|
|
48395
49087
|
const sv=document.createElement("button");sv.className="cds-btn ghost";sv.textContent="Save what they bought";
|
|
48396
|
-
|
|
48397
|
-
|
|
48398
|
-
|
|
49088
|
+
const status=document.createElement("p");status.className="muted";status.setAttribute("role","status");
|
|
49089
|
+
sv.onclick=async()=>{if(!ticked.size){status.textContent="Tick at least one feature, or choose Everything in the plan.";return}
|
|
49090
|
+
if(![...ticked].some(k=>offered.includes(k))){status.textContent="None of the ticked features is in this client's plan. Tick features from the list, or choose Everything in the plan.";return}
|
|
49091
|
+
const all=offered.every(k=>ticked.has(k));const body={locationId:id,features:(all&&!sel.value)?null:[...ticked]};if(sel.value)body.package=sel.value;const r=await postJSON("/api/scope",body);if(r){if(r.approvalWithdrawn)alert(r.message);delete scopeDraft[id];delete assessmentCards[id];load()}};
|
|
49092
|
+
status.textContent=saved?(saved.wholePreset?"Saved: everything in the plan. The build follows this.":saved.features.length?"Saved: "+saved.features.filter(k=>offered.includes(k)).length+" of "+offered.length+" features"+(saved.package?" from the "+saved.package+" package":"")+". The build follows this.":"Saved with nothing ticked, so nothing can be built. Tick what they bought, or choose Everything in the plan, and save."):prefill?"Not confirmed yet: ticked from what they bought. Nothing is built until you save.":"Nothing saved yet. Choose and save before approving.";
|
|
49093
|
+
const unsaved=mk("span","cds-chip warn","Not saved yet");unsaved.hidden=!scopeDraft[id];
|
|
49094
|
+
sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(unsaved);sc.appendChild(status);
|
|
48399
49095
|
return sc}
|
|
48400
49096
|
async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;render(lastState,lastRun)}
|
|
48401
49097
|
// Finding 20 (2026-08-26): writing the review takes 30\u201390 s and the page re-renders every few seconds meanwhile,
|
|
@@ -48426,7 +49122,9 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
|
|
|
48426
49122
|
if(!r||!r.ok){if(!busy){const p=document.createElement("p");if(r&&r.error&&r.error!=="not generated yet"){p.className="cds-callout warn";p.setAttribute("role","alert")}else p.className="muted";
|
|
48427
49123
|
p.textContent=r&&r.error?r.error:"Turn their intake answers into strategy: who they really sell to, whether the offer holds up, what the account is missing, and what to build first.";
|
|
48428
49124
|
d.appendChild(p)}
|
|
48429
|
-
d.appendChild(gen);
|
|
49125
|
+
d.appendChild(gen);
|
|
49126
|
+
/* What they bought decides the build, so it is here even before the review is written (2026-09-29). */
|
|
49127
|
+
d.appendChild(scopeBlock(id,state));return d}
|
|
48430
49128
|
if(r.headline){const h=document.createElement("div");h.className="cds-callout info headline";h.textContent=moneyText(r.headline);d.appendChild(h)}
|
|
48431
49129
|
if(r.ica){const s1=document.createElement("div");s1.className="cds-stack";s1.innerHTML="<h4 class='cds-panel-label'>Who they actually sell to</h4>";
|
|
48432
49130
|
const p1=document.createElement("p");p1.textContent=moneyText(r.ica.who||"");s1.appendChild(p1);
|
|
@@ -48489,7 +49187,7 @@ function clientPageError(raw,next){
|
|
|
48489
49187
|
return text+(action?" "+action:"")}
|
|
48490
49188
|
function showClientError(raw,container,next){const parent=container||document.getElementById("main");let e=parent.querySelector('[role="alert"]');if(!e){e=document.createElement("p");e.setAttribute("role","alert");e.className="cds-callout warn";parent.appendChild(e)}e.textContent=clientPageError(raw,next)}
|
|
48491
49189
|
async function clientPageRequest(url,options){try{const response=await fetch(url,options);const data=await response.json();if(!response.ok&&data.ok!==false)data.ok=false;return {json:async()=>data}}catch(e){return {json:async()=>({ok:false,error:/^[/]api[/](?:client-)?intake/.test(url)?"GHL Command could not confirm the result. Refresh the intake to check its status.":"GHL Command could not confirm the result; check GoHighLevel before trying again to avoid repeating a change."})}}}
|
|
48492
|
-
function pendingCard(c,state,intake=false){const div=document.createElement("section");div.className="cds-panel client";
|
|
49190
|
+
function pendingCard(c,state,intake=false){const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
|
|
48493
49191
|
const head=document.createElement("div");head.className="cds-panel-head client-head";
|
|
48494
49192
|
const txt=mk("div","txt");const h=document.createElement("h2");h.className="cds-panel-title";h.textContent=c.name;txt.appendChild(h);head.appendChild(txt);
|
|
48495
49193
|
const pill=chip("warn","Needs connecting");head.appendChild(pill);
|
|
@@ -48636,7 +49334,7 @@ function card(c,state,run){const st=(state.clients[c.locationId]||{stages:STAGES
|
|
|
48636
49334
|
// A template or hidden account opens only so its owner can lock or unlock it and change its
|
|
48637
49335
|
// type: nothing on its card builds into it (review L-1; a template can be the live snapshot source).
|
|
48638
49336
|
const parkedType=["template","hidden"].includes(typeOf(state,c.locationId));
|
|
48639
|
-
const div=document.createElement("section");div.className="cds-panel client";
|
|
49337
|
+
const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
|
|
48640
49338
|
const head=document.createElement("div");head.className="cds-panel-head client-head";
|
|
48641
49339
|
const htxt=mk("div","txt");const h=document.createElement("h2");h.className="cds-panel-title";h.textContent=c.name;htxt.appendChild(h);head.appendChild(htxt);
|
|
48642
49340
|
if(sandbox){const sb=chip("info","");sb.title="Practice accounts skip typing the client name. Review and approve the plan before building.";sb.textContent="Practice account";htxt.appendChild(sb)}
|
|
@@ -48679,7 +49377,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48679
49377
|
const nextBox=document.createElement("div");nextBox.className="cds-stack snext";div.appendChild(nextBox);
|
|
48680
49378
|
const row=document.createElement("div");row.className="cds-flow stages";row.setAttribute("role","list");
|
|
48681
49379
|
let curLive=false;
|
|
48682
|
-
STAGES.forEach((name,i)=>{const stale=staleOf(i);const el=document.createElement("div");el.className="cds-node stage "+(stale?"pending":(st[i]||"pending"))+(i===curI?" here hit":"");el.setAttribute("role","listitem");
|
|
49380
|
+
STAGES.forEach((name,i)=>{const stale=staleOf(i);const el=document.createElement("div");el.className="cds-node stage "+(stale?"pending":(st[i]||"pending"))+(i===curI?" here hit":"");el.setAttribute("role","listitem");el.setAttribute("data-key",c.locationId+":"+i);
|
|
48683
49381
|
const gate=GATES[String(i)];
|
|
48684
49382
|
const oc0=(state.outcomes||{})[c.locationId+":"+i];const notPassed=(st[i]||"pending")==="pending"&&oc0&&(oc0.issues||[]).length>0;
|
|
48685
49383
|
if(notPassed)el.className+=" notpassed";
|
|
@@ -48713,7 +49411,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48713
49411
|
const clock=document.createElement("div");clock.className="cds-num";
|
|
48714
49412
|
|
|
48715
49413
|
const began=run.startedAt?new Date(run.startedAt).getTime():0;
|
|
48716
|
-
|
|
49414
|
+
/* The clock counts on the page alone, so it only counts while the status is fresh: a page that has
|
|
49415
|
+
not heard from the server for 15s freezes and says so, rather than looking busy (2026-09-29). */
|
|
49416
|
+
const tick=()=>{if(!began)return;if(statusIsStale()){clock.textContent="checking\u2026";return}const sec=Math.max(0,Math.round((Date.now()-began)/1000));
|
|
48717
49417
|
clock.textContent="working "+(sec<60?sec+"s":Math.floor(sec/60)+"m "+(sec%60)+"s")};
|
|
48718
49418
|
tick();if(began){const h=setInterval(()=>{if(!clock.isConnected){clearInterval(h);return}tick()},1000)}
|
|
48719
49419
|
box.appendChild(clock);
|
|
@@ -48752,8 +49452,11 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48752
49452
|
else if(!live){const note=mkNote();what.appendChild(note||about)}
|
|
48753
49453
|
if(POWERED.includes(i)&&(st[i]||"pending")!=="done"){el.style.cursor="default";
|
|
48754
49454
|
el.title="Command OS runs and checks this step. Its result comes from a run, never from a click."}
|
|
48755
|
-
|
|
48756
|
-
|
|
49455
|
+
/* Jerry, 2026-09-29: Approve waits for What they bought. Until a selection is saved the gate
|
|
49456
|
+
is "Choose what the client bought", which opens the Blueprint Review at that block. */
|
|
49457
|
+
const mustChoose=i===2&&!scopeChosen(state,c.locationId);
|
|
49458
|
+
const gateLabel=mustChoose?"Choose what the client bought":({2:"Approve this plan",5:"Confirm these tasks are finished",6:"Sign this client live"})[i];
|
|
49459
|
+
const mkGate=cls=>{const action=document.createElement("button");action.className=cls;action.textContent=gateLabel;action.onclick=()=>{if(mustChoose)return openWhatTheyBought(c.locationId);if(i===2)return approveBuilding();return markStage(c.locationId,i,"done")};return action};
|
|
48757
49460
|
const canBuild=!parkedType&&(i<=1||sandbox||Boolean((state.buildApproved||{})[c.locationId]));
|
|
48758
49461
|
const runIt=async(ev)=>{ev.stopPropagation();if(i===0&&!confirm("In "+c.name+", Intake writes custom fields, one form, and one published notification workflow."+String.fromCharCode(10,10)+INTAKE_NOTIFICATION_REVIEW.join(String.fromCharCode(10))+String.fromCharCode(10,10)+"Create or repair these intake items?"))return;if(i===4&&PAGE_PLANS.includes(c.locationId)&&!confirm("Verify submits one test lead to the pages "+c.name+"\u2019s older build made, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once. Run the check?"))return;const res=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,stage:i,intakeConfirmed:i===0})});const j=await res.json();if(!j.ok)alert(j.error);load()};
|
|
48759
49462
|
const mkRun=(cls,label)=>{const b=document.createElement("button");b.className=cls;
|
|
@@ -48765,10 +49468,11 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48765
49468
|
A gate the owner has already answered offers nothing. */
|
|
48766
49469
|
if(gateOpen&&!anyBusy&&!parkedType&&i!==curI)act.appendChild(mkGate("cds-link sactbtn"));
|
|
48767
49470
|
if(canBuild&&POWERED.includes(i)&&i!==curI)act.appendChild(mkRun("cds-link sactbtn srun",(st[i]||"pending")==="done"?"Run again":"Run this step"));
|
|
48768
|
-
if(i===curI){const main=document.createElement("div");
|
|
49471
|
+
if(i===curI){const main=document.createElement("div");main.setAttribute("data-key",c.locationId+":"+i+":next");
|
|
48769
49472
|
const k=mk("div","cds-panel-label sk",parkedType?"Building is off":"Next step");main.appendChild(k);
|
|
48770
49473
|
const h3=document.createElement("h3");h3.className="cds-panel-title";h3.textContent=parkedType?"Nothing is built into this account":(i+1)+". "+LABELS[i];main.appendChild(h3);
|
|
48771
49474
|
const pa=document.createElement("p");pa.textContent=parkedType?"It opens here so you can lock or unlock it, or change its type to Client to build it.":stepAbout(i,c,true);main.appendChild(pa);
|
|
49475
|
+
if(mustChoose&&!parkedType){const pc=document.createElement("p");pc.className="swarn";pc.textContent="First choose what the client bought. Only those parts get built.";main.appendChild(pc)}
|
|
48772
49476
|
if(live)main.appendChild(mkLive());
|
|
48773
49477
|
if(stale&&!live){const w=document.createElement("div");w.className="cds-row";w.appendChild(chip(chipOf[1],chipOf[0]));w.appendChild(mk("span","swarn",stale.reason));main.appendChild(w)}
|
|
48774
49478
|
const pn=mkNote();if(pn){const w=document.createElement("div");w.className="snoteline";w.appendChild(pn);main.appendChild(w)}
|
|
@@ -48804,7 +49508,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48804
49508
|
if(view.stale.length){const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const stale of view.stale){const p=document.createElement("p");p.textContent=LABELS[stale.stage]+": stale. "+stale.reason;d.appendChild(p)}div.appendChild(d)}
|
|
48805
49509
|
if(view.approvedAt){const p=document.createElement("p");p.textContent="Building approved on "+niceDate(view.approvedAt,true)+".";div.appendChild(p)}
|
|
48806
49510
|
for(const repair of view.repairs){const p=document.createElement("p");p.textContent="Live workflow repair: "+repair.name+". "+repair.changes.join(" ");div.appendChild(p)}
|
|
48807
|
-
|
|
49511
|
+
/* No "Approve building\u2026" while nothing is chosen (review of 89acc7d): it would ask for a name and then refuse. */
|
|
49512
|
+
if(view.planHash&&!parkedType&&!scopeChosen(state,c.locationId)){const ch=document.createElement("button");ch.className="cds-btn ghost";ch.textContent="Choose what the client bought";ch.onclick=()=>openWhatTheyBought(c.locationId);div.appendChild(ch)}
|
|
49513
|
+
else if(view.planHash&&!parkedType){const approve=document.createElement("button");approve.className="cds-btn ghost";approve.textContent="Approve building\u2026";approve.onclick=async()=>{const typed=sandbox?c.name:prompt("Approve building and the listed live workflow repairs for "+c.name+". Type the client name to confirm.");if(typed===null)return;if(await postJSON("/api/build-approval",{locationId:c.locationId,confirmName:typed,repairHashes:view.repairs.map(r=>r.hash)}))load()};div.appendChild(approve)}
|
|
48808
49514
|
}else{const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));const p=document.createElement("p");p.textContent="The executable plan is prepared when Build runs.";d.appendChild(p);div.appendChild(d)}
|
|
48809
49515
|
if(view?.handoff)div.appendChild(unnest(handoffPanel(c,view)));
|
|
48810
49516
|
const _prot=PROTECTED[c.locationId];
|
|
@@ -48823,8 +49529,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48823
49529
|
ab.appendChild(s1);ab.appendChild(rv);ab.appendChild(protectBtn(c))}
|
|
48824
49530
|
else if(parkedType){ab.appendChild(protectBtn(c))}
|
|
48825
49531
|
else{const w=document.createElement("span");w.className="muted";w.textContent="Automated building is off for this client. ";
|
|
48826
|
-
const btn=document.createElement("button");btn.className="cds-btn ghost";
|
|
48827
|
-
btn.onclick=approveBuilding
|
|
49532
|
+
const btn=document.createElement("button");btn.className="cds-btn ghost";
|
|
49533
|
+
if(scopeChosen(lastState,c.locationId)){btn.textContent="Approve building\u2026";btn.onclick=approveBuilding}
|
|
49534
|
+
else{btn.textContent="Choose what the client bought";btn.onclick=()=>openWhatTheyBought(c.locationId)}
|
|
48828
49535
|
ab.appendChild(w);ab.appendChild(btn);ab.appendChild(protectBtn(c))}
|
|
48829
49536
|
div.appendChild(ab)}
|
|
48830
49537
|
// A "live" claim is re-derived from the signature, never trusted from the
|
|
@@ -48942,7 +49649,7 @@ async function postJSON(url,body){
|
|
|
48942
49649
|
catch(e){alert("Command OS could not confirm the result. Refresh this client to read its saved state before retrying.");return false}
|
|
48943
49650
|
try{j=await r.json()}catch(e){}
|
|
48944
49651
|
if(!r.ok||(j&&j.ok===false)){alert((j&&j.error)||"That did not go through, and nothing was changed.");return false}
|
|
48945
|
-
return true}
|
|
49652
|
+
return j||true}
|
|
48946
49653
|
/* Nothing hand-markable that Command OS can check for itself. Steps it runs are marked
|
|
48947
49654
|
by the run; steps that need a person are marked by that person, on the record. */
|
|
48948
49655
|
async function markStage(id,i,status){
|
|
@@ -48952,7 +49659,24 @@ async function markStage(id,i,status){
|
|
|
48952
49659
|
const r=await(await fetch("/api/stage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,stage:i,status:status,by:who})})).json();
|
|
48953
49660
|
if(!r.ok){alert(r.error);return}
|
|
48954
49661
|
await loadQueue();load()}
|
|
48955
|
-
|
|
49662
|
+
/* cheap: a background refresh uses GET /api/queue, which derives the queue from the saved board without re-reading
|
|
49663
|
+
every client's intake from GoHighLevel; the POST (which does) stays for the page's own loads. */
|
|
49664
|
+
/* Three ways to read the queue:
|
|
49665
|
+
- undefined: the page's own load and the operator's actions. POST {}: re-reads every started client's intake.
|
|
49666
|
+
- "pending": a background re-read (a return to the tab, every 4th heartbeat load). POST {only:"pending"}: re-reads
|
|
49667
|
+
only clients whose saved intake is not yet "received", plus the one open here. At most one queue POST is in
|
|
49668
|
+
flight, and a background one is skipped (the GET is used) within 30s of the last one finishing.
|
|
49669
|
+
- "get": GET, derived from the saved board with no GoHighLevel reads.
|
|
49670
|
+
A background read that fails keeps the last good queue rather than blanking it. */
|
|
49671
|
+
let queuePostBusy=false,queuePostAt=0;
|
|
49672
|
+
async function loadQueue(mode){
|
|
49673
|
+
if(mode==="pending"&&(queuePostBusy||Date.now()-queuePostAt<30000))mode="get";
|
|
49674
|
+
const background=mode==="get"||mode==="pending";const post=mode!=="get";
|
|
49675
|
+
const init=mode==="get"?undefined:{method:"POST",headers:{"Content-Type":"application/json"},body:mode==="pending"?JSON.stringify({only:"pending",open:activeId||undefined}):"{}"};
|
|
49676
|
+
if(post)queuePostBusy=true;
|
|
49677
|
+
try{const r=await fetch("/api/queue",init);const q=await r.json();if(!r.ok||!q||q.ok===false)throw new Error((q&&q.error)||"The work queue could not be read.");queueData=q;if(window.cdsQueueBadge&&queueData.counts)window.cdsQueueBadge(queueData.counts.you||0)}
|
|
49678
|
+
catch(e){if(!(background&&queueData&&queueData.ok))queueData={ok:false,error:e.message||"The work queue could not be read."}}
|
|
49679
|
+
finally{if(post){queuePostBusy=false;queuePostAt=Date.now()}}}
|
|
48956
49680
|
/* A whole first sentence, never a line cut mid-thought. */
|
|
48957
49681
|
function firstSentence(s){const t=String(s||"").split(String.fromCharCode(10)).map(x=>x.trim()).filter(Boolean)[0]||"";const m=/^(.+?[.!?])(\\s|$)/.exec(t);return m?m[1]:t}
|
|
48958
49682
|
/* Build text an owner cannot read: quoted names, tag and custom value keys (business_phone), plus signs,
|
|
@@ -49036,11 +49760,11 @@ function queueBlock(match){const q=queueData;if(!q||!q.ok)return queueReadError(
|
|
|
49036
49760
|
frag.appendChild(now);
|
|
49037
49761
|
/* The rest fold away (Disclosure: cds-more), each in a Panel of its own that says what is inside. */
|
|
49038
49762
|
if(later.length){const p=panel("Not started yet",later.length+" "+plural(later.length,"account")+" not started");p.classList.add("q-later");
|
|
49039
|
-
const d=mk("details","cds-more");d.open=showQueueRest;d.ontoggle=()=>{showQueueRest=d.open};
|
|
49763
|
+
const d=mk("details","cds-more");d.setAttribute("data-key","queue-rest");d.open=showQueueRest;d.ontoggle=()=>{showQueueRest=d.open};
|
|
49040
49764
|
d.appendChild(mk("summary","","Show the "+later.length+" "+plural(later.length,"step")+" not started"));
|
|
49041
49765
|
d.appendChild(listRows(later.map(queueRow),"queue-later"));p.appendChild(d);frag.appendChild(p)}
|
|
49042
49766
|
if(fin.length){const p=panel("Finished",fin.length+" "+plural(fin.length,"account")+" finished");p.classList.add("q-finished");
|
|
49043
|
-
const d=mk("details","cds-more");d.open=showLive;d.ontoggle=()=>{showLive=d.open};
|
|
49767
|
+
const d=mk("details","cds-more");d.setAttribute("data-key","queue-live");d.open=showLive;d.ontoggle=()=>{showLive=d.open};
|
|
49044
49768
|
d.appendChild(mk("summary","","Show the "+fin.length+" finished "+plural(fin.length,"account")));
|
|
49045
49769
|
for(const signed of [true,false]){const group=fin.filter(f=>Boolean(f.live)===signed);if(!group.length)continue;
|
|
49046
49770
|
d.appendChild(mk("div","cds-panel-label",signed?"Finished and live ("+group.length+")":"Finished, not signed live ("+group.length+")"));
|
|
@@ -49266,7 +49990,7 @@ send.disabled=!published;
|
|
|
49266
49990
|
if(!confirm("Send one email from "+c.name+"\u2019s account to "+r.preview.email+" with the handoff at "+r.url+"?"))return;
|
|
49267
49991
|
const result=await(await fetch("/api/handoff-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,hash:r.preview.hash,confirmed:true})})).json();note.textContent=result.note||result.error;
|
|
49268
49992
|
}catch(e){note.textContent=e.message||"The result could not be confirmed. Check Conversations before retrying."}finally{send.disabled=false}};box.appendChild(send);return box}
|
|
49269
|
-
function pollCap(){if(capPoll)return;capPoll=setInterval(async()=>{try{const r=await
|
|
49993
|
+
function pollCap(){if(capPoll)return;capPoll=setInterval(async()=>{try{const r=await fetchRunStatus();const s=await(await fetch("/api/state")).json();if(!r.running){clearInterval(capPoll);capPoll=null;capRun=null;capReads=null;capReadsFor=""}if(!shouldRedraw(lastRun,r))return;if(!r.running||dataSig(s,r)!==lastDrawnSig){render(s,r);hideFinishNote()}}catch(e){}},2000)}
|
|
49270
49994
|
|
|
49271
49995
|
/* PageHeader: kicker, the title as a sentence, one muted line, at most one primary action. */
|
|
49272
49996
|
function screenHead(title,sub,kicker,action){const d=mk("div","cds-page-head");const t=mk("div","txt");
|
|
@@ -49313,9 +50037,34 @@ function homeBlock(state,run){const wrap=mk("div","board");
|
|
|
49313
50037
|
li.onclick=go;li.onkeydown=(e)=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();go()}};return li}),"board-needs"))}
|
|
49314
50038
|
two.appendChild(needs);
|
|
49315
50039
|
const priv=panel("Privacy","What stays on this computer");priv.classList.add("privacy");for(const line of PRIVACY_SHORT)priv.appendChild(mk("p","muted small",line));
|
|
49316
|
-
{const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const line of PRIVACY_LINES)d.appendChild(mk("p","muted small",line));priv.appendChild(d)}two.appendChild(priv);
|
|
50040
|
+
{const d=mk("details","cds-details");d.setAttribute("data-key","home-privacy");d.appendChild(mk("summary","","Details"));for(const line of PRIVACY_LINES)d.appendChild(mk("p","muted small",line));priv.appendChild(d)}two.appendChild(priv);
|
|
49317
50041
|
wrap.appendChild(two);return wrap}
|
|
49318
|
-
|
|
50042
|
+
/* Every redraw keeps the view (2026-09-29, Jerry: "anything I expand closes itself within 1 to 3 seconds").
|
|
50043
|
+
The page is rebuilt from data, so a disclosure the operator opened, the scroll position and the cursor in
|
|
50044
|
+
a field would all reset. Before drawing: note which <details> are open or closed, keyed by the nearest
|
|
50045
|
+
data-key (client, client:stage) plus the summary words, and where focus and the caret are. After: put
|
|
50046
|
+
them back. Toggles such as See issues already live in state (oOpen), and survive on their own. */
|
|
50047
|
+
let lastDrawnSig="";
|
|
50048
|
+
/* generatedAt is stamped fresh on every queue read (work-queue.ts), so it is left out: with it, no two reads ever matched
|
|
50049
|
+
and every background refresh redrew (review of c09e946). */
|
|
50050
|
+
function dataSig(state,run){try{return JSON.stringify([state,run,typeof queueData==="undefined"?null:queueData],(k,v)=>k==="generatedAt"?undefined:v)}catch(e){return String(Date.now())}}
|
|
50051
|
+
function keyOf(el,root){const a=el.closest("[data-key]");return a&&root.contains(a)?a.getAttribute("data-key"):"main"}
|
|
50052
|
+
function detailsKeys(root){const out=[];const seen={};for(const d of root.querySelectorAll("details")){const sm=d.querySelector("summary");
|
|
50053
|
+
const base=keyOf(d,root)+"|"+String(d.className||"")+"|"+String(sm?sm.textContent:"").replace(/[0-9]+/g,"#").trim();seen[base]=(seen[base]||0)+1;out.push([d,base+"#"+seen[base]])}return out}
|
|
50054
|
+
function fieldKey(el,root){return keyOf(el,root)+"|"+el.tagName+"|"+(el.getAttribute("aria-label")||el.getAttribute("name")||el.getAttribute("placeholder")||el.id||"")}
|
|
50055
|
+
function captureView(){const m=document.getElementById("main");if(!m)return null;const open=new Set(),closed=new Set();
|
|
50056
|
+
for(const [d,k] of detailsKeys(m))(d.open?open:closed).add(k);
|
|
50057
|
+
let focus=null;const a=document.activeElement;
|
|
50058
|
+
if(a&&m.contains(a)&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)){const k=fieldKey(a,m);const same=[...m.querySelectorAll(a.tagName)].filter(x=>fieldKey(x,m)===k);
|
|
50059
|
+
let st=null,en=null;try{st=a.selectionStart;en=a.selectionEnd}catch(e){}focus={key:k,tag:a.tagName,n:same.indexOf(a),st:st,en:en}}
|
|
50060
|
+
return {open:open,closed:closed,focus:focus,x:window.scrollX,y:window.scrollY}}
|
|
50061
|
+
function restoreView(v){const m=document.getElementById("main");if(!v||!m)return;
|
|
50062
|
+
for(const [d,k] of detailsKeys(m)){if(v.open.has(k)&&!d.open)d.open=true;else if(v.closed.has(k)&&d.open)d.open=false}
|
|
50063
|
+
if(v.focus){const el=[...m.querySelectorAll(v.focus.tag)].filter(x=>fieldKey(x,m)===v.focus.key)[v.focus.n];
|
|
50064
|
+
if(el&&document.activeElement!==el){try{el.focus({preventScroll:true})}catch(e){}try{if(v.focus.st!=null)el.setSelectionRange(v.focus.st,v.focus.en)}catch(e){}}}
|
|
50065
|
+
if(window.scrollX!==v.x||window.scrollY!==v.y){try{window.scrollTo(v.x,v.y)}catch(e){}}}
|
|
50066
|
+
function render(state,run){const v=captureView();try{renderPage(state,run)}finally{lastDrawnSig=dataSig(state,run);restoreView(v)}}
|
|
50067
|
+
function renderPage(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...state.roster)}syncReviews(state);lastState=state;lastRun=run;
|
|
49319
50068
|
if(state&&state.protectedAccounts)PROTECTED=state.protectedAccounts;const m=document.getElementById("main");m.innerHTML="";
|
|
49320
50069
|
/* Four screens share this page's data and differ only in what they show. */
|
|
49321
50070
|
if(SCREEN==="new"){m.classList.add("ncflow");const head=screenHead(HEADS.new.title,HEADS.new.sub,HEADS.new.kicker);head.querySelector(".cds-page-sub").classList.add("nc-lead");m.appendChild(head);
|
|
@@ -49391,7 +50140,7 @@ function render(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...sta
|
|
|
49391
50140
|
const list=panel("Client accounts",actC?"Your other client accounts":"Your client accounts");if(!actC)list.id="clients";
|
|
49392
50141
|
if(rest.length){list.appendChild(listRows(rest.map(c=>acctRow(c,state,run)),"clients-rest","accounts"));
|
|
49393
50142
|
list.appendChild(mk("p","switchnote","One account at a time. Opening another puts this one back in the list. Nothing is lost."))}
|
|
49394
|
-
if(parkedRest.length){const d=mk("details","cds-more");d.open=showParked;d.ontoggle=()=>{showParked=d.open};
|
|
50143
|
+
if(parkedRest.length){const d=mk("details","cds-more");d.setAttribute("data-key","parked-rest");d.open=showParked;d.ontoggle=()=>{showParked=d.open};
|
|
49395
50144
|
d.appendChild(mk("summary","","Show the "+parkedRest.length+" template and hidden "+plural(parkedRest.length,"account")));
|
|
49396
50145
|
d.appendChild(listRows(parkedRest.map(c=>acctRow(c,state,run)),"clients-parked"));list.appendChild(d)}
|
|
49397
50146
|
m.appendChild(list)}}
|
|
@@ -49794,7 +50543,7 @@ async function runDashboard(argv) {
|
|
|
49794
50543
|
let ffAdmin;
|
|
49795
50544
|
try {
|
|
49796
50545
|
const b = workflowReadConnection(registry2, c);
|
|
49797
|
-
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
|
|
50546
|
+
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
|
|
49798
50547
|
ffAdmin = builderAdmin(b);
|
|
49799
50548
|
} catch {
|
|
49800
50549
|
}
|
|
@@ -50560,6 +51309,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50560
51309
|
const p = scopePrefillFor(st, loc);
|
|
50561
51310
|
if (p) scopePrefill[loc] = p;
|
|
50562
51311
|
}
|
|
51312
|
+
const scopeOffered = Object.fromEntries(currentRoster().map((c) => [c.locationId, offeredFeaturesFor(st, c.locationId)]));
|
|
50563
51313
|
const buildViews = Object.fromEntries(currentRoster().map((c) => {
|
|
50564
51314
|
const saved = readSavedPlan(c.locationId);
|
|
50565
51315
|
const approval = st.planApprovals?.[c.locationId];
|
|
@@ -50592,7 +51342,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50592
51342
|
}
|
|
50593
51343
|
for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || st.manualTasks?.[id]?.length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
|
|
50594
51344
|
const outcomes = Object.fromEntries(Object.entries(st.outcomes ?? {}).map(([key, value]) => [key, { ...value, ...key.endsWith(":4") && value.verification ? { plain: verifyPanelOutcome(value) } : { ...plainFallbackOutcome(value), plain: plainFallbackOutcome(value.plain ?? value) }, error: value.error ? "The step did not finish. Check the account before retrying." : void 0 }]));
|
|
50595
|
-
res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
|
|
51345
|
+
res.end(JSON.stringify({ ...st, assessmentFigures, scopePrefill, scopeOffered, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
|
|
50596
51346
|
const records = st.outcomes?.[`${id}:4`]?.verification;
|
|
50597
51347
|
const allowed = records ? verifyReadback(records).allowPublishing : false;
|
|
50598
51348
|
return [id, tasks2.map((t) => ({ ...t, text: verifyPublishingText(plainFallback(t.text, { nextStep: true }), allowed) })).filter((t) => t.text)];
|
|
@@ -50609,24 +51359,23 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50609
51359
|
return;
|
|
50610
51360
|
}
|
|
50611
51361
|
if (req.method === "POST" && url === "/api/queue") {
|
|
50612
|
-
readBody(req, res, 5e3, async () => {
|
|
51362
|
+
readBody(req, res, 5e3, async (body) => {
|
|
50613
51363
|
try {
|
|
50614
51364
|
const roster2 = currentRoster();
|
|
50615
51365
|
const state = readCockpitState();
|
|
50616
|
-
const
|
|
50617
|
-
|
|
50618
|
-
if (
|
|
50619
|
-
const
|
|
50620
|
-
if (
|
|
50621
|
-
const snap = await readIntakeSnapshot(c, entry.locationId);
|
|
50622
|
-
if (snap.status === "unavailable") throw new Error("The work queue could not read intake answers. Retry the queue.");
|
|
51366
|
+
const { reread, failed } = await rereadQueueIntakes({ roster: roster2, state, filter: queueScanFilter(body), read: async (locationId2) => {
|
|
51367
|
+
const c = clientFor(locationId2);
|
|
51368
|
+
if (!c) return null;
|
|
51369
|
+
const snap = await readIntakeSnapshot(c, locationId2);
|
|
51370
|
+
if (snap.status === "unavailable") return "unavailable";
|
|
50623
51371
|
const answers = normalizedIntake(snap);
|
|
50624
|
-
|
|
50625
|
-
}
|
|
50626
|
-
const latest = readCockpitState();
|
|
50627
|
-
writeCockpitState(
|
|
51372
|
+
return { answers, status: snap.status, hash: intakeInput(answers, state.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: state.accounts?.[locationId2]?.industry, agency: readOverlay() })) };
|
|
51373
|
+
} });
|
|
51374
|
+
const latest = mergeQueueIntakes(readCockpitState(), reread);
|
|
51375
|
+
if (Object.keys(reread).length) writeCockpitState(latest);
|
|
51376
|
+
const inputs = latest.intakeInputs ?? {};
|
|
50628
51377
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
50629
|
-
res.end(JSON.stringify(deriveQueue({ roster: roster2, state: { ...latest, intakeInputs: inputs, staleClients: roster2.filter((c) => stageFreshness(inputs[c.locationId]?.hash, readSavedPlan(c.locationId), latest.planApprovals?.[c.locationId], latest.outcomes?.[`${c.locationId}:3`], latest.outcomes?.[`${c.locationId}:4`], latest.outcomes?.[`${c.locationId}:1`]).length).map((c) => c.locationId) }, run: activeRun2 })));
|
|
51378
|
+
res.end(JSON.stringify({ ...deriveQueue({ roster: roster2, state: { ...latest, intakeInputs: inputs, staleClients: roster2.filter((c) => stageFreshness(inputs[c.locationId]?.hash, readSavedPlan(c.locationId), latest.planApprovals?.[c.locationId], latest.outcomes?.[`${c.locationId}:3`], latest.outcomes?.[`${c.locationId}:4`], latest.outcomes?.[`${c.locationId}:1`]).length).map((c) => c.locationId) }, run: activeRun2 }), ...failed.length ? { intakeUnread: failed } : {} }));
|
|
50630
51379
|
} catch {
|
|
50631
51380
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
50632
51381
|
res.end(JSON.stringify({ ok: false, error: "The work queue could not be read. Retry the queue." }));
|
|
@@ -50652,6 +51401,11 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50652
51401
|
let st0 = readCockpitState();
|
|
50653
51402
|
let inputHash;
|
|
50654
51403
|
let normalizedAnswers;
|
|
51404
|
+
if (stage === 3) {
|
|
51405
|
+
if (!hasChosenScope(st0, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_BEFORE_BUILD);
|
|
51406
|
+
const ap = st0.planApprovals?.[rk];
|
|
51407
|
+
if (ap && ap.scopeHash !== scopeHashOf(st0.scope?.[rk])) throw new Error(SCOPE_CHANGED_SINCE_APPROVAL);
|
|
51408
|
+
}
|
|
50655
51409
|
if (who.clientKey && [3, 4].includes(stage)) {
|
|
50656
51410
|
inputHash = secondClientInputHash(readSavedPlan(rk));
|
|
50657
51411
|
assertBuildApproval(st0.planApprovals?.[rk], inputHash);
|
|
@@ -51212,7 +51966,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51212
51966
|
}
|
|
51213
51967
|
const name = currentRoster().find((r) => r.locationId === locationId2)?.name || locationId2;
|
|
51214
51968
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
51215
|
-
res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
|
|
51969
|
+
res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2, readSavedPlan(locationId2)), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
|
|
51216
51970
|
return;
|
|
51217
51971
|
}
|
|
51218
51972
|
if (req.method === "GET" && url === "/api/review") {
|
|
@@ -51228,15 +51982,16 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51228
51982
|
const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
|
|
51229
51983
|
const rk = who.recordKey;
|
|
51230
51984
|
if (PROTECTED_LOCATIONS[who.locationId]) throw new Error(PROTECTED_LOCATIONS[who.locationId] + " can never be approved for building.");
|
|
51985
|
+
const current = readCockpitState();
|
|
51986
|
+
if (!revoke && !hasChosenScope(current, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_WHAT_THEY_BOUGHT);
|
|
51231
51987
|
if (!revoke && !readAccountRulesSafe().sandboxes[who.locationId] && String(confirmName || "").trim().toLowerCase() !== who.name.trim().toLowerCase()) {
|
|
51232
51988
|
throw new Error(`Type the client's name exactly ("${who.name}") to approve automated building.`);
|
|
51233
51989
|
}
|
|
51234
|
-
const current = readCockpitState();
|
|
51235
51990
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
51236
51991
|
const approvals = { ...current.planApprovals };
|
|
51237
51992
|
if (revoke) delete approvals[rk];
|
|
51238
51993
|
else if (who.clientKey) {
|
|
51239
|
-
approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]) };
|
|
51994
|
+
approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: scopeHashOf(current.scope?.[rk]) };
|
|
51240
51995
|
} else {
|
|
51241
51996
|
const c = clientFor(who.locationId);
|
|
51242
51997
|
if (!c) throw new Error(INTAKE_UNREADABLE);
|
|
@@ -51244,7 +51999,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51244
51999
|
if (snap.status === "unavailable") throw new Error(INTAKE_UNREADABLE);
|
|
51245
52000
|
const hash = intakeInput(normalizedIntake(snap), current.accounts?.[who.locationId]?.industry, composeQuestionSet({ industry: current.accounts?.[who.locationId]?.industry, agency: readOverlay() }));
|
|
51246
52001
|
if (current.outcomes?.[`${rk}:1`]?.inputHash && current.outcomes[`${rk}:1`].inputHash !== hash) throw new Error("The intake changed. Run Brief again before approving building.");
|
|
51247
|
-
const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]) };
|
|
52002
|
+
const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: scopeHashOf(current.scope?.[rk]) };
|
|
51248
52003
|
approvals[rk] = approval;
|
|
51249
52004
|
}
|
|
51250
52005
|
const latest = readCockpitState();
|
|
@@ -51609,9 +52364,23 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51609
52364
|
const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
|
|
51610
52365
|
if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
|
|
51611
52366
|
const next = features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) };
|
|
51612
|
-
|
|
52367
|
+
if (Array.isArray(features)) {
|
|
52368
|
+
const ticked = features.map(String).filter(isFeatureId);
|
|
52369
|
+
const offered = offeredFeaturesFor(readCockpitState(), who.recordKey);
|
|
52370
|
+
if (ticked.length && !ticked.some((f) => offered.includes(f))) throw new Error(NOTHING_TICKED_IN_PLAN);
|
|
52371
|
+
}
|
|
52372
|
+
const rk = who.recordKey;
|
|
52373
|
+
let nextState = setClientScope(readCockpitState(), rk, next);
|
|
52374
|
+
const ap = nextState.planApprovals?.[rk];
|
|
52375
|
+
const withdrawn = Boolean(ap && ap.scopeHash !== scopeHashOf(nextState.scope?.[rk]));
|
|
52376
|
+
if (withdrawn) {
|
|
52377
|
+
const approvals = { ...nextState.planApprovals };
|
|
52378
|
+
delete approvals[rk];
|
|
52379
|
+
nextState = { ...setBuildApproval(nextState, rk, null), planApprovals: approvals };
|
|
52380
|
+
}
|
|
52381
|
+
writeCockpitState(nextState);
|
|
51613
52382
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
51614
|
-
res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[
|
|
52383
|
+
res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[rk] ?? null, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_SCOPE } : {} }));
|
|
51615
52384
|
} catch (e) {
|
|
51616
52385
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
51617
52386
|
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
|
|
@@ -51748,7 +52517,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51748
52517
|
process.on("SIGTERM", stop2);
|
|
51749
52518
|
});
|
|
51750
52519
|
}
|
|
51751
|
-
var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
|
|
52520
|
+
var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, SCOPE_REFUSAL, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
|
|
51752
52521
|
var init_dashboard = __esm({
|
|
51753
52522
|
"src/dashboard.ts"() {
|
|
51754
52523
|
"use strict";
|
|
@@ -51870,6 +52639,12 @@ var init_dashboard = __esm({
|
|
|
51870
52639
|
];
|
|
51871
52640
|
ACCOUNT_TYPES = ["client", "sandbox", "template", "hidden"];
|
|
51872
52641
|
SECOND_CLIENT_NO_INTAKE = "This sub-account's intake form belongs to its default client, so a second client's intake and Brief are not run from here. Prepare this client's plan with its clinic prefix (the build preview), then approve and build it here.";
|
|
52642
|
+
CHOOSE_WHAT_THEY_BOUGHT = "Choose what the client bought before approving: Blueprint Review, then What they bought, then Save.";
|
|
52643
|
+
CHOOSE_BEFORE_BUILD = "Choose what the client bought before building: Blueprint Review, then What they bought, then Save.";
|
|
52644
|
+
SCOPE_REFUSAL = /Nobody has chosen what|what this client bought|features (are )?selected for this client|No features are selected|features nobody ticked/i;
|
|
52645
|
+
NOTHING_TICKED_IN_PLAN = "None of the ticked features is in the plan for this client's industry, so nothing was saved. Tick features from the list, or choose Everything in the plan.";
|
|
52646
|
+
SCOPE_CHANGED_SINCE_APPROVAL = "What they bought changed after the build was approved, so Build did not start. Approve this plan again, then build.";
|
|
52647
|
+
APPROVAL_WITHDRAWN_FOR_SCOPE = "What they bought changed, so the build approval was withdrawn. Approve this plan again before building.";
|
|
51873
52648
|
activeRun2 = null;
|
|
51874
52649
|
handoffEmailPreviews = /* @__PURE__ */ new Map();
|
|
51875
52650
|
UPGRADE_MSG = "\n The cockpit is not included in this licence, so it did not start.\n Nothing was changed.\n\n";
|
|
@@ -52308,7 +53083,9 @@ WARNING: saved under ${canonicalCompanyId}, but switch_location resolves ${args.
|
|
|
52308
53083
|
locationId: args.test_location_id,
|
|
52309
53084
|
userId: foundViaRealPath.userId,
|
|
52310
53085
|
companyId: resolvedCompanyId,
|
|
52311
|
-
registry
|
|
53086
|
+
// The registry, so a token rotated by this probe is saved to the
|
|
53087
|
+
// company's own slot instead of being lost (review, 2026-09-29).
|
|
53088
|
+
registry: registry2
|
|
52312
53089
|
});
|
|
52313
53090
|
try {
|
|
52314
53091
|
await probe.listWorkflows(1, 0);
|
|
@@ -54927,7 +55704,7 @@ async function collectInstallChecks(installedVersion, client, builderClient, reg
|
|
|
54927
55704
|
}
|
|
54928
55705
|
if (result.ok) {
|
|
54929
55706
|
const idNote = tokenCompany ? ` Authenticated as company ${tokenCompany}.` : "";
|
|
54930
|
-
return { name: "Firebase auth (workflow builder)", status: "pass", detail: `ID token
|
|
55707
|
+
return { name: "Firebase auth (workflow builder)", status: "pass", detail: `Got a valid ID token. Workflow builder tools are usable.${companyNote}${idNote}` };
|
|
54931
55708
|
}
|
|
54932
55709
|
return { name: "Firebase auth (workflow builder)", status: "fail", detail: `Token refresh failed: ${result.error}.${companyNote} If you're in a client's account, register its Firebase with register_company_firebase. Otherwise re-capture your Firebase values from GHL DevTools and re-run enable_workflow_builder.` };
|
|
54933
55710
|
})();
|
|
@@ -58120,7 +58897,7 @@ function extractHtml(text2) {
|
|
|
58120
58897
|
function claudeGenerate(model = DEFAULT_MODEL, bin = "claude") {
|
|
58121
58898
|
return (instruction, { cwd }) => new Promise((resolveP) => {
|
|
58122
58899
|
const t0 = Date.now();
|
|
58123
|
-
const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model]);
|
|
58900
|
+
const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model, ...NO_MEMBER_HOOKS]);
|
|
58124
58901
|
const child = (0, import_node_child_process6.spawn)(cmd.command, cmd.args, {
|
|
58125
58902
|
cwd,
|
|
58126
58903
|
env: { ...process.env, CLAUDECODE: "", CLAUDE_CODE_ENTRYPOINT: "" },
|
|
@@ -58281,6 +59058,7 @@ var init_run2 = __esm({
|
|
|
58281
59058
|
"use strict";
|
|
58282
59059
|
import_node_child_process6 = require("node:child_process");
|
|
58283
59060
|
init_claude_locate();
|
|
59061
|
+
init_result_text();
|
|
58284
59062
|
import_node_fs12 = require("node:fs");
|
|
58285
59063
|
import_node_path11 = require("node:path");
|
|
58286
59064
|
init_brief();
|
|
@@ -61494,7 +62272,7 @@ function registerSecretShopperTools(server2, client, registry2) {
|
|
|
61494
62272
|
if (registry2) {
|
|
61495
62273
|
try {
|
|
61496
62274
|
const b = workflowReadConnection(registry2, client);
|
|
61497
|
-
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
|
|
62275
|
+
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
|
|
61498
62276
|
} catch {
|
|
61499
62277
|
}
|
|
61500
62278
|
}
|
|
@@ -65893,6 +66671,18 @@ async function readLocationName(client, locationId2) {
|
|
|
65893
66671
|
return locationId2;
|
|
65894
66672
|
}
|
|
65895
66673
|
}
|
|
66674
|
+
function cockpitSelectionRequired(board, locationId2, recordKey, env2 = process.env) {
|
|
66675
|
+
if (env2.GHL_COMMAND_STAGE) return true;
|
|
66676
|
+
const has = (field, key) => {
|
|
66677
|
+
const m = board[field];
|
|
66678
|
+
return Boolean(m && typeof m === "object" && m[key]);
|
|
66679
|
+
};
|
|
66680
|
+
const own = ["clients", "planApprovals", "buildApproved", "assessmentPurchases", "scope", "reviews", "intakeInputs", "assessmentAttachments", "clientProfiles"].some((f) => has(f, recordKey));
|
|
66681
|
+
return own || recordKey === locationId2 && has("accounts", locationId2);
|
|
66682
|
+
}
|
|
66683
|
+
function noSelectionMessage(clientKey) {
|
|
66684
|
+
return clientKey ? secondClientChooseMessage(clientKey) : NO_SELECTION_SCOPE;
|
|
66685
|
+
}
|
|
65896
66686
|
function resolvePlanSource(input) {
|
|
65897
66687
|
const { plan, useSavedPlan, replaceSavedPlan, saved } = input;
|
|
65898
66688
|
if (useSavedPlan) {
|
|
@@ -66027,9 +66817,10 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66027
66817
|
if (!briefArg) {
|
|
66028
66818
|
return jsonResponse({ ok: false, phase: "compose", error: "fromPreset needs the brief \u2014 pass the \xA74 Brief from normalize_submission_to_brief (or assembled from the cockpit intake answers)." });
|
|
66029
66819
|
}
|
|
66030
|
-
|
|
66031
|
-
let
|
|
66032
|
-
|
|
66820
|
+
const briefIn = process.env.GHL_COMMAND_STAGE && briefArg.scope ? (({ scope: _ignored, ...rest }) => rest)(briefArg) : briefArg;
|
|
66821
|
+
let briefForCompose = briefIn;
|
|
66822
|
+
let scopeSource = briefIn.scope ? "brief" : "none";
|
|
66823
|
+
const briefScope = briefIn.scope;
|
|
66033
66824
|
if (briefScope && Array.isArray(briefScope.features) && briefScope.features.length === 0) {
|
|
66034
66825
|
return jsonResponse({
|
|
66035
66826
|
ok: false,
|
|
@@ -66038,13 +66829,15 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66038
66829
|
error: "The brief's scope names no features, so nothing was built. Choose the features to build (prefilled from what they bought), confirm, and run again."
|
|
66039
66830
|
});
|
|
66040
66831
|
}
|
|
66041
|
-
if (!
|
|
66832
|
+
if (!briefIn.scope) {
|
|
66042
66833
|
const board = readBoardState();
|
|
66043
66834
|
const saved2 = board.scope?.[recordKey];
|
|
66044
66835
|
if (saved2?.wholePreset) {
|
|
66045
66836
|
scopeSource = "cockpit";
|
|
66046
66837
|
} else if (!saved2 && Array.isArray(board.assessmentPurchases?.[recordKey])) {
|
|
66047
66838
|
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
|
|
66839
|
+
} else if (!saved2 && cockpitSelectionRequired(board, activeLocation, recordKey)) {
|
|
66840
|
+
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
|
|
66048
66841
|
} else if (saved2 && Array.isArray(saved2.features)) {
|
|
66049
66842
|
const features = saved2.features.filter(isFeatureId);
|
|
66050
66843
|
if (!features.length) {
|
|
@@ -66055,7 +66848,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66055
66848
|
error: "No features are selected for this client, so nothing was built. Open the client's Review screen, tick the features to build (it is prefilled from what they bought), confirm, and run the build again."
|
|
66056
66849
|
});
|
|
66057
66850
|
}
|
|
66058
|
-
briefForCompose = { ...
|
|
66851
|
+
briefForCompose = { ...briefIn, scope: { ...saved2.package ? { package: saved2.package } : {}, features } };
|
|
66059
66852
|
scopeSource = "cockpit";
|
|
66060
66853
|
}
|
|
66061
66854
|
}
|
|
@@ -66108,6 +66901,30 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66108
66901
|
if (!current && Array.isArray(board.assessmentPurchases?.[recordKey])) {
|
|
66109
66902
|
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
|
|
66110
66903
|
}
|
|
66904
|
+
if (!current && cockpitSelectionRequired(board, activeLocation, recordKey)) {
|
|
66905
|
+
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
|
|
66906
|
+
}
|
|
66907
|
+
if (current && !current.wholePreset && Array.isArray(current.features) && !(saved && planSource !== "argument-replaces-saved")) {
|
|
66908
|
+
const chosen = new Set(current.features.filter(isFeatureId));
|
|
66909
|
+
const outside = [];
|
|
66910
|
+
for (const w of plan.workflows ?? []) {
|
|
66911
|
+
let f = null;
|
|
66912
|
+
try {
|
|
66913
|
+
f = featureOfWorkflow(String(w.ref ?? ""), w.feature);
|
|
66914
|
+
} catch {
|
|
66915
|
+
f = String(w.feature);
|
|
66916
|
+
}
|
|
66917
|
+
if (f !== null && !chosen.has(f)) outside.push(`${String(w.name ?? w.ref)} (${isFeatureId(f) ? FEATURES[f].name : f})`);
|
|
66918
|
+
}
|
|
66919
|
+
if (chosen.size && outside.length) {
|
|
66920
|
+
return jsonResponse({
|
|
66921
|
+
ok: false,
|
|
66922
|
+
phase: "scope",
|
|
66923
|
+
scopeSource: "cockpit",
|
|
66924
|
+
error: `This plan builds workflows for features nobody ticked for this client, so nothing was built: ${outside.join(", ")}. Build from the preset (fromPreset), which follows what they bought, or tick those features and save first.`
|
|
66925
|
+
});
|
|
66926
|
+
}
|
|
66927
|
+
}
|
|
66111
66928
|
if (current && !current.wholePreset && Array.isArray(current.features) && !current.features.some(isFeatureId)) {
|
|
66112
66929
|
return jsonResponse({
|
|
66113
66930
|
ok: false,
|
|
@@ -66116,7 +66933,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66116
66933
|
error: "No features are selected for this client, so nothing was built, not even from the saved plan. Open the client's Review screen, tick the features to build, confirm, and run the build again."
|
|
66117
66934
|
});
|
|
66118
66935
|
}
|
|
66119
|
-
if (saved && planSource !== "argument-replaces-saved" && !savedPlanMatchesScope(saved, current)) {
|
|
66936
|
+
if (saved && planSource !== "argument-replaces-saved" && (current ? !savedPlanMatchesScope(saved, current) : Boolean(saved.scope))) {
|
|
66120
66937
|
const planned = saved?.scope ? [...saved.scope.built, ...(saved.scope.omitted ?? []).map((f) => f.id)].join(", ") || "no features" : "everything in the preset";
|
|
66121
66938
|
const now = current?.wholePreset ? "everything in the preset (confirmed)" : current ? current.features.filter(isFeatureId).join(", ") : "everything in the preset (nothing is saved)";
|
|
66122
66939
|
return jsonResponse({
|
|
@@ -66896,7 +67713,7 @@ function intakeFormMatches(expected, actual) {
|
|
|
66896
67713
|
const keys = ["id", "tag", "type", "hiddenFieldQueryKey", "required", "label", "options", "picklistOptions"];
|
|
66897
67714
|
return expected.every((field, i) => keys.every((key) => field[key] === void 0 || JSON.stringify(field[key]) === JSON.stringify(actual[i]?.[key])));
|
|
66898
67715
|
}
|
|
66899
|
-
var import_zod93, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
|
|
67716
|
+
var import_zod93, customFieldItemSchema, sleep6, NO_SELECTION_SCOPE, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
|
|
66900
67717
|
var init_intake_to_build = __esm({
|
|
66901
67718
|
"src/tools/intake-to-build.ts"() {
|
|
66902
67719
|
"use strict";
|
|
@@ -66948,7 +67765,8 @@ var init_intake_to_build = __esm({
|
|
|
66948
67765
|
picklistOptions: import_zod93.z.array(import_zod93.z.string()).optional()
|
|
66949
67766
|
}).passthrough();
|
|
66950
67767
|
sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
66951
|
-
|
|
67768
|
+
NO_SELECTION_SCOPE = "Nobody has chosen what this client bought, so nothing was built. In the cockpit open Blueprint Review, then What they bought: tick what they bought, or choose Everything in the plan, then save and run the build again.";
|
|
67769
|
+
NOT_CONFIRMED_SCOPE = "What this client bought is recorded, but nobody has confirmed what to build, so nothing was built. In the cockpit open Blueprint Review, then What they bought: the features are ticked from what they bought. Change them if you need to, save to confirm, and run the build again.";
|
|
66952
67770
|
WHOLE_PRESET_NOT_KEPT = "Note: an earlier build on this client used everything in the preset, and that choice was not kept, so the Review screen ticks only what they bought. Tick everything again if that is still what you want.";
|
|
66953
67771
|
workflowInventorySchema = import_zod93.z.object({
|
|
66954
67772
|
workflows: import_zod93.z.array(import_zod93.z.object({ id: import_zod93.z.string().min(1), name: import_zod93.z.string() }).passthrough())
|