@synap-core/workspace-templates 0.9.3 → 0.10.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.
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[];
@@ -164,6 +174,29 @@ export interface TemplateEntityLink {
164
174
  label?: string;
165
175
  }
166
176
 
177
+ /**
178
+ * A domain-level relation TYPE definition — a `relation_defs` row, the workspace's
179
+ * relation vocabulary. Distinct from {@link TemplateEntityLink}, which is a
180
+ * profile→profile EDGE (and only carries `type` + `label`). A relation def stands
181
+ * alone: it has no source/target profile pairing and it carries the rich metadata
182
+ * (`description`, `isDirectional`, `uiHints`) that the entity-link path drops.
183
+ *
184
+ * Mirrors `DefaultRelationDef` in synap-backend
185
+ * `packages/database/src/utils/default-relation-defs.ts` VERBATIM — the door
186
+ * find-or-creates a `relation_defs` row from each entry (idempotent by
187
+ * `slug` within the workspace), which is `ensureDefaultRelationDefs`'s body.
188
+ */
189
+ export interface TemplateRelationDef {
190
+ slug: string;
191
+ displayName: string;
192
+ description?: string;
193
+ isDirectional?: boolean;
194
+ uiHints?: {
195
+ category?: "workflow" | "social" | "reference" | "hierarchy";
196
+ inverseLabel?: string;
197
+ };
198
+ }
199
+
167
200
  export interface TemplateSeedEntity {
168
201
  profileSlug: string;
169
202
  title: string;
@@ -201,22 +234,22 @@ export interface TemplateDisplayTemplate {
201
234
  // ============================================================================
202
235
 
203
236
  export interface TemplateSidebarSurface {
204
- kind: 'cell' | 'view' | 'entity' | 'document' | 'channel' | 'app' | 'url';
237
+ kind: "cell" | "view" | "entity" | "document" | "channel" | "app" | "url";
205
238
  cellKey?: string;
206
239
  viewId?: string;
207
240
  viewName?: string;
208
241
  appId?: string;
209
242
  url?: string;
210
- rendererType?: 'native' | 'external' | 'iframe-srcdoc';
211
- placement?: 'main' | 'side' | 'floating' | 'modal' | 'popover' | 'embed';
212
- displayMode?: 'compact' | 'medium' | 'full';
243
+ rendererType?: "native" | "external" | "iframe-srcdoc";
244
+ placement?: "main" | "side" | "floating" | "modal" | "popover" | "embed";
245
+ displayMode?: "compact" | "medium" | "full";
213
246
  props?: Record<string, unknown>;
214
247
  title?: string;
215
248
  meta?: Record<string, unknown>;
216
249
  }
217
250
 
218
251
  export interface TemplateSidebarItem {
219
- kind: 'app' | 'view' | 'profile' | 'external' | 'cell';
252
+ kind: "app" | "view" | "profile" | "external" | "cell";
220
253
  appId?: string;
221
254
  viewName?: string;
222
255
  viewId?: string;
@@ -253,7 +286,7 @@ export interface TemplateLayoutConfig {
253
286
  // ============================================================================
254
287
 
255
288
  export interface TemplateBentoBlock {
256
- kind: 'view' | 'widget';
289
+ kind: "view" | "widget";
257
290
  viewName?: string;
258
291
  widgetType?: string;
259
292
  pos: { x: number; y: number; w: number; h: number };
@@ -272,7 +305,7 @@ export interface TemplateProfileEntityBentoTemplates {
272
305
  // ============================================================================
273
306
 
274
307
  /** Mirrors `WorkspaceSourceRole` in synap-backend `schema/workspaces.ts`. */
275
- export type TemplateSourceRole = 'provider' | 'consumer' | 'provider-consumer';
308
+ export type TemplateSourceRole = "provider" | "consumer" | "provider-consumer";
276
309
 
277
310
  /** Mirrors `WorkspaceDefaultSource` in synap-backend `schema/workspaces.ts`. */
278
311
  export interface TemplateDefaultSource {
@@ -302,7 +335,7 @@ export interface TemplateOnboardingCollectTarget {
302
335
  /** Human description of what to capture for this target. */
303
336
  what: string;
304
337
  /** Roughly how many to expect — guides interview depth. */
305
- cardinality?: 'one' | 'few' | 'several';
338
+ cardinality?: "one" | "few" | "several";
306
339
  /** Key fields the agent should make sure to fill. */
307
340
  keyFields?: string[];
308
341
  }
@@ -357,7 +390,7 @@ export interface TemplateOnboarding {
357
390
 
358
391
  export interface TemplatePlaybookParam {
359
392
  name: string;
360
- type: 'text' | 'number' | 'entity' | 'choice' | 'boolean';
393
+ type: "text" | "number" | "entity" | "choice" | "boolean";
361
394
  label?: string;
362
395
  required?: boolean;
363
396
  defaultValue?: unknown;
@@ -368,12 +401,12 @@ export interface TemplatePlaybook {
368
401
  description?: string;
369
402
  goalTemplate: string;
370
403
  params?: TemplatePlaybookParam[];
371
- executor?: 'is-agent' | 'external-agent' | 'hybrid';
372
- inputStrategy?: 'none' | 'static' | 'rotating' | 'query';
404
+ executor?: "is-agent" | "external-agent" | "hybrid";
405
+ inputStrategy?: "none" | "static" | "rotating" | "query";
373
406
  channelSpec?: {
374
- type: 'GROUP' | 'AGENT_COLLAB' | 'THREAD';
407
+ type: "GROUP" | "AGENT_COLLAB" | "THREAD";
375
408
  members?: string[];
376
- aiReactionMode?: 'on_mention' | 'always' | 'never';
409
+ aiReactionMode?: "on_mention" | "always" | "never";
377
410
  };
378
411
  /**
379
412
  * A cron schedule that maintains ONE backing cron automation (a single
@@ -399,7 +432,7 @@ export interface TemplatePlaybook {
399
432
  * captured entity of this kind can auto-launch the playbook as a session.
400
433
  */
401
434
  subjectProfile?: { profileSlug: string; filter?: Record<string, unknown> };
402
- status?: 'draft' | 'active' | 'paused';
435
+ status?: "draft" | "active" | "paused";
403
436
  }
404
437
 
405
438
  // ============================================================================
@@ -429,10 +462,38 @@ export interface TemplateAutomation {
429
462
  key?: string;
430
463
  name: string;
431
464
  description?: string;
432
- triggerType: 'event' | 'cron' | 'webhook' | 'manual';
465
+ triggerType: "event" | "cron" | "webhook" | "manual";
433
466
  triggerConfig?: Record<string, unknown>;
434
467
  flow: { nodes: unknown[]; edges: unknown[]; precondition?: string };
435
- status?: 'draft' | 'active' | 'paused';
468
+ status?: "draft" | "active" | "paused";
469
+ }
470
+
471
+ // ============================================================================
472
+ // Commands — workspace-scoped intelligence commands seeded with the workspace
473
+ //
474
+ // A prompt-template command (the "Default Command Pack") the workspace exposes
475
+ // in its command surface. Mirrors `DefaultCommandDef` in synap-backend
476
+ // `packages/database/src/utils/ensure-default-commands.ts` — the door
477
+ // find-or-creates an `intelligence_commands` row from each entry, idempotent by
478
+ // `title` within the workspace (`ensureDefaultCommands`'s body generalized).
479
+ // ============================================================================
480
+
481
+ export interface TemplateCommandDerivedInput {
482
+ name: string;
483
+ label?: string;
484
+ /** Mirrors `DerivedInput.type` (text | number | entity | view | choice). */
485
+ type?: string;
486
+ }
487
+
488
+ export interface TemplateCommand {
489
+ title: string;
490
+ promptTemplate: string;
491
+ derivedInputs?: TemplateCommandDerivedInput[];
492
+ /** text | proposal | view */
493
+ outputMode?: string;
494
+ canCreateViews?: boolean;
495
+ /** read_only | propose_writes */
496
+ permissionsProfile?: string;
436
497
  }
437
498
 
438
499
  // ============================================================================
@@ -464,9 +525,9 @@ export interface TemplateActionPlacement {
464
525
  /** Entity kind whose detail surface this action appears on. */
465
526
  profileSlug: string;
466
527
  /** Where the action renders. Only "entity-detail" is consumed today. */
467
- surface: 'entity-detail' | string;
528
+ surface: "entity-detail" | string;
468
529
  /** What the action does when clicked. */
469
- kind: 'capability' | 'playbook' | 'automation';
530
+ kind: "capability" | "playbook" | "automation";
470
531
  /**
471
532
  * Reference to the thing to run: a capability verb key, or a playbook /
472
533
  * automation NAME (resolved to a row id at apply time).
@@ -509,7 +570,7 @@ export interface TemplateIntegration {
509
570
  /** Override default params with {{var}} interpolation. */
510
571
  params?: Record<string, string>;
511
572
  /** "apply" (create+update) or "install" (create only). */
512
- mode?: 'apply' | 'install';
573
+ mode?: "apply" | "install";
513
574
  }
514
575
 
515
576
  // ============================================================================
@@ -583,10 +644,10 @@ export interface TemplateExtendsRef {
583
644
  * 3. Once no payload carries `'automation'`, drop it from the wire enums and this tuple.
584
645
  */
585
646
  export const PACKAGE_DEPENDENCY_KINDS = [
586
- 'workspace',
587
- 'capability',
647
+ "workspace",
648
+ "capability",
588
649
  /** @deprecated Retired by CP 0049 in favour of `workflow`. See note above. */
589
- 'automation',
650
+ "automation",
590
651
  ] as const;
591
652
 
592
653
  /** What a `TemplateDependency` points at. Derived — never re-declare the list. */
@@ -610,7 +671,7 @@ export interface TemplateDependency {
610
671
  * - `'require'` (default): the dependency must be present on the pod as its
611
672
  * own artifact, but this template does not merge into it.
612
673
  */
613
- relation?: 'compose' | 'require';
674
+ relation?: "compose" | "require";
614
675
  /**
615
676
  * Short human reason shown in the install-time "this also installs…" prompt.
616
677
  */
@@ -654,7 +715,7 @@ export interface WorkspaceYaml {
654
715
  * Workspace classifier — consumed by createFromDefinition (settings.workspaceType,
655
716
  * creator-role + agent-workspace behavior). Mirrors the backend union.
656
717
  */
657
- workspaceType?: 'personal' | 'agent' | 'project' | 'operational';
718
+ workspaceType?: "personal" | "agent" | "project" | "operational";
658
719
  visibility?: string;
659
720
  /** Capabilities this workspace provides (e.g. "brand.library", "content.pipeline") */
660
721
  capabilities?: string[];
@@ -684,6 +745,21 @@ export interface WorkspaceYaml {
684
745
  /** Schema-level links between entity types */
685
746
  entityLinks?: TemplateEntityLink[];
686
747
 
748
+ /**
749
+ * Domain-level relation TYPE vocabulary seeded into the workspace's
750
+ * `relation_defs`. Distinct from `entityLinks` (profile→profile edges): a
751
+ * relation def stands alone and carries `description`/`isDirectional`/`uiHints`.
752
+ * The `base` template carries the universal defaults. See {@link TemplateRelationDef}.
753
+ */
754
+ relationDefs?: TemplateRelationDef[];
755
+
756
+ /**
757
+ * Workspace-scoped intelligence commands (prompt-template "Default Command
758
+ * Pack") seeded with the workspace. The `base` template carries the universal
759
+ * pack. See {@link TemplateCommand}.
760
+ */
761
+ commands?: TemplateCommand[];
762
+
687
763
  /** Initial data to seed on workspace creation */
688
764
  seedEntities?: TemplateSeedEntity[];
689
765
 
@@ -751,7 +827,7 @@ export interface WorkspaceYaml {
751
827
 
752
828
  export interface ResolvedWorkspaceTemplate {
753
829
  meta: TemplateMeta;
754
- definition: WorkspaceYaml['workspace'] & {
830
+ definition: WorkspaceYaml["workspace"] & {
755
831
  profiles: TemplateProfile[];
756
832
  views: TemplateView[];
757
833
  entityLinks: TemplateEntityLink[];
@@ -36,9 +36,9 @@
36
36
  * `schema/automations.ts`.
37
37
  */
38
38
 
39
- // ── Contract constants (mirror @synap/database schema/automations.ts) ─────────
39
+ // ── Contract constants (mirror schema/automations.ts — keep in sync) ──────────
40
40
 
41
- /** The 21 node types — `AutomationNodeBase.type` union (schema/automations.ts). */
41
+ /** The 23 node types — `AutomationNodeBase.type` union (schema/automations.ts). */
42
42
  export const FLOW_NODE_TYPES = [
43
43
  "trigger",
44
44
  "command",
@@ -56,6 +56,8 @@ export const FLOW_NODE_TYPES = [
56
56
  "select",
57
57
  "claim",
58
58
  "messages_query",
59
+ "runs_query",
60
+ "proposals_query",
59
61
  "switch",
60
62
  "skill",
61
63
  "capability",
@@ -81,22 +83,73 @@ export const FLOW_OUTPUT_TYPES = [
81
83
  /**
82
84
  * The known `config.channelType` values for a `channel_message` output. Mirrors
83
85
  * the executor's channel_message resolution (automation-executor.ts). The output
84
- * channel is CONTEXT-DERIVED — a channel_message is valid with `channelId`, OR
85
- * `channelEntityRef` (an entity-id expression → that entity's internal channel),
86
- * OR a `channelType` from this set, OR NOTHING (the executor defaults a targetless
87
- * channel_message to the automation's own run channel). A `channelType` that is
88
- * PRESENT but not one of these is still a typo → still an error.
86
+ * channel is now CONTEXT-DERIVED — a channel_message is valid with `channelId`,
87
+ * OR `channelEntityRef` (an entity-id expression → that entity's internal
88
+ * channel), OR a `channelType` from this set, OR NOTHING (the executor defaults a
89
+ * targetless channel_message to the automation's own run channel). A `channelType`
90
+ * that is PRESENT but not one of these is still a typo → still an error.
89
91
  */
92
+ // The executor's channel_message resolution (automation-executor.ts) is the SSOT
93
+ // for these values; config is an untyped Record so there is no compile-time
94
+ // drift guard here — keep this array in sync with the executor by hand.
90
95
  export const CHANNEL_MESSAGE_CHANNEL_TYPES = [
91
96
  "personal_thread",
92
97
  "proactive",
93
98
  "subjectEntity",
94
99
  ] as const;
95
100
 
101
+ /**
102
+ * Node types a `loop` may dispatch PER ITEM. Mirrors `LOOP_BODY_NODE_TYPES` in
103
+ * `packages/jobs/src/workers/automation-executor.ts` (the runtime SSOT — the
104
+ * `switch (childNode.type)` inside the loop body). A loop OWNS the contiguous
105
+ * chain of these reachable from it; traversal STOPS at any other type
106
+ * (switch / delay / nested loop / sub_automation / entity_read / …), which runs
107
+ * ONCE in the main topological pass instead.
108
+ *
109
+ * Hand-mirrored: `@synap/api` does not depend on `@synap/jobs`, so there is no
110
+ * compile-time drift guard here — keep this array in sync with the executor by
111
+ * hand (same convention as CHANNEL_MESSAGE_CHANNEL_TYPES above).
112
+ */
113
+ export const LOOP_BODY_NODE_TYPES = [
114
+ "command",
115
+ "output",
116
+ "playbook_run",
117
+ "messages_query",
118
+ "runs_query",
119
+ "proposals_query",
120
+ "query",
121
+ "fetch",
122
+ "transform",
123
+ "condition",
124
+ "skill",
125
+ "capability",
126
+ ] as const;
127
+
128
+ /**
129
+ * What a loop actually PUTS in scope for its body: `context.loop = { item, index }`
130
+ * (automation-executor.ts, `case "loop"`). Any other `{{loop.<x>}}` segment
131
+ * resolves to undefined at runtime.
132
+ */
133
+ export const LOOP_PROVIDED_BINDINGS = ["item", "index"] as const;
134
+
96
135
  const NODE_TYPE_SET = new Set<string>(FLOW_NODE_TYPES);
136
+ const LOOP_BODY_TYPE_SET = new Set<string>(LOOP_BODY_NODE_TYPES);
137
+ const LOOP_BINDING_SET = new Set<string>(LOOP_PROVIDED_BINDINGS);
97
138
  const OUTPUT_TYPE_SET = new Set<string>(FLOW_OUTPUT_TYPES);
98
139
  const CHANNEL_TYPE_SET = new Set<string>(CHANNEL_MESSAGE_CHANNEL_TYPES);
99
140
 
141
+ // ── Drift guard — NOT compile-time IN THIS COPY ───────────────────────────────
142
+ // The arrays above are hand-mirrored from TS *union types* in the schema. The
143
+ // BACKEND copy carries an `AssertExact` binding that fails `tsc` on drift; THIS
144
+ // package has no `@synap/database` dependency, so that guard cannot live here.
145
+ // Do not assume tsc protects you when editing these arrays — what protects this
146
+ // copy is `validate-flow.drift.test.ts`, which strips comments and therefore
147
+ // could never have caught this paragraph being wrong (it was, until 2026-07-31).
148
+ //
149
+ // ⚠️ If you re-generate this file FROM the backend copy, re-apply this block and
150
+ // de-duplicate the header above: a naive strip of the `AssertExact` CODE leaves
151
+ // its comment behind, asserting a guard that does not exist here.
152
+
100
153
  // ── Result / error types ──────────────────────────────────────────────────────
101
154
 
102
155
  export interface FlowValidationError {
@@ -479,6 +532,54 @@ export function validateFlowDefinition(
479
532
  }
480
533
  }
481
534
 
535
+ // ── Loop bindings: `{{loop.*}}` only inside a loop body, only real paths ──────
536
+ // `context.loop` exists ONLY while a loop dispatches its body and is deleted
537
+ // right after, so a node OUTSIDE every loop body that reads `{{loop.item.id}}`
538
+ // resolves it to `undefined` at runtime — a Zod "expected string, received
539
+ // undefined" at best, a junk entity/relation written with an empty id at worst.
540
+ // The ownership walk below is the SAME rule the executor applies
541
+ // (`computeLoopBodyNodeIds`), so a node behind a boundary type (switch, delay,
542
+ // nested loop, …) or with no edge from the loop at all is correctly reported:
543
+ // those are exactly the shapes that leak into the main pass.
544
+ const loopBodyNodeIds = new Set<string>();
545
+ for (const node of nodes) {
546
+ if (!nonEmptyString(node.id) || node.type !== "loop") continue;
547
+ for (const id of collectLoopBody(node.id, nodesById, edges)) {
548
+ loopBodyNodeIds.add(id);
549
+ }
550
+ }
551
+ for (const node of nodes) {
552
+ if (!nonEmptyString(node.id)) continue;
553
+ const refs = collectLoopBindingRefs(node.data);
554
+ if (refs.length === 0) continue;
555
+ if (!loopBodyNodeIds.has(node.id)) {
556
+ errors.push({
557
+ nodeId: node.id,
558
+ code: "loop_ref_outside_loop_body",
559
+ message: `Node "${node.id}" references ${refs
560
+ .map((r) => `\`{{loop.${r}...}}\``)
561
+ .join(
562
+ ", "
563
+ )} but is not inside a loop body, so there is no item to bind — it would run once with an empty value. Connect it downstream of a loop node with no switch/delay/nested-loop/sub_automation node in between.`,
564
+ });
565
+ continue;
566
+ }
567
+ const unknown = refs.filter((r) => !LOOP_BINDING_SET.has(r));
568
+ if (unknown.length > 0) {
569
+ errors.push({
570
+ nodeId: node.id,
571
+ code: "loop_ref_unknown_path",
572
+ message: `Node "${node.id}" references ${unknown
573
+ .map((r) => `\`{{loop.${r}}}\``)
574
+ .join(
575
+ ", "
576
+ )}, which a loop does not provide. A loop binds only: ${LOOP_PROVIDED_BINDINGS.map(
577
+ (b) => `\`loop.${b}\``
578
+ ).join(", ")}.`,
579
+ });
580
+ }
581
+ }
582
+
482
583
  // ── Acyclic (Kahn's algorithm — mirrors topoSort in automation-executor.ts) ───
483
584
  // Only run when edges reference real nodes; dangling edges are already reported
484
585
  // and would corrupt the in-degree bookkeeping.
@@ -495,6 +596,84 @@ export function validateFlowDefinition(
495
596
  return { valid: errors.length === 0, errors };
496
597
  }
497
598
 
599
+ /**
600
+ * The node ids a loop OWNS as its per-item body — the contiguous chain of
601
+ * LOOP_BODY_NODE_TYPES nodes reachable from it, stopping at any other type.
602
+ * Mirrors `computeLoopBodyNodeIds` in automation-executor.ts exactly (all
603
+ * out-edges, handle-agnostic); the `seen` set makes a cyclic graph terminate
604
+ * (cycles are reported separately).
605
+ */
606
+ function collectLoopBody(
607
+ loopNodeId: string,
608
+ nodesById: Map<string, LooseNode>,
609
+ edges: LooseEdge[]
610
+ ): Set<string> {
611
+ const body = new Set<string>();
612
+ const stack: string[] = [];
613
+ for (const edge of edges) {
614
+ if (edge.source === loopNodeId && nonEmptyString(edge.target))
615
+ stack.push(edge.target);
616
+ }
617
+ while (stack.length > 0) {
618
+ const id = stack.pop()!;
619
+ if (body.has(id)) continue;
620
+ const node = nodesById.get(id);
621
+ if (
622
+ !node ||
623
+ !nonEmptyString(node.type) ||
624
+ !LOOP_BODY_TYPE_SET.has(node.type)
625
+ )
626
+ continue; // boundary — runs once in the main pass, not per item
627
+ body.add(id);
628
+ for (const edge of edges) {
629
+ if (edge.source === id && nonEmptyString(edge.target))
630
+ stack.push(edge.target);
631
+ }
632
+ }
633
+ return body;
634
+ }
635
+
636
+ /**
637
+ * The FIRST path segment of every `{{loop.<segment>…}}` reference anywhere in a
638
+ * node's `data` (deep: objects + arrays), deduped. Mirrors
639
+ * `collectLoopBindingRefs` in automation-executor.ts — including the PRE-PIPE
640
+ * restriction: an array pipe argument (`{{x | map:{{loop.item.id}}}}`) binds
641
+ * `loop` per item inside the RESOLVER, a scope that is valid outside any loop
642
+ * node, so it must not be reported.
643
+ */
644
+ function collectLoopBindingRefs(data: unknown): string[] {
645
+ const found = new Set<string>();
646
+ const walk = (v: unknown): void => {
647
+ if (typeof v === "string") {
648
+ const head = v.split("|")[0];
649
+ const re = /\{\{\s*loop\.([A-Za-z0-9_$]+)/g;
650
+ let m: RegExpExecArray | null;
651
+ let matched = false;
652
+ while ((m = re.exec(head)) !== null) {
653
+ found.add(m[1]);
654
+ matched = true;
655
+ }
656
+ // The executor's own detector is the LOOSER `/\{\{\s*loop\./` — it fires on
657
+ // `{{loop.}}` or `{{loop.-x}}`, which this stricter segment pattern does
658
+ // not. Without this fallback a malformed reference passes save-time
659
+ // validation and then throws at RUN time, which is exactly the class of
660
+ // failure the loop rules were added to eliminate. Report it under the
661
+ // empty segment so it surfaces as `loop_ref_unknown_path`.
662
+ if (!matched && /\{\{\s*loop\./.test(head)) found.add("");
663
+ return;
664
+ }
665
+ if (Array.isArray(v)) {
666
+ for (const x of v) walk(x);
667
+ return;
668
+ }
669
+ if (isRecord(v)) {
670
+ for (const x of Object.values(v)) walk(x);
671
+ }
672
+ };
673
+ walk(data);
674
+ return [...found];
675
+ }
676
+
498
677
  /**
499
678
  * Return the set of node ids that participate in a cycle (empty if acyclic).
500
679
  * Kahn's algorithm — the same ordering the executor's `topoSort` relies on; a
@@ -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
+ });