@synap-core/workspace-templates 0.29.0 → 0.31.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 (44) hide show
  1. package/README.md +12 -4
  2. package/dist/{define-DqoItRiR.d.ts → define-DHDi8C33.d.ts} +19 -18
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +594 -241
  5. package/dist/index.js.map +1 -1
  6. package/dist/wire.d.ts +1 -1
  7. package/package.json +1 -1
  8. package/src/brand-library.yaml +0 -6
  9. package/src/builder-dev-fold.template.test.ts +2 -3
  10. package/src/builder-dev-playbooks.template.test.ts +7 -5
  11. package/src/builder.yaml +162 -32
  12. package/src/business-model.yaml +20 -1
  13. package/src/civic-france.yaml +9 -0
  14. package/src/content-creation.yaml +1 -1
  15. package/src/content-os.yaml +22 -18
  16. package/src/crm-absorbs-outreach.template.test.ts +1 -1
  17. package/src/crm-build-plan.template.test.ts +1 -1
  18. package/src/crm.yaml +5 -14
  19. package/src/decisions-wave.template.test.ts +7 -5
  20. package/src/define.primary-surface.test.ts +9 -0
  21. package/src/define.ts +4 -1
  22. package/src/finance.yaml +10 -6
  23. package/src/foundation.yaml +1 -2
  24. package/src/hr.yaml +3 -2
  25. package/src/intent.test.ts +44 -3
  26. package/src/internal-runbook.yaml +0 -1
  27. package/src/kanban-grouping.test.ts +72 -0
  28. package/src/legal.yaml +4 -4
  29. package/src/lifecycle-coverage.test.ts +7 -64
  30. package/src/lifecycle-declarations.test.ts +70 -74
  31. package/src/marketing.yaml +4 -3
  32. package/src/meetings-knowledge.yaml +1 -2
  33. package/src/operations.yaml +13 -9
  34. package/src/process-contract.validate.test.ts +40 -55
  35. package/src/project-management.yaml +2 -2
  36. package/src/publish-official-lib.test.ts +3 -2
  37. package/src/research-base.yaml +22 -15
  38. package/src/research-decision.yaml +18 -2
  39. package/src/ship-a-change.template.test.ts +3 -2
  40. package/src/social.yaml +4 -1
  41. package/src/start.test.ts +22 -0
  42. package/src/templates.ts +490 -185
  43. package/src/types.ts +28 -18
  44. package/src/validate.ts +143 -77
package/src/types.ts CHANGED
@@ -555,12 +555,11 @@ export interface TemplatePlaybook {
555
555
  * `playbooks.matchForEntity` (filters `subject_profile->>'profileSlug'`), so a
556
556
  * captured entity of this kind can auto-launch the playbook as a session.
557
557
  *
558
- * `statusProperty` names the subject kind's SELECT property that holds its
559
- * lifecycle (e.g. `post-status`). It is what ties `stages[].subjectStatus`
560
- * to the subject: advancing into a stage writes that value (governed), and
561
- * the subject entering the value advances an open run to the stage.
562
- * `validate.ts` checks it is a select property of the kind, and that every
563
- * `subjectStatus` / activator `status` is one of its enum values.
558
+ * The process FOLLOWS the kind's declared lifecycle (`profiles[].lifecycle`):
559
+ * that property is what `stages[].subjectStatus` and activator `status` are
560
+ * values of (`validate.ts`). A playbook never names it again — the
561
+ * `statusProperty`, `onEnd` and `humanOnlyStatuses` fields are retired from
562
+ * the catalog (One model decision 4; rule `playbook-field-retired`).
564
563
  */
565
564
  subjectProfile?: {
566
565
  profileSlug: string;
@@ -572,7 +571,6 @@ export interface TemplatePlaybook {
572
571
  * fan-out is refused, so the schedule would silently never be created.
573
572
  */
574
573
  filter?: string;
575
- statusProperty?: string;
576
574
  };
577
575
  /**
578
576
  * Playbook-level slots: what a run of this process is expected to produce.
@@ -593,14 +591,6 @@ export interface TemplatePlaybook {
593
591
  * `automations:`. Public templates default to `mode: propose`.
594
592
  */
595
593
  activators?: TemplatePlaybookActivator[];
596
- /**
597
- * TEMPLATE-AUTHORING ONLY. Values of `subjectProfile.statusProperty` that no
598
- * stage owns because a PERSON sets them (e.g. Idea, Archived). The lifecycle
599
- * coverage ratchet (`lifecycle-coverage.test.ts`) requires every enum value
600
- * of the status property to be either a stage's `subjectStatus` or listed
601
- * here, so a lifecycle state can never be silently orphaned by a process.
602
- */
603
- humanOnlyStatuses?: string[];
604
594
  status?: "draft" | "active" | "paused";
605
595
  /**
606
596
  * `project` = a TRACK template (a method a project runs over its `stages`);
@@ -640,7 +630,7 @@ export interface TemplatePlaybookStage {
640
630
  */
641
631
  gate?: { kind: "human" | "check"; proposalType?: "playbook.stage_gate" };
642
632
  /**
643
- * The value of the playbook's `subjectProfile.statusProperty` this stage
633
+ * The value of the subject kind's lifecycle property this stage
644
634
  * corresponds to. Advancing INTO the stage writes it to the subject
645
635
  * (governed); the subject ENTERING it advances an open run to the stage.
646
636
  */
@@ -1004,6 +994,15 @@ export interface TemplateMeta {
1004
994
  start?: TemplateStart;
1005
995
  }
1006
996
 
997
+ /**
998
+ * The kind a start is about: its own `profile` when it names another kind,
999
+ * else the space's primary output. The ONE derivation — the validator and the
1000
+ * wire converter (`define.ts`) both read it.
1001
+ */
1002
+ export function startProfileOf(meta: Pick<TemplateMeta, "intent" | "start">): string | undefined {
1003
+ return meta.start?.profile ?? meta.intent?.primary;
1004
+ }
1005
+
1007
1006
  /** `meta.start.label` reuses {@link INTENT_LABEL_MAX}; these bound the lists. */
1008
1007
  export const START_VIEWS_MAX = 3;
1009
1008
  export const START_EXAMPLE_RANGE = [4, 6] as const;
@@ -1018,8 +1017,12 @@ export interface TemplateStart {
1018
1017
  promise: string;
1019
1018
  /** Identity ids this start is suggested for; each id belongs to one start. */
1020
1019
  suggestFor: string[];
1021
- /** The produced kind the views and the first session are about. */
1022
- profile: string;
1020
+ /**
1021
+ * The kind the views and the first session are about. Omit it: it defaults
1022
+ * to `meta.intent.primary` (one source; `define.ts` fills it on the wire).
1023
+ * Name it only when the start is about another kind than the primary.
1024
+ */
1025
+ profile?: string;
1023
1026
  /** 1-3 slugs of THIS template's views, scoped to `profile`; first = default. */
1024
1027
  views: string[];
1025
1028
  /** Preview rows, never written. `values` keys are properties of `profile`. */
@@ -1036,6 +1039,13 @@ export const INTENT_LABEL_MAX = 24;
1036
1039
  export interface TemplateIntent {
1037
1040
  /** ≤ {@link INTENT_LABEL_MAX} chars. */
1038
1041
  label: string;
1042
+ /**
1043
+ * The space's ONE primary output (One model decision 3): the kind or role
1044
+ * its main flow produces, the one its home leads with. Required when the
1045
+ * space produces anything, and one of `produces` (the rest of `produces` are
1046
+ * its operational kinds). A library (no `produces`) names none.
1047
+ */
1048
+ primary?: string;
1039
1049
  produces?: Array<{ profile: string }>;
1040
1050
  }
1041
1051
 
package/src/validate.ts CHANGED
@@ -57,7 +57,7 @@ import type {
57
57
  // eslint-disable-next-line import/extensions -- .ts extension is intentional; see header.
58
58
  import { validateFlowDefinition } from "./validate-flow.ts";
59
59
  // eslint-disable-next-line import/extensions -- .ts extension is intentional; see header.
60
- import { INTENT_LABEL_MAX, START_EXAMPLE_RANGE, START_STARTERS_RANGE, START_VIEWS_MAX } from "./types.ts";
60
+ import { INTENT_LABEL_MAX, START_EXAMPLE_RANGE, START_STARTERS_RANGE, START_VIEWS_MAX, startProfileOf } from "./types.ts";
61
61
 
62
62
  import {
63
63
  CAPABILITY_INTENTS,
@@ -602,9 +602,9 @@ export function knownRelationTypes(
602
602
 
603
603
  /**
604
604
  * The process contract a playbook declares on top of its stages:
605
- * - `subjectProfile.statusProperty` is a SELECT property of the subject kind;
606
- * - every `stages[].subjectStatus`, `activators[].status` and
607
- * `humanOnlyStatuses[]` is one of that property's enum values;
605
+ * - it names no `statusProperty`, `onEnd` or `humanOnlyStatuses` (retired);
606
+ * - every `stages[].subjectStatus` and `activators[].status` is an option of
607
+ * the subject kind's declared lifecycle;
608
608
  * - `onFail.toStage` names a stage (key or name) of the same playbook;
609
609
  * - every `relationToSubject` (playbook- and stage-level outputs) is a
610
610
  * relation the corpus defines, or `none`.
@@ -643,58 +643,55 @@ function checkProcessContract(
643
643
  for (const pb of tpl.playbooks || []) {
644
644
  const at = `playbooks."${pb.name}"`;
645
645
  const kind = pb.subjectProfile?.profileSlug;
646
- const statusProperty = pb.subjectProfile?.statusProperty;
647
646
 
648
- // Resolve the lifecycle vocabulary (null = no status property to check against).
649
- let statuses: Set<string> | null = null;
650
- if (statusProperty !== undefined) {
651
- if (!kind) {
647
+ // Retired from the catalog (One model decision 4, plan §11 row 8): the
648
+ // process follows the kind's declared lifecycle, an end moves nothing by
649
+ // default (6 Nov), and a status no stage owns is derived, not listed.
650
+ const raw = pb as unknown as Record<string, unknown>;
651
+ const subjectRaw = (pb.subjectProfile ?? {}) as Record<string, unknown>;
652
+ for (const [field, present] of [
653
+ ["subjectProfile.statusProperty", subjectRaw.statusProperty !== undefined],
654
+ ["subjectProfile.onEnd", subjectRaw.onEnd !== undefined],
655
+ ["onEnd", raw.onEnd !== undefined],
656
+ ["humanOnlyStatuses", raw.humanOnlyStatuses !== undefined],
657
+ ["subjectProfile.humanOnlyStatuses", subjectRaw.humanOnlyStatuses !== undefined],
658
+ ] as const)
659
+ if (present)
652
660
  add(
653
- "playbook-status-property-no-subject",
654
- `${at}.subjectProfile.statusProperty`,
655
- `playbook "${pb.name}" declares statusProperty "${statusProperty}" without a subjectProfile.profileSlug`
661
+ "playbook-field-retired",
662
+ `${at}.${field}`,
663
+ `playbook "${pb.name}" declares ${field} — retired: the process follows the kind's declared lifecycle (a person-only status is \`humanOnly\` on that lifecycle, a status no stage owns is derived, and a session end moves nothing)`
656
664
  );
657
- } else {
658
- const decls = profileDeclarations(kind, tpl, resolveDep, corpus);
659
- const prop = decls
660
- .flatMap((p) => p.properties ?? [])
661
- .find((pr) => pr.slug === statusProperty);
662
- if (!prop) {
663
- // A kind no declaration in reach carries (unknown kind is reported
664
- // by playbook-ref-unknown; don't double-report it here).
665
- if (decls.length > 0 || hasCorpus(resolveDep))
666
- add(
667
- "playbook-status-property-unknown",
668
- `${at}.subjectProfile.statusProperty`,
669
- `playbook "${pb.name}" statusProperty "${statusProperty}" is not a property of kind "${kind}"`
670
- );
671
- } else if (
672
- prop.inputType !== "select" ||
673
- !Array.isArray(prop.enumValues) ||
674
- prop.enumValues.length === 0
675
- ) {
676
- add(
677
- "playbook-status-property-not-select",
678
- `${at}.subjectProfile.statusProperty`,
679
- `playbook "${pb.name}" statusProperty "${kind}.${statusProperty}" must be a select property with enumValues (found inputType "${String(prop.inputType)}")`
680
- );
681
- } else {
682
- statuses = new Set(prop.enumValues);
683
- }
665
+
666
+ // The lifecycle vocabulary: the kind's DECLARED lifecycle (null = none in
667
+ // reach to check against).
668
+ let statuses: Set<string> | null = null;
669
+ let lifecycleProperty: string | undefined;
670
+ // A kind no declaration in reach carries is not judged without a corpus
671
+ // (an unknown kind is reported by playbook-ref-unknown).
672
+ let judgeable = hasCorpus(resolveDep);
673
+ if (kind) {
674
+ const decls = profileDeclarations(kind, tpl, resolveDep, corpus);
675
+ judgeable ||= decls.length > 0;
676
+ const declared = decls.find((p) => p.lifecycle !== undefined)?.lifecycle;
677
+ if (declared) {
678
+ lifecycleProperty = declared.property;
679
+ statuses = new Set(declared.options.map((o) => o.key));
684
680
  }
685
681
  }
686
682
  const checkStatus = (rule: string, path: string, value: unknown) => {
687
- if (statusProperty === undefined) {
683
+ if (lifecycleProperty === undefined) {
684
+ if (!judgeable) return;
688
685
  add(
689
- `${rule}-no-property`,
686
+ `${rule}-no-lifecycle`,
690
687
  path,
691
- `${path} = "${String(value)}" needs subjectProfile.statusProperty — a status is a value OF that property`
688
+ `${path} = "${String(value)}" needs "${String(kind)}" to declare a lifecycle — a status is a value of the kind's lifecycle property`
692
689
  );
693
690
  } else if (statuses && !statuses.has(value as string)) {
694
691
  add(
695
692
  rule,
696
693
  path,
697
- `${path} = "${String(value)}" is not a value of ${kind}.${statusProperty} (${[...statuses].join(", ")})`
694
+ `${path} = "${String(value)}" is not a value of ${kind}.${lifecycleProperty} (${[...statuses].join(", ")})`
698
695
  );
699
696
  }
700
697
  };
@@ -745,10 +742,6 @@ function checkProcessContract(
745
742
  }
746
743
  });
747
744
 
748
- (pb.humanOnlyStatuses ?? []).forEach((s, i) =>
749
- checkStatus("human-only-status-unknown", `${at}.humanOnlyStatuses[${i}]`, s)
750
- );
751
-
752
745
  const outputs: Array<{ path: string; rel: unknown; optional: unknown; at: string }> = [
753
746
  ...(pb.expectedOutputs ?? []).map((o, i) => ({
754
747
  at: `${at}.expectedOutputs[${i}]`,
@@ -946,8 +939,7 @@ function stableJson(v: unknown): string {
946
939
  * With a corpus: every template that declares this profile WITH the lifecycle
947
940
  * property declares the SAME lifecycle — the pod declares it from whichever
948
941
  * template installs first, so two different declarations would make a pod's
949
- * lifecycle depend on install order. And a playbook binding the kind reads the
950
- * lifecycle property as its `statusProperty`.
942
+ * lifecycle depend on install order.
951
943
  */
952
944
  function checkLifecycleDeclarations(
953
945
  tpl: WorkspaceYaml,
@@ -1026,22 +1018,6 @@ function checkLifecycleDeclarations(
1026
1018
  `"${profile.slug}" declares "${other.property}" here ${profile.lifecycle ? "with a different lifecycle than" : "without the lifecycle"} another template declares — the pod keeps whichever installs first, so every declaration must be identical`
1027
1019
  );
1028
1020
  }
1029
-
1030
- // A playbook moving a kind with a declared lifecycle moves THAT property.
1031
- for (const pb of tpl.playbooks ?? []) {
1032
- const kind = pb.subjectProfile?.profileSlug;
1033
- const statusProperty = pb.subjectProfile?.statusProperty;
1034
- if (!kind || statusProperty === undefined) continue;
1035
- const lc = profileDeclarations(kind, tpl, resolveDep, corpus)
1036
- .map((p) => p.lifecycle)
1037
- .find((l) => l !== undefined);
1038
- if (lc && lc.property !== statusProperty)
1039
- add(
1040
- "lifecycle-status-property",
1041
- `playbooks."${pb.name}".subjectProfile.statusProperty`,
1042
- `playbook "${pb.name}" moves "${kind}.${statusProperty}" but "${kind}"'s lifecycle is "${lc.property}" — a kind has ONE lifecycle`
1043
- );
1044
- }
1045
1021
  }
1046
1022
 
1047
1023
  // ── Presentation declarations (entity rendering, decision 8) ────────────────
@@ -1126,12 +1102,65 @@ function checkPresentationDeclarations(
1126
1102
  }
1127
1103
  }
1128
1104
 
1105
+ // ── Kanban grouping: the board names the field its columns are ─────────────
1106
+
1107
+ /**
1108
+ * A kanban's columns are the values of ONE select property, read by the
1109
+ * renderer from `config.groupByField` (`KanbanAdapter`: `render.groupByField`,
1110
+ * else `properties.status`). Grouped on the kind's lifecycle property it is the
1111
+ * flow board: columns are the states and a drag is the governed status move.
1112
+ * Rules (`view-kanban-group-*`):
1113
+ * - a `groupBy` (top-level or in config) is never read by the board (the
1114
+ * install door stores it as `config.groupBy`), so the board silently groups
1115
+ * by `status`;
1116
+ * - `config.groupByField` is required;
1117
+ * - it names a select property (with enumValues) of the scoped kind, when a
1118
+ * declaration of that kind is in reach (a system kind is not judged).
1119
+ */
1120
+ function checkKanbanGrouping(
1121
+ tpl: WorkspaceYaml,
1122
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
1123
+ corpus: WorkspaceYaml[] | undefined,
1124
+ add: (rule: string, path: string, message: string) => void
1125
+ ): void {
1126
+ for (const v of tpl.views ?? []) {
1127
+ if (String(v.type || "").toLowerCase() !== "kanban") continue;
1128
+ const at = `views."${v.name}"`;
1129
+ const dead = v.groupBy !== undefined ? "groupBy" : (v.config as { groupBy?: unknown } | undefined)?.groupBy !== undefined ? "config.groupBy" : null;
1130
+ if (dead)
1131
+ add(
1132
+ "view-kanban-group-dead",
1133
+ `${at}.${dead}`,
1134
+ `kanban "${v.name}" declares ${dead} — the board never reads it (it groups by status); write config.groupByField instead`
1135
+ );
1136
+ const field = (v.config as { groupByField?: unknown } | undefined)?.groupByField;
1137
+ if (typeof field !== "string" || !field.trim()) {
1138
+ add("view-kanban-group-missing", `${at}.config.groupByField`, `kanban "${v.name}" names no config.groupByField — its columns would be the values of "status"`);
1139
+ continue;
1140
+ }
1141
+ const kind = v.scopeProfileSlug;
1142
+ if (!kind) continue;
1143
+ const slug = field.includes(".") ? field.split(".").pop()! : field;
1144
+ const decls = profileDeclarations(kind, tpl, resolveDep, corpus);
1145
+ if (decls.length === 0) continue;
1146
+ const prop = decls.flatMap((p) => p.properties ?? []).find((p) => p.slug === slug);
1147
+ if (!prop || prop.inputType !== "select" || !Array.isArray(prop.enumValues) || prop.enumValues.length === 0)
1148
+ add(
1149
+ "view-kanban-group-unknown",
1150
+ `${at}.config.groupByField`,
1151
+ `kanban "${v.name}" groups by "${slug}", which is not a select property of "${kind}" with enumValues`
1152
+ );
1153
+ }
1154
+ }
1155
+
1129
1156
  // ── Intent: what a space produces (object-first homes H0) ───────────────────
1130
1157
 
1131
1158
  /**
1132
- * `meta.intent = { label, produces: [{ profile }] }` names what the space
1133
- * PRODUCES, in order; no `produces` makes it a library. Rules:
1159
+ * `meta.intent = { label, primary, produces: [{ profile }] }` names what the
1160
+ * space PRODUCES, in order; no `produces` makes it a library. Rules:
1134
1161
  * - `label` is a non-empty string of at most {@link INTENT_LABEL_MAX} chars;
1162
+ * - a space that produces names its ONE `primary` output, one of `produces`;
1163
+ * a library names none (`intent-primary-*`);
1135
1164
  * - each `profile` is declared by this template or by a template in its
1136
1165
  * dependency closure (a role/kind it depends on), and is named once;
1137
1166
  * - each produced profile DECLARES a lifecycle (in one of those
@@ -1162,16 +1191,42 @@ function checkIntent(
1162
1191
  return;
1163
1192
  }
1164
1193
  if (!intent || typeof intent !== "object" || Array.isArray(intent)) {
1165
- add("intent-shape", "meta.intent", "meta.intent must be { label, produces?: [{ profile }] }");
1194
+ add("intent-shape", "meta.intent", "meta.intent must be { label, primary?, produces?: [{ profile }] }");
1166
1195
  return;
1167
1196
  }
1168
- const { label, produces } = intent as { label?: unknown; produces?: unknown };
1197
+ const { label, produces, primary } = intent as { label?: unknown; produces?: unknown; primary?: unknown };
1169
1198
  if (typeof label !== "string" || !label.trim() || label.trim().length > INTENT_LABEL_MAX)
1170
1199
  add(
1171
1200
  "intent-label",
1172
1201
  "meta.intent.label",
1173
1202
  `meta.intent.label must be a non-empty string of at most ${INTENT_LABEL_MAX} characters (got ${JSON.stringify(label)})`
1174
1203
  );
1204
+ // One primary output per producing space (One model decision 3): the home
1205
+ // leads with its flow, so it is named, and it is one of what the space
1206
+ // produces (the rest are its operational kinds). A library has no flow.
1207
+ const producedSlugs = Array.isArray(produces)
1208
+ ? produces.map((e) => (e as { profile?: unknown } | null)?.profile).filter((s): s is string => typeof s === "string")
1209
+ : [];
1210
+ if (primary !== undefined && (typeof primary !== "string" || !primary.trim()))
1211
+ add("intent-shape", "meta.intent.primary", `meta.intent.primary must be a profile slug (got ${JSON.stringify(primary)})`);
1212
+ else if (primary === undefined && producedSlugs.length > 0)
1213
+ add(
1214
+ "intent-primary-missing",
1215
+ "meta.intent.primary",
1216
+ `"${tpl.meta.slug}" produces ${producedSlugs.join(", ")} but names no meta.intent.primary — name the ONE output its main flow produces`
1217
+ );
1218
+ else if (primary !== undefined && producedSlugs.length === 0)
1219
+ add(
1220
+ "intent-primary-library",
1221
+ "meta.intent.primary",
1222
+ `meta.intent.primary "${primary}" on a library — a space with no produces has no flow; list "${primary}" in produces or drop primary`
1223
+ );
1224
+ else if (primary !== undefined && !producedSlugs.includes(primary as string))
1225
+ add(
1226
+ "intent-primary-not-produced",
1227
+ "meta.intent.primary",
1228
+ `meta.intent.primary "${primary}" is not in meta.intent.produces (${producedSlugs.join(", ")}) — the primary is one of what the space produces`
1229
+ );
1175
1230
  if (produces === undefined) return;
1176
1231
  if (!Array.isArray(produces)) {
1177
1232
  add("intent-shape", "meta.intent.produces", "meta.intent.produces must be a list of { profile }");
@@ -1248,14 +1303,24 @@ function checkStart(
1248
1303
  const shapeOk =
1249
1304
  !!st && typeof st === "object" && !Array.isArray(st) &&
1250
1305
  Number.isInteger(st.order) && st.order > 0 && isStr(st.label) && isStr(st.promise) &&
1251
- strList(st.suggestFor) && isStr(st.profile) && strList(st.views) && strList(st.starters) &&
1306
+ strList(st.suggestFor) && (st.profile === undefined || isStr(st.profile)) && strList(st.views) && strList(st.starters) &&
1252
1307
  Array.isArray(st.example) && st.example.every((e: any) => e && isStr(e.title) && (e.values === undefined || (typeof e.values === "object" && e.values !== null && !Array.isArray(e.values)))) &&
1253
1308
  !!fs && isStr(fs.title) && isStr(fs.goal);
1254
1309
  if (!shapeOk) {
1255
- add("start-shape", "meta.start", "meta.start must be { order (int ≥ 1), label, promise, suggestFor[], profile, views[], example[{ title, values? }], firstSession{ title, goal }, starters[] }");
1310
+ add("start-shape", "meta.start", "meta.start must be { order (int ≥ 1), label, promise, suggestFor[], profile?, views[], example[{ title, values? }], firstSession{ title, goal }, starters[] }");
1256
1311
  return;
1257
1312
  }
1258
1313
  const start = st as import("./types.ts").TemplateStart;
1314
+ // One source: the start is about the space's primary output unless it names
1315
+ // another kind; naming the primary again is a second copy that can drift.
1316
+ const primary = tpl.meta.intent?.primary;
1317
+ if (start.profile !== undefined && start.profile === primary)
1318
+ add("start-profile-redundant", "meta.start.profile", `meta.start.profile repeats meta.intent.primary "${primary}" — omit it (the start defaults to the primary output)`);
1319
+ const profile = startProfileOf(tpl.meta);
1320
+ if (!profile) {
1321
+ add("start-profile-missing", "meta.profile", `meta.start names no kind and "${tpl.meta.slug}" declares no meta.intent.primary to default to`);
1322
+ return;
1323
+ }
1259
1324
  if (start.label.trim().length > INTENT_LABEL_MAX)
1260
1325
  add("start-label", "meta.start.label", `meta.start.label must be at most ${INTENT_LABEL_MAX} characters (got ${JSON.stringify(start.label)})`);
1261
1326
  const [exMin, exMax] = START_EXAMPLE_RANGE;
@@ -1276,26 +1341,26 @@ function checkStart(
1276
1341
  return;
1277
1342
  }
1278
1343
  const scope = [view.scopeProfileSlug, ...(view.scopeProfileSlugs ?? [])].filter(Boolean);
1279
- if (!scope.includes(start.profile))
1280
- add("start-views-scope", at, `view "${slug}" is not scoped to "${start.profile}" (scope: ${scope.join(", ") || "none"})`);
1344
+ if (!scope.includes(profile))
1345
+ add("start-views-scope", at, `view "${slug}" is not scoped to "${profile}" (scope: ${scope.join(", ") || "none"})`);
1281
1346
  });
1282
1347
 
1283
1348
  // Profile + example against the declarations in the closure.
1284
1349
  const closure = [...collectDependencyClosure(tpl, resolveDep)];
1285
1350
  const sources = [tpl, ...closure.map(resolveDep).filter((t): t is WorkspaceYaml => !!t)];
1286
- const decls = sources.flatMap((t) => (t.profiles ?? []).filter((p) => p.slug === start.profile));
1351
+ const decls = sources.flatMap((t) => (t.profiles ?? []).filter((p) => p.slug === profile));
1287
1352
  if (decls.length === 0) {
1288
1353
  if (!closure.some((d) => !resolveDep(d)))
1289
- add("start-profile-unknown", "meta.start.profile", `"${start.profile}" is neither declared by "${tpl.meta.slug}" nor by a template it depends on`);
1354
+ add("start-profile-unknown", "meta.profile", `"${profile}" is neither declared by "${tpl.meta.slug}" nor by a template it depends on`);
1290
1355
  } else {
1291
1356
  const props = new Map(decls.flatMap((p) => p.properties ?? []).map((p) => [p.slug, p] as const));
1292
1357
  start.example.forEach((row, i) => {
1293
1358
  for (const [key, value] of Object.entries(row.values ?? {})) {
1294
1359
  const at = `meta.start.example[${i}].values.${key}`;
1295
1360
  const prop = props.get(key);
1296
- if (!prop) add("start-example-property", at, `"${key}" is not a property of "${start.profile}"`);
1361
+ if (!prop) add("start-example-property", at, `"${key}" is not a property of "${profile}"`);
1297
1362
  else if (prop.enumValues?.length && !prop.enumValues.includes(value as string))
1298
- add("start-example-enum", at, `${JSON.stringify(value)} is not one of ${start.profile}.${key}: ${prop.enumValues.join(", ")}`);
1363
+ add("start-example-enum", at, `${JSON.stringify(value)} is not one of ${profile}.${key}: ${prop.enumValues.join(", ")}`);
1299
1364
  }
1300
1365
  });
1301
1366
  }
@@ -2117,6 +2182,7 @@ export function validateTemplate(
2117
2182
  checkSharedRoleReferences(tpl, resolveDepFrom(opts), add);
2118
2183
  checkStageDomains(tpl, resolveDepFrom(opts), add);
2119
2184
  checkProcessContract(tpl, resolveDepFrom(opts), opts.corpus, add);
2185
+ checkKanbanGrouping(tpl, resolveDepFrom(opts), opts.corpus, add);
2120
2186
  checkLifecycleDeclarations(tpl, resolveDepFrom(opts), opts.corpus, add);
2121
2187
  checkPresentationDeclarations(tpl, resolveDepFrom(opts), opts.corpus, add);
2122
2188
  checkIntent(tpl, resolveDepFrom(opts), add);