@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,192 @@
1
+ # Content Creation — the public editorial + production suite
2
+ # ==========================================================
3
+ # A SUITE template: it stands up a content operation in one install by REQUIRING
4
+ # its constituent workspaces (deps-first), then adds a thin "program tower" home
5
+ # of its own — a place to track the editorial programs that cut across the
6
+ # lenses it just installed.
7
+ #
8
+ # What it composes (all public, all bundled):
9
+ # • brand-library — brand identity spine (both constituents consume `brand`)
10
+ # • content-os — the editorial engine (pillars, posts, platforms, newsletters)
11
+ # AND the video / visual pipeline (compositions, assets,
12
+ # renders); transitively requires foundation + marketing-campaign
13
+ #
14
+ # content-studio was retired into content-os (retired.yaml, 2026-11-03): one
15
+ # space per domain, and content-os.yaml said never to install it beside
16
+ # content-os. This suite no longer requires it.
17
+ #
18
+ # Brand is required because content-os declares `sourceRoles.brand: consumer`
19
+ # — skipping it would silently drop the spine it consumes. Foundation is NOT a direct dep: content-os already
20
+ # requires it (and marketing-campaign) via its own `dependencies[]`; sourceRoles
21
+ # do not consume `strategy`.
22
+ #
23
+ # It uses `require` (not `compose`): each constituent is a REAL workspace, not an
24
+ # overlay. Installing Content Creation a second time is idempotent — the resolver
25
+ # reuses existing constituent workspaces by slug.
26
+
27
+ meta:
28
+ slug: content-creation
29
+ domain: content
30
+ name: Content Creation
31
+ description: Stand up a content operation in one install — brand, editorial engine, and video pipeline — plus a program tower to track what you are making across them.
32
+ icon: pen-tool
33
+ color: "#6366F1"
34
+ tags: [suite, content, creation, editorial, video, studio]
35
+ isPublic: true
36
+
37
+ # Deps-first: brand-library is the brand-vocabulary provider content-os
38
+ # consumes, so it must resolve before content-os reuses it. `require` (not
39
+ # `compose`): each is a standalone workspace, not an additive overlay.
40
+ dependencies:
41
+ - slug: brand-library
42
+ kind: workspace
43
+ relation: require
44
+ reason: "Brand identity spine — Content OS consumes `brand`; skipping it would drop the provider that sourceRole requires."
45
+ - slug: content-os
46
+ kind: workspace
47
+ relation: require
48
+ reason: "The editorial engine and the video / visual pipeline — pillars, posts, platforms, newsletters, compositions and renders. Transitively brings foundation (strategy spine) and marketing-campaign."
49
+
50
+ workspace:
51
+ name: Content Creation
52
+ description: The program tower for the content operation — track the editorial programs that cut across the brand, editorial, and production lenses this suite installs.
53
+ subtype: content-creation
54
+ visibility: pod_visible
55
+ sourceRoles:
56
+ brand: consumer
57
+ content: provider
58
+
59
+ profiles:
60
+ # The one native profile the program tower owns: a content program the
61
+ # operation is driving across the lenses this suite installs. Deliberately
62
+ # light — the depth lives in the constituent workspaces; this is the
63
+ # cross-cutting scoreboard that ties them together.
64
+ - slug: content-program
65
+ displayName: Content Program
66
+ icon: target
67
+ color: "#6366F1"
68
+ description: A content program the operation is driving — the cross-cutting outcome the editorial and production lenses serve.
69
+ # The ONE lifecycle (work model L1): every option's category.
70
+ lifecycle:
71
+ property: status
72
+ options:
73
+ - { key: on-track, category: active }
74
+ - { key: at-risk, category: active }
75
+ - { key: off-track, category: active }
76
+ - { key: shipped, category: done }
77
+ properties:
78
+ - slug: outcome
79
+ label: Outcome
80
+ valueType: string
81
+ inputType: textarea
82
+ - slug: status
83
+ label: Status
84
+ valueType: string
85
+ inputType: select
86
+ enumValues: [on-track, at-risk, off-track, shipped]
87
+ constraints: { defaultValue: on-track }
88
+ - slug: horizon
89
+ label: Horizon
90
+ valueType: string
91
+ inputType: select
92
+ enumValues: [now, next, later]
93
+ constraints: { defaultValue: now }
94
+ - slug: owner
95
+ label: Owner
96
+ valueType: string
97
+ inputType: text
98
+ - slug: targetDate
99
+ label: Target Date
100
+ valueType: date
101
+ inputType: date
102
+
103
+ views:
104
+ - name: Programs
105
+ slug: programs
106
+ type: table
107
+ scopeProfileSlug: content-program
108
+ description: The content programs across editorial and production, by horizon and status.
109
+ defaultView: true
110
+ config:
111
+ columns: [title, status, horizon, owner, targetDate]
112
+ - name: Program Board
113
+ slug: program-board
114
+ type: kanban
115
+ scopeProfileSlug: content-program
116
+ description: Programs grouped by status — a running scoreboard for the content operation.
117
+ config:
118
+ groupBy: status
119
+
120
+ onboarding:
121
+ goal: >
122
+ Stand up the content operation and name the handful of programs it is
123
+ driving — so brand, editorial, and production are all in place and pointed
124
+ at the same outcomes from day one.
125
+ framing: |
126
+ THE EDITORIAL PRODUCER: Act as a sharp producer standing up the content OS.
127
+ Brand and Content OS are already installed — your job is
128
+ to name the 3-5 programs that cut across them, so the operation has a
129
+ scoreboard, not just structure.
130
+ expertise:
131
+ starters:
132
+ - >
133
+ Name few, real programs. A content operation with 20 programs has
134
+ none; force the 3-5 that actually decide this quarter, each with an
135
+ owner and a horizon (now / next / later).
136
+ - >
137
+ Tie each program to a lens. A good program names where it lives —
138
+ a series (editorial), a production pipeline (video and visual
139
+ posts), or a brand system
140
+ (library) — so the scoreboard maps to the structure underneath it.
141
+ blindSpots:
142
+ - >
143
+ Programs with no owner and no date are wishes. Every one needs a
144
+ single owner and a target date, or it will not move.
145
+ - >
146
+ "Make more content" is not a program — it is a category. Push for
147
+ the specific outcome that would make this quarter a win.
148
+ bar: >
149
+ A short, honest scoreboard: 3-5 programs, each owned, dated, placed on a
150
+ horizon, and tied to the lens that serves it — sitting on top of a fully
151
+ installed content operation.
152
+ collect:
153
+ - profileSlug: content-program
154
+ what: The 3-5 content programs the operation is driving this horizon.
155
+ cardinality: few
156
+ keyFields: [status, horizon, owner]
157
+ openingQuestions:
158
+ - "What are the 3-5 content programs that will decide this quarter?"
159
+ - "Who owns each one, and is it a now, next, or later horizon?"
160
+ doneWhen: A few programs captured, each with an owner and a horizon.
161
+
162
+ app:
163
+ kind: stored
164
+ cellKey: entity-detail
165
+
166
+ layout:
167
+ defaultView: Programs
168
+ defaultApp: null
169
+ pinnedApps: [dashboard, data]
170
+ sidebarItems:
171
+ - kind: view
172
+ viewName: Programs
173
+ label: Programs
174
+ icon: target
175
+ - kind: view
176
+ viewName: Program Board
177
+ label: Board
178
+ icon: kanban-square
179
+ - kind: app
180
+ appId: data
181
+ label: Data
182
+ icon: database
183
+
184
+ bento:
185
+ viewName: Programs
186
+ blocks:
187
+ - kind: view
188
+ viewName: Programs
189
+ pos: { x: 0, y: 0, w: 8, h: 4 }
190
+ - kind: view
191
+ viewName: Program Board
192
+ pos: { x: 8, y: 0, w: 4, h: 4 }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The static asset edit loop, W1 (founder D0-D2, 2 Nov 2026; build plan pod document ab3e0a5a):
3
+ *
4
+ * - ONE edit-run contract for every engine, written once in the `edit-loop` skill: the `layer-<pass>` slots,
5
+ * `studio`, `studio-app`, `needs-tool-<tool>`, the three answers, and the rules that keep the person's edits
6
+ * safe. The Draft goal and `facecam-edit` name it instead of spelling it again. The keys keep their names:
7
+ * Content Studio, Relay and runs in flight read them.
8
+ * - Carousels, LinkedIn PDFs and image posts are layered compositions in the pod (`carousel-format`); decks,
9
+ * PDF documents and presentations are built in Open Design (`open-design-edit`). The VISUAL branch no longer
10
+ * routes to the whiteboard generators.
11
+ * - The kinds gain their engines and formats additively (the reconciler is add-only).
12
+ *
13
+ * Read from the YAML source and the skill JSON, so an edit is seen without a rebuild.
14
+ */
15
+ import { readFileSync } from "fs";
16
+ import { dirname, join } from "path";
17
+ import { fileURLToPath } from "url";
18
+ import { load } from "js-yaml";
19
+ import { describe, expect, it } from "vitest";
20
+ import type { TemplatePlaybook, WorkspaceYaml } from "./types.js";
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const yaml = (file: string) => load(readFileSync(join(here, file), "utf8")) as WorkspaceYaml;
24
+ const skill = (slug: string) =>
25
+ JSON.parse(readFileSync(join(here, `../skills/${slug}/${slug}.skill.json`), "utf8")) as {
26
+ meta: { slug: string; description: string };
27
+ spec: { slug: string; body: string; description: string };
28
+ };
29
+ const flat = (s: string) => s.replace(/\s+/g, " ");
30
+
31
+ const source = yaml("content-os.yaml");
32
+ const produce = (source.playbooks ?? []).find((p) => p.name === "Produce Content") as TemplatePlaybook | undefined;
33
+ const draftGoal = flat(produce?.stages?.find((s) => s.key === "draft")?.goal ?? "");
34
+ const loop = skill("edit-loop").spec.body;
35
+
36
+ describe("the edit-loop skill holds the one edit-run contract", () => {
37
+ it("is a package whose slug is its folder", () => {
38
+ for (const slug of ["edit-loop", "open-design-edit"]) {
39
+ expect(skill(slug).meta.slug).toBe(slug);
40
+ expect(skill(slug).spec.slug).toBe(slug);
41
+ }
42
+ });
43
+
44
+ it("keeps every key Content Studio reads, unrenamed", () => {
45
+ for (const key of ['"layer-<pass>"', '"studio"', '"studio-app"', '"needs-tool-<tool>"', "retireOutput studio"]) {
46
+ expect(loop).toContain(key);
47
+ }
48
+ // The video pass keys, in build order (Content Studio's PASSES).
49
+ const video = ["`cut`", "`background`", "`colour`", "`captions`", "`graphics`"].map((k) => loop.indexOf(k));
50
+ expect(video.every((i) => i > 0)).toBe(true);
51
+ expect([...video].sort((a, b) => a - b)).toEqual(video);
52
+ // The static passes: structure and copy, visual, polish.
53
+ expect(loop).toContain("`structure` (structure and copy), `visual`, `polish`");
54
+ });
55
+
56
+ it("asks the same three answers on every pass, and says what each does", () => {
57
+ expect(loop).toContain(
58
+ "options **Approve** (recommended: keeps this pass and builds the next), **Rework** (redoes this pass only) and **Use my edit** (the person changed this pass in its editor)",
59
+ );
60
+ for (const answer of ["- **Approve**:", "- **Rework**:", "- **Use my edit**:", "- **Remove this pass**"]) {
61
+ expect(loop).toContain(answer);
62
+ }
63
+ expect(loop).toContain("keep it as the pass and change nothing of it");
64
+ });
65
+
66
+ it("states the static rules: version first, diff per slide, never regenerate, no write on the person's turn, acknowledge", () => {
67
+ for (const rule of [
68
+ "**A version before every pass of yours.**",
69
+ "**Work from the diff, never from the prompt.**",
70
+ "slide by slide (by slide number, then layer id)",
71
+ "Never regenerate a pass, a slide or a page from the brief or the script",
72
+ "**Never write while a pass is the person's.**",
73
+ "**Acknowledge every answer in the room.**",
74
+ "`Got <answer> on <pass>: <what you do now>`",
75
+ ]) {
76
+ expect(loop).toContain(rule);
77
+ }
78
+ });
79
+
80
+ it("routes each format to its engine skill", () => {
81
+ expect(loop).toContain("| Carousel, LinkedIn PDF | Layered composition in the pod | `carousel-format`");
82
+ expect(loop).toContain("| Deck, PDF document, presentation | Open Design on the person's Mac | `open-design-edit`");
83
+ });
84
+ });
85
+
86
+ describe("the Draft goal and facecam-edit name the contract instead of spelling it twice", () => {
87
+ it("the Draft goal loads edit-loop and routes the static formats to their engines", () => {
88
+ expect(draftGoal).toContain("built pass by pass through the edit-loop skill and its engine's skill");
89
+ expect(draftGoal).toContain("a layered composition saved on the post (carousel-format; passes layer-structure, layer-visual, layer-polish");
90
+ expect(draftGoal).toContain("Open Design (open-design-edit, same passes)");
91
+ for (const rule of [
92
+ "Before each of your passes record a version",
93
+ "never regenerate from the prompt",
94
+ "never write while a pass is the person's",
95
+ "acknowledge each answer in the room",
96
+ ]) {
97
+ expect(draftGoal).toContain(rule);
98
+ }
99
+ });
100
+
101
+ it("every Produce Content stage goal stays under the pod's 5,000-character cap, the Draft goal shorter than before", () => {
102
+ for (const s of produce?.stages ?? []) expect((s.goal ?? "").length).toBeLessThanOrEqual(5000);
103
+ // 4,911 on 63f6f004a: moving the contract into the skill must not grow it.
104
+ expect((produce?.stages?.find((s) => s.key === "draft")?.goal ?? "").length).toBeLessThanOrEqual(4911);
105
+ });
106
+
107
+ it("facecam-edit points at edit-loop, and keeps no second copy of the needs-tool slot", () => {
108
+ const body = skill("facecam-edit").spec.body;
109
+ expect(body).toContain("**The passes follow the `edit-loop` skill**");
110
+ expect(body).toContain("declare ONE `needs-tool-<tool>` slot per missing tool, shaped and answered as the `edit-loop` skill spells out");
111
+ // The slot as facecam-edit spelled it before 2 Nov: now only in edit-loop.
112
+ expect(body).not.toContain('{label:"Use another agent"');
113
+ expect(body).not.toContain('key:"needs-tool-<tool>"');
114
+ });
115
+ });
116
+
117
+ describe("the VISUAL branch uses the edit loop", () => {
118
+ it("drops the whiteboard generators from the process and the creative director", () => {
119
+ const process = [produce?.goalTemplate ?? "", ...(produce?.stages ?? []).map((s) => s.goal ?? "")].join(" ");
120
+ for (const gone of ["generate_carousel", "generate_deck", "whiteboard"]) {
121
+ expect(process).not.toContain(gone);
122
+ expect(skill("creative-director").spec.body).not.toContain(gone === "whiteboard" ? "onto a whiteboard board" : gone);
123
+ }
124
+ expect(flat(produce?.goalTemplate ?? "")).toContain("VISUAL: build it pass by pass through the edit-loop skill");
125
+ });
126
+
127
+ it("the creative director routes carousel/image to the layered composition and decks to Open Design", () => {
128
+ const cd = skill("creative-director").spec.body;
129
+ expect(cd).toContain("| carousel / image | VISUAL — approved carousel template → a layered composition on the post |");
130
+ expect(cd).toContain("| deck / document | VISUAL — approved deck template → Open Design on the person's Mac |");
131
+ expect(cd).toContain("`carousel-format` (carousel, LinkedIn PDF, image), `open-design-edit` (deck, PDF document). Then build pass by pass with `edit-loop`.");
132
+ // The frames and the script gate stay.
133
+ expect(cd).toContain("| hot-take | Claim · Tension · Reason · Invitation |");
134
+ expect(cd).toContain("### 4. Script gate — the written version comes first");
135
+ });
136
+
137
+ it("creative-director stays always-on: its body fits the 2,000-token budget (length / 4), or the IS demotes it", () => {
138
+ // @synap-core/types space-brief `estimateSkillTokens`; the validator only warns (always-skill-over-budget).
139
+ expect(Math.ceil(skill("creative-director").spec.body.length / 4)).toBeLessThanOrEqual(2000);
140
+ });
141
+
142
+ it("video-toolbox (name pinned) maps the static jobs to their engines", () => {
143
+ const toolbox = skill("video-toolbox");
144
+ expect(toolbox.meta.slug).toBe("video-toolbox");
145
+ expect(toolbox.spec.body).toContain("| Carousel, LinkedIn PDF, image post (not a video) | a **layered composition**");
146
+ expect(toolbox.spec.body).toContain("| Deck, PDF document, presentation (not a video) | **Open Design**");
147
+ expect(toolbox.spec.body).toContain("`open-design-edit`, then `edit-loop`");
148
+ });
149
+
150
+ it("carousel-format builds the layered composition through the revision-checked composition door", () => {
151
+ const body = skill("carousel-format").spec.body;
152
+ for (const part of [
153
+ "### Build it: the layered composition",
154
+ '"type": "rect" | "image" | "asset" | "text" | "chip"',
155
+ "`tells`",
156
+ "a headline 96 px or more (104 by default), body 36 px or more (40 by default)",
157
+ "`baseRevision` = that revision",
158
+ "Carousel NN vX — slide n/N",
159
+ "the composition `made_for` the post",
160
+ "Agents never publish",
161
+ ]) {
162
+ expect(body).toContain(part);
163
+ }
164
+ });
165
+
166
+ it("open-design-edit follows the W0 recipe: privacy, project locations (never import/folder), ai versions, export", () => {
167
+ const body = skill("open-design-edit").spec.body;
168
+ for (const part of [
169
+ '`telemetry` must be `{ "metrics": false, "content": false }`',
170
+ "`enabled` must be `false`",
171
+ "`GET http://127.0.0.1:<port>/api/health`",
172
+ "`PUT /api/project-locations`",
173
+ "`POST /api/project-locations/scan`",
174
+ "Never `POST /api/import/folder`",
175
+ '`<section class="slide">`',
176
+ "**Never write into `out/` or `dist/`**",
177
+ '`{ "source": "ai", "label": "<pass> v<n>", "prompt":',
178
+ "`manual` versions",
179
+ "`POST /api/projects/<id>/export/pdf-image`",
180
+ "`needs-tool-open-design`",
181
+ "od-design-system-project/v1",
182
+ ]) {
183
+ expect(body).toContain(part);
184
+ }
185
+ });
186
+ });
187
+
188
+ describe("Content OS installs and links the new skills, and the kinds grow additively", () => {
189
+ it("requires and links edit-loop and open-design-edit", () => {
190
+ for (const slug of ["edit-loop", "open-design-edit"]) {
191
+ expect((source.dependencies ?? []).some((d) => d.slug === slug && d.kind === "skill" && (d.relation ?? "require") === "require")).toBe(true);
192
+ expect((source.skills ?? []).some((s) => s.slug === slug)).toBe(true);
193
+ }
194
+ });
195
+
196
+ it("adds the static engines and formats without dropping a value, in Content OS", () => {
197
+ for (const file of ["content-os.yaml"]) {
198
+ const profiles = yaml(file).profiles;
199
+ const prop = (profile: string, slug: string) =>
200
+ profiles.find((p) => p.slug === profile)?.properties.find((d) => d.slug === slug) as { enumValues?: string[] } | undefined;
201
+ expect(prop("content-composition", "render-engine")?.enumValues).toEqual(["hyperframes", "layered", "open-design"]);
202
+ expect(prop("render-job", "output-format")?.enumValues).toEqual(["mp4", "gif", "webm", "mp3", "pdf", "png", "pptx"]);
203
+ }
204
+ });
205
+
206
+ it("adds no stage and no required param", () => {
207
+ expect(produce?.stages?.map((s) => s.key)).toEqual(["brief", "script", "draft", "review", "schedule", "publish"]);
208
+ expect((produce?.params ?? []).filter((p) => p.required).map((p) => p.name)).toEqual(["post", "platform"]);
209
+ });
210
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * "Publish due posts" (decision A, founder, 3 Nov: a Scheduled post goes out
3
+ * through Unipile on its date, with no second approval) and the fields it
4
+ * reads, pinned on the template that owns them (content-os owns `post` and
5
+ * `platform`).
6
+ *
7
+ * What it proves, each from the authored flow, never a hand copy of it:
8
+ * - the flow passes the catalog's own flow validator;
9
+ * - every property the flow READS from the due post and every property it
10
+ * WRITES is declared on `post`, and every value it writes to a select or the
11
+ * lifecycle is one of its options (an undeclared key would be stored
12
+ * unmodeled and an unknown option refused: the send would go out and the
13
+ * post would never say so);
14
+ * - the send's input maps the verb's required parameters, and only declared
15
+ * ones (`unipile_publish_post` in the unipile-linkedin capability);
16
+ * - the send is never retried, its failure continues to a branch that writes
17
+ * publish-outcome failed and publish-error, and the post is claimed
18
+ * (and the claim checked as written) before it is sent;
19
+ * - one post per run (a loop would abort a failed item with no failure
20
+ * branch): the send is never inside a loop, and it ships paused;
21
+ * - accounts and destinations (founder, 4 Nov, L9): the send passes the
22
+ * company page and the repost's original; a sent post keeps its Unipile
23
+ * id and queues the reposts waiting for it, with that id, in a loop whose
24
+ * body is that one write.
25
+ *
26
+ * NOT covered: the engine running it (query, transform, condition and template
27
+ * semantics live in synap-backend's automation executor; read there on
28
+ * 3 Nov: `to_ms`, numeric `$lte` on a property, `continueOnError` storing
29
+ * `{ error }`, entity_update returning `status: "updated"`); Unipile accepting
30
+ * the post.
31
+ */
32
+ import { readFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { describe, expect, it } from "vitest";
35
+ import { WORKSPACE_TEMPLATES } from "./templates.js";
36
+ import type { TemplateProfile, WorkspaceYaml } from "./types.js";
37
+ import { validateFlowDefinition } from "./validate-flow.js";
38
+
39
+ const contentOs = WORKSPACE_TEMPLATES["content-os"] as WorkspaceYaml;
40
+ const profile = (slug: string): TemplateProfile => {
41
+ const p = (contentOs.profiles ?? []).find((x) => x.slug === slug);
42
+ if (!p) throw new Error(`content-os declares no ${slug}`);
43
+ return p;
44
+ };
45
+ const post = profile("post");
46
+ const declared = new Set((post.properties ?? []).map((p) => p.slug));
47
+ const optionsOf = (slug: string): string[] => {
48
+ const prop = (post.properties ?? []).find((p) => p.slug === slug) as { enumValues?: unknown } | undefined;
49
+ return Array.isArray(prop?.enumValues) ? (prop.enumValues as string[]) : [];
50
+ };
51
+ const lifecycle = (post as { lifecycle?: { property: string; options: Array<{ key: string }> } }).lifecycle;
52
+
53
+ const automation = (contentOs.automations ?? []).find((a) => a.key === "content-os-publish-due-posts");
54
+ type Node = { id: string; type: string; data: Record<string, any> };
55
+ type Edge = { id: string; source: string; target: string; sourceHandle?: string };
56
+ const nodes = (automation?.flow.nodes ?? []) as Node[];
57
+ const edges = (automation?.flow.edges ?? []) as Edge[];
58
+ const node = (id: string) => {
59
+ const n = nodes.find((x) => x.id === id);
60
+ if (!n) throw new Error(`no node ${id}`);
61
+ return n;
62
+ };
63
+
64
+ const unipile = JSON.parse(
65
+ readFileSync(join(import.meta.dirname, "..", "capabilities", "unipile-linkedin.capability.json"), "utf8"),
66
+ ) as { spec: { skills: Array<{ name: string; parameters?: Record<string, string> }> } };
67
+ const verb = unipile.spec.skills.find((s) => s.name === "unipile_publish_post");
68
+
69
+ /** Every `{{steps.due.output.entities.0.properties.<key>}}` the flow reads, from its whole JSON. */
70
+ const READ = /\{\{steps\.due\.output\.entities\.0\.properties\.([a-z0-9-]+)\}\}/g;
71
+
72
+ describe("Publish due posts — the automation", () => {
73
+ it("ships paused, every 15 minutes, and passes the catalog's flow validator", () => {
74
+ expect(automation).toMatchObject({ name: "Publish due posts", triggerType: "cron", status: "paused", triggerConfig: { expression: "*/15 * * * *" } });
75
+ const result = validateFlowDefinition(automation?.flow, { verbExists: (v) => v === "unipile_publish_post" });
76
+ expect(result.errors).toEqual([]);
77
+ });
78
+
79
+ it("sends one post per run, never inside a loop", () => {
80
+ expect(node("due").data.limit).toBe(1);
81
+ // The only loop queues reposts after the send: nothing that sends, and
82
+ // nothing before the send, is reachable from it.
83
+ const loops = nodes.filter((n) => n.type === "loop");
84
+ expect(loops.map((n) => n.id)).toEqual(["each-repost"]);
85
+ const reach = new Set<string>();
86
+ const stack = ["each-repost"];
87
+ while (stack.length) {
88
+ const id = stack.pop() as string;
89
+ for (const e of edges.filter((x) => x.source === id)) if (!reach.has(e.target)) reach.add(e.target), stack.push(e.target);
90
+ }
91
+ expect([...reach]).toEqual(["arm-repost"]);
92
+ });
93
+
94
+ it("finds only an armed Scheduled post whose time has come, oldest first", () => {
95
+ // The filter is a templated JSON string: with a number for its one placeholder it must parse.
96
+ const filter = JSON.parse(String(node("due").data.filter).replace("{{steps.now.output.result}}", "1762416000000"));
97
+ expect(filter).toEqual({ "post-status": "Scheduled", "publish-outcome": "queued", "publish-at-ms": { $lte: 1762416000000 } });
98
+ expect(lifecycle?.property).toBe("post-status");
99
+ expect(lifecycle?.options.map((o) => o.key)).toContain("Scheduled");
100
+ for (const key of Object.keys(filter)) expect(declared, key).toContain(key);
101
+ expect(optionsOf("publish-outcome")).toContain("queued");
102
+ expect(node("due").data).toMatchObject({ profileSlug: "post", orderBy: "properties.publish-at-ms", orderDir: "asc" });
103
+ expect(node("now").data.expression).toBe("{{trigger.payload.scheduledAt}} | to_ms");
104
+ });
105
+
106
+ it("reads only declared post properties", () => {
107
+ const reads = [...JSON.stringify(automation?.flow).matchAll(READ)].map((m) => m[1]);
108
+ // Non-vacuity: it reads the frozen fields, the company page and the repost's original among them.
109
+ expect(new Set(reads)).toEqual(
110
+ new Set(["publish-account-id", "publish-text", "publish-media", "publish-as-organization", "publish-quote-id"]),
111
+ );
112
+ for (const key of reads) expect(declared, key).toContain(key);
113
+ });
114
+
115
+ it("sends through unipile_publish_post with its required parameters, and only declared ones", () => {
116
+ const send = node("send");
117
+ expect(send.type).toBe("capability");
118
+ expect(send.data.verbId).toBe("unipile_publish_post");
119
+ const params = verb?.parameters ?? {};
120
+ const required = Object.entries(params).filter(([, t]) => !t.endsWith("?")).map(([k]) => k);
121
+ expect(required.sort()).toEqual(["accountId", "text"]);
122
+ for (const k of required) expect(Object.keys(send.data.inputMapping)).toContain(k);
123
+ for (const k of Object.keys(send.data.inputMapping)) expect(Object.keys(params)).toContain(k);
124
+ });
125
+
126
+ it("claims the post before it sends, and sends only once the claim is written", () => {
127
+ expect(node("claim").data).toMatchObject({ outputType: "entity_update", config: { properties: { "publish-outcome": "sending" } } });
128
+ expect(optionsOf("publish-outcome")).toContain("sending");
129
+ expect(node("claimed").data.expression).toBe("steps.claim.output.status === 'updated'");
130
+ expect(edges).toEqual(
131
+ expect.arrayContaining([
132
+ expect.objectContaining({ source: "any-due", target: "claim", sourceHandle: "yes" }),
133
+ expect.objectContaining({ source: "claim", target: "claimed" }),
134
+ expect.objectContaining({ source: "claimed", target: "send", sourceHandle: "yes" }),
135
+ ]),
136
+ );
137
+ // Nothing else leads to the send.
138
+ expect(edges.filter((e) => e.target === "send").map((e) => e.source)).toEqual(["claimed"]);
139
+ });
140
+
141
+ it("is never retried, and a failure goes to the branch that writes it on the post", () => {
142
+ expect(node("send").data.errorHandling).toEqual({ continueOnError: true });
143
+ expect(edges).toEqual(
144
+ expect.arrayContaining([
145
+ expect.objectContaining({ source: "send", target: "sent" }),
146
+ expect.objectContaining({ source: "sent", target: "published", sourceHandle: "yes" }),
147
+ expect.objectContaining({ source: "sent", target: "failed", sourceHandle: "no" }),
148
+ ]),
149
+ );
150
+ expect(node("sent").data.expression).toBe("steps.send.output.error === ''");
151
+ expect(node("failed").data.config.properties).toEqual({ "publish-outcome": "failed", "publish-error": "{{steps.send.output.error}}" });
152
+ });
153
+
154
+ it("writes only declared properties, and only options a select or the lifecycle has", () => {
155
+ const writes = nodes.filter((n) => n.type === "output" && n.data.outputType === "entity_update");
156
+ expect(writes.map((w) => w.id).sort()).toEqual(["arm-repost", "claim", "failed", "link", "published", "remember-id"]);
157
+ for (const w of writes) {
158
+ // Every write is on the due post, except the one that queues each of its reposts.
159
+ expect(w.data.config.entityId).toBe(w.id === "arm-repost" ? "{{loop.item.id}}" : "{{steps.due.output.entities.0.id}}");
160
+ for (const [key, value] of Object.entries(w.data.config.properties as Record<string, string>)) {
161
+ expect(declared, `${w.id} writes ${key}`).toContain(key);
162
+ if (key === "post-status") expect(lifecycle?.options.map((o) => o.key)).toContain(value);
163
+ if (key === "publish-outcome") expect(optionsOf(key)).toContain(value);
164
+ }
165
+ }
166
+ expect(node("published").data.config.properties).toMatchObject({ "post-status": "Published", "publish-outcome": "succeeded" });
167
+ // The link is its own write and may fail alone: Unipile may return none.
168
+ expect(node("link").data).toMatchObject({ config: { properties: { "post-url": "{{steps.send.output.shareUrl}}" } }, errorHandling: { continueOnError: true } });
169
+ });
170
+ });
171
+
172
+ describe("Publish due posts — the fields Content Studio freezes when a post is approved", () => {
173
+ it("declares the frozen fields on post, and the Unipile account on platform", () => {
174
+ for (const key of ["publish-text", "publish-account-id", "publish-media", "publish-at-ms"]) expect(declared, key).toContain(key);
175
+ expect((post.properties ?? []).find((p) => p.slug === "publish-at-ms")?.valueType).toBe("number");
176
+ expect((post.properties ?? []).find((p) => p.slug === "publish-media")?.valueType).toBe("array");
177
+ expect(optionsOf("publish-outcome")).toEqual(["succeeded", "failed", "queued", "sending", "waiting"]);
178
+ expect((profile("platform").properties ?? []).map((p) => p.slug)).toContain("unipile-account-id");
179
+ });
180
+
181
+ it("Produce Content's Schedule arms the post for Unipile, and its Publish says who writes Published", () => {
182
+ const pb = (contentOs.playbooks ?? []).find((p) => p.name === "Produce Content") as
183
+ | { stages?: Array<{ key: string; goal?: string }> }
184
+ | undefined;
185
+ const schedule = pb?.stages?.find((s) => s.key === "schedule")?.goal ?? "";
186
+ for (const word of [
187
+ "unipile-account-id",
188
+ "publish-account-id",
189
+ "publish-text",
190
+ "publish-media",
191
+ "publish-at-ms",
192
+ "publish-outcome queued",
193
+ "Publish due posts",
194
+ "account-owner",
195
+ "publish-as-organization",
196
+ "post-copy-to",
197
+ "post-reshare-on",
198
+ "post-copy-of",
199
+ "publish-outcome waiting",
200
+ ])
201
+ expect(schedule, word).toContain(word);
202
+ const publish = pb?.stages?.find((s) => s.key === "publish")?.goal ?? "";
203
+ expect(publish).toContain("Publish due posts");
204
+ expect(publish).toContain("publish-outcome failed");
205
+ });
206
+ });
207
+
208
+ describe("Publish due posts — accounts and destinations (founder, 4 Nov)", () => {
209
+ const platform = profile("platform");
210
+ const platformProp = (slug: string) => (platform.properties ?? []).find((p) => p.slug === slug) as { enumValues?: unknown; valueType?: string } | undefined;
211
+ const postProp = (slug: string) => (post.properties ?? []).find((p) => p.slug === slug) as
212
+ | { enumValues?: unknown; valueType?: string; targetProfileSlug?: string }
213
+ | undefined;
214
+
215
+ it("a platform row is one account: Personal or Company, and a LinkedIn page id", () => {
216
+ expect(platformProp("account-owner")?.enumValues).toEqual(["Personal", "Company"]);
217
+ expect(platformProp("linkedin-organization-id")?.valueType).toBe("string");
218
+ });
219
+
220
+ it("a post names its other accounts, and a post sent for another says which and how", () => {
221
+ expect(postProp("post-copy-to")?.valueType).toBe("array");
222
+ expect(postProp("post-reshare-on")?.valueType).toBe("array");
223
+ expect(postProp("post-copy-of")).toMatchObject({ valueType: "entity_id", targetProfileSlug: "post" });
224
+ expect(postProp("post-send-mode")?.enumValues).toEqual(["copy", "reshare"]);
225
+ for (const key of ["publish-as-organization", "publish-quote-id", "publish-post-id"]) expect(postProp(key)?.valueType, key).toBe("string");
226
+ });
227
+
228
+ it("the send passes the company page and the repost's original, and the verb declares both", () => {
229
+ expect(node("send").data.inputMapping).toMatchObject({
230
+ postAs: "{{steps.due.output.entities.0.properties.publish-as-organization}}",
231
+ quotedPostId: "{{steps.due.output.entities.0.properties.publish-quote-id}}",
232
+ });
233
+ expect(verb?.parameters).toMatchObject({ postAs: "string?", quotedPostId: "string?" });
234
+ });
235
+
236
+ it("a sent post keeps its Unipile id, then queues its waiting reposts with that id", () => {
237
+ expect(edges).toEqual(
238
+ expect.arrayContaining([
239
+ expect.objectContaining({ source: "published", target: "link" }),
240
+ expect.objectContaining({ source: "link", target: "has-id" }),
241
+ expect.objectContaining({ source: "has-id", target: "remember-id", sourceHandle: "yes" }),
242
+ expect.objectContaining({ source: "remember-id", target: "reposts" }),
243
+ expect.objectContaining({ source: "reposts", target: "each-repost" }),
244
+ expect.objectContaining({ source: "each-repost", target: "arm-repost" }),
245
+ ]),
246
+ );
247
+ // Only a sent post gets there: the failure branch leads nowhere near it.
248
+ expect(edges.filter((e) => e.target === "has-id").map((e) => e.source)).toEqual(["link"]);
249
+ expect(node("has-id").data.expression).toBe("steps.send.output.postId !== ''");
250
+ expect(node("remember-id").data).toMatchObject({
251
+ config: { properties: { "publish-post-id": "{{steps.send.output.postId}}" } },
252
+ errorHandling: { continueOnError: true },
253
+ });
254
+ const filter = JSON.parse(String(node("reposts").data.filter).replace("{{steps.due.output.entities.0.id}}", "p1"));
255
+ expect(filter).toEqual({ "post-copy-of": "p1", "publish-outcome": "waiting" });
256
+ expect(node("reposts").data.profileSlug).toBe("post");
257
+ expect(optionsOf("publish-outcome")).toContain("waiting");
258
+ expect(node("each-repost").data).toMatchObject({ iteratorExpression: "steps.reposts.output.entities", itemVariable: "repost" });
259
+ expect(node("arm-repost").data.config.properties).toEqual({ "publish-quote-id": "{{steps.send.output.postId}}", "publish-outcome": "queued" });
260
+ });
261
+ });