@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.
- package/README.md +12 -4
- package/dist/{define-DqoItRiR.d.ts → define-DHDi8C33.d.ts} +19 -18
- package/dist/index.d.ts +2 -2
- package/dist/index.js +594 -241
- 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 +162 -32
- 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 +44 -3
- 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 +22 -15
- 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 +490 -185
- package/src/types.ts +28 -18
- 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
|
-
|
|
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.
|
|
1700
|
-
#
|
|
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).
|
|
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
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/src/foundation.yaml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
371
|
+
groupByField: status
|
|
371
372
|
cardFields: [cycle, rating, reviewer]
|
|
372
373
|
- name: Policies
|
|
373
374
|
slug: policies
|
package/src/intent.test.ts
CHANGED
|
@@ -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", () => {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
* (`
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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).
|
|
34
|
-
* nothing is hand-listed. Negative
|
|
35
|
-
* red "expected 95 to be less than or equal to 94"
|
|
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
|
-
});
|