@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,162 @@
1
+ /**
2
+ * Unipile — what a pod app may ask it to publish (intent actions, decision
3
+ * ef0e917c, 2026-10-28; the Postiz precedent is postiz-contract.test.ts).
4
+ *
5
+ * A pod app asks the person's tools for `publish_post` with ONE input
6
+ * `{ text, media?, link?, when? }`. Unipile reaches several accounts through one
7
+ * key, so `unipile_publish_post` names an account source and the pod offers one
8
+ * action per account: the shared `unipile_list_accounts`, every provider, so
9
+ * LinkedIn and Instagram each get their own action (founder, 28 Oct, on
10
+ * decision 34103d1b). The post's images and videos come as `file[]:bytes`
11
+ * (phase 2c of plan e7c3c98e): the code holds one handle per pod file and the
12
+ * pod streams the bytes into a multipart part, so Instagram can post.
13
+ *
14
+ * SEAM: the verb's REAL `code` body, run the way the pod's executor wraps it
15
+ * (`async (args, context) => { <code> }` with `callProvider` in scope), against
16
+ * a recorded `callProvider`.
17
+ *
18
+ * NOT covered: the pod's own checks of the map and of `file[]:bytes`
19
+ * (backend `intent-input.ts`, `file-parameters.ts`), the handles turning into
20
+ * bytes (backend `pod-file-body.ts`), and whether Unipile accepts the request
21
+ * (not exercised against a live Unipile).
22
+ */
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { describe, expect, it } from "vitest";
26
+
27
+ const capability = JSON.parse(
28
+ readFileSync(join(import.meta.dirname, "..", "capabilities", "unipile-linkedin.capability.json"), "utf8"),
29
+ ) as { spec: { skills: Array<Record<string, any>>; provides?: string[] } };
30
+
31
+ const skill = (name: string) => capability.spec.skills.find((s) => s.name === name)!;
32
+ const verb = skill("unipile_publish_post");
33
+
34
+ const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (
35
+ ...args: string[]
36
+ ) => (...a: unknown[]) => Promise<unknown>;
37
+
38
+ type Call = { tool: string; method: string; path: string; body: Record<string, unknown>; opts?: Record<string, unknown> };
39
+ async function run(args: Record<string, unknown>, reply: Record<string, unknown> = { status: 201, body: { id: "p1", share_url: "https://x/p1" } }) {
40
+ const calls: Call[] = [];
41
+ const callProvider = async (tool: string, method: string, path: string, body: Record<string, unknown>, opts?: Record<string, unknown>) => {
42
+ calls.push({ tool, method, path, body, opts });
43
+ return reply;
44
+ };
45
+ const body = new AsyncFunction("args", "context", "callProvider", "propose", verb.code);
46
+ const result = await body(args, {}, callProvider, {});
47
+ return { result, calls };
48
+ }
49
+
50
+ describe("Unipile — what a pod app may ask it to publish", () => {
51
+ it("unipile_publish_post maps the text, the media and the account picked from unipile_list_accounts", () => {
52
+ expect(verb.intent).toBe("publish_post");
53
+ expect(verb.metadata).toEqual({ verbType: "write" });
54
+ expect(verb.inputMap).toEqual({
55
+ params: { text: "$.text", accountId: "$account.id", provider: "$account.provider", media: "$.media" },
56
+ accounts: { verb: "unipile_list_accounts", id: "id", label: "name", provider: "provider" },
57
+ });
58
+ expect(capability.spec.provides).toContain("publish_post");
59
+ });
60
+
61
+ it("Unipile has no scheduling and links stay out: the map never reads when or link", () => {
62
+ const read = Object.values(verb.inputMap.params).filter((s) => !String(s).startsWith("$account."));
63
+ expect(read.sort()).toEqual(["$.media", "$.text"]);
64
+ });
65
+
66
+ it("the media are pod files sent as bytes, so the verb is code run in the pod, toward its own tool", () => {
67
+ expect(verb.kind).toBe("code");
68
+ expect(verb.executionMode).not.toBe("box");
69
+ expect(verb.parameters).toEqual({
70
+ accountId: "string",
71
+ text: "string",
72
+ provider: "string?",
73
+ media: "file[]:bytes?",
74
+ postAs: "string?",
75
+ quotedPostId: "string?",
76
+ });
77
+ expect(verb.requires).toEqual(["unipile_linkedin"]);
78
+ // An install-time value in the code would stop the pod updating Unipile.
79
+ expect(verb.code).not.toMatch(/\{\{\w+\}\}/);
80
+ });
81
+
82
+ it("text alone is a JSON post of exactly { text } to /{accountId}/posts", async () => {
83
+ const { result, calls } = await run({ accountId: "acc_1", text: "Hello", provider: "linkedin" });
84
+ expect(calls).toEqual([{ tool: "unipile_linkedin", method: "POST", path: "/acc_1/posts", body: { text: "Hello" }, opts: undefined }]);
85
+ expect(result).toEqual({ postId: "p1", shareUrl: "https://x/p1", attachments: 0 });
86
+ });
87
+
88
+ it("with media, a multipart form: the text field and one attachments[i] part per pod file", async () => {
89
+ const { result, calls } = await run({ accountId: "acc_2", text: "Caption", provider: "instagram", media: ["podfile:a", "podfile:b"] });
90
+ expect(calls).toEqual([
91
+ {
92
+ tool: "unipile_linkedin",
93
+ method: "POST",
94
+ path: "/acc_2/posts",
95
+ body: { text: "Caption", "attachments[0]": "podfile:a", "attachments[1]": "podfile:b" },
96
+ opts: { multipart: true },
97
+ },
98
+ ]);
99
+ expect(result).toMatchObject({ postId: "p1", attachments: 2 });
100
+ });
101
+
102
+ it("refuses before any call: Instagram with no media, an account with no posts, no text", async () => {
103
+ await expect(run({ accountId: "acc_2", text: "Caption", provider: "instagram" })).rejects.toThrow(/Instagram needs at least one image or video/);
104
+ await expect(run({ accountId: "acc_3", text: "Hi", provider: "whatsapp" })).rejects.toThrow(/whatsapp account has no posts/);
105
+ await expect(run({ accountId: "acc_1", text: " ", provider: "linkedin" })).rejects.toThrow(/text is required/);
106
+ });
107
+
108
+ it("as a LinkedIn company page: post_as beside the text, as JSON and in the multipart form", async () => {
109
+ const { calls } = await run({ accountId: "acc_1", text: "Hello", provider: "linkedin", postAs: "12345" });
110
+ expect(calls[0]).toMatchObject({ path: "/acc_1/posts", body: { text: "Hello", post_as: "12345" }, opts: undefined });
111
+ const multi = await run({ accountId: "acc_1", text: "Hi", provider: "linkedin", postAs: "12345", media: ["podfile:a"] });
112
+ expect(multi.calls[0]).toMatchObject({ body: { text: "Hi", post_as: "12345", "attachments[0]": "podfile:a" }, opts: { multipart: true } });
113
+ });
114
+
115
+ it("a repost: quoted_post_id, with empty text a plain repost, with text a quote", async () => {
116
+ const plain = await run({ accountId: "acc_1", text: "", provider: "linkedin", quotedPostId: "WzEy" });
117
+ expect(plain.calls[0].body).toEqual({ text: "", quoted_post_id: "WzEy" });
118
+ const quote = await run({ accountId: "acc_1", text: "Worth a read", quotedPostId: "WzEy", postAs: "12345" });
119
+ expect(quote.calls[0].body).toEqual({ text: "Worth a read", post_as: "12345", quoted_post_id: "WzEy" });
120
+ });
121
+
122
+ it("an empty company page or original is left out, and Instagram refuses both before any call", async () => {
123
+ const { calls } = await run({ accountId: "acc_1", text: "Hello", provider: "linkedin", postAs: " ", quotedPostId: "" });
124
+ expect(calls[0].body).toEqual({ text: "Hello" });
125
+ await expect(run({ accountId: "acc_2", text: "C", provider: "instagram", media: ["podfile:a"], quotedPostId: "WzEy" })).rejects.toThrow(/Instagram has no company page post or repost/);
126
+ await expect(run({ accountId: "acc_2", text: "C", provider: "instagram", media: ["podfile:a"], postAs: "1" })).rejects.toThrow(/Instagram has no company page post or repost/);
127
+ });
128
+
129
+ it("linkedin_list_company_pages: a read of the same tool, listing each page's id and name", () => {
130
+ const pages = skill("linkedin_list_company_pages");
131
+ expect(pages).toMatchObject({ intent: "list_records", kind: "declarative", metadata: { readOnly: true }, parameters: { accountId: "string" } });
132
+ expect(pages.requires).toEqual(verb.requires);
133
+ expect(pages.providerSpec).toMatchObject({
134
+ tool: "unipile_linkedin",
135
+ method: "GET",
136
+ pathTemplate: "/{{accountId}}/linkedin/company/pages",
137
+ responseShape: { collectionPath: "data", item: { id: "id", name: "name" } },
138
+ });
139
+ // A read never maps into an app's intent: nothing publishes through it.
140
+ expect(pages.inputMap).toBeUndefined();
141
+ });
142
+
143
+ it("a refused post throws, and a proposed call is never reported as published", async () => {
144
+ await expect(run({ accountId: "acc_1", text: "Hello" }, { status: 422, body: {} })).rejects.toThrow(/status 422/);
145
+ const { result } = await run({ accountId: "acc_1", text: "Hello" }, { proposed: true, proposalId: "pr1" });
146
+ expect(result).toEqual({ success: false, proposed: true, proposalId: "pr1" });
147
+ });
148
+
149
+ it("the account source is a read of the same tool that lists every account with the fields it names", () => {
150
+ const list = skill("unipile_list_accounts");
151
+ expect(list.metadata).toEqual({ readOnly: true });
152
+ expect(list.requires).toEqual(verb.requires);
153
+ expect(list.providerSpec).toMatchObject({ method: "GET", pathTemplate: "/accounts/" });
154
+ expect(list.providerSpec.query).toBeUndefined();
155
+ expect(Object.keys(list.providerSpec.responseShape.item)).toEqual(expect.arrayContaining(["id", "name", "provider"]));
156
+ });
157
+
158
+ it("one Unipile verb publishes for apps", () => {
159
+ const mapped = capability.spec.skills.filter((s) => s.inputMap && s.intent === "publish_post").map((s) => s.name);
160
+ expect(mapped).toEqual(["unipile_publish_post"]);
161
+ });
162
+ });
@@ -22,6 +22,7 @@ import { describe, expect, it } from "vitest";
22
22
  import { readFileSync, existsSync } from "node:fs";
23
23
  import { dirname, join } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
+ import { stripComments } from "../../design-tokens/scripts/strip-comments.mjs";
25
26
 
26
27
  const HERE = dirname(fileURLToPath(import.meta.url));
27
28
  const OWN_COPY = join(HERE, "validate-flow.ts");
@@ -40,11 +41,9 @@ const BACKEND_COPY = join(
40
41
  * normalize whitespace.
41
42
  */
42
43
  function normalizeFlowValidatorSource(src: string): string {
43
- return src
44
+ return stripComments(src, "ts")
44
45
  .replace(/import type \{[^}]*\} from ["']@synap\/database["'];/g, "")
45
46
  .replace(/type AssertExact<[\s\S]*?void _outputTypesInSync;/g, "")
46
- .replace(/\/\*[\s\S]*?\*\//g, "") // block comments (incl. JSDoc)
47
- .replace(/\/\/.*$/gm, "") // line + trailing comments
48
47
  .split("\n")
49
48
  .map((l) => l.replace(/\s+/g, " ").trim())
50
49
  .filter((l) => l.length > 0)
@@ -35,6 +35,7 @@
35
35
  * Until then: keep the constant arrays below identical to `@synap/database`
36
36
  * `schema/automations.ts`.
37
37
  */
38
+ import { PLAYBOOK_RUN_MODES } from "@synap-core/types/automations";
38
39
 
39
40
  // ── Contract constants (mirror schema/automations.ts — keep in sync) ──────────
40
41
 
@@ -65,9 +66,10 @@ export const FLOW_NODE_TYPES = [
65
66
  "playbook_run",
66
67
  ] as const;
67
68
 
68
- /** The 11 output types — `OutputNodeDef.data.outputType` (schema/automations.ts). */
69
+ /** The 15 output types — `OutputNodeDef.data.outputType` (schema/automations.ts). */
69
70
  export const FLOW_OUTPUT_TYPES = [
70
71
  "notification",
72
+ "record_observation",
71
73
  "entity_create",
72
74
  "entity_update",
73
75
  "facet_attach",
@@ -77,6 +79,9 @@ export const FLOW_OUTPUT_TYPES = [
77
79
  "webhook",
78
80
  "channel_message",
79
81
  "session_update",
82
+ "complete_outcome",
83
+ "post_evidence",
84
+ "reevaluate_criterion",
80
85
  "set_state",
81
86
  ] as const;
82
87
 
@@ -380,6 +385,21 @@ export function validateFlowDefinition(
380
385
  }) that does not exist.`,
381
386
  });
382
387
  }
388
+ // An unknown `mode` would silently RUN (`readPlaybookRunMode` reads it
389
+ // as the default) — the opposite of what an author who typed
390
+ // "propose" wanted. Refused by name at the door instead.
391
+ if (
392
+ data.mode !== undefined &&
393
+ !(PLAYBOOK_RUN_MODES as readonly unknown[]).includes(data.mode)
394
+ ) {
395
+ errors.push({
396
+ nodeId,
397
+ code: "playbook_run_bad_mode",
398
+ message: `Playbook-run node "${nodeId}" has mode ${JSON.stringify(
399
+ data.mode
400
+ )}; expected one of ${PLAYBOOK_RUN_MODES.join(", ")}.`,
401
+ });
402
+ }
383
403
  break;
384
404
  }
385
405
 
@@ -667,7 +687,17 @@ function collectLoopBindingRefs(data: unknown): string[] {
667
687
  return;
668
688
  }
669
689
  if (isRecord(v)) {
670
- for (const x of Object.values(v)) walk(x);
690
+ // Skip AUTHORING NOTES. An `_`-prefixed key is documentation the runtime
691
+ // never evaluates (`_filterNote` and friends explain what DOWNSTREAM
692
+ // nodes do, and so legitimately quote `{{loop.item...}}` in prose).
693
+ // Reading them as live references made a correct flow fail validation:
694
+ // a (since-removed) grant capability was reported as "references {{loop.item}} but is
695
+ // not inside a loop body" purely because its note DESCRIBED the nodes
696
+ // that do. Kept identical in all three copies of this walker.
697
+ for (const [k, x] of Object.entries(v)) {
698
+ if (k.startsWith("_")) continue;
699
+ walk(x);
700
+ }
671
701
  }
672
702
  };
673
703
  walk(data);
@@ -85,4 +85,63 @@ describe("validateTemplate onboarding contract", () => {
85
85
  ])
86
86
  );
87
87
  });
88
+
89
+ it("a steady-state brief needs no interview: purpose + anchors + fetch is valid", () => {
90
+ const result = validateTemplate(
91
+ templateWithOnboarding({
92
+ purpose: "Where records live.",
93
+ anchors: [{ profileSlug: "record", role: "root", limit: 1 }],
94
+ fetch: [{ profileSlug: "record", note: "the live ones" }],
95
+ }),
96
+ {}
97
+ );
98
+ expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
99
+ });
100
+
101
+ it("an interview still requires its persona and collect targets", () => {
102
+ const result = validateTemplate(
103
+ templateWithOnboarding({ goal: "Set it up.", openingQuestions: ["Why?"] }),
104
+ {}
105
+ );
106
+ expect(result.errors.map((e) => e.rule)).toEqual(
107
+ expect.arrayContaining(["onboarding-framing-empty", "onboarding-collect-empty"])
108
+ );
109
+ });
110
+
111
+ it("rejects a brief that says nothing, bad anchors, bad fetch hints and bad minimums", () => {
112
+ const result = validateTemplate(
113
+ templateWithOnboarding({
114
+ anchors: [
115
+ { profileSlug: "record", role: "root", seedRef: "Nope" },
116
+ { profileSlug: "record", role: "root" },
117
+ { profileSlug: "ghost", role: "context" },
118
+ ],
119
+ fetch: [{ note: "no target" }],
120
+ collect: [{ profileSlug: "record", what: "x", min: 0 }],
121
+ }),
122
+ {}
123
+ );
124
+ expect(result.errors.map((e) => e.rule)).toEqual(
125
+ expect.arrayContaining([
126
+ "onboarding-brief-empty",
127
+ "onboarding-anchor-seedref-unknown",
128
+ "onboarding-anchor-invalid",
129
+ "onboarding-fetch-invalid",
130
+ "onboarding-collect-min-invalid",
131
+ ])
132
+ );
133
+ });
134
+
135
+ it("template rules need a unique kebab key and an intent", () => {
136
+ const tpl = templateWithOnboarding(undefined);
137
+ tpl.rules = [
138
+ { key: "use-approved", intent: "Use approved assets first." },
139
+ { key: "use-approved", intent: "Dup." },
140
+ { key: "Bad Key", intent: "" },
141
+ ];
142
+ const rules = validateTemplate(tpl, {}).errors.map((e) => e.rule);
143
+ expect(rules).toEqual(
144
+ expect.arrayContaining(["rule-key-duplicate", "rule-key-invalid", "rule-intent-empty"])
145
+ );
146
+ });
88
147
  });
@@ -15,7 +15,7 @@ import type { WorkspaceYaml } from "./types.js";
15
15
  describe("validateTemplate — fires on a bad template", () => {
16
16
  const bad = {
17
17
  // meta.slug MISSING — required field.
18
- meta: { name: "Bad", description: "d", icon: "x", color: "#000000" },
18
+ meta: { name: "Bad", description: "d", icon: "x", color: "violet" },
19
19
  workspace: { name: "Bad", description: "d" },
20
20
  profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
21
21
  automations: [
@@ -65,7 +65,7 @@ describe("validateTemplate — clean template", () => {
65
65
  name: "Good",
66
66
  description: "d",
67
67
  icon: "x",
68
- color: "#000000",
68
+ color: "violet",
69
69
  },
70
70
  workspace: { name: "Good", description: "d" },
71
71
  profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
@@ -80,6 +80,92 @@ describe("validateTemplate — clean template", () => {
80
80
  });
81
81
  });
82
82
 
83
+ describe("validateTemplate — system Knowledge contract", () => {
84
+ const template = (overrides: Record<string, unknown> = {}): WorkspaceYaml =>
85
+ ({
86
+ meta: {
87
+ slug: "knowledge-consumer",
88
+ name: "Knowledge consumer",
89
+ description: "d",
90
+ icon: "brain",
91
+ color: "violet",
92
+ },
93
+ workspace: { name: "Knowledge consumer", description: "d" },
94
+ profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
95
+ views: [
96
+ {
97
+ name: "Knowledge",
98
+ type: "table",
99
+ scopeProfileSlug: "knowledge",
100
+ config: { columns: ["title", "knowledgeForm"] },
101
+ },
102
+ ],
103
+ ...overrides,
104
+ }) as WorkspaceYaml;
105
+
106
+ it("allows templates to consume system Knowledge without redeclaring it", () => {
107
+ const result = validateTemplate(
108
+ template({
109
+ seedEntities: [
110
+ {
111
+ profileSlug: "knowledge",
112
+ title: "Write migrations that preserve meaning",
113
+ properties: { knowledgeForm: "insight" },
114
+ },
115
+ ],
116
+ })
117
+ );
118
+
119
+ expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
120
+ });
121
+
122
+ it("rejects a template-owned Knowledge profile", () => {
123
+ const result = validateTemplate(
124
+ template({
125
+ profiles: [
126
+ {
127
+ slug: "knowledge",
128
+ displayName: "Knowledge",
129
+ properties: [
130
+ {
131
+ slug: "ek_type",
132
+ label: "Type",
133
+ valueType: "string",
134
+ inputType: "select",
135
+ enumValues: ["gotcha", "lesson"],
136
+ },
137
+ ],
138
+ },
139
+ ],
140
+ })
141
+ );
142
+
143
+ expect(result.ok).toBe(false);
144
+ expect(
145
+ result.errors.some((error) => error.rule === "system-profile-redefined")
146
+ ).toBe(true);
147
+ });
148
+
149
+ it("rejects a legacy or missing Knowledge form on seeded Knowledge", () => {
150
+ const result = validateTemplate(
151
+ template({
152
+ seedEntities: [
153
+ {
154
+ profileSlug: "knowledge",
155
+ title: "Legacy knowledge",
156
+ properties: { ek_type: "gotcha" },
157
+ },
158
+ ],
159
+ })
160
+ );
161
+
162
+ expect(result.ok).toBe(false);
163
+ expect(
164
+ result.errors.map((error) => error.rule).sort()
165
+ ).toEqual(["knowledge-seed-form-invalid", "knowledge-seed-legacy-form"]);
166
+ });
167
+ });
168
+
83
169
  // Regression: before wiring `validateFlowDefinition` into validateTemplate, a
84
170
  // template automation with a broken FLOW (bogus node type / dangling edge / cycle
85
171
  // / typo'd channelType) passed the template gate green and then silently never
@@ -92,7 +178,7 @@ describe("validateTemplate — automation flow node contract", () => {
92
178
  name: "Flowy",
93
179
  description: "d",
94
180
  icon: "x",
95
- color: "#000000",
181
+ color: "violet",
96
182
  },
97
183
  workspace: { name: "Flowy", description: "d" },
98
184
  profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
@@ -200,3 +286,70 @@ describe("validateTemplate — automation flow node contract", () => {
200
286
  });
201
287
  });
202
288
  });
289
+
290
+ describe("validateTemplate — a pack needs no profile shell (D8)", () => {
291
+ const pack = (tags: string[], deps: unknown[]) =>
292
+ ({
293
+ meta: {
294
+ slug: "acme-suite",
295
+ name: "Acme",
296
+ description: "d",
297
+ icon: "x",
298
+ color: "violet",
299
+ tags,
300
+ },
301
+ workspace: { name: "Acme", description: "d" },
302
+ profiles: [],
303
+ dependencies: deps,
304
+ }) as unknown as WorkspaceYaml;
305
+ const profileErrors = (t: WorkspaceYaml) =>
306
+ validateTemplate(t, {}).errors.filter((e) => e.rule === "profiles-required");
307
+
308
+ it("a suite that requires a workspace is valid without profiles", () => {
309
+ expect(
310
+ profileErrors(pack(["suite"], [{ slug: "crm", kind: "workspace", relation: "require" }]))
311
+ ).toEqual([]);
312
+ });
313
+
314
+ it("a suite requiring NO workspace still needs a profile", () => {
315
+ expect(profileErrors(pack(["suite"], []))).toHaveLength(1);
316
+ });
317
+
318
+ it("a non-suite template without profiles is still refused", () => {
319
+ expect(
320
+ profileErrors(pack([], [{ slug: "crm", kind: "workspace", relation: "require" }]))
321
+ ).toHaveLength(1);
322
+ });
323
+ });
324
+
325
+ describe("validateTemplate — entityLinks[].required", () => {
326
+ const withLinks = (entityLinks: unknown[]) =>
327
+ ({
328
+ meta: { slug: "links", name: "Links", description: "d", icon: "x", color: "violet" },
329
+ workspace: { name: "Links", description: "d" },
330
+ profiles: [
331
+ { slug: "gadget", displayName: "Gadget", properties: [] },
332
+ { slug: "widget", displayName: "Widget", properties: [] },
333
+ ],
334
+ entityLinks,
335
+ }) as unknown as WorkspaceYaml;
336
+ const link = { sourceProfileSlug: "gadget", targetProfileSlug: "widget", type: "relates_to" };
337
+ const requiredErrors = (t: WorkspaceYaml) =>
338
+ validateTemplate(t, {}).errors.filter((e) => e.rule === "entitylink-required-not-boolean");
339
+
340
+ it("accepts required: true, required: false and no required", () => {
341
+ const result = validateTemplate(
342
+ withLinks([{ ...link, required: true }, { ...link, type: "part_of", required: false }, { ...link, type: "mentions" }]),
343
+ {}
344
+ );
345
+ expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
346
+ });
347
+
348
+ it("refuses a required that is not a boolean, naming the link", () => {
349
+ for (const bad of ["yes", 1, "true", null]) {
350
+ const errors = requiredErrors(withLinks([{ ...link, required: bad }]));
351
+ expect(errors, String(bad)).toHaveLength(1);
352
+ expect(errors[0]!.message).toContain("gadget→widget (relates_to)");
353
+ }
354
+ });
355
+ });