@elitedcs/ghl-mcp 3.70.0 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +8 -5
  3. package/dist/capture-helper.js +10 -1
  4. package/dist/index.js +7479 -1552
  5. package/guide/guide.html +2 -1
  6. package/package.json +2 -2
  7. package/skills/blueprint/SKILL.md +2 -0
  8. package/skills/blueprint/examples/medspa-approval-view.md +58 -53
  9. package/skills/blueprint/examples/medspa-brief.json +70 -8
  10. package/skills/blueprint/examples/medspa-build-plan.json +1435 -123
  11. package/skills/blueprint/examples/medspa-dry-run-report.md +2 -0
  12. package/skills/blueprint/examples/sample-approval-view.md +20 -61
  13. package/skills/blueprint/examples/sample-brief.json +95 -7
  14. package/skills/blueprint/examples/sample-build-plan.json +1448 -119
  15. package/skills/blueprint/examples/validate-plan.cjs +195 -13
  16. package/skills/blueprint/presets/clinic-launch-a2p.preset.json +1 -0
  17. package/skills/blueprint/presets/clinic.md +60 -0
  18. package/skills/blueprint/presets/clinic.preset.json +1737 -0
  19. package/skills/blueprint/presets/coach.md +58 -0
  20. package/skills/blueprint/presets/coach.preset.json +1723 -0
  21. package/skills/blueprint/presets/ecommerce.md +54 -0
  22. package/skills/blueprint/presets/ecommerce.preset.json +1287 -0
  23. package/skills/blueprint/presets/generic-client.md +49 -27
  24. package/skills/blueprint/presets/generic-client.preset.json +1552 -122
  25. package/skills/blueprint/presets/local-service.md +58 -0
  26. package/skills/blueprint/presets/local-service.preset.json +1733 -0
  27. package/skills/blueprint/presets/med-spa.md +47 -48
  28. package/skills/blueprint/presets/med-spa.preset.json +1557 -111
  29. package/skills/blueprint/references/brief-schema.md +48 -1
  30. package/skills/blueprint/references/build-plan-schema.md +66 -5
  31. package/skills/blueprint/references/copy-guide.md +167 -0
  32. package/skills/blueprint/references/intake-question-set.md +64 -3
  33. package/skills/blueprint/references/preset-format.md +97 -51
  34. package/templates/action-schemas.json +12 -0
@@ -1,21 +1,58 @@
1
1
  #!/usr/bin/env node
2
- // Validates an Intake-to-Build §5 build plan against the contract rules.
3
- // Not the MCP's authoritative Zod validator (that lives in the mcp repo) — this is
4
- // atlas's self-check that the skill's output is internally consistent + schema-shaped:
5
- // every ref resolves, no dangling pointers, GHL-correct enums, workflow sanity.
6
- // Usage: node validate-plan.cjs [plan.json]
2
+ // Validates an Intake-to-Build §5 build plan — or a preset skeleton — against the contract rules.
3
+ // Not the MCP's authoritative Zod validator (src/intake-to-build/plan.ts) — this is the skill's
4
+ // self-check that its output is internally consistent + schema-shaped: every ref resolves, no
5
+ // dangling pointers, GHL-correct enums, workflow sanity, and the Tier 1 v2 rules (a nurture spans
6
+ // >= 30 days, a speed-to-lead ENDS with a hand-off, every person-step carries a userRef, every
7
+ // message lives once as a template with full copy).
8
+ //
9
+ // Usage: node validate-plan.cjs [plan.json | preset.json]
10
+ // A file with a top-level `skeleton` is a PRESET: it is validated as the maximal plan (every
11
+ // conditionalOn taken as true, every fillFrom resolved to its default, the role placeholder
12
+ // user.owner accepted). Everything else is validated as a plan.
7
13
 
8
14
  const fs = require('fs');
9
15
  const path = require('path');
10
16
 
11
- const planPath = process.argv[2] || path.join(__dirname, 'sample-build-plan.json');
12
- const plan = JSON.parse(fs.readFileSync(planPath, 'utf8'));
17
+ const filePath = process.argv[2] || path.join(__dirname, 'sample-build-plan.json');
18
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
19
+ const isPreset = raw && typeof raw === 'object' && raw.skeleton && typeof raw.skeleton === 'object';
13
20
 
14
21
  const errors = [];
15
22
  const warns = [];
16
23
  const E = (m) => errors.push(m);
17
24
  const W = (m) => warns.push(m);
18
25
 
26
+ // ---- preset → maximal plan ----
27
+ function resolvePreset(preset) {
28
+ const sk = JSON.parse(JSON.stringify(preset.skeleton));
29
+ const resolveFill = (v) => (v && !Array.isArray(v) && typeof v === 'object' && v.fillFrom) ? (v.default || []) : v;
30
+ if (sk.users) sk.users = resolveFill(sk.users);
31
+ (sk.pipelines || []).forEach((p) => {
32
+ p.stages = resolveFill(p.stages).map((s, i) => ({ ...s, position: s.position == null ? i : s.position }));
33
+ });
34
+ const strip = (v) => {
35
+ if (Array.isArray(v)) return v.map(strip);
36
+ if (v && typeof v === 'object') {
37
+ const o = {};
38
+ for (const [k, val] of Object.entries(v)) { if (k === 'conditionalOn' || k.startsWith('_')) continue; o[k] = strip(val); }
39
+ return o;
40
+ }
41
+ return v;
42
+ };
43
+ const plan = strip(sk);
44
+ plan.schemaVersion = preset.schemaVersion;
45
+ plan.planId = `preset:${preset.presetId}`;
46
+ plan.briefId = 'preset';
47
+ plan.preset = preset.presetId;
48
+ plan.summary = preset.description;
49
+ plan.idMap = {};
50
+ if (!preset.presetVersion) E('preset: missing presetVersion');
51
+ if (!preset.selectors) E('preset: missing selectors');
52
+ return plan;
53
+ }
54
+ const plan = isPreset ? resolvePreset(raw) : raw;
55
+
19
56
  const DATA_TYPES = new Set(['TEXT', 'LARGE_TEXT', 'NUMERICAL', 'PHONE', 'MONETORY', 'CHECKBOX', 'SINGLE_OPTIONS', 'MULTIPLE_OPTIONS', 'FLOAT', 'DATE', 'TEXTBOX_LIST', 'FILE_UPLOAD', 'SIGNATURE']);
20
57
  const CAL_TYPES = new Set(['round_robin', 'event', 'class_booking', 'collective', 'service_booking']);
21
58
  const OWNERS = new Set(['OPERATOR-UI', 'OPERATOR-EXT', 'TEAM', 'JERRY-UI', 'JERRY-EXT']); // canonical (v3.44+) + legacy accepted
@@ -25,6 +62,8 @@ const MODELS = new Set(['contact', 'opportunity']);
25
62
  const refs = new Set();
26
63
  const addRef = (r) => { if (refs.has(r)) E(`duplicate ref: ${r}`); refs.add(r); };
27
64
 
65
+ (plan.users || []).forEach(u => addRef(u.ref)); // v2
66
+ if (isPreset) refs.add('user.owner'); // role placeholder, resolved per step at fill time
28
67
  (plan.pipelines || []).forEach(p => { addRef(p.ref); (p.stages || []).forEach(s => addRef(s.ref)); });
29
68
  (plan.customFields || []).forEach(f => addRef(f.ref));
30
69
  (plan.tags || []).forEach(t => addRef(t.ref));
@@ -34,10 +73,15 @@ const addRef = (r) => { if (refs.has(r)) E(`duplicate ref: ${r}`); refs.add(r);
34
73
  (plan.funnels || []).forEach(f => { addRef(f.ref); (f.pages || []).forEach(pg => addRef(pg.ref)); });
35
74
  (plan.emails || []).forEach(e => addRef(e.ref));
36
75
  (plan.sms || []).forEach(s => addRef(s.ref));
76
+ ((plan.templates || {}).emails || []).forEach(t => addRef(t.ref)); // v2
77
+ ((plan.templates || {}).sms || []).forEach(t => addRef(t.ref)); // v2
37
78
  (plan.workflows || []).forEach(w => addRef(w.ref));
38
79
  (plan.handoffs || []).forEach(h => addRef(h.ref));
39
80
 
40
81
  const need = (r, where) => { if (r == null) return; if (!refs.has(r)) E(`dangling ref ${r} (in ${where})`); };
82
+ // v2: a userRef may be the pending sentinel ("no staff yet — build it, report it as waiting").
83
+ const USER_PENDING = 'user.__pending__';
84
+ const needUser = (r, where) => { if (r == null) return; if (r === USER_PENDING) { W(`${where}: ${USER_PENDING} — built, reported as waiting for a staff member`); return; } need(r, where); };
41
85
 
42
86
  // ---- top level ----
43
87
  if (plan.schemaVersion !== '0.1') W(`schemaVersion is ${plan.schemaVersion}, expected 0.1`);
@@ -48,11 +92,24 @@ if (plan.idMap == null || Object.keys(plan.idMap).length) W('idMap should be pre
48
92
  (plan.customFields || []).forEach(f => {
49
93
  if (!DATA_TYPES.has(f.dataType)) E(`customField ${f.ref}: bad dataType "${f.dataType}"`);
50
94
  if (f.model && !MODELS.has(f.model)) E(`customField ${f.ref}: bad model "${f.model}"`);
95
+ if (['SINGLE_OPTIONS', 'MULTIPLE_OPTIONS', 'CHECKBOX'].includes(f.dataType) && !(f.options && f.options.length)) W(`customField ${f.ref}: ${f.dataType} with no options`);
96
+ });
97
+
98
+ // ---- users (v2) ----
99
+ const emailsSeen = new Set();
100
+ (plan.users || []).forEach(u => {
101
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(u.email || '')) E(`user ${u.ref}: email "${u.email}" is not an address`);
102
+ if (!['admin', 'user'].includes(u.role)) E(`user ${u.ref}: bad role "${u.role}"`);
103
+ if (!u.firstName || !u.lastName) E(`user ${u.ref}: firstName and lastName are required`);
104
+ const k = String(u.email || '').toLowerCase(); if (emailsSeen.has(k)) E(`user ${u.ref}: duplicate email ${u.email}`); emailsSeen.add(k);
51
105
  });
52
106
 
53
107
  // ---- calendars ----
54
108
  (plan.calendars || []).forEach(c => {
55
109
  if (!CAL_TYPES.has(c.calendarType)) E(`calendar ${c.ref}: bad calendarType "${c.calendarType}"`);
110
+ (c.teamMemberRefs || []).forEach(r => need(r, `calendar ${c.ref}.teamMemberRefs`)); // v2
111
+ const needsStaff = c.requiresStaff === true || c.calendarType === 'round_robin' || c.calendarType === 'collective';
112
+ if ((plan.users || []).length && needsStaff && !(c.teamMemberRefs && c.teamMemberRefs.length)) W(`calendar ${c.ref}: needs staff but lists no teamMemberRefs`);
56
113
  });
57
114
 
58
115
  // ---- custom values ----
@@ -72,30 +129,136 @@ if (plan.idMap == null || Object.keys(plan.idMap).length) W('idMap should be pre
72
129
  (f.pages || []).forEach(pg => { need(pg.formRef, `page ${pg.ref}.formRef`); need(pg.calendarRef, `page ${pg.ref}.calendarRef`); });
73
130
  });
74
131
 
132
+ // ---- templates (v2): every message lives once, with FULL copy ----
133
+ const tplEmails = (plan.templates || {}).emails || [];
134
+ const tplSms = (plan.templates || {}).sms || [];
135
+ const smsEstimate = (body) => body
136
+ .replace(/\{\{contact\.first_name\}\}/g, 'Jennifer').replace(/\{\{custom_values\.business_name\}\}/g, 'Glow Aesthetics Med Spa')
137
+ .replace(/\{\{custom_values\.owner_first_name\}\}/g, 'Dana').replace(/\{\{custom_values\.booking_link\}\}/g, 'https://link.example/abcdefghijk')
138
+ .replace(/\{\{custom_values\.store_link\}\}/g, 'https://shop.example/abc').replace(/\{\{custom_values\.business_phone\}\}/g, '(555) 555-0100')
139
+ .replace(/\{\{appointment\.start_time\}\}/g, 'Tue Sep 2, 10:30 AM').replace(/\{\{[^}]+\|\|\s*([^}]+)\}\}/g, '$1').replace(/\{\{[^}]+\}\}/g, 'XXXXXXXX');
140
+ tplEmails.forEach(t => {
141
+ if (!t.ref.startsWith('email_template.')) E(`template ${t.ref}: email templates use the email_template.* namespace`);
142
+ if (!t.name) E(`template ${t.ref}: missing name`);
143
+ if (!t.subject || !t.subject.trim()) E(`template ${t.ref}: missing subject`);
144
+ else if (t.subject.replace(/\{\{[^}]+\}\}/g, 'Jennifer').length > 60) W(`template ${t.ref}: subject over 60 chars (copy-guide: 45 or fewer)`);
145
+ if (!t.html || !t.html.trim()) E(`template ${t.ref}: html is empty (a template is FULL copy, never an outline)`);
146
+ else if (!/^\s*<[a-z]/i.test(t.html)) E(`template ${t.ref}: html must be HTML (starts with a tag)`);
147
+ if (t.html && !/\{\{contact\.first_name\}\}/.test(t.html)) W(`template ${t.ref}: no {{contact.first_name}} — copy-guide asks every message to address one person`);
148
+ });
149
+ tplSms.forEach(t => {
150
+ if (!t.ref.startsWith('sms_template.')) E(`template ${t.ref}: SMS templates use the sms_template.* namespace`);
151
+ if (!t.name) E(`template ${t.ref}: missing name`);
152
+ if (!t.body || !t.body.trim()) E(`template ${t.ref}: body is empty (a template is FULL copy, never an outline)`);
153
+ else {
154
+ const n = smsEstimate(t.body).length;
155
+ if (n > 320) E(`template ${t.ref}: ~${n} chars rendered — SMS hard cap is 320 (two segments)`);
156
+ else if (n > 250) W(`template ${t.ref}: ~${n} chars rendered — over the 160-char single segment; keep under 250 where you can`);
157
+ }
158
+ });
159
+ const nameDup = (list, label) => { const seen = new Map(); list.forEach(o => { const k = String(o.name || '').trim().toLowerCase(); if (seen.has(k)) E(`${label} "${o.ref}" and "${seen.get(k)}" share the name "${o.name}"`); seen.set(k, o.ref); }); };
160
+ nameDup(tplEmails, 'templates.emails'); nameDup(tplSms, 'templates.sms'); nameDup(plan.workflows || [], 'workflows');
161
+
162
+ // ---- email / sms assets: templateRef resolves to the right kind; a sent asset has a body or a template ----
163
+ (plan.emails || []).forEach(e => {
164
+ if (e.templateRef) { need(e.templateRef, `email ${e.ref}.templateRef`); if (!e.templateRef.startsWith('email_template.')) E(`email ${e.ref}: templateRef must be an email_template.*`); }
165
+ if (!isPreset && !e.body && !e.templateRef) W(`email ${e.ref}: outline only (no body, no templateRef) — will report needsContent if a workflow sends it`);
166
+ });
167
+ (plan.sms || []).forEach(s => {
168
+ if (s.templateRef) { need(s.templateRef, `sms ${s.ref}.templateRef`); if (!s.templateRef.startsWith('sms_template.')) E(`sms ${s.ref}: templateRef must be an sms_template.*`); }
169
+ if (!isPreset && !s.body && !s.templateRef) W(`sms ${s.ref}: outline only (no body, no templateRef) — will report needsContent if a workflow sends it`);
170
+ });
171
+
75
172
  // ---- workflows ----
76
173
  const stageRefs = new Set();
77
174
  (plan.pipelines || []).forEach(p => (p.stages || []).forEach(s => stageRefs.add(s.ref)));
175
+ const days = (acts) => (acts || []).reduce((d, a) => a.type === 'wait' ? d + a.value * ({ minutes: 1 / 1440, hours: 1 / 24, days: 1 }[a.unit] || 0) : d, 0);
176
+ const spanDays = (w) => { let d = days(w.actions); (w.actions || []).forEach(a => { if (a.type === 'find_opportunity') d += Math.max(days(a.found), days(a.notFound)); }); return d; };
177
+ const nodeCount = (w) => (w.actions || []).reduce((n, a) => n + (a.type === 'find_opportunity' ? 3 + (a.found || []).length + (a.notFound || []).length : 1), 0);
178
+ const nurtureTags = new Set((plan.workflows || []).filter(x => /nurture/i.test(x.name || '') && x.trigger && x.trigger.tagRef).map(x => x.trigger.tagRef));
78
179
  (plan.workflows || []).forEach(w => {
79
180
  if (!w.trigger || !w.trigger.type) E(`workflow ${w.ref}: missing trigger`);
80
- if (w.trigger) { need(w.trigger.formRef, `wf ${w.ref} trigger.formRef`); need(w.trigger.tagRef, `wf ${w.ref} trigger.tagRef`); need(w.trigger.calendarRef, `wf ${w.ref} trigger.calendarRef`); }
181
+ if (w.trigger) {
182
+ need(w.trigger.formRef, `wf ${w.ref} trigger.formRef`); need(w.trigger.tagRef, `wf ${w.ref} trigger.tagRef`); need(w.trigger.calendarRef, `wf ${w.ref} trigger.calendarRef`);
183
+ need(w.trigger.pipelineRef, `wf ${w.ref} trigger.pipelineRef`); need(w.trigger.hasTagRef, `wf ${w.ref} trigger.hasTagRef`);
184
+ if (w.trigger.stageRef && !stageRefs.has(w.trigger.stageRef)) E(`wf ${w.ref} trigger.stageRef ${w.trigger.stageRef} not a defined stage`);
185
+ if (w.trigger.type === 'call_status' && !(w.trigger.callStatuses && w.trigger.callStatuses.length)) E(`workflow ${w.ref}: a call_status trigger needs callStatuses (else it fires on EVERY call)`);
186
+ if (['tag_added', 'appointment_status', 'appointment_booked'].includes(w.trigger.type)) E(`workflow ${w.ref}: trigger type "${w.trigger.type}" is not native — use contact_tag / appointment`);
187
+ }
81
188
  if (!Array.isArray(w.actions) || w.actions.length === 0) E(`workflow ${w.ref}: no actions`);
82
189
  if ((w.actions || []).length > 40) E(`workflow ${w.ref}: >40 actions (split required)`);
83
- (w.actions || []).forEach((a, i) => {
84
- const at = `wf ${w.ref} action[${i}] ${a.type}`;
85
- need(a.tagRef, `${at}.tagRef`); need(a.emailRef, `${at}.emailRef`); need(a.smsRef, `${at}.smsRef`);
190
+ if (nodeCount(w) > 40) E(`workflow ${w.ref}: expands to ${nodeCount(w)} nodes (> 40)`);
191
+ const checkAction = (a, at) => {
192
+ need(a.tagRef, `${at}.tagRef`); need(a.emailRef, `${at}.emailRef`); need(a.smsRef, `${at}.smsRef`); need(a.fieldRef, `${at}.fieldRef`);
86
193
  need(a.pipelineRef, `${at}.pipelineRef`); need(a.workflowRef, `${at}.workflowRef`);
194
+ need(a.templateRef, `${at}.templateRef`); needUser(a.userRef, `${at}.userRef`); // v2
195
+ if (a.type === 'send_email' && !a.emailRef && !a.templateRef) E(`${at}: needs emailRef or templateRef`);
196
+ if (a.type === 'send_sms' && !a.smsRef && !a.templateRef) E(`${at}: needs smsRef or templateRef`);
197
+ if (a.type === 'internal_notification' && !a.userRef && !a.to) E(`E_NO_USER_REF: ${at}: needs userRef (or user.__pending__)`);
198
+ if (a.type === 'internal_notification' && !a.userRef && a.to) W(`${at}: literal "to" instead of a userRef`);
199
+ if (a.type === 'task_notification' && !a.userRef) W(`${at}: task has no userRef — nobody owns it`);
200
+ if (a.type === 'assign_user' && !a.userRef) E(`${at}: needs userRef`);
87
201
  if (a.stageRef && !stageRefs.has(a.stageRef)) E(`${at}: stageRef ${a.stageRef} not a defined stage`);
202
+ if (a.type === 'wait' && !(a.value > 0 && ['minutes', 'hours', 'days'].includes(a.unit))) E(`${at}: wait needs value > 0 and unit minutes|hours|days`);
203
+ if (a.type === 'wait_appointment' && (!w.trigger || w.trigger.type !== 'appointment')) W(`${at}: wait_appointment outside an appointment-triggered workflow`);
204
+ };
205
+ let seenFind = false;
206
+ (w.actions || []).forEach((a, i) => {
207
+ const at = `wf ${w.ref} action[${i}] ${a.type}`;
208
+ checkAction(a, at);
209
+ if (a.type === 'find_opportunity') {
210
+ seenFind = true;
211
+ if (i !== w.actions.length - 1) E(`${at}: find_opportunity must be the LAST action`);
212
+ (a.found || []).forEach((c, ci) => checkAction(c, `${at}.found[${ci}] ${c.type}`));
213
+ (a.notFound || []).forEach((c, ci) => checkAction(c, `${at}.notFound[${ci}] ${c.type}`));
214
+ } else if (a.type === 'update_opportunity' && !seenFind) {
215
+ W(`${at}: update_opportunity with no find_opportunity before it silently does nothing`);
216
+ }
88
217
  });
218
+ // v2 rules (mirror validateBuildPlan): a nurture spans >= 30 days; a speed-to-lead ENDS with a hand-off.
219
+ if (/nurture/i.test(w.name || '')) {
220
+ const d = spanDays(w);
221
+ if (d < 30) E(`E_NURTURE_TOO_SHORT: workflow ${w.ref} spans ${d} days of waits (needs >= 30)`);
222
+ }
223
+ if (/win.?back/i.test(w.name || '')) {
224
+ const d = spanDays(w);
225
+ if (d < 30) E(`E_WINBACK_TOO_SHORT: workflow ${w.ref} spans ${d} days of waits (owner rule: win-back is 30+ days)`);
226
+ }
227
+ if (/speed/i.test(w.name || '')) {
228
+ const handoff = (a) => !!a && (a.type === 'add_to_workflow' || (a.type === 'add_contact_tag' && nurtureTags.has(a.tagRef)));
229
+ const last = (w.actions || [])[(w.actions || []).length - 1];
230
+ const ok = last && last.type === 'find_opportunity'
231
+ ? [last.found, last.notFound].every(arm => arm && arm.length && handoff(arm[arm.length - 1]))
232
+ : handoff(last);
233
+ if (!ok) E(`E_NO_HANDOFF: workflow ${w.ref} must END with add_to_workflow or add_contact_tag of a nurture trigger tag`);
234
+ const kinds = new Set((w.actions || []).map(a => a.type));
235
+ for (const k of ['send_sms', 'send_email', 'task_notification', 'internal_notification', 'create_opportunity']) {
236
+ if (!kinds.has(k)) W(`workflow ${w.ref}: a v2 speed-to-lead should include ${k}`);
237
+ }
238
+ if ((w.actions || []).filter(a => a.type === 'send_sms' || a.type === 'send_email').length < 3) W(`workflow ${w.ref}: fewer than 3 touches — the owner rule is instant + 2nd + 3rd touch`);
239
+ if ((w.actions || []).length < 10) W(`workflow ${w.ref}: under 10 actions — v2 speed-to-lead is 10+`);
240
+ if ((w.actions || []).some(a => a.type === 'add_to_workflow')) W(`workflow ${w.ref}: add_to_workflow has no live-proven native shape; prefer the tag hand-off`);
241
+ }
242
+ if (/replied|reply/i.test(w.name || '')) {
243
+ const kinds = new Set((w.actions || []).map(a => a.type));
244
+ for (const k of ['remove_from_workflow', 'add_contact_tag', 'internal_notification']) if (!kinds.has(k)) W(`workflow ${w.ref}: an exit workflow should include ${k}`);
245
+ }
89
246
  // nurture exit hygiene: any wf with send_* + waits should stop on response OR be a one-shot route
90
247
  const hasSends = (w.actions || []).some(a => a.type === 'send_email' || a.type === 'send_sms');
91
248
  const hasWaits = (w.actions || []).some(a => a.type === 'wait');
92
249
  if (hasSends && hasWaits && w.stopOnResponse !== true) W(`workflow ${w.ref}: multi-step send sequence not stopOnResponse`);
93
250
  });
251
+ // every nurture trigger tag must be added somewhere (the hand-off actually happens)
252
+ for (const t of nurtureTags) {
253
+ const added = (plan.workflows || []).some(w => JSON.stringify(w.actions).includes(`"add_contact_tag","tagRef":"${t}"`));
254
+ if (!added) W(`nurture trigger tag ${t} is never added by any workflow — nothing hands leads into the nurture`);
255
+ }
94
256
 
95
257
  // ---- handoffs ----
96
258
  (plan.handoffs || []).forEach(h => {
97
259
  if (!OWNERS.has(h.owner)) E(`handoff ${h.ref}: bad owner "${h.owner}"`);
98
260
  if (!h.successCheck) E(`handoff ${h.ref}: missing successCheck`);
261
+ if (!h.instruction) E(`handoff ${h.ref}: missing instruction`);
99
262
  (h.blocks || []).forEach(b => {
100
263
  // blocks may use a wildcard like sms.* — accept wildcard if any ref in that namespace exists
101
264
  if (b.endsWith('.*')) { const ns = b.slice(0, -2); if (![...refs].some(r => r.startsWith(ns + '.'))) W(`handoff ${h.ref}: blocks "${b}" matches nothing`); }
@@ -103,9 +266,24 @@ const stageRefs = new Set();
103
266
  });
104
267
  if (h.produces) need(h.produces, `handoff ${h.ref}.produces`);
105
268
  });
269
+ // A2P gating is workflow-level: every SMS-bearing workflow must be listed by handoff.a2p when it exists
270
+ const a2p = (plan.handoffs || []).find(h => h.ref === 'handoff.a2p');
271
+ if (a2p) {
272
+ (plan.workflows || []).forEach(w => {
273
+ if (JSON.stringify(w.actions).includes('"send_sms"') && !(a2p.blocks || []).includes(w.ref)) E(`handoff.a2p.blocks does not list SMS-bearing workflow ${w.ref} (it would publish before A2P)`);
274
+ });
275
+ }
276
+
277
+ // ---- merge-field hygiene: custom values referenced by copy must exist ----
278
+ const cvNames = new Set((plan.customValues || []).map(c => c.name));
279
+ const allCopy = [...tplEmails.map(t => t.subject + ' ' + t.html), ...tplSms.map(t => t.body)].join('\n');
280
+ for (const m of allCopy.matchAll(/\{\{custom_values\.([a-z0-9_]+)\}\}/g)) {
281
+ if (!cvNames.has(m[1])) E(`copy references {{custom_values.${m[1]}}} but no custom value named "${m[1]}" is in the plan`);
282
+ }
106
283
 
107
284
  // ---- report ----
108
285
  const counts = {
286
+ users: (plan.users || []).length,
109
287
  pipelines: (plan.pipelines || []).length,
110
288
  stages: stageRefs.size,
111
289
  customFields: (plan.customFields || []).length,
@@ -116,12 +294,16 @@ const counts = {
116
294
  funnels: (plan.funnels || []).length,
117
295
  emails: (plan.emails || []).length,
118
296
  sms: (plan.sms || []).length,
297
+ templates: tplEmails.length + tplSms.length,
119
298
  workflows: (plan.workflows || []).length,
299
+ actions: (plan.workflows || []).reduce((n, w) => n + (w.actions || []).length, 0),
300
+ touches: (plan.workflows || []).reduce((n, w) => n + (w.actions || []).filter(a => a.type === 'send_email' || a.type === 'send_sms').length, 0),
120
301
  handoffs: (plan.handoffs || []).length,
121
302
  };
122
303
 
123
- console.log(`Plan: ${path.basename(planPath)} (preset ${plan.preset}, ${refs.size} refs)`);
304
+ console.log(`${isPreset ? 'Preset' : 'Plan'}: ${path.basename(filePath)} (${isPreset ? `${raw.presetId} v${raw.presetVersion}` : `preset ${plan.preset}`}, ${refs.size} refs)`);
124
305
  console.log('Counts:', JSON.stringify(counts));
306
+ (plan.workflows || []).forEach(w => console.log(` ${w.name}: ${(w.actions || []).length} actions, ${Math.round(spanDays(w) * 100) / 100} days of waits${w.stopOnResponse ? ', stops on reply' : ''}`));
125
307
  if (warns.length) { console.log(`\nWARN (${warns.length}):`); warns.forEach(w => console.log(' - ' + w)); }
126
308
  if (errors.length) { console.log(`\nERRORS (${errors.length}):`); errors.forEach(e => console.log(' ✗ ' + e)); process.exit(1); }
127
- console.log('\n✓ VALID — every ref resolves, enums correct, workflows sane. Schema-valid §5 plan.');
309
+ console.log(`\n✓ VALID — every ref resolves, enums correct, workflows sane, v2 rules met. Schema-valid §5 ${isPreset ? 'preset skeleton' : 'plan'}.`);
@@ -102,6 +102,7 @@
102
102
  "ref": "calendar.event",
103
103
  "name": "{{business.name}} Launch Event || Launch Event",
104
104
  "calendarType": "event",
105
+ "_slotDuration": "Carry the appointment length from the brief (brief.calendars[].durationMinutes, or the words in the calendar answer: \"15 minutes\" \u2192 15) onto slotDuration (minutes, slotDurationUnit \"mins\"). This preset value applies ONLY when the brief gives no length; without any slotDuration GoHighLevel builds 30-minute slots (finding 25).",
105
106
  "openHours": [{ "daysOfTheWeek": [1, 2, 3, 4, 5], "hours": [{ "openHour": 9, "openMinute": 0, "closeHour": 18, "closeMinute": 0 }] }],
106
107
  "availabilityType": 0,
107
108
  "slotDuration": 30,
@@ -0,0 +1,60 @@
1
+ # Preset: Clinic / Practice
2
+
3
+ **File:** `clinic.preset.json` · **id:** `clinic` · **version:** 2.0.0 · **default:** no
4
+ **Source:** Generalized from the proven clinic builds (Lux Bio Therapy, QA Test Clinic patterns) with the intake industry pack "clinic" (insurance_or_cash, new_patient_offer, compliance_notes); v2 depth per the owner review of 2026-08-26.
5
+
6
+ ## When it is selected
7
+ Routes from the "Clinic / practice" business type and the `clinic` / `practice` / `chiro` / `dental` / `pt` / `functional_medicine` aliases. Med spas route to `med_spa`; a time-boxed launch event routes to `clinic_launch_a2p`.
8
+
9
+ ## What it builds (skeleton, v2 depth)
10
+ - **Users** — one per `brief.team.staff[]` entry. Every alert, task and assignment step points at a `userRef`; in the skeleton that is the role placeholder `user.owner`, resolved at fill time from `team.notifyName` (alerts) and `team.callsName` (tasks, assignment). No staff yet → the steps stay, pointed at `user.__pending__`, and the build reports them as waiting.
11
+ - **1 pipeline** — "Patient Pipeline": New Patient Lead → Contacted → Visit Booked* → Showed* → Care Plan Accepted → Active Patient → Lost. Overridden by `goal.salesStages` when the brief supplies them.
12
+ - **6 custom fields** — Lead Source, What Brings You In, Insurance Status, Appointment Date*, New Patient Offer Used*, Estimated Lifetime Value.
13
+ - **24 tags** — source, lifecycle, the hand-off trigger tags (`nurture-start`, `winback-start`, `missed-call`, `no-show`), and interest tags mirrored from the interest field.
14
+ - **4 custom values** — business_name, business_phone, owner_first_name, booking_link. Every message reads business name / phone / signature / links from these, so the copy stays right when details change.
15
+ - **1 calendar** — New Patient Visit (round_robin, Mon-Fri 8-17 default, 45-minute slots, `teamMemberRefs` → the plan users).*
16
+ - **1 intake form + 1 funnel** — "New Patient Request" (first/last/email/phone required + what brings you in + insurance status) and a New Patient Visit opt-in page (the new-patient offer, what the first visit is, A2P consent block) plus a Thank You page with the booking widget.
17
+ - **15 email + 17 SMS templates** — complete, send-ready copy written to `references/copy-guide.md`. The build stage rewrites every one from the real brief (offer, pain points, objections, prices, lead magnet) and keeps the cadence, the single CTA and the merge fields.
18
+ - **8 workflows, 32 customer touches** — see the table.
19
+
20
+ \* conditional on `goal.bookingNeeded == true`.
21
+
22
+ ## Workflows
23
+
24
+ | Workflow | Trigger | Actions | Touches | Span of waits | Stops on reply |
25
+ |---|---|---|---|---|---|
26
+ | Speed to Lead | form_submission | 15 | 5 | 1.1 days | yes |
27
+ | Missed Call Text-Back | call_status (missed: no-answer/busy/voicemail) | 7 | 2 | 1 h | yes |
28
+ | Lead Nurture (30 days) | contact_tag (tag.nurture_start) | 28 | 11 | 31 days | yes |
29
+ | Replied - Stop & Route | customer_reply | 9 | 0 | instant | no |
30
+ | Win-back (30 days) | contact_tag (tag.winback_start) | 13 | 5 | 31 days | yes |
31
+ | Visit Booked - Confirm & Move | appointment (confirmed) | 8 | 2 | instant | no |
32
+ | Visit Reminders | appointment (confirmed) | 5 | 3 | instant | no |
33
+ | No-Show Rescue | appointment (noshow) | 14 | 4 | 7 days | yes |
34
+
35
+ - **Speed to Lead** (15 actions): instant text + email, opportunity card, the contact assigned to whoever takes calls, alert to a real person, a call task due today, a 2nd touch at 20 minutes, a 3rd at 3 hours, a 4th and 5th by the next day, then the hand-off tag `nurture-start` as the last action.
36
+ - **Lead Nurture** (28 actions, 11 touches, 31 days of waits): give, give, ask. Ends with `lifecycle-lapsed` + the `winback-start` hand-off.
37
+ - **Win-back** (13 actions, 5 touches, 31 days of waits): one reason to come back per message, then `lifecycle-lost`.
38
+ - **Replied - Stop & Route**: the exit workflow. Pulls the contact out of every sequence, notes it, marks contacted, alerts a person and creates a 15-minute reply task.
39
+ - **Missed Call Text-Back**: `call_status` trigger scoped to no-answer / busy / voicemail, inbound. Text within seconds, tag, alert, call-back task, one more text an hour later.
40
+ - **New Patient Visit → Confirm & Move, Reminders, No-Show Rescue**: booking stops every sequence and moves (or creates) the opportunity; reminders at 24 h and 2 h; a no-show gets a same-day call task, text and email, two more touches, then hands off to Win-back and moves the card back to Contacted.
41
+
42
+ ### Why sequences hand off with a tag, not add_to_workflow
43
+ Every hand-off is `add_contact_tag` + a `contact_tag` trigger. Both halves are live-proven shapes captured from working GHL workflows (`templates/action-schemas.json`). `add_to_workflow` has no captured native shape there and has not been proven at runtime, so a preset may not use it. Each sequence removes its own trigger tag as its first action so a later hand-off can start it again.
44
+
45
+ ## How the brief shapes it
46
+ | Brief signal | Effect |
47
+ |---|---|
48
+ | `goal.salesStages` present | replaces the default pipeline stages (workflow stage refs are mapped to the nearest equivalent) |
49
+ | `goal.bookingNeeded == false` | drops the calendar, booking stage/tag/fields, the three appointment workflows and the calendar handoffs |
50
+ | `channels.sms == true` | keeps SMS templates, the send_sms actions, Missed Call Text-Back and the A2P gating; otherwise they drop (the email-only nurture still spans 31 days) |
51
+ | staff answers present | fills `users`; every `userRef` resolves to a real person |
52
+ | staff empty | every `userRef` → `user.__pending__`; the steps stay; `handoff.add_staff` holds those workflows DRAFT |
53
+ | `flags` | `needs_a2p` / `stripe_not_connected` / `calendar_oauth_needed` / `email_domain_needed` add the matching handoff |
54
+ | `business.name`, `offer.*`, `audience.*`, `extended.*` | fill object names, custom values and ground every template rewrite |
55
+
56
+ ## Compliance rule for every rewrite
57
+ The templates say "your visit", "what brought you in", "what you came in for". They never say what that is. Keep it that way when the build stage rewrites from the brief, even if the brief names a specialty; the specialty may appear in the business name and the landing page, not in a text or email to a lead. `handoff.compliance_review` puts the final read on the operator.
58
+
59
+ ## Copy notes
60
+ Compliance first: no message names a condition, diagnosis, treatment or medication, and the intake pack's compliance_notes are honored on rewrite. The through-line is "the first visit is mostly listening": what happens minute by minute, how insurance and cash pricing work in plain words, no surprise bills, and why the easy-to-fix stage is the cheap stage. Texts ask one-word questions (new / ongoing / check-up; mornings / afternoons; YES / later).