@synap-core/workspace-templates 0.30.0 → 0.32.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/README.md +13 -5
- package/agents/hermes.agent.yaml +3 -0
- package/dist/{define-DqoItRiR.d.ts → define-DHDi8C33.d.ts} +19 -18
- package/dist/index.d.ts +2 -2
- package/dist/index.js +428 -239
- package/dist/index.js.map +1 -1
- package/dist/wire.d.ts +1 -1
- package/package.json +1 -1
- package/src/brand-library.yaml +0 -6
- package/src/builder-dev-fold.template.test.ts +2 -3
- package/src/builder-dev-playbooks.template.test.ts +7 -5
- package/src/builder.yaml +60 -42
- package/src/business-model.yaml +20 -1
- package/src/civic-france.yaml +9 -0
- package/src/content-creation.yaml +1 -1
- package/src/content-os.yaml +22 -18
- package/src/crm-absorbs-outreach.template.test.ts +1 -1
- package/src/crm-build-plan.template.test.ts +1 -1
- package/src/crm.yaml +5 -14
- package/src/decisions-wave.template.test.ts +7 -5
- package/src/define.primary-surface.test.ts +9 -0
- package/src/define.ts +4 -1
- package/src/finance.yaml +10 -6
- package/src/foundation.yaml +1 -2
- package/src/hr.yaml +3 -2
- package/src/intent.test.ts +43 -2
- package/src/internal-runbook.yaml +0 -1
- package/src/kanban-grouping.test.ts +72 -0
- package/src/legal.yaml +4 -4
- package/src/lifecycle-coverage.test.ts +7 -64
- package/src/lifecycle-declarations.test.ts +70 -74
- package/src/marketing.yaml +4 -3
- package/src/meetings-knowledge.yaml +1 -2
- package/src/operations.yaml +13 -9
- package/src/process-contract.validate.test.ts +40 -55
- package/src/project-management.yaml +2 -2
- package/src/publish-official-lib.test.ts +3 -2
- package/src/research-base.yaml +12 -5
- package/src/research-decision.yaml +18 -2
- package/src/ship-a-change.template.test.ts +3 -2
- package/src/social.yaml +4 -1
- package/src/start.test.ts +22 -0
- package/src/templates.ts +324 -183
- package/src/types.ts +28 -18
- 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
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
1022
|
-
|
|
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
|
-
* -
|
|
606
|
-
* - every `stages[].subjectStatus
|
|
607
|
-
*
|
|
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
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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-
|
|
654
|
-
`${at}
|
|
655
|
-
`playbook "${pb.name}" declares
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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 (
|
|
683
|
+
if (lifecycleProperty === undefined) {
|
|
684
|
+
if (!judgeable) return;
|
|
688
685
|
add(
|
|
689
|
-
`${rule}-no-
|
|
686
|
+
`${rule}-no-lifecycle`,
|
|
690
687
|
path,
|
|
691
|
-
`${path} = "${String(value)}" needs
|
|
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}.${
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
1280
|
-
add("start-views-scope", at, `view "${slug}" is not scoped to "${
|
|
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 ===
|
|
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.
|
|
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 "${
|
|
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 ${
|
|
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);
|