@synap-core/workspace-templates 0.9.3 → 0.9.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
package/src/crm.yaml CHANGED
@@ -464,6 +464,72 @@ entityLinks:
464
464
  targetProfileSlug: deal
465
465
  type: linked_to_deal
466
466
 
467
+ onboarding:
468
+ goal: >
469
+ Turn the user's real commercial motion into a usable pipeline — the right
470
+ companies and people, their active opportunities, and an explicit next
471
+ step for every deal — so CRM becomes the place sales work moves forward.
472
+ framing: >
473
+ THE REVENUE OPERATOR: Act as a pragmatic sales lead setting up a CRM people
474
+ will actually maintain. Start from the user's existing motion, vocabulary,
475
+ and active conversations. Resolve before creating so imported or Pod-wide
476
+ people are reused rather than duplicated. Push for a concrete next step and
477
+ date; never invent revenue, probability, or contact details.
478
+ expertise:
479
+ starters:
480
+ - >
481
+ A useful first pipeline is deliberately small. Start with the few stages
482
+ that change how the team acts — contacted, qualifying, proposal,
483
+ negotiating, won, and lost — and keep the template's default stages
484
+ unless the user's real process needs a different view.
485
+ - >
486
+ Populate the workspace with live work, not a perfect historical archive:
487
+ propose the three to five opportunities the user is actively trying to
488
+ move, then add the companies and people needed to understand those deals.
489
+ - >
490
+ Every active deal needs one observable next step with a date. If the next
491
+ step is "follow up" or "keep warm", help turn it into a specific action
492
+ such as "send the revised proposal Thursday" before capturing it.
493
+ blindSpots:
494
+ - >
495
+ A deal without a real person is usually an account-shaped hope. If an
496
+ opportunity has no stakeholder, surface the gap instead of fabricating a
497
+ placeholder contact.
498
+ - >
499
+ Pipelines become fiction when stages describe confidence rather than an
500
+ event the buyer completed. Ask what evidence moves a deal into its current
501
+ stage, especially around proposal and negotiation.
502
+ - >
503
+ More fields do not create better sales hygiene. Prioritize stage, value
504
+ when known, expected close date, and next step; leave unverifiable fields
505
+ empty and offer enrichment separately.
506
+ bar: >
507
+ A sales lead can open the pipeline and immediately see the few live
508
+ opportunities, the real company and stakeholder behind each one, its
509
+ evidence-based stage, and the dated next action — with no duplicate people
510
+ and no invented commercial data.
511
+ collect:
512
+ - profileSlug: company
513
+ what: The real prospect organisations behind current conversations.
514
+ cardinality: few
515
+ keyFields: [website, industry, companySize, source, nextStep]
516
+ - profileSlug: person
517
+ what: The known stakeholders for those organisations and opportunities.
518
+ cardinality: few
519
+ keyFields: [email, jobTitle, company, source, nextFollowUpDate]
520
+ - profileSlug: deal
521
+ what: The live opportunities worth actively moving through the pipeline.
522
+ cardinality: few
523
+ keyFields: [dealStage, value, closeDate, nextStep]
524
+ openingQuestions:
525
+ - "Which three to five opportunities are you actively trying to move right now?"
526
+ - "For the most important one, which company and real stakeholder are involved?"
527
+ - "What has the buyer actually done so far, and what is the next dated action?"
528
+ doneWhen: >
529
+ At least one real opportunity is linked to its company or stakeholder and
530
+ has an evidence-based stage plus a concrete next step; the user can see how
531
+ to add or import the rest without duplicating existing people.
532
+
467
533
  # Playbook — enriches a lead/prospect from public sources (company site,
468
534
  # LinkedIn, firmographic signal) so ICP scoring and qualification start from
469
535
  # real data instead of a blank record. Bound to `lead` so a newly captured
package/src/templates.ts CHANGED
@@ -10643,6 +10643,66 @@ const DATA = {
10643
10643
  "type": "linked_to_deal"
10644
10644
  }
10645
10645
  ],
10646
+ "onboarding": {
10647
+ "goal": "Turn the user's real commercial motion into a usable pipeline — the right companies and people, their active opportunities, and an explicit next step for every deal — so CRM becomes the place sales work moves forward.\n",
10648
+ "framing": "THE REVENUE OPERATOR: Act as a pragmatic sales lead setting up a CRM people will actually maintain. Start from the user's existing motion, vocabulary, and active conversations. Resolve before creating so imported or Pod-wide people are reused rather than duplicated. Push for a concrete next step and date; never invent revenue, probability, or contact details.\n",
10649
+ "expertise": {
10650
+ "starters": [
10651
+ "A useful first pipeline is deliberately small. Start with the few stages that change how the team acts — contacted, qualifying, proposal, negotiating, won, and lost — and keep the template's default stages unless the user's real process needs a different view.\n",
10652
+ "Populate the workspace with live work, not a perfect historical archive: propose the three to five opportunities the user is actively trying to move, then add the companies and people needed to understand those deals.\n",
10653
+ "Every active deal needs one observable next step with a date. If the next step is \"follow up\" or \"keep warm\", help turn it into a specific action such as \"send the revised proposal Thursday\" before capturing it.\n"
10654
+ ],
10655
+ "blindSpots": [
10656
+ "A deal without a real person is usually an account-shaped hope. If an opportunity has no stakeholder, surface the gap instead of fabricating a placeholder contact.\n",
10657
+ "Pipelines become fiction when stages describe confidence rather than an event the buyer completed. Ask what evidence moves a deal into its current stage, especially around proposal and negotiation.\n",
10658
+ "More fields do not create better sales hygiene. Prioritize stage, value when known, expected close date, and next step; leave unverifiable fields empty and offer enrichment separately.\n"
10659
+ ],
10660
+ "bar": "A sales lead can open the pipeline and immediately see the few live opportunities, the real company and stakeholder behind each one, its evidence-based stage, and the dated next action — with no duplicate people and no invented commercial data.\n"
10661
+ },
10662
+ "collect": [
10663
+ {
10664
+ "profileSlug": "company",
10665
+ "what": "The real prospect organisations behind current conversations.",
10666
+ "cardinality": "few",
10667
+ "keyFields": [
10668
+ "website",
10669
+ "industry",
10670
+ "companySize",
10671
+ "source",
10672
+ "nextStep"
10673
+ ]
10674
+ },
10675
+ {
10676
+ "profileSlug": "person",
10677
+ "what": "The known stakeholders for those organisations and opportunities.",
10678
+ "cardinality": "few",
10679
+ "keyFields": [
10680
+ "email",
10681
+ "jobTitle",
10682
+ "company",
10683
+ "source",
10684
+ "nextFollowUpDate"
10685
+ ]
10686
+ },
10687
+ {
10688
+ "profileSlug": "deal",
10689
+ "what": "The live opportunities worth actively moving through the pipeline.",
10690
+ "cardinality": "few",
10691
+ "keyFields": [
10692
+ "dealStage",
10693
+ "value",
10694
+ "closeDate",
10695
+ "nextStep"
10696
+ ]
10697
+ }
10698
+ ],
10699
+ "openingQuestions": [
10700
+ "Which three to five opportunities are you actively trying to move right now?",
10701
+ "For the most important one, which company and real stakeholder are involved?",
10702
+ "What has the buyer actually done so far, and what is the next dated action?"
10703
+ ],
10704
+ "doneWhen": "At least one real opportunity is linked to its company or stakeholder and has an evidence-based stage plus a concrete next step; the user can see how to add or import the rest without duplicating existing people.\n"
10705
+ },
10646
10706
  "playbooks": [
10647
10707
  {
10648
10708
  "name": "Enrich Lead/Company",
package/src/types.ts CHANGED
@@ -10,14 +10,14 @@
10
10
  // App Reference — what opens as first screen when entering this workspace
11
11
  // ============================================================================
12
12
 
13
- export type TemplateAppKind = 'native' | 'stored' | 'url';
13
+ export type TemplateAppKind = "native" | "stored" | "url";
14
14
 
15
15
  export interface TemplateAppRef {
16
16
  kind: TemplateAppKind;
17
17
  /** For kind='native': the appId registered in the browser manifest */
18
18
  appId?: string;
19
19
  /** For kind='native': how the platform renders the app */
20
- rendererType?: 'native' | 'external' | 'iframe-srcdoc';
20
+ rendererType?: "native" | "external" | "iframe-srcdoc";
21
21
  /** For kind='stored': the cell key registered in the cell registry */
22
22
  cellKey?: string;
23
23
  /** For kind='stored': default props passed to the cell */
@@ -42,28 +42,38 @@ export type WorkspaceJsonValue =
42
42
 
43
43
  export type WorkspacePrimarySurface =
44
44
  | {
45
- kind: 'app';
45
+ kind: "app";
46
46
  appId: string;
47
- rendererType: 'native';
47
+ rendererType: "native";
48
48
  title?: string;
49
49
  props?: Record<string, WorkspaceJsonValue>;
50
50
  }
51
51
  | {
52
- kind: 'app';
52
+ kind: "app";
53
53
  appId: string;
54
- rendererType: 'external';
54
+ rendererType: "external";
55
55
  url: string;
56
56
  title?: string;
57
57
  props?: Record<string, WorkspaceJsonValue>;
58
58
  }
59
59
  | {
60
- kind: 'cell';
60
+ kind: "cell";
61
61
  cellKey: string;
62
62
  title?: string;
63
63
  props?: Record<string, WorkspaceJsonValue>;
64
64
  }
65
65
  | {
66
- kind: 'view';
66
+ /**
67
+ * Ordinary website. Unlike an external `app`, this has no trusted app
68
+ * identity and is always opened through the Browser's unprivileged URL
69
+ * surface.
70
+ */
71
+ kind: "url";
72
+ url: string;
73
+ title?: string;
74
+ }
75
+ | {
76
+ kind: "view";
67
77
  viewId: string;
68
78
  title?: string;
69
79
  };
@@ -75,13 +85,13 @@ export type WorkspacePrimarySurface =
75
85
  */
76
86
  export type WorkspacePrimarySurfaceViewReference =
77
87
  | {
78
- kind: 'view';
88
+ kind: "view";
79
89
  viewName: string;
80
90
  viewSlug?: string;
81
91
  title?: string;
82
92
  }
83
93
  | {
84
- kind: 'view';
94
+ kind: "view";
85
95
  viewName?: string;
86
96
  viewSlug: string;
87
97
  title?: string;
@@ -134,11 +144,11 @@ export interface TemplateProfile {
134
144
  * `entity_scope` of an existing profile (a divergence is reported as an
135
145
  * advisory `scopeConflict` and the live row is left alone).
136
146
  */
137
- entityScope?: 'pod' | 'workspace';
147
+ entityScope?: "pod" | "workspace";
138
148
  /** Semantic slug for system profiles. */
139
149
  semanticSlug?: string;
140
150
  /** Kind vs role profile. Omit for default ('kind'). */
141
- profileKind?: 'kind' | 'role';
151
+ profileKind?: "kind" | "role";
142
152
  /** For profileKind='role': which base-kind profile slugs this role can attach to. */
143
153
  applicableKinds?: string[];
144
154
  properties: TemplateProperty[];
@@ -201,22 +211,22 @@ export interface TemplateDisplayTemplate {
201
211
  // ============================================================================
202
212
 
203
213
  export interface TemplateSidebarSurface {
204
- kind: 'cell' | 'view' | 'entity' | 'document' | 'channel' | 'app' | 'url';
214
+ kind: "cell" | "view" | "entity" | "document" | "channel" | "app" | "url";
205
215
  cellKey?: string;
206
216
  viewId?: string;
207
217
  viewName?: string;
208
218
  appId?: string;
209
219
  url?: string;
210
- rendererType?: 'native' | 'external' | 'iframe-srcdoc';
211
- placement?: 'main' | 'side' | 'floating' | 'modal' | 'popover' | 'embed';
212
- displayMode?: 'compact' | 'medium' | 'full';
220
+ rendererType?: "native" | "external" | "iframe-srcdoc";
221
+ placement?: "main" | "side" | "floating" | "modal" | "popover" | "embed";
222
+ displayMode?: "compact" | "medium" | "full";
213
223
  props?: Record<string, unknown>;
214
224
  title?: string;
215
225
  meta?: Record<string, unknown>;
216
226
  }
217
227
 
218
228
  export interface TemplateSidebarItem {
219
- kind: 'app' | 'view' | 'profile' | 'external' | 'cell';
229
+ kind: "app" | "view" | "profile" | "external" | "cell";
220
230
  appId?: string;
221
231
  viewName?: string;
222
232
  viewId?: string;
@@ -253,7 +263,7 @@ export interface TemplateLayoutConfig {
253
263
  // ============================================================================
254
264
 
255
265
  export interface TemplateBentoBlock {
256
- kind: 'view' | 'widget';
266
+ kind: "view" | "widget";
257
267
  viewName?: string;
258
268
  widgetType?: string;
259
269
  pos: { x: number; y: number; w: number; h: number };
@@ -272,7 +282,7 @@ export interface TemplateProfileEntityBentoTemplates {
272
282
  // ============================================================================
273
283
 
274
284
  /** Mirrors `WorkspaceSourceRole` in synap-backend `schema/workspaces.ts`. */
275
- export type TemplateSourceRole = 'provider' | 'consumer' | 'provider-consumer';
285
+ export type TemplateSourceRole = "provider" | "consumer" | "provider-consumer";
276
286
 
277
287
  /** Mirrors `WorkspaceDefaultSource` in synap-backend `schema/workspaces.ts`. */
278
288
  export interface TemplateDefaultSource {
@@ -302,7 +312,7 @@ export interface TemplateOnboardingCollectTarget {
302
312
  /** Human description of what to capture for this target. */
303
313
  what: string;
304
314
  /** Roughly how many to expect — guides interview depth. */
305
- cardinality?: 'one' | 'few' | 'several';
315
+ cardinality?: "one" | "few" | "several";
306
316
  /** Key fields the agent should make sure to fill. */
307
317
  keyFields?: string[];
308
318
  }
@@ -357,7 +367,7 @@ export interface TemplateOnboarding {
357
367
 
358
368
  export interface TemplatePlaybookParam {
359
369
  name: string;
360
- type: 'text' | 'number' | 'entity' | 'choice' | 'boolean';
370
+ type: "text" | "number" | "entity" | "choice" | "boolean";
361
371
  label?: string;
362
372
  required?: boolean;
363
373
  defaultValue?: unknown;
@@ -368,12 +378,12 @@ export interface TemplatePlaybook {
368
378
  description?: string;
369
379
  goalTemplate: string;
370
380
  params?: TemplatePlaybookParam[];
371
- executor?: 'is-agent' | 'external-agent' | 'hybrid';
372
- inputStrategy?: 'none' | 'static' | 'rotating' | 'query';
381
+ executor?: "is-agent" | "external-agent" | "hybrid";
382
+ inputStrategy?: "none" | "static" | "rotating" | "query";
373
383
  channelSpec?: {
374
- type: 'GROUP' | 'AGENT_COLLAB' | 'THREAD';
384
+ type: "GROUP" | "AGENT_COLLAB" | "THREAD";
375
385
  members?: string[];
376
- aiReactionMode?: 'on_mention' | 'always' | 'never';
386
+ aiReactionMode?: "on_mention" | "always" | "never";
377
387
  };
378
388
  /**
379
389
  * A cron schedule that maintains ONE backing cron automation (a single
@@ -399,7 +409,7 @@ export interface TemplatePlaybook {
399
409
  * captured entity of this kind can auto-launch the playbook as a session.
400
410
  */
401
411
  subjectProfile?: { profileSlug: string; filter?: Record<string, unknown> };
402
- status?: 'draft' | 'active' | 'paused';
412
+ status?: "draft" | "active" | "paused";
403
413
  }
404
414
 
405
415
  // ============================================================================
@@ -429,10 +439,10 @@ export interface TemplateAutomation {
429
439
  key?: string;
430
440
  name: string;
431
441
  description?: string;
432
- triggerType: 'event' | 'cron' | 'webhook' | 'manual';
442
+ triggerType: "event" | "cron" | "webhook" | "manual";
433
443
  triggerConfig?: Record<string, unknown>;
434
444
  flow: { nodes: unknown[]; edges: unknown[]; precondition?: string };
435
- status?: 'draft' | 'active' | 'paused';
445
+ status?: "draft" | "active" | "paused";
436
446
  }
437
447
 
438
448
  // ============================================================================
@@ -464,9 +474,9 @@ export interface TemplateActionPlacement {
464
474
  /** Entity kind whose detail surface this action appears on. */
465
475
  profileSlug: string;
466
476
  /** Where the action renders. Only "entity-detail" is consumed today. */
467
- surface: 'entity-detail' | string;
477
+ surface: "entity-detail" | string;
468
478
  /** What the action does when clicked. */
469
- kind: 'capability' | 'playbook' | 'automation';
479
+ kind: "capability" | "playbook" | "automation";
470
480
  /**
471
481
  * Reference to the thing to run: a capability verb key, or a playbook /
472
482
  * automation NAME (resolved to a row id at apply time).
@@ -509,7 +519,7 @@ export interface TemplateIntegration {
509
519
  /** Override default params with {{var}} interpolation. */
510
520
  params?: Record<string, string>;
511
521
  /** "apply" (create+update) or "install" (create only). */
512
- mode?: 'apply' | 'install';
522
+ mode?: "apply" | "install";
513
523
  }
514
524
 
515
525
  // ============================================================================
@@ -583,10 +593,10 @@ export interface TemplateExtendsRef {
583
593
  * 3. Once no payload carries `'automation'`, drop it from the wire enums and this tuple.
584
594
  */
585
595
  export const PACKAGE_DEPENDENCY_KINDS = [
586
- 'workspace',
587
- 'capability',
596
+ "workspace",
597
+ "capability",
588
598
  /** @deprecated Retired by CP 0049 in favour of `workflow`. See note above. */
589
- 'automation',
599
+ "automation",
590
600
  ] as const;
591
601
 
592
602
  /** What a `TemplateDependency` points at. Derived — never re-declare the list. */
@@ -610,7 +620,7 @@ export interface TemplateDependency {
610
620
  * - `'require'` (default): the dependency must be present on the pod as its
611
621
  * own artifact, but this template does not merge into it.
612
622
  */
613
- relation?: 'compose' | 'require';
623
+ relation?: "compose" | "require";
614
624
  /**
615
625
  * Short human reason shown in the install-time "this also installs…" prompt.
616
626
  */
@@ -654,7 +664,7 @@ export interface WorkspaceYaml {
654
664
  * Workspace classifier — consumed by createFromDefinition (settings.workspaceType,
655
665
  * creator-role + agent-workspace behavior). Mirrors the backend union.
656
666
  */
657
- workspaceType?: 'personal' | 'agent' | 'project' | 'operational';
667
+ workspaceType?: "personal" | "agent" | "project" | "operational";
658
668
  visibility?: string;
659
669
  /** Capabilities this workspace provides (e.g. "brand.library", "content.pipeline") */
660
670
  capabilities?: string[];
@@ -751,7 +761,7 @@ export interface WorkspaceYaml {
751
761
 
752
762
  export interface ResolvedWorkspaceTemplate {
753
763
  meta: TemplateMeta;
754
- definition: WorkspaceYaml['workspace'] & {
764
+ definition: WorkspaceYaml["workspace"] & {
755
765
  profiles: TemplateProfile[];
756
766
  views: TemplateView[];
757
767
  entityLinks: TemplateEntityLink[];
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { validateTemplate } from "./validate.js";
3
+ import type { WorkspaceYaml } from "./types.js";
4
+
5
+ function templateWithOnboarding(onboarding: unknown): WorkspaceYaml {
6
+ return {
7
+ meta: {
8
+ slug: "onboarding-validation",
9
+ name: "Onboarding validation",
10
+ description: "Validation fixture",
11
+ icon: "circle",
12
+ color: "blue",
13
+ },
14
+ workspace: {
15
+ name: "Onboarding validation",
16
+ description: "Validation fixture",
17
+ },
18
+ profiles: [
19
+ {
20
+ slug: "record",
21
+ displayName: "Record",
22
+ properties: [],
23
+ },
24
+ ],
25
+ onboarding,
26
+ } as WorkspaceYaml;
27
+ }
28
+
29
+ describe("validateTemplate onboarding contract", () => {
30
+ it("accepts a complete domain recipe", () => {
31
+ const result = validateTemplate(
32
+ templateWithOnboarding({
33
+ goal: "Create the first useful record.",
34
+ framing: "Act as a domain expert.",
35
+ expertise: {
36
+ starters: ["Begin with the live work."],
37
+ blindSpots: ["Do not collect records without an owner."],
38
+ bar: "Every record has a clear next step.",
39
+ },
40
+ collect: [
41
+ {
42
+ profileSlug: "record",
43
+ what: "The records currently in motion.",
44
+ cardinality: "few",
45
+ keyFields: ["title"],
46
+ },
47
+ ],
48
+ openingQuestions: ["What is currently in motion?"],
49
+ doneWhen: "At least one useful record exists.",
50
+ }),
51
+ {}
52
+ );
53
+
54
+ expect(result, JSON.stringify(result.errors)).toEqual({
55
+ ok: true,
56
+ errors: [],
57
+ });
58
+ });
59
+
60
+ it("rejects empty expertise prose and collection instructions", () => {
61
+ const result = validateTemplate(
62
+ templateWithOnboarding({
63
+ goal: "Set up this workspace.",
64
+ framing: "Act as a domain expert.",
65
+ expertise: {
66
+ starters: [""],
67
+ blindSpots: [" "],
68
+ bar: "",
69
+ },
70
+ collect: [{ profileSlug: "record", what: "", keyFields: [""] }],
71
+ openingQuestions: [""],
72
+ doneWhen: "",
73
+ }),
74
+ {}
75
+ );
76
+
77
+ expect(result.ok).toBe(false);
78
+ expect(result.errors.map((error) => error.rule)).toEqual(
79
+ expect.arrayContaining([
80
+ "onboarding-expertise-invalid",
81
+ "onboarding-collect-what-empty",
82
+ "onboarding-collect-key-fields-invalid",
83
+ "onboarding-opening-questions-invalid",
84
+ "onboarding-done-when-invalid",
85
+ ])
86
+ );
87
+ });
88
+ });
package/src/validate.ts CHANGED
@@ -274,7 +274,8 @@ export function validateTemplate(
274
274
  if (!tpl.meta) add("meta-required", "meta", "Missing required field: meta");
275
275
  else {
276
276
  for (const f of REQUIRED_META_FIELDS) {
277
- if (!tpl.meta[f]) add("meta-field-required", `meta.${f}`, `meta.${f} is required`);
277
+ if (!tpl.meta[f])
278
+ add("meta-field-required", `meta.${f}`, `meta.${f} is required`);
278
279
  }
279
280
  }
280
281
  if (!tpl.workspace)
@@ -283,7 +284,9 @@ export function validateTemplate(
283
284
  for (const f of REQUIRED_WORKSPACE_FIELDS) {
284
285
  if (!tpl.workspace[f])
285
286
  add(
286
- "workspace-field-required", `workspace.${f}`, `workspace.${f} is required`
287
+ "workspace-field-required",
288
+ `workspace.${f}`,
289
+ `workspace.${f} is required`
287
290
  );
288
291
  }
289
292
  }
@@ -434,7 +437,110 @@ export function validateTemplate(
434
437
  }
435
438
  }
436
439
 
437
- for (const c of tpl.onboarding?.collect || []) {
440
+ const onboarding = tpl.onboarding;
441
+ if (onboarding) {
442
+ if (typeof onboarding.goal !== "string" || onboarding.goal.trim() === "") {
443
+ add(
444
+ "onboarding-goal-empty",
445
+ "onboarding.goal",
446
+ "onboarding.goal must be a non-empty string"
447
+ );
448
+ }
449
+ if (
450
+ typeof onboarding.framing !== "string" ||
451
+ onboarding.framing.trim() === ""
452
+ ) {
453
+ add(
454
+ "onboarding-framing-empty",
455
+ "onboarding.framing",
456
+ "onboarding.framing must be a non-empty string"
457
+ );
458
+ }
459
+ if (!Array.isArray(onboarding.collect) || onboarding.collect.length === 0) {
460
+ add(
461
+ "onboarding-collect-empty",
462
+ "onboarding.collect",
463
+ "onboarding.collect must contain at least one structured target"
464
+ );
465
+ }
466
+
467
+ const expertise = onboarding.expertise;
468
+ for (const [field, values] of [
469
+ ["starters", expertise?.starters],
470
+ ["blindSpots", expertise?.blindSpots],
471
+ ] as const) {
472
+ if (
473
+ values !== undefined &&
474
+ (!Array.isArray(values) ||
475
+ values.some(
476
+ (value) => typeof value !== "string" || value.trim() === ""
477
+ ))
478
+ ) {
479
+ add(
480
+ "onboarding-expertise-invalid",
481
+ `onboarding.expertise.${field}`,
482
+ `onboarding.expertise.${field} must contain only non-empty strings`
483
+ );
484
+ }
485
+ }
486
+ if (
487
+ expertise?.bar !== undefined &&
488
+ (typeof expertise.bar !== "string" || expertise.bar.trim() === "")
489
+ ) {
490
+ add(
491
+ "onboarding-expertise-invalid",
492
+ "onboarding.expertise.bar",
493
+ "onboarding.expertise.bar must be a non-empty string"
494
+ );
495
+ }
496
+
497
+ if (
498
+ onboarding.openingQuestions !== undefined &&
499
+ (!Array.isArray(onboarding.openingQuestions) ||
500
+ onboarding.openingQuestions.some(
501
+ (question) => typeof question !== "string" || question.trim() === ""
502
+ ))
503
+ ) {
504
+ add(
505
+ "onboarding-opening-questions-invalid",
506
+ "onboarding.openingQuestions",
507
+ "onboarding.openingQuestions must contain only non-empty strings"
508
+ );
509
+ }
510
+ if (
511
+ onboarding.doneWhen !== undefined &&
512
+ (typeof onboarding.doneWhen !== "string" ||
513
+ onboarding.doneWhen.trim() === "")
514
+ ) {
515
+ add(
516
+ "onboarding-done-when-invalid",
517
+ "onboarding.doneWhen",
518
+ "onboarding.doneWhen must be a non-empty string"
519
+ );
520
+ }
521
+ }
522
+
523
+ for (const c of onboarding?.collect || []) {
524
+ if (typeof c.what !== "string" || c.what.trim() === "") {
525
+ add(
526
+ "onboarding-collect-what-empty",
527
+ `onboarding.collect.${c.profileSlug}.what`,
528
+ `onboarding.collect target "${c.profileSlug}" must describe what to capture`
529
+ );
530
+ }
531
+ if (
532
+ c.keyFields !== undefined &&
533
+ (!Array.isArray(c.keyFields) ||
534
+ c.keyFields.some(
535
+ (field) => typeof field !== "string" || field.trim() === ""
536
+ ))
537
+ ) {
538
+ add(
539
+ "onboarding-collect-key-fields-invalid",
540
+ `onboarding.collect.${c.profileSlug}.keyFields`,
541
+ `onboarding.collect target "${c.profileSlug}" keyFields must contain only non-empty strings`
542
+ );
543
+ }
438
544
  if (!known(c.profileSlug))
439
545
  add(
440
546
  "onboarding-ref-unknown",
@@ -457,14 +563,17 @@ export function validateTemplate(
457
563
  for (const a of tpl.automations || []) {
458
564
  const refs: Array<{ where: string; slug: string }> = [];
459
565
  collectProfileSlugRefs(
460
- a.triggerConfig, `automation "${a.name}".triggerConfig`,
566
+ a.triggerConfig,
567
+ `automation "${a.name}".triggerConfig`,
461
568
  refs
462
569
  );
463
570
  collectProfileSlugRefs(a.flow, `automation "${a.name}".flow`, refs);
464
571
  for (const r of refs)
465
572
  if (!known(r.slug))
466
573
  add(
467
- "automation-ref-unknown", r.where, `${r.where} references unknown profile "${r.slug}"`
574
+ "automation-ref-unknown",
575
+ r.where,
576
+ `${r.where} references unknown profile "${r.slug}"`
468
577
  );
469
578
 
470
579
  // Node-contract validation. Previously validate.ts checked automations for
@@ -476,9 +585,8 @@ export function validateTemplate(
476
585
  // author time); the structural + per-node contract checks always run.
477
586
  const flow = a.flow ?? (a as { flowDefinition?: unknown }).flowDefinition;
478
587
  if (flow != null) {
479
- const { errors: flowErrors
480
- } = validateFlowDefinition(flow);
481
- for (const fe of flowErrors) {
588
+ const { errors: flowErrors } = validateFlowDefinition(flow);
589
+ for (const fe of flowErrors) {
482
590
  const loc = fe.nodeId
483
591
  ? `automation "${a.name}".flow.node "${fe.nodeId}"`
484
592
  : fe.edgeId
@@ -602,11 +710,15 @@ export function validateTemplate(
602
710
 
603
711
  const doors: Array<
604
712
  [
605
- string, Array<{ widgetType?: unknown }>, Array<{ viewName?: unknown; kind?: unknown }>,
713
+ string,
714
+ Array<{ widgetType?: unknown }>,
715
+ Array<{ viewName?: unknown; kind?: unknown }>,
606
716
  ]
607
717
  > = [
608
718
  [
609
- "tRPC createFromDefinition", definition.bentoLayout ?? [], definition.bentoViewBlocks ?? [],
719
+ "tRPC createFromDefinition",
720
+ definition.bentoLayout ?? [],
721
+ definition.bentoViewBlocks ?? [],
610
722
  ],
611
723
  ["CP packages/apply", pkg.bentoLayout ?? [], pkg.bentoViewBlocks ?? []],
612
724
  ];