@synap-core/workspace-templates 0.9.4 → 0.11.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.
@@ -47,6 +47,10 @@ profiles:
47
47
  description: A liquidity provider — makes markets and provides depth for a token.
48
48
  scope: shared
49
49
  profileKind: role
50
+ # Shared supply-role category → the intro-matcher's roster spans ALL roles
51
+ # tagged "provider" via entity.query { roleCategory: "provider" } (no per-role
52
+ # hardcoding). Keep this string in sync with networking.yaml's solution-provider.
53
+ roleCategory: provider
50
54
  applicableKinds:
51
55
  - company
52
56
  - person
@@ -81,6 +85,10 @@ profiles:
81
85
  description: A security firm or individual that audits smart contracts and protocols.
82
86
  scope: shared
83
87
  profileKind: role
88
+ # Shared supply-role category → the intro-matcher's roster spans ALL roles
89
+ # tagged "provider" via entity.query { roleCategory: "provider" } (no per-role
90
+ # hardcoding). Keep this string in sync with networking.yaml's solution-provider.
91
+ roleCategory: provider
84
92
  applicableKinds:
85
93
  - company
86
94
  - person
@@ -115,6 +123,10 @@ profiles:
115
123
  description: An outsourced engineering studio or build partner.
116
124
  scope: shared
117
125
  profileKind: role
126
+ # Shared supply-role category → the intro-matcher's roster spans ALL roles
127
+ # tagged "provider" via entity.query { roleCategory: "provider" } (no per-role
128
+ # hardcoding). Keep this string in sync with networking.yaml's solution-provider.
129
+ roleCategory: provider
118
130
  applicableKinds:
119
131
  - company
120
132
  - person
@@ -149,6 +161,10 @@ profiles:
149
161
  description: A brand, growth, or content marketing agency.
150
162
  scope: shared
151
163
  profileKind: role
164
+ # Shared supply-role category → the intro-matcher's roster spans ALL roles
165
+ # tagged "provider" via entity.query { roleCategory: "provider" } (no per-role
166
+ # hardcoding). Keep this string in sync with networking.yaml's solution-provider.
167
+ roleCategory: provider
152
168
  applicableKinds:
153
169
  - company
154
170
  - person
@@ -183,6 +199,10 @@ profiles:
183
199
  description: A performance / paid-media buying agency.
184
200
  scope: shared
185
201
  profileKind: role
202
+ # Shared supply-role category → the intro-matcher's roster spans ALL roles
203
+ # tagged "provider" via entity.query { roleCategory: "provider" } (no per-role
204
+ # hardcoding). Keep this string in sync with networking.yaml's solution-provider.
205
+ roleCategory: provider
186
206
  applicableKinds:
187
207
  - company
188
208
  - person
@@ -217,6 +237,10 @@ profiles:
217
237
  description: A key opinion leader or influencer who amplifies reach.
218
238
  scope: shared
219
239
  profileKind: role
240
+ # Shared supply-role category → the intro-matcher's roster spans ALL roles
241
+ # tagged "provider" via entity.query { roleCategory: "provider" } (no per-role
242
+ # hardcoding). Keep this string in sync with networking.yaml's solution-provider.
243
+ roleCategory: provider
220
244
  applicableKinds:
221
245
  - company
222
246
  - person
@@ -243,6 +267,88 @@ profiles:
243
267
  valueType: string
244
268
  inputType: textarea
245
269
 
270
+ # ── Grant Program (the per-chain funding PROGRAM — parent of a round) ───────
271
+ # The 3-layer grants spine is PROGRAM ≠ ROUND ≠ APPLICATION. `round` (in
272
+ # grants.yaml) is the funding-cycle/deadline carrier; a program is the
273
+ # long-lived, chain-scoped funder above it (e.g. Stellar SCF, Optimism RetroPGF).
274
+ # It lives HERE, with the web3 vocabulary, because a program IS a blockchain-
275
+ # ecosystem thing (it carries a `blockchain` and program mechanics), and it is
276
+ # a real-world entity referenced from every workspace — hence pod-wide, mirroring
277
+ # company/person in ecosystem.yaml (`entityScope: pod`). `scope: shared` makes
278
+ # the profile DEF pod-wide too, so a REUSING template (grants.yaml `require`s this
279
+ # overlay) resolves `grant-program` for its `round → belongs_to → grant-program`
280
+ # edge and deadline view. `defaultPlaybook` is a text ref for now — Wave 2 will
281
+ # create the actual per-chain playbook and bind it.
282
+ - slug: grant-program
283
+ displayName: Grant Program
284
+ icon: landmark
285
+ color: emerald
286
+ description: A per-chain funding program (e.g. Stellar SCF, Optimism RetroPGF) — the long-lived funder above a round.
287
+ profileKind: kind
288
+ entityScope: pod
289
+ scope: shared
290
+ properties:
291
+ - slug: blockchain
292
+ label: Blockchain / Chain
293
+ valueType: string
294
+ inputType: text
295
+ - slug: programName
296
+ label: Program Name
297
+ valueType: string
298
+ inputType: text
299
+ - slug: programType
300
+ label: Program Type
301
+ valueType: string
302
+ inputType: select
303
+ enumValues:
304
+ - direct
305
+ - retroactive
306
+ - quadratic
307
+ - rfp
308
+ - slug: status
309
+ label: Status
310
+ valueType: string
311
+ inputType: select
312
+ enumValues:
313
+ - active
314
+ - paused
315
+ - retired
316
+ - slug: url
317
+ label: Program URL
318
+ valueType: string
319
+ inputType: url
320
+ - slug: cadence
321
+ label: Cadence
322
+ valueType: string
323
+ inputType: text
324
+ placeholder: every 6 weeks
325
+ - slug: nextDeadline
326
+ label: Next Deadline
327
+ valueType: date
328
+ inputType: date
329
+ - slug: submissionTracks
330
+ label: Submission Tracks
331
+ valueType: array
332
+ inputType: text
333
+ - slug: trancheModel
334
+ label: Tranche Model
335
+ valueType: string
336
+ inputType: text
337
+ placeholder: 4 tranches / 90 days
338
+ - slug: defaultPlaybook
339
+ label: Default Playbook
340
+ valueType: string
341
+ inputType: text
342
+ # Defaults to the BASE grant playbook (grants.yaml `Grant Process`), the
343
+ # data-driven process that reads THIS program's cadence/tracks/tranche
344
+ # model at runtime. Override per-program only for a differently-shaped chain.
345
+ constraints:
346
+ defaultValue: Grant Process
347
+ - slug: description
348
+ label: Description
349
+ valueType: string
350
+ inputType: textarea
351
+
246
352
  # Web3 actor↔actor relations (company→company). `company` resolves from the
247
353
  # Ecosystem base — not redeclared here.
248
354
  entityLinks:
@@ -47,17 +47,30 @@ describe("Business Developer workspace overlay", () => {
47
47
  expect(template.workspace.visibility).toBeUndefined();
48
48
  });
49
49
 
50
- it("keeps CRM lifecycle facets workspace-local", () => {
51
- for (const slug of ["crm-lead", "crm-client"]) {
50
+ it("extends the SHARED lead/client roles instead of forking them", () => {
51
+ // The lifecycle roles are the pod-wide foundation SSOT, re-declared so this
52
+ // workspace receives the grant. Row-level fields must match foundation
53
+ // VERBATIM (one row, no overlay mechanism — shared-role-ssot rule (c)); the
54
+ // business-development fields ride along as workspace-overlay PROPERTIES.
55
+ for (const slug of ["lead", "client"]) {
52
56
  const facet = profile(slug);
53
- expect(facet.scope).toBe("WORKSPACE");
57
+ expect(facet.scope).toBe("shared");
54
58
  expect(facet.profileKind).toBe("role");
55
- expect(facet.applicableKinds).toEqual(["person", "company"]);
59
+ expect(facet.applicableKinds).toEqual(["company", "person"]);
60
+ expect(facet.semanticSlug).toBeUndefined();
56
61
  }
57
62
 
58
- expect(template.profiles.some((candidate) => ["lead", "client"].includes(candidate.slug))).toBe(false);
63
+ // The forked spellings are GONE — reintroducing either is the regression.
64
+ expect(
65
+ template.profiles.some((candidate) => ["crm-lead", "crm-client"].includes(candidate.slug)),
66
+ ).toBe(false);
67
+
68
+ // Extras only: foundation's own `leadStage` / `clientStatus` / `notes` are
69
+ // NOT restated here (restating with a different definition is base drift).
70
+ expect([...propertySlugs("lead")]).not.toContain("leadStage");
71
+ expect([...propertySlugs("client")]).not.toContain("clientStatus");
59
72
 
60
- expect([...propertySlugs("crm-lead")].sort()).toEqual([
73
+ expect([...propertySlugs("lead")].sort()).toEqual([
61
74
  "assignedUserId",
62
75
  "convertedAt",
63
76
  "disqualificationReason",
@@ -70,6 +83,13 @@ describe("Business Developer workspace overlay", () => {
70
83
  "qualificationNotes",
71
84
  "source",
72
85
  ].sort());
86
+
87
+ expect([...propertySlugs("client")].sort()).toEqual([
88
+ "becameClientAt",
89
+ "handoffDestination",
90
+ "handoffNotes",
91
+ "handoffStatus",
92
+ ].sort());
73
93
  });
74
94
 
75
95
  it("models proposals and auditable commission separately from leads", () => {
@@ -136,6 +156,10 @@ describe("Business Developer workspace overlay", () => {
136
156
  });
137
157
 
138
158
  it("relies on CRM's single generic counterparty relationship", () => {
159
+ // `linked_to_deal` stays the ONE generic counterparty edge the handoff
160
+ // flow's `find-party` node reads. The role-qualified edges beside it are
161
+ // ADDITIVE (a brokered deal has a client side and a partner side) and must
162
+ // never displace it — hence the exact-order assertion.
139
163
  expect(
140
164
  crmTemplate.entityLinks?.map(({ sourceProfileSlug, targetProfileSlug, type }) => ({
141
165
  sourceProfileSlug,
@@ -145,6 +169,20 @@ describe("Business Developer workspace overlay", () => {
145
169
  ).toEqual([
146
170
  { sourceProfileSlug: "person", targetProfileSlug: "deal", type: "linked_to_deal" },
147
171
  { sourceProfileSlug: "company", targetProfileSlug: "deal", type: "linked_to_deal" },
172
+ { sourceProfileSlug: "person", targetProfileSlug: "deal", type: "client_on_deal" },
173
+ { sourceProfileSlug: "company", targetProfileSlug: "deal", type: "client_on_deal" },
174
+ { sourceProfileSlug: "person", targetProfileSlug: "deal", type: "partner_on_deal" },
175
+ { sourceProfileSlug: "company", targetProfileSlug: "deal", type: "partner_on_deal" },
176
+ ]);
177
+
178
+ // The handoff still reads ONLY the generic edge — a brokered deal's partner
179
+ // side must not be mistaken for the counterparty that becomes the client.
180
+ const handoff = template.automations?.find((a) => a.name === "Win and hand off");
181
+ const findParty = (handoff?.flow.nodes as Array<{ id?: string; data?: unknown }>).find(
182
+ (n) => n.id === "find-party",
183
+ );
184
+ expect((findParty?.data as { relationTypes?: string[] }).relationTypes).toEqual([
185
+ "linked_to_deal",
148
186
  ]);
149
187
  });
150
188
 
@@ -152,7 +190,7 @@ describe("Business Developer workspace overlay", () => {
152
190
  expect(
153
191
  template.playbooks?.map(({ name, subjectProfile }) => ({ name, profileSlug: subjectProfile?.profileSlug })),
154
192
  ).toEqual([
155
- { name: "Qualify a CRM lead", profileSlug: "crm-lead" },
193
+ { name: "Qualify a CRM lead", profileSlug: "lead" },
156
194
  { name: "Advance a commercial proposal", profileSlug: "deal" },
157
195
  ]);
158
196
  expect(template.playbooks?.[0]?.goalTemplate ?? "").toMatch(/Do not create a deal/);
@@ -215,18 +253,49 @@ describe("Business Developer workspace overlay", () => {
215
253
  }),
216
254
  );
217
255
 
256
+ // The handoff writes the SHARED role slugs — this is what makes the party
257
+ // a client pod-wide (and therefore visible to the Operations pickup).
258
+ expect(handoff?.flow.nodes).toContainEqual(
259
+ expect.objectContaining({
260
+ id: "attach-client",
261
+ data: expect.objectContaining({
262
+ outputType: "facet_attach",
263
+ config: expect.objectContaining({ facetSlug: "client" }),
264
+ }),
265
+ }),
266
+ );
267
+ expect(handoff?.flow.nodes).toContainEqual(
268
+ expect.objectContaining({
269
+ id: "convert-lead",
270
+ data: expect.objectContaining({
271
+ outputType: "facet_update",
272
+ config: expect.objectContaining({ facetSlug: "lead" }),
273
+ }),
274
+ }),
275
+ );
276
+ expect(handoff?.flow.nodes).toContainEqual(
277
+ expect.objectContaining({
278
+ id: "guard-lead",
279
+ data: expect.objectContaining({
280
+ checks: expect.arrayContaining([
281
+ expect.objectContaining({ arrayIncludes: "lead" }),
282
+ ]),
283
+ }),
284
+ }),
285
+ );
286
+
218
287
  expect(template.actionPlacements).toEqual([
219
288
  expect.objectContaining({
220
289
  profileSlug: "person",
221
290
  kind: "playbook",
222
291
  ref: "Qualify a CRM lead",
223
- when: { requiredFacetSlugs: ["crm-lead"] },
292
+ when: { requiredFacetSlugs: ["lead"] },
224
293
  }),
225
294
  expect.objectContaining({
226
295
  profileSlug: "company",
227
296
  kind: "playbook",
228
297
  ref: "Qualify a CRM lead",
229
- when: { requiredFacetSlugs: ["crm-lead"] },
298
+ when: { requiredFacetSlugs: ["lead"] },
230
299
  }),
231
300
  expect.objectContaining({
232
301
  profileSlug: "deal",
@@ -256,7 +325,7 @@ describe("Business Developer workspace overlay", () => {
256
325
  expect(playbooks).toHaveLength(2);
257
326
  expect(playbooks[0]).toMatchObject({
258
327
  name: "Qualify a CRM lead",
259
- subjectProfile: { profileSlug: "crm-lead" },
328
+ subjectProfile: { profileSlug: "lead" },
260
329
  params: [{ name: "lead", type: "string", description: "CRM lead" }],
261
330
  });
262
331
  expect(playbooks[1]).toMatchObject({
@@ -1,13 +1,19 @@
1
1
  # Business Developer — a composable, lead-first CRM operating model.
2
2
  #
3
3
  # This is an ADDITIVE overlay on the CRM workspace, not a second CRM and not a
4
- # rewrite of shared identity profiles. It deliberately uses workspace-scoped
5
- # `crm-lead` and `crm-client` role facets instead of the pod-wide `lead` and
6
- # `client` vocabulary from Foundation. A person or company can therefore be a
7
- # lead in one CRM workspace without becoming a lead everywhere in the pod.
4
+ # rewrite of shared identity profiles. It REUSES the pod-wide `lead` and
5
+ # `client` roles from Foundation (`scope: shared`, `profileKind: role`) and adds
6
+ # its business-development properties to them as WORKSPACE OVERLAYS — the
7
+ # documented extension mechanism (`getEffectiveProperties`, and rule (b) of
8
+ # `shared-role-ssot.test.ts`). It used to fork them into workspace-scoped
9
+ # `crm-lead` / `crm-client`; that was a vocabulary fork of one real-world
10
+ # concept: the same company was a "lead" under one spelling and not under the
11
+ # other, so pod-wide surfaces, the Operations handoff, and the CRM app
12
+ # disagreed about who was a client. One concept, one slug, workspace-specific
13
+ # FIELDS.
8
14
  #
9
15
  # Lifecycle:
10
- # person / company + crm-lead → optional deal(s) → won deal → crm-client
16
+ # person / company + lead → optional deal(s) → won deal → client
11
17
  #
12
18
  # Deals are commercial proposals attached through CRM's existing
13
19
  # `linked_to_deal` relation. The CRM requires one counterparty before a deal
@@ -41,23 +47,33 @@ workspace:
41
47
  - crm.business-development
42
48
 
43
49
  profiles:
44
- # Workspace-local operational status. This is intentionally separate from
45
- # Foundation's shared `lead` role: lead ownership, next actions, and CRM
46
- # lifecycle status must be visible to this CRM workspace's members only.
47
- - slug: crm-lead
48
- displayName: CRM Lead
49
- icon: user-round-search
50
- color: amber
51
- description: A person or company actively being qualified or contacted by this CRM workspace.
52
- scope: WORKSPACE
50
+ # ── Lead (reused shared role — SSOT is foundation.yaml) ───────────────────
51
+ # NOT a workspace-local copy: this RE-DECLARES foundation's pod-wide `lead`
52
+ # role so the CRM workspace receives the grant (shared profiles are
53
+ # grant-gated — a workspace only sees a shared role if it references the
54
+ # slug), and ADDS the business-development properties below.
55
+ #
56
+ # Row-level fields (displayName/icon/color/description/applicableKinds) are
57
+ # restated VERBATIM from foundation, exactly as crm.yaml does: there is ONE
58
+ # pod-wide row and no overlay mechanism for row-level fields, so a divergent
59
+ # value here would be silently false on every pod depending on apply order
60
+ # (shared-role-ssot rule (c)).
61
+ #
62
+ # The properties are EXTRAS ONLY. Foundation's own `leadStage` / `notes` are
63
+ # deliberately NOT restated — omitting is "no claim", and restating with a
64
+ # different definition is the base-drift rule (b) forbids. Extras land as
65
+ # WORKSPACE OVERLAYS via getEffectiveProperties, so this workspace's members
66
+ # see lifecycle/ownership fields that other workspaces don't.
67
+ - slug: lead
68
+ displayName: Lead
69
+ icon: user-plus
70
+ color: yellow
71
+ description: An early-pipeline prospect — not yet a client, being qualified or nurtured.
72
+ scope: shared
53
73
  profileKind: role
54
- # Explicit concept identity: this is the WORKSPACE tier of the `lead`
55
- # concept, not a second, unrelated one. Declaring it makes the two-tier
56
- # design machine-readable (both tiers are reachable via getBySemanticSlug)
57
- # and is what the cross-template concept guard requires alongside its
58
- # DECLARED_DIVERGENCES entry — see src/concept-guard.ts.
59
- semanticSlug: crm.lead
60
- applicableKinds: [person, company]
74
+ applicableKinds:
75
+ - company
76
+ - person
61
77
  properties:
62
78
  - slug: lifecycleStage
63
79
  label: Lifecycle stage
@@ -129,19 +145,22 @@ profiles:
129
145
  valueType: date
130
146
  inputType: date
131
147
 
132
- # The same counterparty receives this facet when one of its commercial
133
- # proposals is won. It intentionally carries only handoff context; client
134
- # delivery, renewal, and service health remain outside this pre-sale addon.
135
- - slug: crm-client
136
- displayName: CRM Client
137
- icon: badge-check
148
+ # ── Client (reused shared role — SSOT is foundation.yaml) ─────────────────
149
+ # Same pattern as `lead` above: foundation's pod-wide `client` role, restated
150
+ # verbatim at row level, EXTENDED with the handoff context this pre-sale
151
+ # addon owns. Client delivery, renewal, and service health belong to
152
+ # operations.yaml, which reads the SAME `client` role pod-wide — that shared
153
+ # slug is what makes the CRM → Operations handoff possible at all.
154
+ - slug: client
155
+ displayName: Client
156
+ icon: briefcase
138
157
  color: emerald
139
- description: A CRM lead converted by a won commercial proposal and ready for handoff.
140
- scope: WORKSPACE
158
+ description: An active commercial relationship — created when a deal is won.
159
+ scope: shared
141
160
  profileKind: role
142
- # Workspace tier of the `client` concept — see the note on `crm-lead` above.
143
- semanticSlug: crm.client
144
- applicableKinds: [person, company]
161
+ applicableKinds:
162
+ - company
163
+ - person
145
164
  properties:
146
165
  - slug: becameClientAt
147
166
  label: Became client at
@@ -404,7 +423,7 @@ playbooks:
404
423
  deal simply because the lead is qualified. If a commercial proposal is
405
424
  warranted, explain why and ask for or propose that next step separately.
406
425
  subjectProfile:
407
- profileSlug: crm-lead
426
+ profileSlug: lead
408
427
  params:
409
428
  - { name: lead, type: entity, label: CRM lead, required: true }
410
429
  executor: is-agent
@@ -477,8 +496,11 @@ automations:
477
496
  - path: steps.read-deal.output.entity.properties.handoffStatus
478
497
  notEquals: handed_off
479
498
  message: This proposal has already been handed off.
499
+ # minLength, NOT exists: `exists` is a null check, so an empty
500
+ # string satisfies it and the handoff would proceed with no
501
+ # sourcer actually set. The intent here is "has a value".
480
502
  - path: steps.read-deal.output.entity.properties.sourcedByUserId
481
- exists: true
503
+ minLength: 1
482
504
  message: Set the business developer who sourced this proposal before handing it off.
483
505
  - path: steps.read-deal.output.entity.properties.synapFeeAmount
484
506
  numberGte: 0
@@ -518,7 +540,7 @@ automations:
518
540
  label: Confirm qualified CRM lead
519
541
  checks:
520
542
  - path: steps.read-party.output.entity.facetSlugs
521
- arrayIncludes: crm-lead
543
+ arrayIncludes: lead
522
544
  message: The commercial counterparty must still be a CRM lead before it becomes a CRM client.
523
545
 
524
546
  # Source-deal claim is the durable idempotency boundary for the whole
@@ -699,7 +721,7 @@ automations:
699
721
  outputType: facet_attach
700
722
  config:
701
723
  entityId: "{{steps.read-party.output.entity.id}}"
702
- facetSlug: crm-client
724
+ facetSlug: client
703
725
  properties:
704
726
  becameClientAt: "{{steps.handoff-time.output.result}}"
705
727
  handoffStatus: handed_off
@@ -712,7 +734,7 @@ automations:
712
734
  outputType: facet_update
713
735
  config:
714
736
  entityId: "{{steps.read-party.output.entity.id}}"
715
- facetSlug: crm-lead
737
+ facetSlug: lead
716
738
  properties:
717
739
  lifecycleStage: converted
718
740
  convertedAt: "{{steps.handoff-time.output.result}}"
@@ -809,14 +831,14 @@ actionPlacements:
809
831
  kind: playbook
810
832
  ref: Qualify a CRM lead
811
833
  label: Qualify lead
812
- when: { requiredFacetSlugs: [crm-lead] }
834
+ when: { requiredFacetSlugs: [lead] }
813
835
 
814
836
  - profileSlug: company
815
837
  surface: entity-detail
816
838
  kind: playbook
817
839
  ref: Qualify a CRM lead
818
840
  label: Qualify lead
819
- when: { requiredFacetSlugs: [crm-lead] }
841
+ when: { requiredFacetSlugs: [lead] }
820
842
 
821
843
  - profileSlug: deal
822
844
  surface: entity-detail
package/src/catalog.ts CHANGED
@@ -33,6 +33,20 @@ import type { WorkspaceYaml } from "./types.js";
33
33
  // Inputs
34
34
  // ============================================================================
35
35
 
36
+ /**
37
+ * A lightweight, QUERY-TIME summary of one sub-object a package carries (a cell
38
+ * today; the shape generalizes to views/automations without change). Rides the
39
+ * catalog LIST projection so a surface can match/surface a sub-item WITHOUT the
40
+ * full `definition` — it carries `key + name` only, never the sub-object's code.
41
+ * `kind` is DERIVED (in the CP SELECT, from which sub-array the row came from),
42
+ * never a stored taxonomy.
43
+ */
44
+ export interface PackageItemSummary {
45
+ kind: string;
46
+ key: string;
47
+ name: string;
48
+ }
49
+
36
50
  /**
37
51
  * A bundled template — the canonical `WorkspaceYaml` this package ships. Callers
38
52
  * pass `listPublicTemplates()` (18) or `listWorkspaceTemplates()` (21); the door
@@ -69,6 +83,13 @@ export interface RemoteEntry {
69
83
  * never "unknown-so-hide".
70
84
  */
71
85
  isPrivate?: boolean;
86
+ /**
87
+ * Lightweight computed summary of the row's sub-objects (cells), carried on the
88
+ * LIST projection so a surface can surface a matching sub-item without the full
89
+ * `definition`. `undefined` on sources that don't compute it (bundled) — treated
90
+ * as "no known sub-items". See `PackageItemSummary`.
91
+ */
92
+ items?: PackageItemSummary[];
72
93
  /**
73
94
  * The CP `definition` jsonb — opaque install payload. The door reads only
74
95
  * `definition.sourcePackage.version` (the winner signal). Absent on browse rows
@@ -128,6 +149,11 @@ export interface CpPackageRow {
128
149
  icon?: string | null;
129
150
  /** Present only on `/mine` rows. `false` ⇒ private. Browse rows omit it. */
130
151
  isPublic?: boolean;
152
+ /**
153
+ * Computed sub-object summary from `GET /api/packages` / `/mine` (query-time,
154
+ * not a stored column). Carries `key + name` per cell; never the cell code.
155
+ */
156
+ items?: PackageItemSummary[];
131
157
  }
132
158
 
133
159
  /**
@@ -154,6 +180,7 @@ export function rowToRemoteEntry(
154
180
  tags: row.tags ?? [],
155
181
  icon: row.icon ?? null,
156
182
  isPrivate,
183
+ items: row.items,
157
184
  // Both list routes strip `definition`; absence ⇒ bundle wins a slug conflict,
158
185
  // and the install path fetches the full definition per-slug when needed.
159
186
  };
@@ -175,6 +202,12 @@ interface CatalogEntryBase {
175
202
  icon?: string | null;
176
203
  /** `true` ⇒ not publicly listed (internal bundled template, or private CP row). */
177
204
  isPrivate: boolean;
205
+ /**
206
+ * The entry's sub-object summary (cells). Always present (defaults to `[]`) so a
207
+ * consumer can match sub-items uniformly. Bundled workspace templates carry no
208
+ * cells → `[]` (honest); a remote row carries its CP-computed `items`.
209
+ */
210
+ items: PackageItemSummary[];
178
211
  }
179
212
 
180
213
  /** A catalog entry backed by a bundled template. Install via `toWorkspaceDefinition()` / `toPackageDefinition()` on `template`. */
@@ -256,6 +289,8 @@ function bundledToEntry(tpl: WorkspaceYaml): BundledCatalogEntry {
256
289
  tags: tpl.meta.tags ?? [],
257
290
  icon: tpl.meta.icon,
258
291
  isPrivate: tpl.meta.isPublic === false,
292
+ // Bundled workspace templates carry no cells — be honest, not `undefined`.
293
+ items: [],
259
294
  template: tpl,
260
295
  };
261
296
  }
@@ -271,6 +306,7 @@ function remoteToEntry(row: RemoteEntry): RemoteCatalogEntry {
271
306
  tags: row.tags ?? [],
272
307
  icon: row.icon ?? null,
273
308
  isPrivate: row.isPrivate ?? false,
309
+ items: row.items ?? [],
274
310
  remote: row,
275
311
  };
276
312
  }