@synap-core/workspace-templates 0.30.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 +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/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)", () => {
|
|
@@ -150,6 +174,23 @@ describe("meta.intent — the catalog", () => {
|
|
|
150
174
|
expect(table["research-base"]).toEqual(["Research", "hypothesis", "research-finding"]);
|
|
151
175
|
});
|
|
152
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
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
153
194
|
it("libraries carry a label only; overlays and packs carry none", () => {
|
|
154
195
|
for (const slug of ["brand-library", "foundation", "ecosystem"]) expect(table[slug], slug).toHaveLength(1);
|
|
155
196
|
for (const t of corpus) {
|
|
@@ -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
|
-
});
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* - the validator's category list equals `@synap-core/types/lifecycle`;
|
|
8
8
|
* - the mandatory declarations (deal, lead, client, the post that Produce
|
|
9
9
|
* Content drives) carry the founder-confirmed categories;
|
|
10
|
-
* - COVERAGE, derived: every non-system kind a playbook moves
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* - COVERAGE, derived: every non-system kind a playbook moves (a stage
|
|
11
|
+
* `subjectStatus` or a worker `status`) declares a lifecycle, on every
|
|
12
|
+
* declaration that carries its property — the playbook follows it;
|
|
13
|
+
* - `humanOnly` lives ONLY on the kind's lifecycle (One model decision 4, the
|
|
14
|
+
* playbook's `humanOnlyStatuses` list is retired): the exact ledger below
|
|
15
|
+
* pins every person-only option, so changing one is a deliberate edit;
|
|
16
16
|
* - the whole corpus validates with zero `lifecycle-*` errors, and each rule
|
|
17
17
|
* fires on a broken clone (self-checks).
|
|
18
18
|
*/
|
|
@@ -72,18 +72,24 @@ describe("lifecycle declarations — the mandatory four", () => {
|
|
|
72
72
|
|
|
73
73
|
it("the post Produce Content drives declares post-status", () => {
|
|
74
74
|
const produce = tpl("content-os").playbooks!.find((p) => p.name === "Produce Content")!;
|
|
75
|
-
expect(produce.subjectProfile).
|
|
75
|
+
expect(produce.subjectProfile).toEqual({ profileSlug: "post" });
|
|
76
76
|
const lc = lifecycleOf(tpl("content-os"), "post");
|
|
77
77
|
expect(lc?.property).toBe("post-status");
|
|
78
78
|
expect(categories(lc)).toMatchObject({ Idea: "open", Published: "done", Archived: "lost" });
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
/** The lifecycle property a kind declares anywhere in the corpus. */
|
|
83
|
+
const lifecyclePropertyOf = (kind: string) =>
|
|
84
|
+
corpus.flatMap((t) => t.profiles).find((p) => p.slug === kind && p.lifecycle)?.lifecycle?.property;
|
|
85
|
+
|
|
82
86
|
/**
|
|
83
|
-
* Every (kind,
|
|
84
|
-
*
|
|
85
|
-
* lifecycle the
|
|
86
|
-
*
|
|
87
|
+
* Every (kind, lifecycle property, playbook) a catalog playbook moves: a stage
|
|
88
|
+
* with a `subjectStatus` or a worker with a `status`. The property is the
|
|
89
|
+
* kind's declared lifecycle (the playbook names none). `withSystem` false drops
|
|
90
|
+
* the pod's system kinds (task, decision, question…), whose lifecycle the pod
|
|
91
|
+
* seeds itself (`ensure-system-lifecycles.ts`) — except `deal`, a system kind
|
|
92
|
+
* whose lifecycle the CRM template owns.
|
|
87
93
|
*/
|
|
88
94
|
const TEMPLATE_OWNED_SYSTEM_LIFECYCLES = new Set(["deal"]);
|
|
89
95
|
function playbookBindings({ withSystem = false } = {}) {
|
|
@@ -91,12 +97,13 @@ function playbookBindings({ withSystem = false } = {}) {
|
|
|
91
97
|
(t.playbooks ?? [])
|
|
92
98
|
.filter(
|
|
93
99
|
(pb) =>
|
|
94
|
-
pb.subjectProfile?.
|
|
100
|
+
pb.subjectProfile?.profileSlug &&
|
|
101
|
+
((pb.stages ?? []).some((st) => st.subjectStatus) || (pb.activators ?? []).some((a) => a.status)) &&
|
|
95
102
|
(withSystem ||
|
|
96
103
|
!SYSTEM_PROFILES.has(pb.subjectProfile.profileSlug) ||
|
|
97
104
|
TEMPLATE_OWNED_SYSTEM_LIFECYCLES.has(pb.subjectProfile.profileSlug))
|
|
98
105
|
)
|
|
99
|
-
.map((pb) => ({ template: t, pb, kind: pb.subjectProfile!.profileSlug, property: pb.subjectProfile!.
|
|
106
|
+
.map((pb) => ({ template: t, pb, kind: pb.subjectProfile!.profileSlug, property: lifecyclePropertyOf(pb.subjectProfile!.profileSlug) }))
|
|
100
107
|
);
|
|
101
108
|
}
|
|
102
109
|
|
|
@@ -137,7 +144,9 @@ describe("lifecycle declarations — coverage (derived from the playbooks)", ()
|
|
|
137
144
|
});
|
|
138
145
|
|
|
139
146
|
it("every kind a playbook moves declares that property as its lifecycle, wherever it is declared", () => {
|
|
140
|
-
const missing: string[] =
|
|
147
|
+
const missing: string[] = playbookBindings({ withSystem: true })
|
|
148
|
+
.filter((b) => !b.property)
|
|
149
|
+
.map((b) => `${b.template.meta.slug}:${b.pb.name} moves ${b.kind}, which declares no lifecycle`);
|
|
141
150
|
for (const b of playbookBindings())
|
|
142
151
|
for (const t of corpus)
|
|
143
152
|
for (const p of t.profiles.filter((x) => x.slug === b.kind && x.properties.some((pr) => pr.slug === b.property)))
|
|
@@ -146,58 +155,56 @@ describe("lifecycle declarations — coverage (derived from the playbooks)", ()
|
|
|
146
155
|
});
|
|
147
156
|
|
|
148
157
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* - outreach-touch.sent — the agent drafts, a person sends (founder decision
|
|
157
|
-
* 2026-10-20; the CRM absorbed Outreach & Comms 2026-10-23): no agent ever
|
|
158
|
-
* marks a message sent, and the reply reader starts only once a person did.
|
|
158
|
+
* Every person-only option in the catalog, EXACT. Until 7 Nov 2026 this was
|
|
159
|
+
* derived from the binding playbooks' `humanOnlyStatuses`; that list is
|
|
160
|
+
* retired (One model decision 4), so the flags themselves are the source and
|
|
161
|
+
* this ledger makes any change to one a deliberate, reviewed edit. Founder
|
|
162
|
+
* decisions among them: ui-design.approved (design review, DESIGN-DEV-XP-PLAN
|
|
163
|
+
* P6, 2026-10-12), outreach-touch.sent (a person sends, 2026-10-20),
|
|
164
|
+
* devplane_feature.cancelled / devplane_story.dropped (homes, 2026-10-13).
|
|
159
165
|
*/
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
* yet (so no `humanOnlyStatuses` can carry them). Each one a dated decision;
|
|
165
|
-
* EXACT, like the ledger above.
|
|
166
|
-
* - devplane_feature.cancelled / devplane_story.dropped — "Dropped (lost,
|
|
167
|
-
* humanOnly)", object-first homes decision 2026-10-13.
|
|
168
|
-
*/
|
|
169
|
-
const FOUNDER_HUMAN_ONLY = new Set([
|
|
166
|
+
const HUMAN_ONLY = [
|
|
167
|
+
"brand-library:brand-asset.deprecated",
|
|
168
|
+
"brand-library:brand-voice-guide.deprecated",
|
|
169
|
+
"brand-library:content-guideline.deprecated",
|
|
170
170
|
"builder-workspace:devplane_feature.cancelled",
|
|
171
171
|
"builder-workspace:devplane_story.dropped",
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
172
|
+
"builder-workspace:finding.superseded",
|
|
173
|
+
"builder-workspace:finding.wont_fix",
|
|
174
|
+
"builder-workspace:issue.wont_fix",
|
|
175
|
+
"builder-workspace:ui-design.approved",
|
|
176
|
+
"builder-workspace:ui-design.archived",
|
|
177
|
+
"content-os:campaign.cancelled",
|
|
178
|
+
"content-os:campaign.paused",
|
|
179
|
+
"content-os:newsletter.Archived",
|
|
180
|
+
"content-os:post.Archived",
|
|
181
|
+
"crm:campaign.cancelled",
|
|
182
|
+
"crm:campaign.paused",
|
|
183
|
+
"crm:deal.lost",
|
|
184
|
+
"crm:outreach-touch.replaced",
|
|
185
|
+
"crm:outreach-touch.sent",
|
|
186
|
+
"finance:expense.reimbursed",
|
|
187
|
+
"finance:expense.rejected",
|
|
188
|
+
"finance:invoice.void",
|
|
189
|
+
"foundation:lead.converted",
|
|
190
|
+
"foundation:lead.disqualified",
|
|
191
|
+
"marketing-campaign:campaign.cancelled",
|
|
192
|
+
"marketing-campaign:campaign.paused",
|
|
193
|
+
"operations:contract.expired",
|
|
194
|
+
"operations:contract.terminated",
|
|
195
|
+
"operations:deliverable.cancelled",
|
|
196
|
+
"operations:engagement.cancelled",
|
|
197
|
+
"operations:engagement.on-hold",
|
|
198
|
+
"social:post.Archived",
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
it("humanOnly sits on the kind's lifecycle, exactly the ledger", () => {
|
|
202
|
+
const seen = corpus.flatMap((t) =>
|
|
203
|
+
t.profiles.flatMap((p) =>
|
|
204
|
+
(p.lifecycle?.options ?? []).filter((o) => o.humanOnly).map((o) => `${t.meta.slug}:${p.slug}.${o.key}`)
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
expect([...new Set(seen)].sort()).toEqual(HUMAN_ONLY);
|
|
201
208
|
});
|
|
202
209
|
});
|
|
203
210
|
|
|
@@ -238,15 +245,4 @@ describe("lifecycle declarations — the validator", () => {
|
|
|
238
245
|
delete campaignIn(c2, "crm").lifecycle;
|
|
239
246
|
expect(lifecycleErrors(crmIn(c2), c2).map((e) => e.rule)).toContain("lifecycle-diverges");
|
|
240
247
|
});
|
|
241
|
-
|
|
242
|
-
it("self-check: a playbook moving another property than the lifecycle is refused", () => {
|
|
243
|
-
const c = clone();
|
|
244
|
-
const produce = c.find((t) => t.meta.slug === "content-os")!.playbooks!.find((p) => p.name === "Produce Content")!;
|
|
245
|
-
const post = c.find((t) => t.meta.slug === "content-os")!.profiles.find((p) => p.slug === "post")!;
|
|
246
|
-
post.lifecycle!.property = "platform";
|
|
247
|
-
produce.subjectProfile!.statusProperty = "post-status";
|
|
248
|
-
expect(lifecycleErrors(c.find((t) => t.meta.slug === "content-os")!, c).map((e) => e.rule)).toContain(
|
|
249
|
-
"lifecycle-status-property"
|
|
250
|
-
);
|
|
251
|
-
});
|
|
252
248
|
});
|
package/src/marketing.yaml
CHANGED
|
@@ -13,6 +13,8 @@ meta:
|
|
|
13
13
|
# What this space PRODUCES (homes): the purpose word, then its kinds/roles.
|
|
14
14
|
intent:
|
|
15
15
|
label: Marketing
|
|
16
|
+
# The ONE primary output (One model decision 3): a run campaign (Run a campaign).
|
|
17
|
+
primary: campaign
|
|
16
18
|
produces:
|
|
17
19
|
- profile: campaign
|
|
18
20
|
|
|
@@ -261,7 +263,8 @@ views:
|
|
|
261
263
|
slug: campaigns
|
|
262
264
|
type: kanban
|
|
263
265
|
scopeProfileSlug: campaign
|
|
264
|
-
|
|
266
|
+
config:
|
|
267
|
+
groupByField: campaign-status
|
|
265
268
|
description: "Campaign kanban board by status."
|
|
266
269
|
- name: Contacts
|
|
267
270
|
slug: contacts
|
|
@@ -399,8 +402,6 @@ playbooks:
|
|
|
399
402
|
person's call.
|
|
400
403
|
subjectProfile:
|
|
401
404
|
profileSlug: campaign
|
|
402
|
-
statusProperty: campaign-status
|
|
403
|
-
humanOnlyStatuses: [paused, cancelled]
|
|
404
405
|
activators:
|
|
405
406
|
- { on: created, mode: propose }
|
|
406
407
|
params:
|
|
@@ -172,10 +172,9 @@ views:
|
|
|
172
172
|
slug: meeting-action-items
|
|
173
173
|
type: kanban
|
|
174
174
|
scopeProfileSlug: task
|
|
175
|
-
groupBy: status
|
|
176
175
|
description: Tasks generated from meetings, by status.
|
|
177
176
|
config:
|
|
178
|
-
|
|
177
|
+
groupByField: status
|
|
179
178
|
|
|
180
179
|
entityLinks:
|
|
181
180
|
- sourceProfileSlug: meeting
|