@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/crm.yaml CHANGED
@@ -51,7 +51,6 @@ meta:
51
51
  label: Sales
52
52
  promise: Leads and deals, each with a dated next step.
53
53
  suggestFor: [small-business, founder]
54
- profile: deal
55
54
  views: [deal-pipeline, deals-table, deals-list]
56
55
  example:
57
56
  - { title: "Acme renewal", values: { commercialStage: negotiating, value: 12000, nextStep: "Send revised quote Thu" } }
@@ -69,6 +68,8 @@ meta:
69
68
  # What this space PRODUCES (homes): the purpose word, then its kinds/roles.
70
69
  intent:
71
70
  label: Sales
71
+ # The ONE primary output (One model decision 3): the won deal; the lead-becomes-client role flow is not modeled yet (S8, decision 10).
72
+ primary: deal
72
73
  produces:
73
74
  - profile: lead
74
75
  - profile: client
@@ -829,10 +830,9 @@ views:
829
830
  slug: outreach-touches
830
831
  type: kanban
831
832
  scopeProfileSlug: outreach-touch
832
- groupBy: status
833
833
  description: Every outreach message, grouped by where it stands — drafted for a person to send, through replied.
834
834
  config:
835
- groupBy: status
835
+ groupByField: status
836
836
  cardFields: [channel, touchType, sentDate]
837
837
 
838
838
  onboarding:
@@ -971,8 +971,6 @@ playbooks:
971
971
  step converts the lead. Never mark the lead a client.
972
972
  subjectProfile:
973
973
  profileSlug: lead
974
- statusProperty: leadStage
975
- humanOnlyStatuses: [new, outreach, engaged, nurturing, converted, disqualified]
976
974
  # Manual only (founder decision 2026-10-27): the `created` worker started
977
975
  # one run per lead and burned the budget; a list is scored in ONE run by
978
976
  # "Score a list" instead, and a person qualifies the leads worth it.
@@ -1096,8 +1094,6 @@ playbooks:
1096
1094
  person's call — record why it was lost.
1097
1095
  subjectProfile:
1098
1096
  profileSlug: deal
1099
- statusProperty: commercialStage
1100
- humanOnlyStatuses: [lost]
1101
1097
  activators:
1102
1098
  - { on: created, mode: propose }
1103
1099
  params:
@@ -1696,8 +1692,8 @@ playbooks:
1696
1692
  # next step, and a deal is never opened. "Read the reply" keeps only the
1697
1693
  # touches marked sent before the channels. A lifecycle READER of leadStage:
1698
1694
  # no stage moves the lead; nurturing after the second follow-up is proposed
1699
- # through the door once the last date passes in silence. `statusProperty`
1700
- # is named because the schedule worker reads `outreach`.
1695
+ # through the door once the last date passes in silence. Its schedule worker
1696
+ # reads `outreach`, a value of the lead role's lifecycle (foundation).
1701
1697
  # Next action (CRM review 2026-10-27): a drafted message becomes the lead's
1702
1698
  # `nextAction` ("Send the drafted message to <name>", `nextActionAt` today),
1703
1699
  # and a message marked sent sets `nextActionAt` by the CADENCE (founder
@@ -1783,7 +1779,6 @@ playbooks:
1783
1779
  reads it and proposes the next step.
1784
1780
  subjectProfile:
1785
1781
  profileSlug: lead
1786
- statusProperty: leadStage
1787
1782
  activators:
1788
1783
  # Weekdays 07:00 UTC, one batch over the leads in outreach.
1789
1784
  - { on: schedule, cron: "0 7 * * 1-5", status: outreach, mode: propose }
@@ -1906,8 +1901,6 @@ playbooks:
1906
1901
  meeting. Pausing or cancelling is a person's call.
1907
1902
  subjectProfile:
1908
1903
  profileSlug: campaign
1909
- statusProperty: campaign-status
1910
- humanOnlyStatuses: [paused, cancelled]
1911
1904
  activators:
1912
1905
  - { on: created, mode: propose }
1913
1906
  params:
@@ -2004,11 +1997,9 @@ playbooks:
2004
1997
  person does. Opened is recorded by a person.
2005
1998
  subjectProfile:
2006
1999
  profileSlug: outreach-touch
2007
- statusProperty: status
2008
2000
  # No stage of this process owns these: drafted and sent come before it
2009
2001
  # starts, opened is a person's record, replaced closes a draft moved into
2010
2002
  # the person's channel.
2011
- humanOnlyStatuses: [drafted, sent, opened, replaced]
2012
2003
  activators:
2013
2004
  - { on: enters_status, status: sent, mode: run }
2014
2005
  params:
@@ -54,15 +54,18 @@ describe("decision 1 — Business Developer folds into CRM", () => {
54
54
  it("'Qualify a lead' is a process on the lead ROLE's leadStage, started by a person", () => {
55
55
  const crm = tpl("crm");
56
56
  const pb = playbookOf(crm, "Qualify a lead");
57
- expect(pb?.subjectProfile).toMatchObject({ profileSlug: "lead", statusProperty: "leadStage" });
57
+ expect(pb?.subjectProfile).toEqual({ profileSlug: "lead" });
58
58
  // Manual only since 2026-10-27 (founder): a `created` worker ran once per
59
59
  // lead and burned the budget; "Score a list" scores a whole list in one run.
60
60
  expect(pb?.activators).toEqual([{ on: "manual", mode: "propose" }]);
61
61
  // The subject is a ROLE, declared pod-wide by foundation.
62
62
  expect(profileOf(tpl("foundation"), "lead")).toMatchObject({ profileKind: "role", scope: "shared" });
63
- // Every leadStage value is a stage's status or the person's.
64
- const owned = new Set([...(pb?.stages ?? []).map((s) => s.subjectStatus), ...(pb?.humanOnlyStatuses ?? [])]);
65
- expect([...owned].filter(Boolean).sort()).toEqual([...(propOf(tpl("foundation"), "lead", "leadStage")?.enumValues ?? [])].sort());
63
+ // It owns researching and qualified; the other leadStage values belong to a
64
+ // person or another flow — derived by the pod, never listed (One model
65
+ // decision 4, 7 Nov 2026; conflict C2 on `outreach` closed).
66
+ const owned = (pb?.stages ?? []).map((s) => s.subjectStatus).filter(Boolean);
67
+ expect(owned).toEqual(["researching", "qualified"]);
68
+ for (const s of owned) expect(propOf(tpl("foundation"), "lead", "leadStage")?.enumValues).toContain(s);
66
69
  // Only the last stage, reached when the person chose pursue, moves the lead
67
70
  // to qualified. It opens no deal (CRM build plan A4, 2026-10-22): a deal is
68
71
  // opened when a person records a booked meeting, which converts the lead.
@@ -130,7 +133,6 @@ describe("decision 4 — 'Deliver a deliverable' starts when the deliverable ent
130
133
  expect(pb.stages?.[0]?.subjectStatus).toBeUndefined();
131
134
  expect(pb.stages?.map((s) => s.subjectStatus)).not.toContain("not-started");
132
135
  // `cancelled` (0.20.0, plan aac47c4a W5) is a person's call too: no stage writes it.
133
- expect(pb.humanOnlyStatuses).toEqual(["not-started", "cancelled"]);
134
136
  expect(pb.stages?.map((s) => s.subjectStatus)).not.toContain("cancelled");
135
137
  expect(errorsOf(tpl("operations"))).toEqual([]);
136
138
  });
@@ -90,6 +90,7 @@ describe('workspace primary-surface conversion', () => {
90
90
  slug: 'pipeline',
91
91
  type: 'kanban',
92
92
  scopeProfileSlug: 'thing',
93
+ config: { groupByField: 'status' },
93
94
  },
94
95
  ],
95
96
  layout: {
@@ -100,6 +101,14 @@ describe('workspace primary-surface conversion', () => {
100
101
  },
101
102
  },
102
103
  });
104
+ // The kanban groups by a real select of its kind (view-kanban-group-*).
105
+ source.profiles = [
106
+ {
107
+ slug: 'thing',
108
+ displayName: 'Thing',
109
+ properties: [{ slug: 'status', label: 'Status', valueType: 'string', inputType: 'select', enumValues: ['open', 'done'] }],
110
+ },
111
+ ] as typeof source.profiles;
103
112
 
104
113
  expect(toWorkspaceDefinition(source).definition.layoutConfig).toMatchObject(
105
114
  {
package/src/define.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  WorkspacePrimarySurface,
17
17
  WorkspaceYaml, } from './types.js';
18
18
  import { getWorkspaceTemplate } from './templates.js';
19
+ import { startProfileOf } from './types.js';
19
20
  import { resolveLifecyclePresets } from './lifecycle-presets.js';
20
21
  import { spacePackageCells, type SpacePackageCellWire } from './space-packages.js';
21
22
 
@@ -753,7 +754,9 @@ export function toPackageDefinition(
753
754
  // `taskIntents` note on the PackageDefinition type above.
754
755
  taskIntents: tpl.taskIntents,
755
756
  ...(tpl.meta.intent ? { intent: tpl.meta.intent } : {}),
756
- ...(tpl.meta.start ? { start: tpl.meta.start } : {}),
757
+ // The start's kind defaults to the primary output (one source); the wire
758
+ // always carries it, so the pod, the CP and the landing read `start.profile`.
759
+ ...(tpl.meta.start ? { start: { ...tpl.meta.start, profile: startProfileOf(tpl.meta) } } : {}),
757
760
  sourceRoles: ws.sourceRoles,
758
761
  defaultSources: ws.defaultSources,
759
762
  profiles: tpl.profiles,
package/src/finance.yaml CHANGED
@@ -15,6 +15,8 @@ meta:
15
15
  # What this space PRODUCES (homes): the purpose word, then its kinds/roles.
16
16
  intent:
17
17
  label: Finance
18
+ # The ONE primary output (One model decision 3): a paid invoice is the money in (Bill a client).
19
+ primary: invoice
18
20
  produces:
19
21
  - profile: invoice
20
22
  - profile: expense
@@ -346,14 +348,20 @@ views:
346
348
  defaultSort:
347
349
  field: due-date
348
350
  direction: asc
351
+ - name: Invoice Board
352
+ slug: invoice-board
353
+ type: kanban
354
+ scopeProfileSlug: invoice
355
+ description: Invoices by status, draft to paid — the flow board of Bill a client.
356
+ config:
357
+ groupByField: status
349
358
  - name: Expenses
350
359
  slug: expenses
351
360
  type: kanban
352
361
  scopeProfileSlug: expense
353
- groupBy: status
354
362
  description: Accounts payable and spend, grouped by approval/payment status.
355
363
  config:
356
- groupBy: status
364
+ groupByField: status
357
365
  cardFields: [category, amount, vendor, expense-date]
358
366
  - name: Revenue
359
367
  slug: revenue
@@ -583,8 +591,6 @@ playbooks:
583
591
  back to Paid. Voiding an invoice is a person's call.
584
592
  subjectProfile:
585
593
  profileSlug: invoice
586
- statusProperty: status
587
- humanOnlyStatuses: [void]
588
594
  activators:
589
595
  - { on: created, mode: propose }
590
596
  params:
@@ -689,8 +695,6 @@ playbooks:
689
695
  a person's call.
690
696
  subjectProfile:
691
697
  profileSlug: expense
692
- statusProperty: status
693
- humanOnlyStatuses: [reimbursed, rejected]
694
698
  activators:
695
699
  - { on: created, mode: propose }
696
700
  params:
@@ -643,9 +643,8 @@ views:
643
643
  slug: pain-points
644
644
  type: kanban
645
645
  scopeProfileSlug: pain-point
646
- groupBy: severity
647
646
  config:
648
- groupBy: severity
647
+ groupByField: severity
649
648
  cardFields: [description, audience, evidence]
650
649
  description: Pains grouped by severity.
651
650
  - name: Positioning & Differentiators
package/src/hr.yaml CHANGED
@@ -16,6 +16,8 @@ meta:
16
16
  # What this space PRODUCES (homes): the purpose word, then its kinds/roles.
17
17
  intent:
18
18
  label: Team
19
+ # The ONE primary output (One model decision 3): a hire is what the hiring pipeline produces.
20
+ primary: candidate
19
21
  produces:
20
22
  - profile: candidate
21
23
  - profile: review
@@ -364,10 +366,9 @@ views:
364
366
  slug: reviews
365
367
  type: kanban
366
368
  scopeProfileSlug: review
367
- groupBy: status
368
369
  description: Performance reviews across the cycle, by status.
369
370
  config:
370
- groupBy: status
371
+ groupByField: status
371
372
  cardFields: [cycle, rating, reviewer]
372
373
  - name: Policies
373
374
  slug: policies
@@ -7,6 +7,8 @@
7
7
  * lifecycle, duplicate, shape);
8
8
  * - a produced profile may come from the dependency closure, and its
9
9
  * lifecycle may be declared there (a role foundation owns);
10
+ * - one `primary` output per producing space, one of `produces`; a library
11
+ * names none (`intent-primary-*`, One model decision 3);
10
12
  * - (the catalog header half lives in `schema/intent-header.test.ts`);
11
13
  * - BOTH install doors carry it (top-level `intent` on both), driven through
12
14
  * the real converters.
@@ -26,9 +28,14 @@ const lifecycled = (slug: string): TemplateProfile => ({
26
28
  } as TemplateProfile);
27
29
  const plain = (slug: string): TemplateProfile => ({ slug, displayName: slug, properties: [] } as unknown as TemplateProfile);
28
30
 
31
+ /** Fixtures name their first produced profile as the primary unless they set one (or pass `primary: undefined`). */
32
+ const withPrimary = (intent: TemplateIntent): TemplateIntent => {
33
+ if ("primary" in intent || !Array.isArray(intent.produces) || intent.produces.length === 0) return intent;
34
+ return { ...intent, primary: intent.produces[0]!.profile };
35
+ };
29
36
  const make = (slug: string, profiles: TemplateProfile[], intent?: TemplateIntent, deps: string[] = []): WorkspaceYaml =>
30
37
  ({
31
- meta: { slug, name: slug, description: "d", icon: "box", color: "blue", ...(intent ? { intent } : {}) },
38
+ meta: { slug, name: slug, description: "d", icon: "box", color: "blue", ...(intent ? { intent: withPrimary(intent) } : {}) },
32
39
  workspace: { name: slug, description: "d" },
33
40
  profiles,
34
41
  dependencies: deps.map((d) => ({ slug: d, relation: "require" as const })),
@@ -97,6 +104,23 @@ describe("meta.intent — validator rules", () => {
97
104
  expect(intentErrors(pack).map((e) => e.rule)).toEqual(["intent-no-workspace"]);
98
105
  });
99
106
 
107
+ // Negative control: each goes red with the intent-primary-* block removed from checkIntent.
108
+ it("intent-primary-missing: a producing space names its primary", () => {
109
+ const errs = intentErrors(make("t", [lifecycled("thing")], { label: "P", primary: undefined, produces: [{ profile: "thing" }] }));
110
+ expect(errs.map((e) => e.rule)).toEqual(["intent-primary-missing"]);
111
+ });
112
+
113
+ it("intent-primary-not-produced: the primary is one of produces", () => {
114
+ const errs = intentErrors(make("t", [lifecycled("thing"), lifecycled("other")], { label: "P", primary: "other", produces: [{ profile: "thing" }] }));
115
+ expect(errs.map((e) => e.rule)).toEqual(["intent-primary-not-produced"]);
116
+ expect(intentErrors(make("t", [lifecycled("thing"), lifecycled("other")], { label: "P", primary: "other", produces: [{ profile: "thing" }, { profile: "other" }] }))).toEqual([]);
117
+ });
118
+
119
+ it("intent-primary-library: a library names no primary", () => {
120
+ const errs = intentErrors(make("t", [lifecycled("thing")], { label: "Library", primary: "thing" }));
121
+ expect(errs.map((e) => e.rule)).toEqual(["intent-primary-library"]);
122
+ });
123
+
100
124
  it("an unresolvable dependency does not fabricate an unknown-profile error", () => {
101
125
  const t = make("t", [lifecycled("thing")], { label: "P", produces: [{ profile: "elsewhere" }] }, ["not-in-corpus"]);
102
126
  expect(intentErrors(t)).toEqual([]);
@@ -104,7 +128,7 @@ describe("meta.intent — validator rules", () => {
104
128
  });
105
129
 
106
130
  describe("meta.intent — both install doors carry it", () => {
107
- const intent = { label: "Product", produces: [{ profile: "thing" }] };
131
+ const intent = { label: "Product", primary: "thing", produces: [{ profile: "thing" }] };
108
132
  const t = make("t", [lifecycled("thing")], intent);
109
133
 
110
134
  it("package door: top-level intent, never in _meta (the CP rebuilds _meta on serve)", () => {
@@ -147,7 +171,24 @@ describe("meta.intent — the catalog", () => {
147
171
  expect(table["operations"]).toEqual(["Delivery", "engagement", "deliverable"]);
148
172
  expect(table["content-os"]).toEqual(["Content", "post", "newsletter", "campaign"]);
149
173
  expect(table["finance"]).toEqual(["Finance", "invoice", "expense"]);
150
- expect(table["research-base"]).toEqual(["Research", "hypothesis", "finding"]);
174
+ expect(table["research-base"]).toEqual(["Research", "hypothesis", "research-finding"]);
175
+ });
176
+
177
+ it("every producing space names its primary (One model decision 3)", () => {
178
+ const primaries = Object.fromEntries(corpus.filter((t) => t.meta.intent?.produces?.length).map((t) => [t.meta.slug, t.meta.intent!.primary]));
179
+ expect(primaries).toEqual({
180
+ "builder-workspace": "devplane_feature",
181
+ "content-os": "post",
182
+ crm: "deal",
183
+ finance: "invoice",
184
+ hr: "candidate",
185
+ legal: "legal-contract",
186
+ "marketing-campaign": "campaign",
187
+ operations: "engagement",
188
+ "project-management": "sprint",
189
+ "research-base": "research-finding",
190
+ social: "post",
191
+ });
151
192
  });
152
193
 
153
194
  it("libraries carry a label only; overlays and packs carry none", () => {
@@ -324,7 +324,6 @@ views:
324
324
  slug: workflows
325
325
  type: kanban
326
326
  scopeProfileSlug: workflow
327
- groupBy: workflow-status
328
327
  description: Recurring processes on a board by status.
329
328
  config:
330
329
  groupByField: workflow-status
@@ -0,0 +1,72 @@
1
+ /**
2
+ * A kanban's columns are the values of the field the board reads from
3
+ * `config.groupByField` (`KanbanAdapter`: `render.groupByField`, else
4
+ * `properties.status`). Until 7 Nov 2026, 21 catalog kanbans wrote a
5
+ * `groupBy` instead, which the board never reads, so each silently grouped by
6
+ * `status`. Rules `view-kanban-group-*` (validate.ts `checkKanbanGrouping`).
7
+ *
8
+ * Negative control: each rule test goes red with `checkKanbanGrouping` removed
9
+ * from `validateTemplate`; the corpus test goes red on the pre-fix catalog.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { validateTemplate } from "./validate.js";
13
+ import type { WorkspaceYaml } from "./types.js";
14
+ import { readYamlTemplates } from "./generate.mjs";
15
+
16
+ const make = (view: Record<string, unknown>): WorkspaceYaml =>
17
+ ({
18
+ meta: { slug: "t", name: "t", description: "d", icon: "box", color: "blue" },
19
+ workspace: { name: "t", description: "d" },
20
+ profiles: [
21
+ {
22
+ slug: "thing",
23
+ displayName: "Thing",
24
+ properties: [
25
+ { slug: "thing-status", label: "Status", valueType: "string", inputType: "select", enumValues: ["a", "b"] },
26
+ { slug: "notes", label: "Notes", valueType: "string", inputType: "text" },
27
+ ],
28
+ },
29
+ ],
30
+ views: [{ name: "Board", type: "kanban", scopeProfileSlug: "thing", ...view }],
31
+ }) as unknown as WorkspaceYaml;
32
+
33
+ const rules = (t: WorkspaceYaml) =>
34
+ validateTemplate(t).errors.filter((e) => e.rule.startsWith("view-kanban-group")).map((e) => e.rule);
35
+
36
+ describe("kanban grouping — view-kanban-group-*", () => {
37
+ it("config.groupByField naming a select of the kind passes (bare or properties.-prefixed)", () => {
38
+ expect(rules(make({ config: { groupByField: "thing-status" } }))).toEqual([]);
39
+ expect(rules(make({ config: { groupByField: "properties.thing-status" } }))).toEqual([]);
40
+ });
41
+
42
+ it("a top-level groupBy, or one in config, is dead", () => {
43
+ expect(rules(make({ groupBy: "thing-status", config: { groupByField: "thing-status" } }))).toEqual(["view-kanban-group-dead"]);
44
+ expect(rules(make({ config: { groupBy: "thing-status", groupByField: "thing-status" } }))).toEqual(["view-kanban-group-dead"]);
45
+ });
46
+
47
+ it("no config.groupByField is refused", () => {
48
+ expect(rules(make({}))).toEqual(["view-kanban-group-missing"]);
49
+ expect(rules(make({ groupBy: "thing-status" }))).toEqual(["view-kanban-group-dead", "view-kanban-group-missing"]);
50
+ });
51
+
52
+ it("a field that is not a select of the kind is refused", () => {
53
+ expect(rules(make({ config: { groupByField: "notes" } }))).toEqual(["view-kanban-group-unknown"]);
54
+ expect(rules(make({ config: { groupByField: "ghost" } }))).toEqual(["view-kanban-group-unknown"]);
55
+ });
56
+
57
+ it("a kind with no declaration in reach (a system kind) is not judged", () => {
58
+ expect(rules(make({ scopeProfileSlug: "task", config: { groupByField: "anything" } }))).toEqual([]);
59
+ });
60
+
61
+ it("the whole catalog: every kanban names a real grouping (non-vacuity: at least 30 kanbans)", () => {
62
+ const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
63
+ const kanbans = corpus.flatMap((t) => (t.views ?? []).filter((v) => v.type === "kanban"));
64
+ expect(kanbans.length).toBeGreaterThanOrEqual(30);
65
+ const errs = corpus.flatMap((t) =>
66
+ validateTemplate(t, { corpus })
67
+ .errors.filter((e) => e.rule.startsWith("view-kanban-group"))
68
+ .map((e) => `${t.meta.slug}: ${e.message}`)
69
+ );
70
+ expect(errs).toEqual([]);
71
+ });
72
+ });
package/src/legal.yaml CHANGED
@@ -16,6 +16,8 @@ meta:
16
16
  # What this space PRODUCES (homes): the purpose word, then its kinds/roles.
17
17
  intent:
18
18
  label: Legal
19
+ # The ONE primary output (One model decision 3): a signed contract is what legal work produces.
20
+ primary: legal-contract
19
21
  produces:
20
22
  - profile: legal-contract
21
23
  - profile: compliance-item
@@ -387,20 +389,18 @@ views:
387
389
  slug: renewals
388
390
  type: kanban
389
391
  scopeProfileSlug: legal-contract
390
- groupBy: status
391
392
  description: Contracts by lifecycle stage — catch renewals and expirations before they bite.
392
393
  config:
393
- groupBy: status
394
+ groupByField: status
394
395
  cardFields:
395
396
  [counterparty, renewalType, renewalDate, expirationDate, value]
396
397
  - name: Compliance
397
398
  slug: compliance
398
399
  type: kanban
399
400
  scopeProfileSlug: compliance-item
400
- groupBy: status
401
401
  description: Obligations by status — nothing goes overdue.
402
402
  config:
403
- groupBy: status
403
+ groupByField: status
404
404
  cardFields: [obligationType, jurisdiction, dueDate, frequency, owner]
405
405
  - name: IP Assets
406
406
  slug: ip-assets
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A kind with a lifecycle select (`post-status`, `deal-stage`, …) is a process
5
5
  * waiting to be declared: the engine can bind a playbook's stages to it
6
- * (`subjectProfile.statusProperty` + `stages[].subjectStatus`), but only if a
6
+ * (`stages[].subjectStatus`, values of the kind's declared lifecycle), but only if a
7
7
  * playbook binds the kind at all. This file holds two guards.
8
8
  *
9
9
  * (1) RATCHET. Count the kinds across the WHOLE corpus that have a lifecycle and
@@ -18,22 +18,14 @@
18
18
  * (RUNTIME_STATUS below). Kind identity is the profile slug, deduplicated
19
19
  * across templates (a shared kind re-declared in two templates counts once).
20
20
  *
21
- * (2) COVERAGE. For every playbook whose stages bind the lifecycle (it declares
22
- * `subjectProfile.statusProperty` AND at least one stage `subjectStatus`),
23
- * every enum value of that property must be some stage's `subjectStatus` or
24
- * listed in `humanOnlyStatuses`. A value in neither is a state the process
25
- * silently cannot reach or leave.
26
- *
27
- * NOT covered: a playbook that declares `statusProperty` only to READ the
28
- * lifecycle (an activator, e.g. Content OS "Repurpose" fires on Published
29
- * and creates NEW posts) and binds no stage. It owns no status, so asking it
30
- * to cover the enum would force it to list every value as human-only.
21
+ * (2) COVERAGE was retired with the playbook's `humanOnlyStatuses` list (One
22
+ * model decision 4, 7 Nov 2026): a status no stage owns is derived by the
23
+ * pod, and a person-only status is `humanOnly` on the kind's lifecycle.
31
24
  *
32
25
  * The functions + BASELINE live in scripts/catalog-lib.mjs (validateCatalog
33
- * enforces them at publish). Both scans derive their set by globbing `src/*.yaml` (readYamlTemplates);
34
- * nothing is hand-listed. Negative controls (run 2026-10-08): baseline 94 →
35
- * red "expected 95 to be less than or equal to 94"; removing "Archived" from
36
- * Produce Content's humanOnlyStatuses → red, naming `post-status = Archived`.
26
+ * enforces them at publish). The scan derives its set by globbing `src/*.yaml`
27
+ * (readYamlTemplates); nothing is hand-listed. Negative control (run
28
+ * 2026-10-08): baseline 94 → red "expected 95 to be less than or equal to 94".
37
29
  */
38
30
 
39
31
  import { describe, expect, it } from "vitest";
@@ -47,7 +39,6 @@ import {
47
39
  boundKinds,
48
40
  lifecycleKinds,
49
41
  unboundLifecycleKinds,
50
- uncoveredStatuses,
51
42
  } from "../scripts/catalog-lib.mjs";
52
43
 
53
44
  const { RUNTIME_STATUS, LIFECYCLE } = LIFECYCLE_PATTERNS;
@@ -74,51 +65,3 @@ describe("lifecycle coverage — ratchet", () => {
74
65
  expect(unbound.length, unbound.join(", ")).toBeLessThanOrEqual(BASELINE);
75
66
  });
76
67
  });
77
-
78
- describe("lifecycle coverage — every status of a stage-bound lifecycle is owned", () => {
79
- it("non-vacuity: at least two playbooks bind stages to a lifecycle", () => {
80
- const binders = corpus.flatMap((t) =>
81
- (t.playbooks ?? []).filter(
82
- (pb) => pb.subjectProfile?.statusProperty && (pb.stages ?? []).some((s) => s.subjectStatus)
83
- )
84
- );
85
- expect(binders.length).toBeGreaterThanOrEqual(2);
86
- });
87
-
88
- it("self-check: an un-owned status is reported", () => {
89
- const clone = structuredClone(corpus);
90
- const produce = clone
91
- .find((t) => t.meta.slug === "content-os")!
92
- .playbooks!.find((p) => p.name === "Produce Content")!;
93
- produce.humanOnlyStatuses = ["Idea"];
94
- expect(uncoveredStatuses(clone)).toContain("content-os › Produce Content: post-status = Archived");
95
- });
96
-
97
- it("every enum value is a stage's subjectStatus or a humanOnlyStatus", () => {
98
- expect(uncoveredStatuses(corpus)).toEqual([]);
99
- });
100
- });
101
-
102
- // ONE LIFECYCLE PER KIND OR ROLE (work model, founder rule 2026-10-11). A
103
- // second lifecycle select on the same kind is two truths about where the thing
104
- // stands (deal once carried `dealStage` AND `commercialStage`; the lead role
105
- // `leadStage` AND an overlay `lifecycleStage`). RATCHET on the EXCESS — the sum
106
- // over kinds of (lifecycle-named selects − 1) — so re-adding a second stage to a
107
- // kind that is already counted still turns this red. It counts by NAME, so side
108
- // statuses (`handoffStatus`, `commissionStatus`) are counted too: that is the
109
- // limit of a name scan, and why this is a ratchet, not a zero assertion.
110
- // Lower EXCESS_BASELINE when you fold one; never raise it.
111
- const EXCESS_BASELINE = 9;
112
- describe("one lifecycle per kind or role — ratchet", () => {
113
- it(`excess lifecycle-named selects never grow past ${EXCESS_BASELINE}`, () => {
114
- const kinds = lifecycleKinds(corpus);
115
- const multi = [...kinds].filter(([, set]) => set.size > 1);
116
- const excess = multi.reduce((n, [, set]) => n + set.size - 1, 0);
117
- expect(kinds.get("deal")?.has("commercialStage")).toBe(true); // non-vacuity
118
- expect(kinds.get("deal")?.has("dealStage"), "deal's retired dealStage is back").toBe(false);
119
- expect(kinds.get("lead")?.has("lifecycleStage"), "lead's retired lifecycleStage is back").toBe(false);
120
- expect(excess, multi.map(([k, s]) => `${k}: ${[...s].join(",")}`).join("; ")).toBeLessThanOrEqual(
121
- EXCESS_BASELINE
122
- );
123
- });
124
- });