@synap-core/workspace-templates 0.11.0 → 0.23.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 (249) hide show
  1. package/README.md +483 -0
  2. package/automations/bookmark-enrichment.automation.json +321 -0
  3. package/automations/github-pr-events.automation.json +505 -0
  4. package/automations/mail-feed.automation.json +86 -0
  5. package/automations/ship-a-change-checks-green.automation.json +106 -0
  6. package/automations/ship-a-change-checks-red.automation.json +106 -0
  7. package/automations/ship-a-change-deployed.automation.json +56 -0
  8. package/automations/ship-a-change-pr-merged.automation.json +110 -0
  9. package/automations/ship-a-change-pr-opened.automation.json +104 -0
  10. package/automations/url-bookmark-capture.automation.json +132 -0
  11. package/capabilities/agency-skills.capability.json +157 -0
  12. package/capabilities/apify.capability.json +265 -0
  13. package/capabilities/apollo.capability.json +120 -0
  14. package/capabilities/arch-backend.capability.json +97 -0
  15. package/capabilities/arch-client-intelligence.capability.json +401 -0
  16. package/capabilities/backblaze-b2.capability.json +205 -0
  17. package/capabilities/cal-com.capability.json +266 -0
  18. package/capabilities/canva.capability.json +274 -0
  19. package/capabilities/claude-code-routines.capability.json +172 -0
  20. package/capabilities/claude-managed-agents.capability.json +215 -0
  21. package/capabilities/client-comms-report.capability.json +246 -0
  22. package/capabilities/content-idea-to-production.capability.json +29 -0
  23. package/capabilities/datagouv.capability.json +315 -0
  24. package/capabilities/discord-bot.capability.json +157 -0
  25. package/capabilities/dropbox.capability.json +281 -0
  26. package/capabilities/exa.capability.json +109 -0
  27. package/capabilities/fal-ai.capability.json +83 -0
  28. package/capabilities/fal-media-lab.capability.json +158 -0
  29. package/capabilities/fireflies.capability.json +191 -0
  30. package/capabilities/freellmapi.capability.json +114 -0
  31. package/capabilities/generic-apikey.capability.json +92 -0
  32. package/capabilities/github-merge.capability.json +99 -0
  33. package/capabilities/github-read.capability.json +325 -0
  34. package/capabilities/governance-tighten-recommender.capability.json +81 -0
  35. package/capabilities/jev.capability.json +226 -0
  36. package/capabilities/lead-conversion-enrollment.capability.json +246 -0
  37. package/capabilities/mail-feed.capability.json +277 -0
  38. package/capabilities/mailgun.capability.json +52 -0
  39. package/capabilities/nango-google.capability.json +639 -0
  40. package/capabilities/notion.capability.json +417 -0
  41. package/capabilities/omniroute.capability.json +137 -0
  42. package/capabilities/posthog-analytics.capability.json +258 -0
  43. package/capabilities/postiz.capability.json +467 -0
  44. package/capabilities/proactive-digest.capability.json +190 -0
  45. package/capabilities/proton.capability.json +288 -0
  46. package/capabilities/replicate-rvm.capability.json +97 -0
  47. package/capabilities/research-methods.capability.json +57 -0
  48. package/capabilities/shopify.capability.json +395 -0
  49. package/capabilities/slack.capability.json +369 -0
  50. package/capabilities/stripe.capability.json +420 -0
  51. package/capabilities/telegram-bridge.capability.json +56 -0
  52. package/capabilities/treg.capability.json +102 -0
  53. package/capabilities/unipile-linkedin.capability.json +471 -0
  54. package/capabilities/vercel.capability.json +424 -0
  55. package/dist/define-CPS0n8BV.d.ts +1331 -0
  56. package/dist/index.d.ts +189 -900
  57. package/dist/index.js +24654 -21639
  58. package/dist/index.js.map +1 -1
  59. package/dist/wire.d.ts +222 -0
  60. package/dist/wire.js +77 -0
  61. package/dist/wire.js.map +1 -0
  62. package/package.json +41 -27
  63. package/packs/bookmark-views-pack.json +33 -0
  64. package/packs/crm-pack.json +76 -0
  65. package/packs/customer-profile.json +28 -0
  66. package/packs/deep-research.playbook.json +30 -0
  67. package/packs/event-views-pack.json +33 -0
  68. package/packs/habit-profile.json +27 -0
  69. package/packs/hr-pack.json +54 -0
  70. package/packs/invoice-profile.json +28 -0
  71. package/packs/knowledge-base-pack.json +56 -0
  72. package/packs/meeting-profile.json +29 -0
  73. package/packs/product-profile.json +30 -0
  74. package/packs/project-management-pack.json +49 -0
  75. package/packs/recipe-profile.json +29 -0
  76. package/packs/task-views-pack.json +39 -0
  77. package/retired.yaml +136 -0
  78. package/skills/app-factory-build-slice/SKILL.md +70 -0
  79. package/skills/app-factory-build-slice.catalog.json +17 -0
  80. package/skills/app-factory-design-canvas/SKILL.md +96 -0
  81. package/skills/app-factory-design-canvas.catalog.json +17 -0
  82. package/skills/app-factory-design-review/SKILL.md +99 -0
  83. package/skills/app-factory-design-review.catalog.json +18 -0
  84. package/skills/app-factory-research/SKILL.md +123 -0
  85. package/skills/app-factory-research.catalog.json +17 -0
  86. package/skills/bookmark-enrichment/SKILL.md +40 -0
  87. package/skills/carousel-format/carousel-format.skill.json +18 -0
  88. package/skills/collage-broll/collage-broll.skill.json +20 -0
  89. package/skills/content-taste/content-taste.skill.json +18 -0
  90. package/skills/creative-director/creative-director.skill.json +18 -0
  91. package/skills/design-critique/SKILL.md +135 -0
  92. package/skills/design-critique.catalog.json +12 -0
  93. package/skills/designing-with-real-components/SKILL.md +140 -0
  94. package/skills/designing-with-real-components.catalog.json +12 -0
  95. package/skills/edit-loop/edit-loop.skill.json +25 -0
  96. package/skills/facecam-edit/facecam-edit.skill.json +24 -0
  97. package/skills/mapping-the-server/SKILL.md +54 -0
  98. package/skills/math-explainer-manimgx/math-explainer-manimgx.skill.json +20 -0
  99. package/skills/motion-quality-gates/motion-quality-gates.skill.json +20 -0
  100. package/skills/open-design-edit/open-design-edit.skill.json +24 -0
  101. package/skills/reading-client-progress/SKILL.md +65 -0
  102. package/skills/routing-to-channels/SKILL.md +54 -0
  103. package/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
  104. package/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
  105. package/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
  106. package/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
  107. package/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
  108. package/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
  109. package/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
  110. package/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
  111. package/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
  112. package/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
  113. package/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
  114. package/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
  115. package/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
  116. package/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
  117. package/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
  118. package/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
  119. package/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
  120. package/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
  121. package/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
  122. package/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
  123. package/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
  124. package/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
  125. package/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
  126. package/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
  127. package/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
  128. package/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
  129. package/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
  130. package/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
  131. package/skills/video-direction/video-direction.skill.json +20 -0
  132. package/skills/video-style-vocabulary/video-style-vocabulary.skill.json +20 -0
  133. package/skills/video-toolbox/video-toolbox.skill.json +20 -0
  134. package/src/__fixtures__/lifecycle-presets.flat-before.json +205 -0
  135. package/src/agency-os.yaml +122 -0
  136. package/src/agent-os-skill-slugs.test.ts +65 -0
  137. package/src/apify-social-reader.test.ts +317 -0
  138. package/src/base.template.test.ts +25 -19
  139. package/src/base.yaml +95 -39
  140. package/src/blockchain-ecosystem.yaml +1 -0
  141. package/src/brand-library.yaml +525 -14
  142. package/src/builder-dev-fold.template.test.ts +113 -0
  143. package/src/builder.yaml +798 -92
  144. package/src/business-model-catalog-description.test.ts +47 -0
  145. package/src/business-model-grp.template.test.ts +214 -0
  146. package/src/business-model.yaml +598 -0
  147. package/src/catalog-safety.test.ts +198 -0
  148. package/src/catalog.template-items.test.ts +75 -0
  149. package/src/catalog.ts +85 -2
  150. package/src/civic-france.yaml +102 -0
  151. package/src/concept-guard.test.ts +28 -8
  152. package/src/concept-guard.ts +2 -2
  153. package/src/content-creation.template.test.ts +52 -0
  154. package/src/content-creation.yaml +192 -0
  155. package/src/content-os.edit-loop.template.test.ts +210 -0
  156. package/src/content-os.publish-due-posts.template.test.ts +261 -0
  157. package/src/content-os.template.test.ts +322 -0
  158. package/src/content-os.video-takes.template.test.ts +338 -0
  159. package/src/content-os.work-a-take.template.test.ts +125 -0
  160. package/src/content-os.yaml +1830 -39
  161. package/src/content-studio-retired.template.test.ts +82 -0
  162. package/src/corpus-flow-validity.test.ts +4 -5
  163. package/src/crm-absorbs-outreach.template.test.ts +117 -0
  164. package/src/crm-build-plan.template.test.ts +128 -0
  165. package/src/crm-lists.template.test.ts +96 -0
  166. package/src/crm-messages-cadence.template.test.ts +164 -0
  167. package/src/crm-one-send-door.template.test.ts +65 -0
  168. package/src/crm.yaml +1456 -181
  169. package/src/decisions-wave.template.test.ts +186 -0
  170. package/src/define.ts +130 -17
  171. package/src/design-to-implementation.template.test.ts +94 -0
  172. package/src/dist-freshness.test.ts +97 -0
  173. package/src/domain-template-type.test.ts +75 -0
  174. package/src/ecosystem.yaml +27 -66
  175. package/src/enterprise-os.yaml +10 -134
  176. package/src/facecam-edit.audio-studio.test.ts +188 -0
  177. package/src/fal-generate-image.test.ts +200 -0
  178. package/src/fal-media-lab-pod-files.test.ts +180 -0
  179. package/src/finance.yaml +254 -1
  180. package/src/foundation.yaml +130 -28
  181. package/src/frame-host-modules.d.mts +6 -0
  182. package/src/frame-host-modules.mjs +58 -0
  183. package/src/generate.mjs +9 -3
  184. package/src/github-merge.test.ts +210 -0
  185. package/src/grants.yaml +57 -17
  186. package/src/hr.yaml +39 -6
  187. package/src/index.ts +36 -1
  188. package/src/intent.test.ts +161 -0
  189. package/src/internal-runbook.yaml +19 -1
  190. package/src/jev.test.ts +356 -0
  191. package/src/legal.yaml +33 -1
  192. package/src/life-os.yaml +65 -240
  193. package/src/lifecycle-coverage.test.ts +124 -0
  194. package/src/lifecycle-declarations.test.ts +252 -0
  195. package/src/lifecycle-presets.test.ts +468 -0
  196. package/src/lifecycle-presets.ts +494 -0
  197. package/src/lifecycle-stuck.test.ts +46 -0
  198. package/src/lifecycle-waits.parity.test.ts +319 -0
  199. package/src/marketing.yaml +108 -4
  200. package/src/meetings-knowledge.yaml +18 -0
  201. package/src/networking.yaml +23 -2
  202. package/src/operations.yaml +304 -4
  203. package/src/pipeline-views.template.test.ts +88 -0
  204. package/src/playbook-goal-placeholders.test.ts +57 -0
  205. package/src/postiz-contract.test.ts +134 -0
  206. package/src/presentation-declarations.test.ts +174 -0
  207. package/src/process-contract.validate.test.ts +352 -0
  208. package/src/project-management.yaml +39 -740
  209. package/src/publish-body.d.mts +29 -0
  210. package/src/publish-body.mjs +74 -0
  211. package/src/publish-body.test.ts +74 -0
  212. package/src/publish-official-lib.test.ts +176 -0
  213. package/src/replicate-rvm.test.ts +504 -0
  214. package/src/research-base.yaml +119 -20
  215. package/src/research-decision.yaml +60 -3
  216. package/src/reserved-profile-slugs.test.ts +83 -0
  217. package/src/retired-goal-kinds.test.ts +58 -0
  218. package/src/role-reference-and-step-domain.test.ts +192 -0
  219. package/src/seed-obligation.test.ts +176 -0
  220. package/src/send-message-maps.test.ts +227 -0
  221. package/src/shared-role-ssot.test.ts +42 -1
  222. package/src/ship-a-change.template.test.ts +177 -0
  223. package/src/skills.test.ts +170 -0
  224. package/src/social.yaml +85 -27
  225. package/src/space-packages.freshness.test.ts +97 -0
  226. package/src/space-packages.generated.ts +40 -0
  227. package/src/space-packages.test.ts +125 -0
  228. package/src/space-packages.ts +73 -0
  229. package/src/start.test.ts +188 -0
  230. package/src/task-intents.test.ts +293 -0
  231. package/src/templates.ts +21639 -21522
  232. package/src/types.ts +443 -60
  233. package/src/unipile-publish-post.test.ts +162 -0
  234. package/src/validate-flow.drift.test.ts +2 -3
  235. package/src/validate-flow.ts +32 -2
  236. package/src/validate-onboarding.test.ts +59 -0
  237. package/src/validate.test.ts +156 -3
  238. package/src/validate.ts +1384 -7
  239. package/src/wire-core.ts +231 -0
  240. package/src/wire.test.ts +159 -0
  241. package/src/wire.ts +107 -0
  242. package/src/__fixtures__/report-automation-flow.json +0 -393
  243. package/src/agent-fleet.yaml +0 -1645
  244. package/src/business-developer.template.test.ts +0 -351
  245. package/src/business-developer.yaml +0 -860
  246. package/src/content-studio.yaml +0 -361
  247. package/src/dev-dashboard.yaml +0 -990
  248. package/src/outreach-comms.yaml +0 -486
  249. package/src/the-arch.yaml +0 -201
@@ -0,0 +1,198 @@
1
+ /**
2
+ * W0 catalog safety — checks that SPAN SOURCES (before this file, no guard
3
+ * spanned sources). Read-only over sibling repos; a sibling that is absent (this
4
+ * package checked out alone, e.g. in synap-app CI) SKIPS its half honestly,
5
+ * with the reason logged, instead of passing vacuously.
6
+ *
7
+ * (a) ONE SOURCE PER PLAYBOOK NAME. The pod reuses playbooks by name (one live
8
+ * row per lower(name) per workspace, migration 0227), so the same name
9
+ * declared by two sources — a workspace YAML and a CP capability, or two
10
+ * capabilities — contend for ONE row, and whichever applies first silently
11
+ * wins. Every name may be declared by exactly one source file; the known
12
+ * exceptions are listed with their reason and must match EXACTLY, so a new
13
+ * duplicate AND a resolved one both turn this red.
14
+ *
15
+ * (b) EVERY SKILL REFERENCE RESOLVES. `dependencies[]` of kind `skill`, every
16
+ * `skills:` link, and every `system/<pkg>/<stem>` named in template or
17
+ * capability prose must name a skill package in this corpus
18
+ * (`skills/*.skill.json` or `skills/<slug>/*.skill.json`) or a system skill on disk under
19
+ * `synap-backend/skills/<pkg>/<stem>.md` (the pod's `ensureSystemSkills`
20
+ * derives `system/<pkg>/<file-stem>` from exactly that layout).
21
+ * Not covered: whether the corpus skill package is PUBLISHED.
22
+ *
23
+ * (c) The catalog's content directories ship in the npm bundle (`files` +
24
+ * the `exports` subpaths, in both `exports` and `publishConfig.exports`),
25
+ * and the build reads none of them (it compiles `src/*.yaml` only).
26
+ *
27
+ * The capability half reads the CATALOG's `capabilities/*.capability.json`
28
+ * (manifests; the CapabilityDefinition is `spec`) — the catalog is the source;
29
+ * the CP's `src/seeds/capability-templates/` is a transition mirror held
30
+ * identical by the CP's `catalog-mirror.tripwire.test.ts`.
31
+ */
32
+
33
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
34
+ import { dirname, join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { describe, expect, it } from "vitest";
37
+ import { readYamlTemplates } from "./generate.mjs";
38
+ import type { WorkspaceYaml } from "./types.js";
39
+ // One duplicate detector, shared with validateCatalog (duplicate-playbook-name).
40
+ import { duplicateNames } from "../scripts/catalog-lib.mjs";
41
+
42
+ const HERE = dirname(fileURLToPath(import.meta.url));
43
+ const PKG = join(HERE, "..");
44
+ const MONO = join(HERE, "../../../..");
45
+ const CATALOG_CAPS = join(PKG, "capabilities");
46
+ const BACKEND_SKILLS = join(MONO, "synap-backend/skills");
47
+ const CORPUS_SKILLS = join(PKG, "skills");
48
+
49
+ const yamls = readYamlTemplates() as Array<{ file: string; slug: string; data: WorkspaceYaml }>;
50
+ const hasBackend = existsSync(BACKEND_SKILLS);
51
+ if (!hasBackend) console.warn(`[catalog-safety] SKIP the system-skill half: ${BACKEND_SKILLS} is absent`);
52
+
53
+ const capFiles = readdirSync(CATALOG_CAPS)
54
+ .filter((f) => f.endsWith(".capability.json"))
55
+ .map((f) => ({ file: f, text: readFileSync(join(CATALOG_CAPS, f), "utf8") }));
56
+
57
+ // ── (a) one source per playbook name ────────────────────────────────────────
58
+
59
+ type Declared = { name: string; source: string };
60
+
61
+ const declared: Declared[] = [
62
+ ...yamls.flatMap((t) =>
63
+ (t.data.playbooks ?? []).map((p) => ({ name: p.name, source: `yaml:${t.file}` }))
64
+ ),
65
+ ...capFiles.flatMap((c) =>
66
+ ((JSON.parse(c.text).spec?.playbooks ?? []) as Array<{ name: string }>).map((p) => ({
67
+ name: p.name,
68
+ source: `cap:${c.file}`,
69
+ }))
70
+ ),
71
+ ];
72
+
73
+ /**
74
+ * Known duplicates, each with its reason. Both are CP-internal (out of this
75
+ * package's lane); giving each one home is wave W4.
76
+ */
77
+ const KNOWN_DUPLICATES: Record<string, string[]> = {
78
+ "client onboarding": ["cap:agency-skills.capability.json", "cap:discord-bot.capability.json"],
79
+ "lead outreach": ["cap:agency-skills.capability.json", "cap:discord-bot.capability.json"],
80
+ };
81
+
82
+ describe("(a) one source per playbook name", () => {
83
+ it("non-vacuity: the scan sees the YAML playbooks (and the capability ones when present)", () => {
84
+ expect(declared.filter((d) => d.source.startsWith("yaml:")).length).toBeGreaterThanOrEqual(15);
85
+ expect(capFiles.length).toBeGreaterThanOrEqual(30);
86
+ expect(declared.filter((d) => d.source.startsWith("cap:")).length).toBeGreaterThanOrEqual(5);
87
+ });
88
+
89
+ it("self-check: a name declared by two sources, in any case, is reported", () => {
90
+ expect(
91
+ duplicateNames([
92
+ { name: "Produce Content", source: "yaml:a.yaml" },
93
+ { name: "produce content ", source: "cap:b.json" },
94
+ { name: "Solo", source: "yaml:a.yaml" },
95
+ ])
96
+ ).toEqual({ "produce content": ["cap:b.json", "yaml:a.yaml"] });
97
+ });
98
+
99
+ it("no playbook name has two sources beyond the listed exceptions (exact)", () => {
100
+ expect(duplicateNames(declared)).toEqual(KNOWN_DUPLICATES);
101
+ });
102
+ });
103
+
104
+ // ── (b) every skill reference resolves ──────────────────────────────────────
105
+
106
+ /** `skills/*.skill.json` and `skills/<slug>/*.skill.json` (one level, as the catalog lib reads them). */
107
+ const corpusSkillFiles = readdirSync(CORPUS_SKILLS, { withFileTypes: true }).flatMap((d) =>
108
+ d.isDirectory()
109
+ ? readdirSync(join(CORPUS_SKILLS, d.name))
110
+ .filter((f) => f.endsWith(".skill.json"))
111
+ .map((f) => join(CORPUS_SKILLS, d.name, f))
112
+ : d.name.endsWith(".skill.json")
113
+ ? [join(CORPUS_SKILLS, d.name)]
114
+ : []
115
+ );
116
+ const corpusSkillSlugs = new Set(corpusSkillFiles.map((f) => JSON.parse(readFileSync(f, "utf8")).meta.slug as string));
117
+
118
+ /** `system/<pkg>/<stem>` → is there a `synap-backend/skills/<pkg>/<stem>.md` (stem case-insensitive)? */
119
+ function systemSkillExists(ref: string): boolean {
120
+ const [, pkg, stem] = ref.split("/");
121
+ const dir = join(BACKEND_SKILLS, pkg ?? "");
122
+ if (!pkg || !stem || !existsSync(dir)) return false;
123
+ return readdirSync(dir).some((f) => f.toLowerCase() === `${stem.toLowerCase()}.md`);
124
+ }
125
+
126
+ const SYSTEM_REF = /(?<![A-Za-z0-9_/-])system\/[a-z0-9_-]+\/[a-z0-9_-]+/g;
127
+
128
+ const structuredRefs = yamls.flatMap((t) => [
129
+ ...(t.data.dependencies ?? [])
130
+ .filter((d) => d.kind === "skill")
131
+ .map((d) => ({ where: `${t.file} dependencies`, slug: d.slug })),
132
+ ...(t.data.skills ?? []).map((s) => ({ where: `${t.file} skills:`, slug: s.slug })),
133
+ ]);
134
+
135
+ const proseRefs = [
136
+ ...yamls.flatMap((t) =>
137
+ [...readFileSync(join(HERE, t.file), "utf8").matchAll(SYSTEM_REF)].map((m) => ({
138
+ where: t.file,
139
+ slug: m[0],
140
+ }))
141
+ ),
142
+ ...capFiles.flatMap((c) =>
143
+ [...c.text.matchAll(SYSTEM_REF)].map((m) => ({ where: c.file, slug: m[0] }))
144
+ ),
145
+ ];
146
+
147
+ describe("(b) every skill reference resolves", () => {
148
+ it("non-vacuity: structured refs exist, and the resolvers see a literal sample", () => {
149
+ expect(structuredRefs.length).toBeGreaterThanOrEqual(2);
150
+ expect(corpusSkillSlugs.has("creative-director")).toBe(true);
151
+ // The prose matcher catches a system ref and ignores a path that merely ends in one.
152
+ expect("load system/synap/concepts now".match(SYSTEM_REF)).toEqual(["system/synap/concepts"]);
153
+ expect("ecosystem/operations/crm".match(SYSTEM_REF)).toBeNull();
154
+ });
155
+
156
+ it("every skill dependency and skills: link names a corpus or system skill", () => {
157
+ const dangling = structuredRefs.filter((r) =>
158
+ r.slug.startsWith("system/")
159
+ ? hasBackend && !systemSkillExists(r.slug)
160
+ : !corpusSkillSlugs.has(r.slug)
161
+ );
162
+ expect(dangling.map((r) => `${r.where}: ${r.slug}`)).toEqual([]);
163
+ });
164
+
165
+ it.skipIf(!hasBackend)("every system/<pkg>/<stem> named in prose exists under synap-backend/skills", () => {
166
+ expect(systemSkillExists("system/synap/concepts")).toBe(true); // literal sample
167
+ expect(systemSkillExists("system/synap/creative-director")).toBe(false); // the stale ref
168
+ const dangling = proseRefs.filter((r) => !systemSkillExists(r.slug));
169
+ expect(dangling.map((r) => `${r.where}: ${r.slug}`)).toEqual([]);
170
+ });
171
+ });
172
+
173
+ // ── (c) the content directories ship, the build reads none of them ─────────
174
+
175
+ const CONTENT_DIRS = ["capabilities", "skills", "packs", "automations"];
176
+
177
+ describe("(c) catalog content ships in the npm bundle; the build reads only src/*.yaml", () => {
178
+ const pkg = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8"));
179
+
180
+ it("non-vacuity: every content directory exists and holds files", () => {
181
+ for (const d of CONTENT_DIRS) expect(readdirSync(join(PKG, d)).length, d).toBeGreaterThan(0);
182
+ });
183
+
184
+ it("`files` carries every content directory, and both export maps expose it", () => {
185
+ for (const d of CONTENT_DIRS) {
186
+ expect(pkg.files, d).toContain(d);
187
+ expect(pkg.exports[`./${d}/*`], d).toBe(`./${d}/*`);
188
+ expect(pkg.publishConfig.exports[`./${d}/*`], d).toBe(`./${d}/*`);
189
+ }
190
+ expect(pkg.files).toEqual(expect.arrayContaining(["dist", "src"]));
191
+ });
192
+
193
+ it("build.mjs compiles src/*.yaml only", () => {
194
+ expect(yamls.every((t) => t.file.endsWith(".yaml") && !t.file.includes("/"))).toBe(true);
195
+ const build = readFileSync(join(PKG, "build.mjs"), "utf8");
196
+ for (const d of CONTENT_DIRS) expect(build, d).not.toMatch(new RegExp(`["'\`/]${d}/`));
197
+ });
198
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Bundled catalog entries carry DERIVED playbook sub-items — `track-template`
3
+ * for `scope: project`, `work-template` otherwise — the same rule the control
4
+ * plane computes in SQL for remote rows (`packageItemsSql`). A bundle usually
5
+ * WINS the merge for an official template, so without this the marketplace's
6
+ * "Track templates" lens could never see the templates this repo ships.
7
+ *
8
+ * Driven from the REAL bundled corpus through the REAL `mergeCatalog` door, not
9
+ * a hand-built entry.
10
+ */
11
+ import { test } from "vitest";
12
+ import assert from "node:assert/strict";
13
+ import { mergeCatalog, TRACK_TEMPLATE_ITEM_KIND, WORK_TEMPLATE_ITEM_KIND } from "./catalog.js";
14
+ import { listWorkspaceTemplates } from "./index.js";
15
+
16
+ test("a bundled template with a project-scope playbook reads as shipping a track template", () => {
17
+ const bundled = listWorkspaceTemplates();
18
+ const withMethod = bundled.filter((t) =>
19
+ (t.playbooks ?? []).some((p) => p.scope === "project")
20
+ );
21
+ // Non-vacuity: the corpus really ships at least one track template.
22
+ assert.ok(withMethod.length >= 1, "no bundled template ships a scope:project playbook");
23
+
24
+ const { entries } = mergeCatalog({ bundled, remote: [], bundleVersion: "0.0.0", remoteStatus: "ok" });
25
+ for (const tpl of withMethod) {
26
+ const entry = entries.find((e) => e.slug === tpl.meta.slug);
27
+ assert.ok(entry, `bundled ${tpl.meta.slug} missing from the merge`);
28
+ const tracks = entry.items.filter((i) => i.kind === TRACK_TEMPLATE_ITEM_KIND);
29
+ const expected = (tpl.playbooks ?? []).filter((p) => p.scope === "project").map((p) => p.name);
30
+ assert.deepEqual(tracks.map((i) => i.name), expected);
31
+ }
32
+ });
33
+
34
+ test("a session / unscoped playbook is a work template, never a track template", () => {
35
+ const tpl = listWorkspaceTemplates()[0];
36
+ const synthetic = {
37
+ ...tpl,
38
+ meta: { ...tpl.meta, slug: "zz-synthetic" },
39
+ playbooks: [
40
+ { name: "Digest", goalTemplate: "g", scope: "session" as const },
41
+ { name: "Enrich", goalTemplate: "g" },
42
+ ],
43
+ };
44
+ const { entries } = mergeCatalog({ bundled: [synthetic], remote: [], bundleVersion: "0.0.0", remoteStatus: "ok" });
45
+ assert.deepEqual(
46
+ entries[0].items
47
+ .filter((i) => i.kind === WORK_TEMPLATE_ITEM_KIND || i.kind === TRACK_TEMPLATE_ITEM_KIND)
48
+ .map((i) => i.kind),
49
+ [WORK_TEMPLATE_ITEM_KIND, WORK_TEMPLATE_ITEM_KIND]
50
+ );
51
+ });
52
+
53
+ // G1 (2026-09-27): what a package brings BESIDE its templates — kinds, roles,
54
+ // views — rides the same derived items, so a card can say "+ 4 kinds, 4 views"
55
+ // without the definition. Same rule as the CP `packageItemsSql`.
56
+ test("a bundled template's profiles and views derive kind / role / view items", () => {
57
+ const bundled = listWorkspaceTemplates();
58
+ const { entries } = mergeCatalog({ bundled, remote: [], bundleVersion: "0.0.0", remoteStatus: "ok" });
59
+ const grp = bundled.find((t) => t.meta.slug === "business-model");
60
+ assert.ok(grp, "business-model is no longer bundled");
61
+ const entry = entries.find((e) => e.slug === "business-model")!;
62
+ const count = (k: string) => entry.items.filter((i) => i.kind === k).length;
63
+ const kinds = grp.profiles.filter((p) => p.profileKind !== "role").length;
64
+ const roles = grp.profiles.filter((p) => p.profileKind === "role").length;
65
+ assert.ok(kinds > 0 && (grp.views ?? []).length > 0, "non-vacuity: GRP ships kinds and views");
66
+ assert.equal(count("kind"), kinds);
67
+ assert.equal(count("role"), roles);
68
+ assert.equal(count("view"), (grp.views ?? []).length);
69
+ // A role-profile reads as a role, never a kind.
70
+ const withRole = bundled.find((t) => t.profiles.some((p) => p.profileKind === "role"));
71
+ assert.ok(withRole, "non-vacuity: some bundled template declares a role");
72
+ const roleEntry = entries.find((e) => e.slug === withRole.meta.slug)!;
73
+ const roleSlug = withRole.profiles.find((p) => p.profileKind === "role")!.slug;
74
+ assert.equal(roleEntry.items.find((i) => i.key === roleSlug)?.kind, "role");
75
+ });
package/src/catalog.ts CHANGED
@@ -47,6 +47,68 @@ export interface PackageItemSummary {
47
47
  name: string;
48
48
  }
49
49
 
50
+ /**
51
+ * The DERIVED template kinds a playbook sub-item carries. A playbook's own
52
+ * `scope` decides it — `"project"` is a method a project runs as a track, any
53
+ * other value (the schema default `session`) is one sitting of work. The author
54
+ * never declares it. The control plane derives the SAME rule in SQL for remote
55
+ * rows (`packageItemsSql`, synap-control-plane-api `routes/packages.ts`); the
56
+ * user-facing noun is `resolveTemplateNoun(scope)` (`@synap-core/types/vocabulary`).
57
+ */
58
+ export const TRACK_TEMPLATE_ITEM_KIND = "track-template";
59
+ export const WORK_TEMPLATE_ITEM_KIND = "work-template";
60
+
61
+ /** Playbooks → `{kind,key,name}` sub-items. `key` = name (playbooks carry no key). */
62
+ export function derivePlaybookItems(
63
+ playbooks:
64
+ | ReadonlyArray<{ name?: unknown; scope?: unknown } | null | undefined>
65
+ | null
66
+ | undefined
67
+ ): PackageItemSummary[] {
68
+ const out: PackageItemSummary[] = [];
69
+ for (const pb of playbooks ?? []) {
70
+ if (!pb || typeof pb.name !== "string" || !pb.name) continue;
71
+ out.push({
72
+ kind: pb.scope === "project" ? TRACK_TEMPLATE_ITEM_KIND : WORK_TEMPLATE_ITEM_KIND,
73
+ key: pb.name,
74
+ name: pb.name,
75
+ });
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Schema sub-items: what a package brings BESIDE its templates — each profile as
82
+ * `kind` (or `role` when `profileKind: role`), each view as `view`. The CP derives
83
+ * the SAME rule in SQL (`packageItemsSql`). A card reads them only to say, in a
84
+ * secondary line, what else comes with the package ("+ 4 kinds, 4 views").
85
+ */
86
+ export function deriveSchemaItems(tpl: {
87
+ profiles?: ReadonlyArray<{ slug?: unknown; displayName?: unknown; profileKind?: unknown } | null | undefined> | null;
88
+ views?: ReadonlyArray<{ name?: unknown; displayName?: unknown; slug?: unknown } | null | undefined> | null;
89
+ }): PackageItemSummary[] {
90
+ const out: PackageItemSummary[] = [];
91
+ for (const p of tpl.profiles ?? []) {
92
+ if (!p || typeof p.slug !== "string" || !p.slug) continue;
93
+ out.push({
94
+ kind: p.profileKind === "role" ? "role" : "kind",
95
+ key: p.slug,
96
+ name: typeof p.displayName === "string" && p.displayName ? p.displayName : p.slug,
97
+ });
98
+ }
99
+ for (const v of tpl.views ?? []) {
100
+ const name =
101
+ v && typeof v.name === "string" && v.name
102
+ ? v.name
103
+ : v && typeof v.displayName === "string" && v.displayName
104
+ ? v.displayName
105
+ : null;
106
+ if (!v || !name) continue;
107
+ out.push({ kind: "view", key: typeof v.slug === "string" && v.slug ? v.slug : name, name });
108
+ }
109
+ return out;
110
+ }
111
+
50
112
  /**
51
113
  * A bundled template — the canonical `WorkspaceYaml` this package ships. Callers
52
114
  * pass `listPublicTemplates()` (18) or `listWorkspaceTemplates()` (21); the door
@@ -83,6 +145,13 @@ export interface RemoteEntry {
83
145
  * never "unknown-so-hide".
84
146
  */
85
147
  isPrivate?: boolean;
148
+ /**
149
+ * The minimum subscription tier required to INSTALL this package (`solo` | `pro`
150
+ * | `team` | `enterprise`); `null`/absent ⇒ ungated. DISPLAY only here — the real
151
+ * install floor is the pod's `assertPackageTierAccess`. Kept a bare `string` so
152
+ * this pure/shared door stays enum-agnostic (like `category`).
153
+ */
154
+ requiredTier?: string | null;
86
155
  /**
87
156
  * Lightweight computed summary of the row's sub-objects (cells), carried on the
88
157
  * LIST projection so a surface can surface a matching sub-item without the full
@@ -149,6 +218,8 @@ export interface CpPackageRow {
149
218
  icon?: string | null;
150
219
  /** Present only on `/mine` rows. `false` ⇒ private. Browse rows omit it. */
151
220
  isPublic?: boolean;
221
+ /** The package's `required_tier` gate (`solo`|`pro`|…); `null`/absent ⇒ ungated. */
222
+ requiredTier?: string | null;
152
223
  /**
153
224
  * Computed sub-object summary from `GET /api/packages` / `/mine` (query-time,
154
225
  * not a stored column). Carries `key + name` per cell; never the cell code.
@@ -180,6 +251,7 @@ export function rowToRemoteEntry(
180
251
  tags: row.tags ?? [],
181
252
  icon: row.icon ?? null,
182
253
  isPrivate,
254
+ requiredTier: row.requiredTier ?? null,
183
255
  items: row.items,
184
256
  // Both list routes strip `definition`; absence ⇒ bundle wins a slug conflict,
185
257
  // and the install path fetches the full definition per-slug when needed.
@@ -202,6 +274,12 @@ interface CatalogEntryBase {
202
274
  icon?: string | null;
203
275
  /** `true` ⇒ not publicly listed (internal bundled template, or private CP row). */
204
276
  isPrivate: boolean;
277
+ /**
278
+ * Minimum install tier (`solo`|`pro`|…); `null` ⇒ ungated. Bundled templates are
279
+ * always `null` (they ship in-client, never gated). DISPLAY only — a surface
280
+ * shows a "requires <tier>" badge; the real floor is the pod's install check.
281
+ */
282
+ requiredTier?: string | null;
205
283
  /**
206
284
  * The entry's sub-object summary (cells). Always present (defaults to `[]`) so a
207
285
  * consumer can match sub-items uniformly. Bundled workspace templates carry no
@@ -289,8 +367,12 @@ function bundledToEntry(tpl: WorkspaceYaml): BundledCatalogEntry {
289
367
  tags: tpl.meta.tags ?? [],
290
368
  icon: tpl.meta.icon,
291
369
  isPrivate: tpl.meta.isPublic === false,
292
- // Bundled workspace templates carry no cells — be honest, not `undefined`.
293
- items: [],
370
+ // Bundled templates ship in-client and are never tier-gated.
371
+ requiredTier: null,
372
+ // Bundled workspace templates carry no cells; their playbooks are summarized
373
+ // with the same derived kind the CP computes for a remote row, so a bundle
374
+ // that wins the merge still reads as shipping a track template.
375
+ items: [...derivePlaybookItems(tpl.playbooks), ...deriveSchemaItems(tpl)],
294
376
  template: tpl,
295
377
  };
296
378
  }
@@ -306,6 +388,7 @@ function remoteToEntry(row: RemoteEntry): RemoteCatalogEntry {
306
388
  tags: row.tags ?? [],
307
389
  icon: row.icon ?? null,
308
390
  isPrivate: row.isPrivate ?? false,
391
+ requiredTier: row.requiredTier ?? null,
309
392
  items: row.items ?? [],
310
393
  remote: row,
311
394
  };
@@ -0,0 +1,102 @@
1
+ # France civic open-data research overlay — STUB (enhance later)
2
+ #
3
+ # Thin overlay that composes onto research-base for investigating French public
4
+ # data (open data / Licence Ouverte). NOT a government product, NOT a marketplace
5
+ # civic-tech app. Private workspace template only.
6
+ #
7
+ # Data access: consume official APIs only — data.gouv.fr, api.gouv.fr, PISTE.
8
+ # Never scrape HTML. Do not invent a public pod host or resource_shares.
9
+ # No automations that fetch the web in this stub.
10
+
11
+ meta:
12
+ slug: civic-france
13
+ domain: knowledge
14
+ name: France Civic Research
15
+ description: Overlay for investigating French public data (open data / Licence Ouverte). Composes onto Research. Not a government product — a private research stub.
16
+ icon: landmark
17
+ color: "#1D4ED8"
18
+ tags: [research, civic, france, open-data, licence-ouverte]
19
+ isPublic: false
20
+
21
+ workspace:
22
+ name: Research
23
+ description: Adds a French civic open-data lens onto the Research substrate — track public datasets consulted during investigations.
24
+ subtype: research-base
25
+
26
+ # Layer additively onto the research-base workspace (no second workspace).
27
+ # Visibility and capabilities OMITTED so the base Research workspace keeps its own.
28
+ dependencies:
29
+ - slug: research-base
30
+ relation: compose
31
+ reason: France civic research is a research method — it needs the Research substrate (question / research / source) to work over.
32
+
33
+ profiles:
34
+ # Slugs this overlay reuses from research-base — declared for the per-file
35
+ # validator (base owns their full schema; the applier skips already-existing
36
+ # properties). Mirror research-decision's reuse pattern.
37
+ - slug: question
38
+ displayName: Question
39
+ icon: help-circle
40
+ color: blue
41
+ description: An open question driving a civic-data investigation.
42
+ properties:
43
+ - slug: questionStatus
44
+ label: Status
45
+ valueType: string
46
+ inputType: select
47
+ enumValues: [open, exploring, answered, abandoned]
48
+ constraints: { defaultValue: open }
49
+
50
+ - slug: research
51
+ displayName: Research
52
+ icon: microscope
53
+ color: violet
54
+ description: An investigation into French public data — sources, findings, conclusion.
55
+ properties:
56
+ - slug: researchStatus
57
+ label: Status
58
+ valueType: string
59
+ inputType: select
60
+ enumValues: [ongoing, concluded, abandoned]
61
+ constraints: { defaultValue: ongoing }
62
+
63
+ - slug: source
64
+ displayName: Source
65
+ icon: file-text
66
+ color: cyan
67
+ description: Anything consulted — API payload, dataset page, decree, report.
68
+ properties:
69
+ - slug: url
70
+ label: URL
71
+ valueType: string
72
+ inputType: url
73
+
74
+ # Workspace-owned catalog entry for a French public dataset (thin stub).
75
+ - slug: public_dataset
76
+ displayName: Public dataset
77
+ icon: database
78
+ color: blue
79
+ description: A French public dataset consulted via official open-data APIs (data.gouv.fr / api.gouv.fr / PISTE) — never scraped HTML.
80
+ scope: WORKSPACE
81
+ properties:
82
+ - slug: sourceUrl
83
+ label: Source URL
84
+ valueType: string
85
+ inputType: url
86
+ - slug: license
87
+ label: License
88
+ valueType: string
89
+ inputType: text
90
+ - slug: publisher
91
+ label: Publisher
92
+ valueType: string
93
+ inputType: text
94
+
95
+ views:
96
+ - name: Public datasets
97
+ slug: civic-france-public-datasets
98
+ type: table
99
+ scopeProfileSlug: public_dataset
100
+ description: French public datasets tracked in this research workspace.
101
+ config:
102
+ columns: [title, publisher, license, sourceUrl]
@@ -45,18 +45,19 @@ describe("concept guard — shipped corpus", () => {
45
45
 
46
46
  it("needs NO declared divergence — the CRM fork was resolved, not allowlisted", () => {
47
47
  // The allowlist was born carrying `crm-lead|lead` + `client|crm-client`.
48
- // business-developer.yaml now re-declares the SHARED `lead`/`client` roles
49
- // and carries its business-development fields as workspace-overlay
50
- // properties, so there is no fork left to declare. Rule 3 (stale-allowlist)
48
+ // crm.yaml re-declares the SHARED `lead`/`client` roles and carries the
49
+ // qualification/handoff fields (folded in from business-developer,
50
+ // 2026-10-12) as workspace-overlay properties, so there is no fork left to
51
+ // declare. Rule 3 (stale-allowlist)
51
52
  // is what forces this to stay true: a leftover entry fails CI.
52
53
  expect(Object.keys(DECLARED_DIVERGENCES)).toEqual([]);
53
54
 
54
- const bd = WORKSPACE_TEMPLATES["business-developer"]?.profiles ?? [];
55
- expect(bd.map((p) => p.slug)).not.toContain("crm-lead");
56
- expect(bd.map((p) => p.slug)).not.toContain("crm-client");
55
+ const crm = WORKSPACE_TEMPLATES["crm"]?.profiles ?? [];
56
+ expect(crm.map((p) => p.slug)).not.toContain("crm-lead");
57
+ expect(crm.map((p) => p.slug)).not.toContain("crm-client");
57
58
  for (const slug of ["lead", "client"]) {
58
- const reused = bd.find((p) => p.slug === slug);
59
- expect(reused, `business-developer must reuse shared '${slug}'`).toBeDefined();
59
+ const reused = crm.find((p) => p.slug === slug);
60
+ expect(reused, `crm must reuse shared '${slug}'`).toBeDefined();
60
61
  expect(reused?.scope).toBe("shared");
61
62
  expect(reused?.profileKind).toBe("role");
62
63
  // No alias: the shared slug IS the concept identity.
@@ -190,3 +191,22 @@ describe("concept guard — stays green on legitimate patterns", () => {
190
191
  expect(violations).toEqual([]);
191
192
  });
192
193
  });
194
+
195
+ // "Pipeline" is a VIEW, never a stored kind (work model L3, 2026-10-11): the
196
+ // pipeline is computed from a kind or role + its lifecycle. A stored kind named
197
+ // pipeline (content-studio's old `content-pipeline`) makes "open the pipeline"
198
+ // ambiguous. Scans every profile slug and semanticSlug in the shipped corpus.
199
+ // Does NOT scan property slugs (builder's agent-run `pipeline_phase` is a run
200
+ // phase, a different word) nor view names ("Lead Pipeline" IS the view).
201
+ describe("no stored kind is named pipeline", () => {
202
+ it("the corpus declares no profile whose slug or semanticSlug says pipeline", () => {
203
+ const all = Object.values(WORKSPACE_TEMPLATES).flatMap((t) =>
204
+ (t.profiles ?? []).map((p) => ({ tpl: t.meta?.slug, p }))
205
+ );
206
+ expect(all.length).toBeGreaterThan(100); // non-vacuity
207
+ const hits = all
208
+ .filter(({ p }) => /pipeline/i.test(p.slug) || /pipeline/i.test(p.semanticSlug ?? ""))
209
+ .map(({ tpl, p }) => `${tpl}:${p.slug}`);
210
+ expect(hits).toEqual([]);
211
+ });
212
+ });
@@ -51,8 +51,8 @@ export interface ConceptViolation {
51
51
  *
52
52
  * Currently EMPTY, and that is the healthy state. The two entries this list was
53
53
  * born for — `crm-lead|lead` and `client|crm-client` — were RESOLVED rather
54
- * than kept: business-developer.yaml now re-declares the shared `lead`/`client`
55
- * roles and carries its business-development fields as workspace-overlay
54
+ * than kept: crm.yaml (business-developer folded into it, 2026-10-12)
55
+ * re-declares the shared `lead`/`client` roles and carries its business-development fields as workspace-overlay
56
56
  * PROPERTIES. One concept, one slug, workspace-specific fields. Rule 3 below
57
57
  * would have failed CI had the entries been left behind.
58
58
  */
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Contract tests for the Content Creation suite.
3
+ *
4
+ * Reads the generated `templates.ts` corpus (same as every installer).
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { getWorkspaceTemplate } from "./templates.js";
8
+
9
+ const template = (() => {
10
+ const resolved = getWorkspaceTemplate("content-creation");
11
+ if (!resolved) throw new Error("content-creation template is not registered");
12
+ return resolved;
13
+ })();
14
+
15
+ describe("Content Creation suite", () => {
16
+ it("is a public suite tagged for content creation", () => {
17
+ expect(template.meta.slug).toBe("content-creation");
18
+ expect(template.meta.isPublic).toBe(true);
19
+ expect(template.meta.domain).toBe("content");
20
+ expect(template.meta.tags).toEqual(
21
+ expect.arrayContaining(["suite", "content", "creation"])
22
+ );
23
+ });
24
+
25
+ it("requires content-os and brand-library because content-os consumes brand; never the retired content-studio", () => {
26
+ const required = (template.dependencies ?? []).map((d) => ({
27
+ slug: d.slug,
28
+ relation: d.relation ?? "require",
29
+ }));
30
+ expect(required).toEqual(
31
+ expect.arrayContaining([
32
+ { slug: "content-os", relation: "require" },
33
+ { slug: "brand-library", relation: "require" },
34
+ ])
35
+ );
36
+ // content-studio is retired into content-os (retired.yaml, 2026-11-03).
37
+ expect(required.map((d) => d.slug)).not.toContain("content-studio");
38
+ expect(required.every((d) => d.relation === "require")).toBe(true);
39
+ // Foundation is NOT a direct dep: content-os already requires it, and
40
+ // neither constituent's sourceRoles consume `strategy`.
41
+ expect(required.map((d) => d.slug)).not.toContain("foundation");
42
+ });
43
+
44
+ it("does not silently skip the brand spine its constituents consume", () => {
45
+ expect(template.workspace.sourceRoles?.brand).toBe("consumer");
46
+ const contentOs = getWorkspaceTemplate("content-os");
47
+ expect(contentOs?.workspace.sourceRoles?.brand).toBe("consumer");
48
+ expect((template.dependencies ?? []).some((d) => d.slug === "brand-library")).toBe(
49
+ true
50
+ );
51
+ });
52
+ });