@synap-core/workspace-templates 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +12 -4
  2. package/dist/{define-DqoItRiR.d.ts → define-DHDi8C33.d.ts} +19 -18
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +594 -241
  5. package/dist/index.js.map +1 -1
  6. package/dist/wire.d.ts +1 -1
  7. package/package.json +1 -1
  8. package/src/brand-library.yaml +0 -6
  9. package/src/builder-dev-fold.template.test.ts +2 -3
  10. package/src/builder-dev-playbooks.template.test.ts +7 -5
  11. package/src/builder.yaml +162 -32
  12. package/src/business-model.yaml +20 -1
  13. package/src/civic-france.yaml +9 -0
  14. package/src/content-creation.yaml +1 -1
  15. package/src/content-os.yaml +22 -18
  16. package/src/crm-absorbs-outreach.template.test.ts +1 -1
  17. package/src/crm-build-plan.template.test.ts +1 -1
  18. package/src/crm.yaml +5 -14
  19. package/src/decisions-wave.template.test.ts +7 -5
  20. package/src/define.primary-surface.test.ts +9 -0
  21. package/src/define.ts +4 -1
  22. package/src/finance.yaml +10 -6
  23. package/src/foundation.yaml +1 -2
  24. package/src/hr.yaml +3 -2
  25. package/src/intent.test.ts +44 -3
  26. package/src/internal-runbook.yaml +0 -1
  27. package/src/kanban-grouping.test.ts +72 -0
  28. package/src/legal.yaml +4 -4
  29. package/src/lifecycle-coverage.test.ts +7 -64
  30. package/src/lifecycle-declarations.test.ts +70 -74
  31. package/src/marketing.yaml +4 -3
  32. package/src/meetings-knowledge.yaml +1 -2
  33. package/src/operations.yaml +13 -9
  34. package/src/process-contract.validate.test.ts +40 -55
  35. package/src/project-management.yaml +2 -2
  36. package/src/publish-official-lib.test.ts +3 -2
  37. package/src/research-base.yaml +22 -15
  38. package/src/research-decision.yaml +18 -2
  39. package/src/ship-a-change.template.test.ts +3 -2
  40. package/src/social.yaml +4 -1
  41. package/src/start.test.ts +22 -0
  42. package/src/templates.ts +490 -185
  43. package/src/types.ts +28 -18
  44. package/src/validate.ts +143 -77
@@ -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
@@ -18,7 +18,6 @@ meta:
18
18
  label: Client delivery
19
19
  promise: Every client engagement and where it stands.
20
20
  suggestFor: [consultant]
21
- profile: engagement
22
21
  views: [engagements]
23
22
  example:
24
23
  - { title: "Website redesign for Maison Dupont", values: { engagement-status: active, engagement-value: 18000 } }
@@ -35,6 +34,8 @@ meta:
35
34
  # What this space PRODUCES (homes): the purpose word, then its kinds/roles.
36
35
  intent:
37
36
  label: Delivery
37
+ # The ONE primary output (One model decision 3): a delivered engagement (Deliver a client engagement).
38
+ primary: engagement
38
39
  produces:
39
40
  - profile: engagement
40
41
  - profile: deliverable
@@ -304,19 +305,28 @@ views:
304
305
  slug: engagements
305
306
  type: kanban
306
307
  scopeProfileSlug: engagement
307
- groupBy: engagement-status
308
+ config:
309
+ groupByField: engagement-status
308
310
  description: "Client engagements on a board by status."
309
311
  - name: Deliverables
310
312
  slug: deliverables
311
313
  type: kanban
312
314
  scopeProfileSlug: deliverable
313
- groupBy: deliverable-status
315
+ config:
316
+ groupByField: deliverable-status
314
317
  description: "Deliverables on a board by status."
315
318
  - name: Contracts
316
319
  slug: contracts
317
320
  type: table
318
321
  scopeProfileSlug: contract
319
322
  description: "Contracts with their status, value, and term."
323
+ - name: Contract Board
324
+ slug: contract-board
325
+ type: kanban
326
+ scopeProfileSlug: contract
327
+ description: "Contracts by status — the flow board of Get a contract signed."
328
+ config:
329
+ groupByField: contract-status
320
330
  - name: Client Briefings
321
331
  slug: client-briefings
322
332
  type: list
@@ -938,8 +948,6 @@ playbooks:
938
948
  call.
939
949
  subjectProfile:
940
950
  profileSlug: engagement
941
- statusProperty: engagement-status
942
- humanOnlyStatuses: [on-hold, cancelled]
943
951
  activators:
944
952
  - { on: created, mode: propose }
945
953
  params:
@@ -1018,8 +1026,6 @@ playbooks:
1018
1026
  close bills it.
1019
1027
  subjectProfile:
1020
1028
  profileSlug: deliverable
1021
- statusProperty: deliverable-status
1022
- humanOnlyStatuses: [not-started, cancelled]
1023
1029
  activators:
1024
1030
  - { on: enters_status, status: in-progress, mode: propose }
1025
1031
  params:
@@ -1095,8 +1101,6 @@ playbooks:
1095
1101
  document. Expiry and termination are a person's call.
1096
1102
  subjectProfile:
1097
1103
  profileSlug: contract
1098
- statusProperty: contract-status
1099
- humanOnlyStatuses: [expired, terminated]
1100
1104
  activators:
1101
1105
  - { on: created, mode: propose }
1102
1106
  params:
@@ -2,11 +2,11 @@
2
2
  * The process contract a playbook declares is checked at authoring time
3
3
  * (`checkProcessContract` in validate.ts), because every one of these mistakes
4
4
  * is STORED by the pod and then silently never fires:
5
- * - a `statusProperty` that is not a select of the subject kind → the stage's
6
- * status write lands on a field nobody reads;
7
- * - a `subjectStatus` / activator `status` / `humanOnlyStatuses` value that is
8
- * not one of the enum values → a write the select refuses, or a rule on a
9
- * value no entity ever enters;
5
+ * - a `statusProperty` / `onEnd` / `humanOnlyStatuses` → retired (One model
6
+ * decision 4): the process follows the kind's declared lifecycle;
7
+ * - a `subjectStatus` / activator `status` that is not an option of the
8
+ * kind's declared lifecycle (or a kind with none) → a write the select
9
+ * refuses, or a rule on a value no entity ever enters;
10
10
  * - an `onFail.toStage` naming no stage → a rejected gate with nowhere to go;
11
11
  * - a `relationToSubject` naming no relation → an edge type nothing renders.
12
12
  *
@@ -24,21 +24,17 @@ import type { WorkspaceYaml } from "./types.js";
24
24
  import { readYamlTemplates } from "./generate.mjs";
25
25
 
26
26
  const PROCESS_RULES = new Set([
27
- "playbook-status-property-no-subject",
28
- "playbook-status-property-unknown",
29
- "playbook-status-property-not-select",
27
+ "playbook-field-retired",
30
28
  "stage-subject-status-unknown",
31
- "stage-subject-status-unknown-no-property",
29
+ "stage-subject-status-unknown-no-lifecycle",
32
30
  "stage-onfail-unknown",
33
31
  "activator-on-invalid",
34
32
  "activator-mode-invalid",
35
33
  "activator-no-subject",
36
34
  "activator-status-required",
37
35
  "activator-status-unknown",
38
- "activator-status-unknown-no-property",
36
+ "activator-status-unknown-no-lifecycle",
39
37
  "activator-status-unexpected",
40
- "human-only-status-unknown",
41
- "human-only-status-unknown-no-property",
42
38
  "output-relation-no-subject",
43
39
  "output-relation-unknown",
44
40
  "output-optional-not-boolean",
@@ -46,10 +42,19 @@ const PROCESS_RULES = new Set([
46
42
  ]);
47
43
 
48
44
  /**
49
- * `owner` OWNS the shared kind `widget` (with its lifecycle select). `proc`
45
+ * `owner` OWNS the shared kind `widget` (with its declared lifecycle). `proc`
50
46
  * requires it and only re-declares `widget` with no properties — so resolving
51
- * `widget-stage` proves the status property is found ACROSS the corpus.
47
+ * `widget-stage` proves the lifecycle is found ACROSS the corpus.
52
48
  */
49
+ const WIDGET_LIFECYCLE = {
50
+ property: "widget-stage",
51
+ options: [
52
+ { key: "New", category: "open" },
53
+ { key: "Built", category: "active" },
54
+ { key: "Shipped", category: "done" },
55
+ { key: "Archived", category: "lost", humanOnly: true },
56
+ ],
57
+ };
53
58
  const owner = {
54
59
  meta: { slug: "owner", name: "Owner", description: "d", icon: "x", color: "gray" },
55
60
  workspace: { name: "Owner", description: "d" },
@@ -58,6 +63,7 @@ const owner = {
58
63
  slug: "widget",
59
64
  displayName: "Widget",
60
65
  scope: "shared",
66
+ lifecycle: WIDGET_LIFECYCLE,
61
67
  properties: [
62
68
  { slug: "widget-stage", label: "Stage", valueType: "string", inputType: "select", enumValues: ["New", "Built", "Shipped", "Archived"] },
63
69
  { slug: "widget-notes", label: "Notes", valueType: "string", inputType: "text" },
@@ -72,8 +78,7 @@ function proc(mutate: (pb: Record<string, any>) => void = () => {}): WorkspaceYa
72
78
  name: "Build Widget",
73
79
  goalTemplate: "Build {widget}",
74
80
  params: [{ name: "widget", type: "entity", required: true }],
75
- subjectProfile: { profileSlug: "widget", statusProperty: "widget-stage" },
76
- humanOnlyStatuses: ["New", "Archived"],
81
+ subjectProfile: { profileSlug: "widget" },
77
82
  activators: [{ on: "enters_status", status: "Built", mode: "propose" }],
78
83
  expectedOutputs: [{ kind: "widget", label: "Spin-off", relationToSubject: "spawned_from" }],
79
84
  stages: [
@@ -112,26 +117,16 @@ describe("process contract — the clean fixture is clean", () => {
112
117
  });
113
118
 
114
119
  describe("process contract — each rule fires on its own fixture", () => {
115
- it("statusProperty that the kind does not carry", () => {
116
- expect(rulesOf(proc((pb) => (pb.subjectProfile.statusProperty = "widget-phase")))).toEqual([
117
- "playbook-status-property-unknown",
120
+ // Negative control: each assertion goes red with the `playbook-field-retired`
121
+ // loop removed from checkProcessContract.
122
+ it("each retired field is refused (statusProperty, onEnd, humanOnlyStatuses)", () => {
123
+ expect(rulesOf(proc((pb) => (pb.subjectProfile.statusProperty = "widget-stage")))).toEqual([
124
+ "playbook-field-retired",
118
125
  ]);
119
- });
120
-
121
- it("statusProperty that is not a select", () => {
122
- expect(
123
- rulesOf(
124
- proc((pb) => {
125
- pb.subjectProfile.statusProperty = "widget-notes";
126
- // Without a resolvable vocabulary the statuses are not re-reported.
127
- })
128
- )
129
- ).toEqual(["playbook-status-property-not-select"]);
130
- });
131
-
132
- it("statusProperty with no subject kind", () => {
133
- const rules = rulesOf(proc((pb) => (pb.subjectProfile = { statusProperty: "widget-stage" })));
134
- expect(rules).toContain("playbook-status-property-no-subject");
126
+ expect(rulesOf(proc((pb) => (pb.subjectProfile.onEnd = { cancelled: "New" })))).toEqual([
127
+ "playbook-field-retired",
128
+ ]);
129
+ expect(rulesOf(proc((pb) => (pb.humanOnlyStatuses = ["New"])))).toEqual(["playbook-field-retired"]);
135
130
  });
136
131
 
137
132
  it("a stage subjectStatus outside the enum", () => {
@@ -140,17 +135,14 @@ describe("process contract — each rule fires on its own fixture", () => {
140
135
  ]);
141
136
  });
142
137
 
143
- it("a stage subjectStatus with no statusProperty to belong to", () => {
144
- const rules = rulesOf(
145
- proc((pb) => {
146
- delete pb.subjectProfile.statusProperty;
147
- delete pb.humanOnlyStatuses;
148
- pb.activators = [{ on: "created", mode: "propose" }];
149
- })
150
- );
151
- expect(rules).toEqual([
152
- "stage-subject-status-unknown-no-property",
153
- "stage-subject-status-unknown-no-property",
138
+ it("a stage subjectStatus on a kind that declares no lifecycle", () => {
139
+ const noLifecycle = structuredClone(owner) as unknown as { profiles: Array<Record<string, unknown>> };
140
+ delete noLifecycle.profiles[0]!.lifecycle;
141
+ const tpl = proc((pb) => (pb.activators = [{ on: "created", mode: "propose" }]));
142
+ const { errors } = validateTemplate(tpl, { corpus: [noLifecycle as unknown as WorkspaceYaml, tpl] });
143
+ expect(errors.filter((e) => PROCESS_RULES.has(e.rule)).map((e) => e.rule)).toEqual([
144
+ "stage-subject-status-unknown-no-lifecycle",
145
+ "stage-subject-status-unknown-no-lifecycle",
154
146
  ]);
155
147
  });
156
148
 
@@ -218,7 +210,6 @@ describe("process contract — each rule fires on its own fixture", () => {
218
210
  proc((pb) => {
219
211
  delete pb.subjectProfile;
220
212
  delete pb.stages;
221
- delete pb.humanOnlyStatuses;
222
213
  delete pb.expectedOutputs;
223
214
  pb.activators = [{ on: "created", mode: "propose" }];
224
215
  })
@@ -226,12 +217,6 @@ describe("process contract — each rule fires on its own fixture", () => {
226
217
  expect(rules).toEqual(["activator-no-subject"]);
227
218
  });
228
219
 
229
- it("a humanOnlyStatuses value outside the enum", () => {
230
- expect(rulesOf(proc((pb) => (pb.humanOnlyStatuses = ["New", "Retired"])))).toEqual([
231
- "human-only-status-unknown",
232
- ]);
233
- });
234
-
235
220
  it("a playbook-level relationToSubject the corpus does not define", () => {
236
221
  expect(rulesOf(proc((pb) => (pb.expectedOutputs[0].relationToSubject = "spawned_by")))).toEqual([
237
222
  "output-relation-unknown",
@@ -277,7 +262,6 @@ describe("process contract — each rule fires on its own fixture", () => {
277
262
  const strip = (pb: Record<string, any>) => {
278
263
  delete pb.subjectProfile;
279
264
  delete pb.stages;
280
- delete pb.humanOnlyStatuses;
281
265
  delete pb.activators;
282
266
  };
283
267
  expect(rulesOf(proc(strip))).toEqual(["output-relation-no-subject"]);
@@ -340,7 +324,8 @@ describe("process contract — the shipped corpus", () => {
340
324
  const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
341
325
  const contentOs = corpus.find((t) => t.meta.slug === "content-os")!;
342
326
  const produce = contentOs.playbooks!.find((p) => p.name === "Produce Content")!;
343
- expect(produce.subjectProfile?.statusProperty).toBe("post-status");
327
+ expect(produce.subjectProfile).toEqual({ profileSlug: "post" });
328
+ expect(contentOs.profiles.find((p) => p.slug === "post")?.lifecycle?.property).toBe("post-status");
344
329
  expect(produce.stages!.filter((s) => s.subjectStatus).length).toBe(6);
345
330
  const errs = corpus.flatMap((t) =>
346
331
  validateTemplate(t, { corpus })
@@ -17,6 +17,8 @@ meta:
17
17
  # What this space PRODUCES (homes): the purpose word, then its kinds/roles.
18
18
  intent:
19
19
  label: Planning
20
+ # The ONE primary output (One model decision 3): a finished sprint, its one produced kind.
21
+ primary: sprint
20
22
  produces:
21
23
  - profile: sprint
22
24
 
@@ -192,7 +194,6 @@ views:
192
194
  slug: sprint-board
193
195
  type: kanban
194
196
  scopeProfileSlug: task
195
- groupBy: task-status
196
197
  description: "Active sprint tasks in Backlog / Todo / In Progress / Review / Done lanes"
197
198
  config:
198
199
  groupByField: task-status
@@ -276,7 +277,6 @@ views:
276
277
  slug: retrospective-board
277
278
  type: kanban
278
279
  scopeProfileSlug: sprint
279
- groupBy: sprint-status
280
280
  description: "Sprint retrospectives — completed sprints with retrospective notes attached as documents"
281
281
  config:
282
282
  groupByField: sprint-status
@@ -77,7 +77,7 @@ describe("plan → execute — the wire", () => {
77
77
  .playbooks as any[];
78
78
  const produce = yamlPlaybooks.find((p) => p.name === "Produce Content");
79
79
  // Non-vacuity: the YAML really declares the contract fields compared below.
80
- expect(produce.subjectProfile.statusProperty).toBe("post-status");
80
+ expect(produce.subjectProfile).toEqual({ profileSlug: "post" });
81
81
  expect(produce.activators).toEqual([{ on: "enters_status", status: "Briefed", mode: "propose" }]);
82
82
  expect(produce.stages.find((s: any) => s.key === "review").onFail).toEqual({ toStage: "draft" });
83
83
  expect(
@@ -98,7 +98,8 @@ describe("plan → execute — the wire", () => {
98
98
  "Scheduled",
99
99
  "Published",
100
100
  ]);
101
- expect(postedProduce.humanOnlyStatuses).toEqual(["Idea", "Archived"]);
101
+ // Retired from the catalog (One model decision 4): nothing to carry.
102
+ expect(postedProduce.humanOnlyStatuses).toBeUndefined();
102
103
  });
103
104
 
104
105
  it("reports a stamp the CP did not store", async () => {
@@ -29,9 +29,11 @@ meta:
29
29
  # What this space PRODUCES (homes): the purpose word, then its kinds/roles.
30
30
  intent:
31
31
  label: Research
32
+ # The ONE primary output (One model decision 3): a finding is what research yields (questions are its input).
33
+ primary: research-finding
32
34
  produces:
33
35
  - profile: hypothesis
34
- - profile: finding
36
+ - profile: research-finding
35
37
 
36
38
  workspace:
37
39
  name: Research
@@ -60,6 +62,15 @@ profiles:
60
62
  icon: help-circle
61
63
  color: blue
62
64
  description: An open question driving an investigation — the thing research answers.
65
+ # The pod seeds this lifecycle on every pod (ensure-system-lifecycles.ts);
66
+ # declared here to match the seed exactly, so a playbook can follow it.
67
+ lifecycle:
68
+ property: questionStatus
69
+ options:
70
+ - { key: open, category: open }
71
+ - { key: exploring, category: active }
72
+ - { key: answered, category: done }
73
+ - { key: abandoned, category: lost }
63
74
  properties:
64
75
  - slug: questionStatus
65
76
  label: Status
@@ -214,7 +225,7 @@ profiles:
214
225
  valueType: string
215
226
  inputType: textarea
216
227
 
217
- - slug: finding
228
+ - slug: research-finding
218
229
  displayName: Finding
219
230
  icon: check-circle
220
231
  color: "#10B981"
@@ -253,7 +264,7 @@ views:
253
264
  - name: Knowledge Map
254
265
  slug: research-knowledge-map
255
266
  type: graph
256
- scopeProfileSlugs: [field, concept, finding]
267
+ scopeProfileSlugs: [field, concept, research-finding]
257
268
  description: The cartography — fields and concepts, their overlaps and prerequisites, and the findings attached to them.
258
269
  defaultView: true
259
270
  config:
@@ -280,14 +291,13 @@ views:
280
291
  slug: research-hypotheses
281
292
  type: kanban
282
293
  scopeProfileSlug: hypothesis
283
- groupBy: stance
284
294
  description: Candidate theories, grouped by where the evidence has left them.
285
295
  config:
286
- groupBy: stance
296
+ groupByField: stance
287
297
  - name: Findings
288
298
  slug: research-findings
289
299
  type: table
290
- scopeProfileSlug: finding
300
+ scopeProfileSlug: research-finding
291
301
  description: What's been established, with its confidence.
292
302
  config:
293
303
  columns: [title, confidence, status]
@@ -349,7 +359,7 @@ entityLinks:
349
359
  }
350
360
  - {
351
361
  sourceProfileSlug: research,
352
- targetProfileSlug: finding,
362
+ targetProfileSlug: research-finding,
353
363
  type: concluded,
354
364
  label: Concluded,
355
365
  }
@@ -373,19 +383,19 @@ entityLinks:
373
383
  label: Cites,
374
384
  }
375
385
  - {
376
- sourceProfileSlug: finding,
386
+ sourceProfileSlug: research-finding,
377
387
  targetProfileSlug: research,
378
388
  type: derived_from,
379
389
  label: Derived from,
380
390
  }
381
391
  - {
382
- sourceProfileSlug: finding,
392
+ sourceProfileSlug: research-finding,
383
393
  targetProfileSlug: source,
384
394
  type: supported_by,
385
395
  label: Supported by,
386
396
  }
387
397
  - {
388
- sourceProfileSlug: finding,
398
+ sourceProfileSlug: research-finding,
389
399
  targetProfileSlug: concept,
390
400
  type: about,
391
401
  label: About,
@@ -410,7 +420,7 @@ playbooks:
410
420
  rejected with a reason (never silently drop a source). WEIGH: form the
411
421
  candidate answers as `hypothesis` entities that answer the question; link
412
422
  every source that bears on a hypothesis with `supports` or
413
- `contradicts`; use the evidence-synthesis skill to emit `finding`
423
+ `contradicts`; use the evidence-synthesis skill to emit `research-finding`
414
424
  entities with honestly calibrated confidence, each `supported_by` its
415
425
  sources and `derived_from` this research; use the concept-cartography
416
426
  skill to place the findings onto the knowledge map. ANSWER: create the
@@ -420,8 +430,6 @@ playbooks:
420
430
  proposal-gated and reviewable.
421
431
  subjectProfile:
422
432
  profileSlug: question
423
- statusProperty: questionStatus
424
- humanOnlyStatuses: [abandoned]
425
433
  activators:
426
434
  - { on: created, mode: propose }
427
435
  params:
@@ -486,8 +494,7 @@ playbooks:
486
494
  and link each one derived_from the finding. Quote only what the sources
487
495
  support.
488
496
  subjectProfile:
489
- profileSlug: finding
490
- statusProperty: status
497
+ profileSlug: research-finding
491
498
  activators:
492
499
  - { on: enters_status, status: validated, mode: propose }
493
500
  params: