@synap-core/workspace-templates 0.12.0 → 0.24.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 (237) hide show
  1. package/README.md +509 -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 +1496 -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 +494 -0
  54. package/capabilities/vercel.capability.json +424 -0
  55. package/dist/define-DqoItRiR.d.ts +1370 -0
  56. package/dist/index.d.ts +174 -900
  57. package/dist/index.js +26447 -24075
  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 +27 -4
  63. package/retired.yaml +136 -0
  64. package/skills/app-factory-build-slice/SKILL.md +70 -0
  65. package/skills/app-factory-build-slice.catalog.json +17 -0
  66. package/skills/app-factory-design-canvas/SKILL.md +96 -0
  67. package/skills/app-factory-design-canvas.catalog.json +17 -0
  68. package/skills/app-factory-design-review/SKILL.md +105 -0
  69. package/skills/app-factory-design-review.catalog.json +18 -0
  70. package/skills/app-factory-research/SKILL.md +123 -0
  71. package/skills/app-factory-research.catalog.json +17 -0
  72. package/skills/bookmark-enrichment/SKILL.md +40 -0
  73. package/skills/carousel-format/carousel-format.skill.json +18 -0
  74. package/skills/collage-broll/collage-broll.skill.json +20 -0
  75. package/skills/content-taste/content-taste.skill.json +18 -0
  76. package/skills/creative-director/creative-director.skill.json +18 -0
  77. package/skills/design-critique/SKILL.md +138 -0
  78. package/skills/design-critique.catalog.json +12 -0
  79. package/skills/designing-with-real-components/SKILL.md +140 -0
  80. package/skills/designing-with-real-components.catalog.json +12 -0
  81. package/skills/edit-loop/edit-loop.skill.json +25 -0
  82. package/skills/facecam-edit/facecam-edit.skill.json +24 -0
  83. package/skills/mapping-the-server/SKILL.md +54 -0
  84. package/skills/math-explainer-manimgx/math-explainer-manimgx.skill.json +20 -0
  85. package/skills/motion-quality-gates/motion-quality-gates.skill.json +20 -0
  86. package/skills/open-design-edit/open-design-edit.skill.json +24 -0
  87. package/skills/reading-client-progress/SKILL.md +65 -0
  88. package/skills/routing-to-channels/SKILL.md +54 -0
  89. package/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
  90. package/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
  91. package/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
  92. package/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
  93. package/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
  94. package/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
  95. package/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
  96. package/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
  97. package/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
  98. package/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
  99. package/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
  100. package/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
  101. package/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
  102. package/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
  103. package/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
  104. package/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
  105. package/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
  106. package/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
  107. package/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
  108. package/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
  109. package/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
  110. package/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
  111. package/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
  112. package/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
  113. package/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
  114. package/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
  115. package/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
  116. package/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
  117. package/skills/video-direction/video-direction.skill.json +20 -0
  118. package/skills/video-style-vocabulary/video-style-vocabulary.skill.json +20 -0
  119. package/skills/video-toolbox/video-toolbox.skill.json +20 -0
  120. package/src/__fixtures__/lifecycle-presets.flat-before.json +205 -0
  121. package/src/agency-os.yaml +122 -0
  122. package/src/agent-os-skill-slugs.test.ts +65 -0
  123. package/src/apify-social-reader.test.ts +317 -0
  124. package/src/base.yaml +85 -39
  125. package/src/blockchain-ecosystem.yaml +1 -0
  126. package/src/brand-library.yaml +525 -14
  127. package/src/builder-dev-fold.template.test.ts +113 -0
  128. package/src/builder.yaml +779 -91
  129. package/src/business-model-catalog-description.test.ts +47 -0
  130. package/src/business-model-grp.template.test.ts +214 -0
  131. package/src/business-model.yaml +296 -49
  132. package/src/catalog-safety.test.ts +198 -0
  133. package/src/catalog.template-items.test.ts +75 -0
  134. package/src/catalog.ts +66 -2
  135. package/src/concept-guard.test.ts +28 -8
  136. package/src/concept-guard.ts +2 -2
  137. package/src/content-creation.template.test.ts +52 -0
  138. package/src/content-creation.yaml +192 -0
  139. package/src/content-os.edit-loop.template.test.ts +210 -0
  140. package/src/content-os.publish-due-posts.template.test.ts +261 -0
  141. package/src/content-os.template.test.ts +349 -0
  142. package/src/content-os.video-takes.template.test.ts +338 -0
  143. package/src/content-os.work-a-take.template.test.ts +125 -0
  144. package/src/content-os.yaml +1873 -39
  145. package/src/content-studio-retired.template.test.ts +82 -0
  146. package/src/corpus-flow-validity.test.ts +4 -5
  147. package/src/crm-absorbs-outreach.template.test.ts +117 -0
  148. package/src/crm-build-plan.template.test.ts +128 -0
  149. package/src/crm-lists.template.test.ts +96 -0
  150. package/src/crm-messages-cadence.template.test.ts +246 -0
  151. package/src/crm-one-send-door.template.test.ts +65 -0
  152. package/src/crm.yaml +1563 -181
  153. package/src/decisions-wave.template.test.ts +186 -0
  154. package/src/define.ts +146 -17
  155. package/src/design-to-implementation.template.test.ts +94 -0
  156. package/src/domain-template-type.test.ts +75 -0
  157. package/src/ecosystem.yaml +27 -66
  158. package/src/enterprise-os.yaml +10 -134
  159. package/src/facecam-edit.audio-studio.test.ts +188 -0
  160. package/src/fal-generate-image.test.ts +200 -0
  161. package/src/fal-media-lab-pod-files.test.ts +180 -0
  162. package/src/finance.yaml +254 -1
  163. package/src/foundation.yaml +78 -38
  164. package/src/frame-host-modules.d.mts +6 -0
  165. package/src/frame-host-modules.mjs +58 -0
  166. package/src/generate.mjs +9 -3
  167. package/src/github-merge.test.ts +210 -0
  168. package/src/grants.yaml +46 -17
  169. package/src/hr.yaml +39 -6
  170. package/src/index.ts +36 -1
  171. package/src/intent.test.ts +161 -0
  172. package/src/internal-runbook.yaml +19 -1
  173. package/src/jev.test.ts +356 -0
  174. package/src/legal.yaml +33 -1
  175. package/src/life-os.yaml +51 -240
  176. package/src/lifecycle-coverage.test.ts +124 -0
  177. package/src/lifecycle-declarations.test.ts +252 -0
  178. package/src/lifecycle-presets.test.ts +468 -0
  179. package/src/lifecycle-presets.ts +494 -0
  180. package/src/lifecycle-stuck.test.ts +46 -0
  181. package/src/lifecycle-waits.parity.test.ts +319 -0
  182. package/src/marketing.yaml +108 -4
  183. package/src/meetings-knowledge.yaml +18 -0
  184. package/src/networking.yaml +23 -2
  185. package/src/operations.yaml +304 -4
  186. package/src/pipeline-views.template.test.ts +88 -0
  187. package/src/playbook-goal-placeholders.test.ts +57 -0
  188. package/src/postiz-contract.test.ts +134 -0
  189. package/src/presentation-declarations.test.ts +174 -0
  190. package/src/process-contract.validate.test.ts +352 -0
  191. package/src/project-management.yaml +39 -740
  192. package/src/publish-body.d.mts +29 -0
  193. package/src/publish-body.mjs +74 -0
  194. package/src/publish-body.test.ts +74 -0
  195. package/src/publish-official-lib.test.ts +176 -0
  196. package/src/replicate-rvm.test.ts +504 -0
  197. package/src/research-base.yaml +119 -20
  198. package/src/research-decision.yaml +60 -3
  199. package/src/reserved-profile-slugs.test.ts +81 -0
  200. package/src/retired-goal-kinds.test.ts +52 -0
  201. package/src/role-reference-and-step-domain.test.ts +192 -0
  202. package/src/seed-obligation.test.ts +1 -6
  203. package/src/send-message-maps.test.ts +227 -0
  204. package/src/shared-role-ssot.test.ts +40 -2
  205. package/src/ship-a-change.template.test.ts +177 -0
  206. package/src/skills.test.ts +170 -0
  207. package/src/social.yaml +85 -27
  208. package/src/space-packages.freshness.test.ts +97 -0
  209. package/src/space-packages.generated.ts +40 -0
  210. package/src/space-packages.test.ts +125 -0
  211. package/src/space-packages.ts +73 -0
  212. package/src/start.test.ts +188 -0
  213. package/src/stripe-capability.test.ts +192 -0
  214. package/src/task-intents.test.ts +293 -0
  215. package/src/template-documents.test.ts +158 -0
  216. package/src/templates.ts +20907 -21464
  217. package/src/types.ts +475 -61
  218. package/src/unipile-linkedin-find-profile.test.ts +183 -0
  219. package/src/unipile-publish-post.test.ts +162 -0
  220. package/src/validate-flow.drift.test.ts +2 -3
  221. package/src/validate-flow.ts +22 -2
  222. package/src/validate-onboarding.test.ts +59 -0
  223. package/src/validate.test.ts +71 -4
  224. package/src/validate.ts +1416 -7
  225. package/src/wire-core.ts +231 -0
  226. package/src/wire.test.ts +159 -0
  227. package/src/wire.ts +107 -0
  228. package/views/bookmark-views-pack.view.json +39 -0
  229. package/views/event-views-pack.view.json +45 -0
  230. package/views/task-views-pack.view.json +53 -0
  231. package/src/agent-fleet.yaml +0 -1645
  232. package/src/business-developer.template.test.ts +0 -351
  233. package/src/business-developer.yaml +0 -860
  234. package/src/content-studio.yaml +0 -361
  235. package/src/dev-dashboard.yaml +0 -990
  236. package/src/outreach-comms.yaml +0 -486
  237. package/src/the-arch.yaml +0 -201
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `meta.start` — a template declares itself an onboarding start (A1).
3
+ *
4
+ * Pinned:
5
+ * - a well-formed start validates with zero `start-*` errors;
6
+ * - each `start-*` rule fires on a minimal broken fixture (and only it);
7
+ * - the package door forwards a TOP-LEVEL `start`, never inside `_meta`;
8
+ * - the catalog header accepts the shape and refuses it on a non-space kind.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { validateTemplate } from "./validate.js";
12
+ import { toPackageDefinition } from "./define.js";
13
+ import { INTENT_LABEL_MAX } from "./types.js";
14
+ import type { TemplateProfile, TemplateStart, WorkspaceYaml } from "./types.js";
15
+ import { manifestSchemaFor } from "../schema/index.ts";
16
+ import { listWorkspaceTemplates } from "./templates.js";
17
+
18
+ const deal: TemplateProfile = {
19
+ slug: "deal",
20
+ displayName: "Deal",
21
+ properties: [
22
+ { slug: "commercialStage", label: "Stage", valueType: "string", inputType: "select", enumValues: ["lead", "negotiating", "won"] },
23
+ { slug: "value", label: "Value", valueType: "number" },
24
+ ],
25
+ } as TemplateProfile;
26
+
27
+ const goodStart = (over: Partial<TemplateStart> = {}): TemplateStart => ({
28
+ order: 1,
29
+ label: "Sales",
30
+ promise: "Leads and deals, each with a dated next step.",
31
+ suggestFor: ["founder"],
32
+ profile: "deal",
33
+ views: ["deal-pipeline", "deals-table"],
34
+ example: [1, 2, 3, 4].map((n) => ({ title: `Deal ${n}`, values: { commercialStage: "negotiating", value: n * 1000 } })),
35
+ firstSession: { title: "Your live deals", goal: "Add the deals you are moving now." },
36
+ starters: ["a", "b", "c"],
37
+ ...over,
38
+ });
39
+
40
+ const make = (slug: string, start?: Partial<TemplateStart> | null, extra: Partial<WorkspaceYaml> = {}): WorkspaceYaml =>
41
+ ({
42
+ meta: { slug, name: slug, description: "d", icon: "box", color: "blue", ...(start === null ? {} : { start: goodStart(start) }) },
43
+ workspace: { name: slug, description: "d" },
44
+ profiles: [deal],
45
+ views: [
46
+ { name: "Pipeline", slug: "deal-pipeline", type: "kanban", scopeProfileSlug: "deal" },
47
+ { name: "Deals", slug: "deals-table", type: "table", scopeProfileSlugs: ["deal"] },
48
+ { name: "Other", slug: "other-table", type: "table", scopeProfileSlug: "task" },
49
+ ],
50
+ ...extra,
51
+ }) as unknown as WorkspaceYaml;
52
+
53
+ const startErrors = (t: WorkspaceYaml, corpus?: WorkspaceYaml[]) =>
54
+ validateTemplate(t, { corpus }).errors.filter((e) => e.rule.startsWith("start-")).map((e) => e.rule);
55
+
56
+ describe("meta.start — validator rules", () => {
57
+ it("a well-formed start passes", () => {
58
+ expect(startErrors(make("t"))).toEqual([]);
59
+ });
60
+
61
+ it("no start → no start-* error", () => {
62
+ expect(startErrors(make("t", null))).toEqual([]);
63
+ });
64
+
65
+ it("start-shape: a missing field", () => {
66
+ const t = make("t");
67
+ delete (t.meta.start as Partial<TemplateStart>).promise;
68
+ expect(startErrors(t)).toEqual(["start-shape"]);
69
+ });
70
+
71
+ it("start-no-workspace: an overlay owns no space", () => {
72
+ const t = make("t", {}, { dependencies: [{ slug: "base", relation: "compose" }] } as Partial<WorkspaceYaml>);
73
+ expect(startErrors(t)).toEqual(["start-no-workspace"]);
74
+ });
75
+
76
+ it("start-label: longer than INTENT_LABEL_MAX", () => {
77
+ expect(startErrors(make("t", { label: "x".repeat(INTENT_LABEL_MAX + 1) }))).toEqual(["start-label"]);
78
+ });
79
+
80
+ it("start-example-count and start-starters-count", () => {
81
+ expect(startErrors(make("t", { example: goodStart().example.slice(0, 3) }))).toEqual(["start-example-count"]);
82
+ expect(startErrors(make("t", { starters: ["a"] }))).toEqual(["start-starters-count"]);
83
+ });
84
+
85
+ it("start-views-count: none, or more than three", () => {
86
+ expect(startErrors(make("t", { views: [] }))).toEqual(["start-views-count"]);
87
+ expect(startErrors(make("t", { views: ["deal-pipeline", "deals-table", "deal-pipeline", "deals-table"] }))).toEqual(["start-views-count"]);
88
+ });
89
+
90
+ it("start-views-exist: a slug this template does not declare", () => {
91
+ expect(startErrors(make("t", { views: ["deal-pipeline", "nope"] }))).toEqual(["start-views-exist"]);
92
+ });
93
+
94
+ it("start-views-scope: a view scoped to another kind", () => {
95
+ expect(startErrors(make("t", { views: ["other-table"] }))).toEqual(["start-views-scope"]);
96
+ });
97
+
98
+ it("start-profile-unknown: a kind nobody declares", () => {
99
+ expect(startErrors(make("t", { profile: "ghost", views: [], example: goodStart().example.map((e) => ({ title: e.title })) }))).toContain("start-profile-unknown");
100
+ });
101
+
102
+ it("start-profile-unknown: the kind may come from the dependency closure", () => {
103
+ const dep = { meta: { slug: "dep", name: "dep", description: "d", icon: "b", color: "c" }, workspace: { name: "d", description: "d" }, profiles: [deal] } as unknown as WorkspaceYaml;
104
+ const t = make("t", { views: [] }, { profiles: [], views: [], dependencies: [{ slug: "dep", relation: "require" }] } as Partial<WorkspaceYaml>);
105
+ expect(startErrors(t, [t, dep])).toEqual(["start-views-count"]); // profile resolves; only the empty views fire
106
+ });
107
+
108
+ it("start-example-property: a key the kind does not have", () => {
109
+ const example = goodStart().example.map((e) => ({ ...e, values: { ...e.values, mood: "x" } }));
110
+ expect(startErrors(make("t", { example }))).toEqual(Array(4).fill("start-example-property"));
111
+ });
112
+
113
+ it("start-example-enum: a select value outside enumValues", () => {
114
+ const example = goodStart().example.map((e) => ({ ...e, values: { commercialStage: "dancing" } }));
115
+ expect(startErrors(make("t", { example }))).toEqual(Array(4).fill("start-example-enum"));
116
+ });
117
+
118
+ it("start-suggest-duplicate: an id twice in one start, and across starts", () => {
119
+ expect(startErrors(make("t", { suggestFor: ["founder", "founder"] }))).toEqual(["start-suggest-duplicate"]);
120
+ const a = make("a", { order: 1, suggestFor: ["founder"] });
121
+ const b = make("b", { order: 2, suggestFor: ["founder"] });
122
+ expect(startErrors(b, [a, b])).toEqual(["start-suggest-duplicate"]);
123
+ });
124
+
125
+ it("start-order-duplicate: two starts on one card slot", () => {
126
+ const a = make("a", { order: 1, suggestFor: ["x"] });
127
+ const b = make("b", { order: 1, suggestFor: ["y"] });
128
+ expect(startErrors(b, [a, b])).toEqual(["start-order-duplicate"]);
129
+ expect(startErrors(make("b", { order: 2, suggestFor: ["y"] }), [a, make("b", { order: 2, suggestFor: ["y"] })])).toEqual([]);
130
+ });
131
+ });
132
+
133
+ describe("meta.start — package door", () => {
134
+ it("carries a top-level start, never in _meta", () => {
135
+ const def = toPackageDefinition(make("t"));
136
+ expect(def.start).toEqual(goodStart());
137
+ expect("start" in def._meta).toBe(false);
138
+ });
139
+
140
+ it("no start → no field", () => {
141
+ expect("start" in toPackageDefinition(make("t", null))).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe("meta.start — catalog header", () => {
146
+ const manifest = (kind: "workspace" | "skill", meta: Record<string, unknown>) => ({
147
+ apiVersion: "synap/v1",
148
+ kind,
149
+ meta: { slug: "t", name: "T", ...meta },
150
+ spec: kind === "workspace" ? { workspace: { name: "T", description: "d" }, profiles: [] } : { name: "t", content: "x" },
151
+ });
152
+
153
+ it("a workspace header accepts the shape and refuses an unknown key", () => {
154
+ expect(manifestSchemaFor("workspace").safeParse(manifest("workspace", { start: goodStart() })).success).toBe(true);
155
+ expect(manifestSchemaFor("workspace").safeParse(manifest("workspace", { start: { ...goodStart(), why: 1 } })).success).toBe(false);
156
+ });
157
+
158
+ it("is refused on a kind that is not a workspace", () => {
159
+ const r = manifestSchemaFor("skill").safeParse(manifest("skill", { start: goodStart() }));
160
+ expect(r.success).toBe(false);
161
+ expect(r.error?.issues.some((i) => i.path.join(".") === "meta.start")).toBe(true);
162
+ });
163
+ });
164
+
165
+ describe("meta.start — the shipped catalog (A2)", () => {
166
+ const all = listWorkspaceTemplates();
167
+ const starts = all.filter((t) => t.meta.start);
168
+
169
+ it("declares exactly the four starts, card orders 1-4", () => {
170
+ expect(starts.map((t) => [t.meta.start!.order, t.meta.slug]).sort()).toEqual([
171
+ [1, "builder-workspace"],
172
+ [2, "content-os"],
173
+ [3, "crm"],
174
+ [4, "operations"],
175
+ ]);
176
+ });
177
+
178
+ it("every start passes the start-* rules against the whole corpus", () => {
179
+ for (const t of starts) expect(startErrors(t, all), t.meta.slug).toEqual([]);
180
+ });
181
+
182
+ it("CRM has three deal views scoped to deal", () => {
183
+ const crm = all.find((t) => t.meta.slug === "crm")!;
184
+ const dealViews = (crm.views ?? []).filter((v) => v.scopeProfileSlug === "deal").map((v) => v.slug);
185
+ expect(dealViews).toEqual(["deal-pipeline", "deals-table", "deals-list"]);
186
+ expect(crm.meta.start!.views).toEqual(dealViews);
187
+ });
188
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The Stripe capability (session 92e83544, "Stripe capability: read-write,
3
+ * governed"): reads run, every write is reviewed, and the call can only ever
4
+ * reach https://api.stripe.com/v1/.
5
+ *
6
+ * Pinned here, where the verbs are authored:
7
+ * - a GET verb declares `metadata.readOnly: true` (the gate runs it at once);
8
+ * a verb that writes (POST, DELETE) never does, so it stays on the propose
9
+ * grant the pod seeds for every capability tool (`GRANT_DEFAULT_EXEC_MODE`)
10
+ * and its provider call re-checks the tool gate (`isReadMethod`);
11
+ * - a write verb declares no read intent (list_records / fetch_record), and
12
+ * its name or description carries a whole-word write signal, so the pod's
13
+ * `deriveVerbKind` files it as an `action`, never a `read`;
14
+ * - the tool's host is fixed (`baseUrl`), its bodies are form-encoded
15
+ * (`config.bodyEncoding: "form"`, Stripe v1 takes no JSON body);
16
+ * - every path is under `/v1/`; the generic verbs take their resource path
17
+ * through `encode: "path"` (the pod refuses "." / ".." / empty segments);
18
+ * - every Stripe-native field travels through `bodyFrom` (`fields` on a typed
19
+ * verb, `params` on a generic one), never a `body` template that would send
20
+ * an absent optional as an empty value (which Stripe reads as "unset");
21
+ * - the eight read verbs live pods already run are all still there.
22
+ *
23
+ * NOT covered: Stripe accepting the calls (checked against the API reference,
24
+ * docs.stripe.com/api, not the live account; no write is ever sent to it), and
25
+ * the pod's encoder and gate (backend: form-encode.test.ts,
26
+ * execute-provider-verb.body-from.test.ts, form-body.vault.test.ts).
27
+ */
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { describe, expect, it } from "vitest";
32
+
33
+ const manifest = JSON.parse(
34
+ readFileSync(join(__dirname, "..", "capabilities", "stripe.capability.json"), "utf8"),
35
+ );
36
+ type Verb = {
37
+ name: string;
38
+ intent?: string;
39
+ kind: string;
40
+ description: string;
41
+ requires: string[];
42
+ parameters?: Record<string, string>;
43
+ metadata?: { readOnly?: boolean };
44
+ providerSpec: {
45
+ method: string;
46
+ pathTemplate: string;
47
+ body?: unknown;
48
+ bodyFrom?: string;
49
+ paramMapping?: Record<string, { encode?: string; required?: boolean }>;
50
+ };
51
+ };
52
+ const verbs: Verb[] = manifest.spec.skills;
53
+ const isRead = (v: Verb) => /^(GET|HEAD)$/i.test(v.providerSpec.method);
54
+ const reads = verbs.filter(isRead);
55
+ const writes = verbs.filter((v) => !isRead(v));
56
+
57
+ // A mirror of the pod's own word lists (synap-backend create-from-definition.ts
58
+ // VERB_WRITE_SIGNALS / VERB_READ_SIGNALS): name first, then description,
59
+ // whole words, write signals before read signals. Held equal to the pod's
60
+ // source by the "mirror" describe below.
61
+ const WRITE_SIGNALS = ["send", "invite", "post", "create", "write", "update", "delete", "reply", "message", "email", "publish", "comment", "add", "remove", "set", "run", "execute"];
62
+ const READ_SIGNALS = ["search", "list", "get", "fetch", "read", "find", "lookup", "query", "pull"];
63
+ const words = (t: string) => new Set(t.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
64
+ const classify = (t: string) => {
65
+ const w = words(t);
66
+ if (WRITE_SIGNALS.some((s) => w.has(s))) return "action";
67
+ if (READ_SIGNALS.some((s) => w.has(s))) return "read";
68
+ return null;
69
+ };
70
+ const derivedKind = (v: Verb) => classify(v.name) ?? classify(v.description) ?? "action";
71
+
72
+ describe("stripe capability: reads run, writes are reviewed", () => {
73
+ it("has reads and writes", () => {
74
+ expect(reads.length).toBeGreaterThan(10);
75
+ expect(writes.length).toBeGreaterThan(10);
76
+ });
77
+
78
+ it("every GET verb is read-only (runs at once)", () => {
79
+ expect(reads.filter((v) => v.metadata?.readOnly !== true).map((v) => v.name)).toEqual([]);
80
+ });
81
+
82
+ it("no write verb is read-only: each stays on the propose grant", () => {
83
+ expect(writes.filter((v) => v.metadata?.readOnly === true).map((v) => v.name)).toEqual([]);
84
+ });
85
+
86
+ it("a write declares no read intent and the pod files it as an action", () => {
87
+ expect(writes.filter((v) => v.intent === "list_records" || v.intent === "fetch_record").map((v) => v.name)).toEqual([]);
88
+ expect(writes.filter((v) => derivedKind(v) !== "action").map((v) => v.name)).toEqual([]);
89
+ expect(reads.filter((v) => derivedKind(v) !== "read").map((v) => v.name)).toEqual([]);
90
+ });
91
+
92
+ it("covers what the founder asked for: tax settings, tax registrations, payment links, checkout, refunds, payouts", () => {
93
+ const has = (method: string, path: string) =>
94
+ writes.some((v) => v.providerSpec.method === method && v.providerSpec.pathTemplate === path);
95
+ expect(has("POST", "/v1/tax/settings")).toBe(true);
96
+ expect(has("POST", "/v1/tax/registrations")).toBe(true);
97
+ expect(has("POST", "/v1/payment_links")).toBe(true);
98
+ expect(has("POST", "/v1/checkout/sessions")).toBe(true);
99
+ expect(has("POST", "/v1/refunds")).toBe(true);
100
+ expect(has("POST", "/v1/payouts")).toBe(true);
101
+ expect(has("DELETE", "/v1/subscriptions/{{subscriptionId}}")).toBe(true);
102
+ });
103
+
104
+ it("keeps the eight read verbs live pods already run", () => {
105
+ const names = new Set(reads.map((v) => v.name));
106
+ for (const n of ["stripe_list_customers", "stripe_get_customer", "stripe_list_charges", "stripe_list_invoices", "stripe_list_subscriptions", "stripe_list_products", "stripe_list_payouts", "stripe_get_balance"])
107
+ expect(names.has(n), n).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("stripe capability: one host, form bodies, paths under /v1/", () => {
112
+ const tool = manifest.spec.tools.find((t: { name: string }) => t.name === "stripe");
113
+
114
+ it("the tool's host is fixed and its bodies are form-encoded", () => {
115
+ expect(tool.config.baseUrl).toBe("https://api.stripe.com");
116
+ expect(tool.config.bodyEncoding).toBe("form");
117
+ expect(tool.credentialRef).toBe("stripe");
118
+ expect(verbs.every((v) => v.requires.length === 1 && v.requires[0] === "stripe")).toBe(true);
119
+ });
120
+
121
+ it("every path starts with /v1/; every path parameter is encoded (uri for an id, path for a generic resource path)", () => {
122
+ for (const v of verbs) {
123
+ expect(v.providerSpec.pathTemplate.startsWith("/v1/"), v.name).toBe(true);
124
+ for (const [, name] of v.providerSpec.pathTemplate.matchAll(/\{\{(\w+)\}\}/g)) {
125
+ const m = v.providerSpec.paramMapping?.[name!];
126
+ expect(m?.required, `${v.name}.${name}`).toBe(true);
127
+ expect(["uri", "path"], `${v.name}.${name}`).toContain(m?.encode);
128
+ }
129
+ }
130
+ });
131
+
132
+ it("the generic verbs are one per method, each taking a /v1/-relative path", () => {
133
+ const generic = verbs.filter((v) => v.providerSpec.pathTemplate === "/v1/{{path}}");
134
+ expect(generic.map((v) => `${v.name} ${v.providerSpec.method}`).sort()).toEqual([
135
+ "stripe_api_delete DELETE",
136
+ "stripe_api_read GET",
137
+ "stripe_api_write POST",
138
+ ]);
139
+ for (const v of generic) {
140
+ expect(v.providerSpec.paramMapping?.path).toEqual({ required: true, encode: "path" });
141
+ expect(v.providerSpec.bodyFrom).toBe("params");
142
+ }
143
+ });
144
+
145
+ it("Stripe's own fields travel only through bodyFrom (no body template sending an absent optional as empty)", () => {
146
+ for (const v of verbs) expect(v.providerSpec.body, v.name).toBeUndefined();
147
+ for (const v of writes) {
148
+ expect(v.providerSpec.bodyFrom, v.name).toBeDefined();
149
+ expect(v.parameters?.[v.providerSpec.bodyFrom!], v.name).toMatch(/^object\??$/);
150
+ }
151
+ });
152
+
153
+ it("the connect copy names the restricted key and the permissions to grant", () => {
154
+ const param = manifest.spec.params.find((p: { name: string }) => p.name === "stripeApiKey");
155
+ expect(param.description).toMatch(/RESTRICTED/);
156
+ expect(param.description).toMatch(/Write only/);
157
+ expect(param.description).toMatch(/Tax Settings, Tax Registrations/);
158
+ expect(manifest.spec.description).toMatch(/ADAPTIVE PRICING/);
159
+ });
160
+ });
161
+
162
+ // ── The mirror: the word lists above equal the pod's ─────────────────────────
163
+ // Read from synap-backend's source (a sibling checkout), so a word the pod
164
+ // adds or drops turns this red instead of the classification above drifting
165
+ // unseen. Skips, with the reason logged, when synap-backend is not a sibling.
166
+ const POD_SOURCE = resolve(
167
+ dirname(fileURLToPath(import.meta.url)),
168
+ "../../../../synap-backend/packages/api/src/services/capabilities/create-from-definition.ts",
169
+ );
170
+ const hasPodSource = existsSync(POD_SOURCE);
171
+ if (!hasPodSource) console.warn(`[stripe signals mirror] SKIP: ${POD_SOURCE} is absent`);
172
+
173
+ /** The string literals of `const <name> = [ ... ]` in `src`, in order. */
174
+ function podList(src: string, name: string): string[] {
175
+ const m = new RegExp(`const ${name}\\s*=\\s*\\[([^\\]]*)\\]`).exec(src);
176
+ expect(m, `${name} is declared in create-from-definition.ts`).not.toBeNull();
177
+ return [...m![1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!);
178
+ }
179
+
180
+ describe.skipIf(!hasPodSource)("stripe capability: the word lists mirror the pod's", () => {
181
+ const src = hasPodSource ? readFileSync(POD_SOURCE, "utf8") : "";
182
+ it("WRITE_SIGNALS equals VERB_WRITE_SIGNALS", () => {
183
+ const pod = podList(src, "VERB_WRITE_SIGNALS");
184
+ expect(pod.length).toBeGreaterThan(5);
185
+ expect(WRITE_SIGNALS).toEqual(pod);
186
+ });
187
+ it("READ_SIGNALS equals VERB_READ_SIGNALS", () => {
188
+ const pod = podList(src, "VERB_READ_SIGNALS");
189
+ expect(pod.length).toBeGreaterThan(3);
190
+ expect(READ_SIGNALS).toEqual(pod);
191
+ });
192
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * TRIPWIRE: `taskIntents` — the workspace's DECLARED task requirements.
3
+ *
4
+ * A template declares the intents (closed vocabulary) it needs to function, so
5
+ * an agent entering that space can be told what it is FOR without discovering
6
+ * it by failing. Three failure classes are guarded here, in increasing order of
7
+ * how quietly they would ship:
8
+ *
9
+ * 1. UNKNOWN SLUG — a `taskIntents` value outside the closed vocabulary. Caught
10
+ * at authoring time by `validateTemplate`; asserted here over the whole
11
+ * corpus so a seed added without ever running the validator still fails.
12
+ * 2. A TEMPLATE WITH NO DECLARATION — not an error by itself (a workspace
13
+ * that needs no external capability declares nothing), but asserted
14
+ * against a DERIVED set so a new template cannot silently ship undeclared.
15
+ * 3. CONTENT STUDIO SPECIFICALLY — the founder-specified reference list, and
16
+ * the one template whose exact declaration is pinned as data in this file.
17
+ *
18
+ * WHAT IT PROVES: that the declared set is (a) inside the closed vocabulary,
19
+ * (b) free of duplicates, (c) agreed on by `validateTemplate` — the gate
20
+ * `build.mjs`, the CP publish path and the CLI actually call — and (d) exactly
21
+ * the founder-specified list for Content Studio. It REPORTS (never gates) how
22
+ * much of each declaration is currently backed by an installed pack; see the
23
+ * long note on that describe block for why a gap is a fact, not an error.
24
+ *
25
+ * WHAT IT DOES NOT PROVE: that any declared intent is the RIGHT requirement for
26
+ * a workspace — that is a judgement, not a derivable fact.
27
+ *
28
+ * ⚠️ THE SCAN IS DERIVED, NOT HAND-LISTED. The template set comes from
29
+ * `readYamlTemplates()` (the same reader `build.mjs` uses), so a template added
30
+ * tomorrow joins this scan BY EXISTING. A hand-written array of slugs is how a
31
+ * guard ends up holding only the entries that were already correct.
32
+ *
33
+ * ⚠️ NON-VACUITY IS ASSERTED EXPLICITLY, twice: the corpus must be non-empty,
34
+ * and the vocabulary must be a real closed set. A glob that matches zero files
35
+ * passes every "no unknown intent" assertion there is.
36
+ */
37
+
38
+ import { describe, expect, it } from "vitest";
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { dirname, join } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ import { CAPABILITY_INTENTS } from "@synap-core/types/capability-intents";
44
+ import { validateTemplate } from "./validate.js";
45
+ import type { WorkspaceYaml } from "./types.js";
46
+
47
+ const HERE = dirname(fileURLToPath(import.meta.url));
48
+ // The reference list the founder specified for Content Studio, now carried by
49
+ // Content OS (content-studio retired into it, 2026-11-03). Pinned HERE as
50
+ // data so the test states what "correct" is rather than reading it from the
51
+ // file it is meant to police.
52
+ const CONTENT_OS_INTENTS = [
53
+ "generate_media",
54
+ "manage_file",
55
+ "capture_into_pod",
56
+ "search_external",
57
+ ];
58
+
59
+ // The catalog's own capability manifests (the CP keeps a byte-checked mirror
60
+ // until its pin bump; see the CP's catalog-mirror.tripwire.test.ts).
61
+ const CAPABILITY_TEMPLATES_DIR = join(HERE, "../capabilities");
62
+
63
+ /** The closed vocabulary as a set, plus a readable unknown-value report. */
64
+ const VOCABULARY = new Set<string>(CAPABILITY_INTENTS);
65
+
66
+ /**
67
+ * The intents a capability pack actually provides, from its declared verb
68
+ * intents. Reads the catalog manifest (its `spec` is the CP seed JSON, kept
69
+ * identical by the mirror tripwire) — so "declared" here means what the seeder
70
+ * will really install.
71
+ *
72
+ * Returns null for a pack this checkout cannot read, which callers treat as
73
+ * "cannot check" rather than "provides nothing" — an unresolvable pack must not
74
+ * silently pass a subset assertion.
75
+ */
76
+ function providedIntents(templateKey: string): Set<string> | null {
77
+ const file = join(CAPABILITY_TEMPLATES_DIR, `${templateKey}.capability.json`);
78
+ if (!existsSync(file)) return null;
79
+ const json = JSON.parse(readFileSync(file, "utf-8")) as unknown;
80
+ const intents = new Set<string>();
81
+ const walk = (node: unknown): void => {
82
+ if (Array.isArray(node)) {
83
+ node.forEach(walk);
84
+ return;
85
+ }
86
+ if (node && typeof node === "object") {
87
+ for (const [key, value] of Object.entries(node)) {
88
+ if (key === "intent" && typeof value === "string") intents.add(value);
89
+ walk(value);
90
+ }
91
+ }
92
+ };
93
+ walk(json);
94
+ return intents;
95
+ }
96
+
97
+ /** Load the corpus through the ONE reader `build.mjs` uses. */
98
+ function loadTemplates(): Array<{ file: string; slug: string; data: WorkspaceYaml }> {
99
+ // Imported lazily so this module's import cost is paid inside the tests, and
100
+ // so a YAML parse error surfaces as a test failure with context.
101
+ const { readYamlTemplates } = require("./generate.mjs") as {
102
+ readYamlTemplates: () => Array<{ file: string; slug: string; data: unknown }>;
103
+ };
104
+ return readYamlTemplates() as Array<{
105
+ file: string;
106
+ slug: string;
107
+ data: WorkspaceYaml;
108
+ }>;
109
+ }
110
+
111
+ const TEMPLATES = loadTemplates();
112
+ const DECLARED = TEMPLATES.filter((t) => Array.isArray(t.data.taskIntents));
113
+
114
+ describe("taskIntents — every declared slug is a known intent", () => {
115
+ it("non-vacuity: the scan actually found templates and a real vocabulary", () => {
116
+ // Without these, "no unknown intent" passes on an empty set.
117
+ expect(TEMPLATES.length).toBeGreaterThanOrEqual(10);
118
+ expect(CAPABILITY_INTENTS.length).toBeGreaterThanOrEqual(13);
119
+ expect(CAPABILITY_INTENTS.length).toBe(new Set(CAPABILITY_INTENTS).size);
120
+ expect(DECLARED.length).toBeGreaterThanOrEqual(5);
121
+ // The scan can still see a literal it hunts: prove the reader picks up a
122
+ // value rather than normalising it away.
123
+ expect(CONTENT_OS_INTENTS.filter((i) => VOCABULARY.has(i))).toHaveLength(
124
+ CONTENT_OS_INTENTS.length
125
+ );
126
+ });
127
+
128
+ it("no template declares an intent outside the closed vocabulary", () => {
129
+ const offenders: string[] = [];
130
+ for (const t of DECLARED) {
131
+ for (const intent of t.data.taskIntents as string[]) {
132
+ if (!VOCABULARY.has(intent)) {
133
+ offenders.push(`${t.file}: "${intent}"`);
134
+ }
135
+ }
136
+ }
137
+ expect(
138
+ offenders,
139
+ `Unknown taskIntents (closed vocabulary: ${CAPABILITY_INTENTS.join(", ")}):`
140
+ ).toEqual([]);
141
+ });
142
+
143
+ it("no template declares a duplicate intent", () => {
144
+ const offenders: string[] = [];
145
+ for (const t of DECLARED) {
146
+ const list = t.data.taskIntents as string[];
147
+ const dupes = list.filter((v, i) => list.indexOf(v) !== i);
148
+ if (dupes.length) offenders.push(`${t.file}: ${[...new Set(dupes)].join(", ")}`);
149
+ }
150
+ expect(offenders, "taskIntents must not repeat an intent").toEqual([]);
151
+ });
152
+
153
+ it("validateTemplate agrees with this scan (the authoring-time gate fires)", () => {
154
+ // The scanner above reads raw YAML; the validator is what build.mjs, the
155
+ // CP publish path and the CLI actually call. Assert the two cannot diverge
156
+ // on the case that matters — an unknown slug must be an ERROR there, not
157
+ // merely a warning this file swallowed.
158
+ const withUnknown: WorkspaceYaml = {
159
+ ...TEMPLATES[0].data,
160
+ taskIntents: ["generate_media", "definitely_not_an_intent"],
161
+ };
162
+ const result = validateTemplate(withUnknown);
163
+ const rule = result.errors.filter((e) => e.rule === "task-intent-unknown");
164
+ expect(rule).toHaveLength(1);
165
+ expect(rule[0].path).toContain("taskIntents");
166
+ // And a fully-valid list produces no taskIntents error at all.
167
+ const ok = validateTemplate({
168
+ ...TEMPLATES[0].data,
169
+ taskIntents: [...CONTENT_OS_INTENTS],
170
+ });
171
+ expect(ok.errors.filter((e) => e.rule.startsWith("task-intent"))).toEqual([]);
172
+ });
173
+ });
174
+
175
+ describe("taskIntents — declared requirements vs. what is installed today", () => {
176
+ /**
177
+ * ⚠️ THIS IS A REPORT, NOT A GATE — deliberately, and the reason is the point
178
+ * of the field.
179
+ *
180
+ * An earlier draft asserted `taskIntents ⊆ intents(provided by integrations)`
181
+ * as a hard failure. It went red immediately and was WRONG: the founder's
182
+ * reference declaration for Content Studio is
183
+ * `[generate_media, manage_file, capture_into_pod, search_external]`, while
184
+ * at the time its only installed pack (`remotion`, since retired) provided
185
+ * just `generate_media` — and today it ships NO pack at all (HyperFrames
186
+ * runs on the user's own agent, plan D9). A subset rule would have forbidden
187
+ * the specification itself.
188
+ *
189
+ * `taskIntents` is what a workspace NEEDS TO FUNCTION, not a snapshot of what
190
+ * it currently has. Declaring an intent ahead of the pack that serves it is
191
+ * the requirement doing its job — it is what a later install, or a Phase-3
192
+ * match, is checked against. So a gap is a FACT to surface, not an error to
193
+ * reject.
194
+ *
195
+ * What this therefore does NOT prove: that every requirement is satisfiable.
196
+ * A template could declare an intent nothing can ever provide, and this stays
197
+ * green. Only a human (or the Phase-3 resolver) can say a requirement is
198
+ * met; a structural check can only say whether it is currently backed.
199
+ *
200
+ * It DOES prove the reporting machinery works: the corpus is walked, packs are
201
+ * resolved for real, and an unresolvable pack is reported rather than counted
202
+ * as providing nothing — the "silently passing" failure this file exists to
203
+ * prevent.
204
+ */
205
+ it("reports each declaration's backing and its gaps", () => {
206
+ interface Coverage {
207
+ file: string;
208
+ declared: number;
209
+ backed: number;
210
+ gaps: string[];
211
+ unreadable: string[];
212
+ }
213
+ const coverage: Coverage[] = [];
214
+ for (const t of DECLARED) {
215
+ const installed = t.data.integrations ?? [];
216
+ const provided = new Set<string>();
217
+ const unreadable: string[] = [];
218
+ for (const integration of installed) {
219
+ const intents = providedIntents(integration.templateKey);
220
+ if (intents === null) {
221
+ unreadable.push(integration.templateKey);
222
+ continue;
223
+ }
224
+ intents.forEach((i) => provided.add(i));
225
+ }
226
+ const declared = t.data.taskIntents as string[];
227
+ coverage.push({
228
+ file: t.file,
229
+ declared: declared.length,
230
+ backed: declared.filter((i) => provided.has(i)).length,
231
+ gaps: declared.filter((i) => !provided.has(i)),
232
+ unreadable,
233
+ });
234
+ }
235
+
236
+ // Non-vacuity: the report is real — every declared template was walked and
237
+ // at least one pack resolved, so the gap list is measured, not empty-by-
238
+ // construction.
239
+ expect(coverage.length).toBeGreaterThanOrEqual(5);
240
+ const resolvable = coverage.filter((c) => c.unreadable.length < (c.declared ? 1 : 0));
241
+ expect(
242
+ coverage.some((c) => c.backed > 0),
243
+ "no declared intent matched any installed pack — the derivation is broken"
244
+ ).toBe(true);
245
+
246
+ // The only hard assertion: a template cannot claim MORE intents than it
247
+ // declared. (Trivially true by construction — kept as the shape check that
248
+ // fails if the coverage walk ever stops being total.)
249
+ for (const c of coverage) {
250
+ expect(c.backed).toBeLessThanOrEqual(c.declared);
251
+ }
252
+
253
+ if (process.env.SYNAP_REPORT_TASK_INTENTS === "1") {
254
+ for (const c of coverage) {
255
+ console.warn(
256
+ `[taskIntents] ${c.file}: ${c.backed}/${c.declared} backed` +
257
+ (c.gaps.length ? ` · gaps: ${c.gaps.join(", ")}` : "") +
258
+ (c.unreadable.length
259
+ ? ` · unreadable packs: ${c.unreadable.join(", ")}`
260
+ : "")
261
+ );
262
+ }
263
+ }
264
+ });
265
+ });
266
+
267
+ // Content OS carries the founder-specified intents over from the retired
268
+ // content-studio (retired.yaml, 2026-11-03), with the video pipeline they describe.
269
+ describe("taskIntents — Content OS is the reference declaration", () => {
270
+ it("declares exactly the founder-specified intents", () => {
271
+ const contentOs = TEMPLATES.find((t) => t.slug === "content-os");
272
+ expect(contentOs, "content-os template not found in src/*.yaml").toBeTruthy();
273
+ expect(contentOs?.data.taskIntents, "content-os must declare taskIntents")
274
+ .toEqual(CONTENT_OS_INTENTS);
275
+ });
276
+
277
+ it("every one of them is a real intent", () => {
278
+ for (const intent of CONTENT_OS_INTENTS) {
279
+ expect(VOCABULARY.has(intent), `"${intent}" is not a known intent`).toBe(
280
+ true
281
+ );
282
+ }
283
+ });
284
+
285
+ it("and the template itself validates", () => {
286
+ const contentOs = TEMPLATES.find((t) => t.slug === "content-os");
287
+ const result = validateTemplate(contentOs!.data);
288
+ expect(
289
+ result.errors.filter((e) => e.rule.startsWith("task-intent")),
290
+ "content-os failed taskIntents validation"
291
+ ).toEqual([]);
292
+ });
293
+ });