@synap-core/workspace-templates 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +13 -5
  2. package/agents/hermes.agent.yaml +3 -0
  3. package/dist/{define-DqoItRiR.d.ts → define-DHDi8C33.d.ts} +19 -18
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.js +428 -239
  6. package/dist/index.js.map +1 -1
  7. package/dist/wire.d.ts +1 -1
  8. package/package.json +1 -1
  9. package/src/brand-library.yaml +0 -6
  10. package/src/builder-dev-fold.template.test.ts +2 -3
  11. package/src/builder-dev-playbooks.template.test.ts +7 -5
  12. package/src/builder.yaml +60 -42
  13. package/src/business-model.yaml +20 -1
  14. package/src/civic-france.yaml +9 -0
  15. package/src/content-creation.yaml +1 -1
  16. package/src/content-os.yaml +22 -18
  17. package/src/crm-absorbs-outreach.template.test.ts +1 -1
  18. package/src/crm-build-plan.template.test.ts +1 -1
  19. package/src/crm.yaml +5 -14
  20. package/src/decisions-wave.template.test.ts +7 -5
  21. package/src/define.primary-surface.test.ts +9 -0
  22. package/src/define.ts +4 -1
  23. package/src/finance.yaml +10 -6
  24. package/src/foundation.yaml +1 -2
  25. package/src/hr.yaml +3 -2
  26. package/src/intent.test.ts +43 -2
  27. package/src/internal-runbook.yaml +0 -1
  28. package/src/kanban-grouping.test.ts +72 -0
  29. package/src/legal.yaml +4 -4
  30. package/src/lifecycle-coverage.test.ts +7 -64
  31. package/src/lifecycle-declarations.test.ts +70 -74
  32. package/src/marketing.yaml +4 -3
  33. package/src/meetings-knowledge.yaml +1 -2
  34. package/src/operations.yaml +13 -9
  35. package/src/process-contract.validate.test.ts +40 -55
  36. package/src/project-management.yaml +2 -2
  37. package/src/publish-official-lib.test.ts +3 -2
  38. package/src/research-base.yaml +12 -5
  39. package/src/research-decision.yaml +18 -2
  40. package/src/ship-a-change.template.test.ts +3 -2
  41. package/src/social.yaml +4 -1
  42. package/src/start.test.ts +22 -0
  43. package/src/templates.ts +324 -183
  44. package/src/types.ts +28 -18
  45. 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
- ...(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)", () => {
@@ -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) {
@@ -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
- });
@@ -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 through
11
- * `subjectProfile.statusProperty` declares that property as its lifecycle;
12
- * - `humanOnly` = the binding playbooks' `humanOnlyStatuses` that stop, pause
13
- * or end the thing (category paused/done/lost) — never an invented one —
14
- * plus the exact ledger of founder-decided ACTIVE person-only options
15
- * (`PERSON_ONLY_ACTIVE`);
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).toMatchObject({ profileSlug: "post", statusProperty: "post-status" });
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, statusProperty, playbook) a catalog playbook moves. `withSystem`
84
- * false drops the pod's system kinds (task, decision, question…), whose
85
- * lifecycle the pod seeds itself (`ensure-system-lifecycles.ts`) — except
86
- * `deal`, a system kind whose lifecycle the CRM template owns.
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?.statusProperty &&
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!.statusProperty! }))
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
- * ACTIVE options a person must set, each a founder decision — the only place
150
- * `humanOnly` may sit outside paused/done/lost. Still never invented: each
151
- * must ALSO be in its binding playbook's `humanOnlyStatuses` (the rule below
152
- * reads both). EXACT: an entry that is no longer humanOnly turns this red.
153
- * - ui-design.approved — the design review is a person's call; a coding
154
- * agent implements only from an approved design (DESIGN-DEV-XP-PLAN P6,
155
- * founder-approved 2026-10-12).
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 PERSON_ONLY_ACTIVE = new Set(["builder-workspace:ui-design.approved", "crm:outreach-touch.sent"]);
161
-
162
- /**
163
- * Person-only options the FOUNDER decided on a lifecycle no playbook moves
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
- it("humanOnly = the binding playbooks' humanOnlyStatuses that pause or end the thing", () => {
175
- const drift: string[] = [];
176
- const activeSeen: string[] = [];
177
- const founderSeen: string[] = [];
178
- for (const t of corpus)
179
- for (const p of t.profiles) {
180
- if (!p.lifecycle) continue;
181
- const ho = new Set(
182
- playbookBindings({ withSystem: true })
183
- .filter((b) => b.kind === p.slug && b.property === p.lifecycle!.property)
184
- .flatMap((b) => b.pb.humanOnlyStatuses ?? [])
185
- );
186
- for (const o of p.lifecycle.options) {
187
- const id = `${t.meta.slug}:${p.slug}.${o.key}`;
188
- const want =
189
- (ho.has(o.key) && (["paused", "done", "lost"].includes(o.category) || PERSON_ONLY_ACTIVE.has(id))) ||
190
- FOUNDER_HUMAN_ONLY.has(id);
191
- if (want && PERSON_ONLY_ACTIVE.has(id)) activeSeen.push(id);
192
- if (o.humanOnly && FOUNDER_HUMAN_ONLY.has(id)) founderSeen.push(id);
193
- if (Boolean(o.humanOnly) !== want) drift.push(`${id} humanOnly=${Boolean(o.humanOnly)}`);
194
- }
195
- }
196
- expect(drift).toEqual([]);
197
- expect(activeSeen.sort()).toEqual([...PERSON_ONLY_ACTIVE].sort());
198
- expect(founderSeen.sort()).toEqual([...FOUNDER_HUMAN_ONLY].sort());
199
- // non-vacuity: the rule marks real options
200
- expect(lifecycleOf(tpl("content-os"), "post")?.options.find((o) => o.key === "Archived")?.humanOnly).toBe(true);
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
  });
@@ -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
- groupBy: campaign-status
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
- groupBy: status
177
+ groupByField: status
179
178
 
180
179
  entityLinks:
181
180
  - sourceProfileSlug: meeting