@synap-core/workspace-templates 0.12.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 (245) 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 +174 -900
  57. package/dist/index.js +26148 -23931
  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/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.yaml +85 -39
  139. package/src/blockchain-ecosystem.yaml +1 -0
  140. package/src/brand-library.yaml +525 -14
  141. package/src/builder-dev-fold.template.test.ts +113 -0
  142. package/src/builder.yaml +779 -91
  143. package/src/business-model-catalog-description.test.ts +47 -0
  144. package/src/business-model-grp.template.test.ts +214 -0
  145. package/src/business-model.yaml +296 -49
  146. package/src/catalog-safety.test.ts +198 -0
  147. package/src/catalog.template-items.test.ts +75 -0
  148. package/src/catalog.ts +66 -2
  149. package/src/concept-guard.test.ts +28 -8
  150. package/src/concept-guard.ts +2 -2
  151. package/src/content-creation.template.test.ts +52 -0
  152. package/src/content-creation.yaml +192 -0
  153. package/src/content-os.edit-loop.template.test.ts +210 -0
  154. package/src/content-os.publish-due-posts.template.test.ts +261 -0
  155. package/src/content-os.template.test.ts +322 -0
  156. package/src/content-os.video-takes.template.test.ts +338 -0
  157. package/src/content-os.work-a-take.template.test.ts +125 -0
  158. package/src/content-os.yaml +1830 -39
  159. package/src/content-studio-retired.template.test.ts +82 -0
  160. package/src/corpus-flow-validity.test.ts +4 -5
  161. package/src/crm-absorbs-outreach.template.test.ts +117 -0
  162. package/src/crm-build-plan.template.test.ts +128 -0
  163. package/src/crm-lists.template.test.ts +96 -0
  164. package/src/crm-messages-cadence.template.test.ts +164 -0
  165. package/src/crm-one-send-door.template.test.ts +65 -0
  166. package/src/crm.yaml +1456 -181
  167. package/src/decisions-wave.template.test.ts +186 -0
  168. package/src/define.ts +130 -17
  169. package/src/design-to-implementation.template.test.ts +94 -0
  170. package/src/domain-template-type.test.ts +75 -0
  171. package/src/ecosystem.yaml +27 -66
  172. package/src/enterprise-os.yaml +10 -134
  173. package/src/facecam-edit.audio-studio.test.ts +188 -0
  174. package/src/fal-generate-image.test.ts +200 -0
  175. package/src/fal-media-lab-pod-files.test.ts +180 -0
  176. package/src/finance.yaml +254 -1
  177. package/src/foundation.yaml +78 -38
  178. package/src/frame-host-modules.d.mts +6 -0
  179. package/src/frame-host-modules.mjs +58 -0
  180. package/src/generate.mjs +9 -3
  181. package/src/github-merge.test.ts +210 -0
  182. package/src/grants.yaml +46 -17
  183. package/src/hr.yaml +39 -6
  184. package/src/index.ts +36 -1
  185. package/src/intent.test.ts +161 -0
  186. package/src/internal-runbook.yaml +19 -1
  187. package/src/jev.test.ts +356 -0
  188. package/src/legal.yaml +33 -1
  189. package/src/life-os.yaml +51 -240
  190. package/src/lifecycle-coverage.test.ts +124 -0
  191. package/src/lifecycle-declarations.test.ts +252 -0
  192. package/src/lifecycle-presets.test.ts +468 -0
  193. package/src/lifecycle-presets.ts +494 -0
  194. package/src/lifecycle-stuck.test.ts +46 -0
  195. package/src/lifecycle-waits.parity.test.ts +319 -0
  196. package/src/marketing.yaml +108 -4
  197. package/src/meetings-knowledge.yaml +18 -0
  198. package/src/networking.yaml +23 -2
  199. package/src/operations.yaml +304 -4
  200. package/src/pipeline-views.template.test.ts +88 -0
  201. package/src/playbook-goal-placeholders.test.ts +57 -0
  202. package/src/postiz-contract.test.ts +134 -0
  203. package/src/presentation-declarations.test.ts +174 -0
  204. package/src/process-contract.validate.test.ts +352 -0
  205. package/src/project-management.yaml +39 -740
  206. package/src/publish-body.d.mts +29 -0
  207. package/src/publish-body.mjs +74 -0
  208. package/src/publish-body.test.ts +74 -0
  209. package/src/publish-official-lib.test.ts +176 -0
  210. package/src/replicate-rvm.test.ts +504 -0
  211. package/src/research-base.yaml +119 -20
  212. package/src/research-decision.yaml +60 -3
  213. package/src/reserved-profile-slugs.test.ts +83 -0
  214. package/src/retired-goal-kinds.test.ts +58 -0
  215. package/src/role-reference-and-step-domain.test.ts +192 -0
  216. package/src/seed-obligation.test.ts +1 -6
  217. package/src/send-message-maps.test.ts +227 -0
  218. package/src/shared-role-ssot.test.ts +40 -2
  219. package/src/ship-a-change.template.test.ts +177 -0
  220. package/src/skills.test.ts +170 -0
  221. package/src/social.yaml +85 -27
  222. package/src/space-packages.freshness.test.ts +97 -0
  223. package/src/space-packages.generated.ts +40 -0
  224. package/src/space-packages.test.ts +125 -0
  225. package/src/space-packages.ts +73 -0
  226. package/src/start.test.ts +188 -0
  227. package/src/task-intents.test.ts +293 -0
  228. package/src/templates.ts +20851 -21496
  229. package/src/types.ts +443 -60
  230. package/src/unipile-publish-post.test.ts +162 -0
  231. package/src/validate-flow.drift.test.ts +2 -3
  232. package/src/validate-flow.ts +22 -2
  233. package/src/validate-onboarding.test.ts +59 -0
  234. package/src/validate.test.ts +71 -4
  235. package/src/validate.ts +1335 -7
  236. package/src/wire-core.ts +231 -0
  237. package/src/wire.test.ts +159 -0
  238. package/src/wire.ts +107 -0
  239. package/src/agent-fleet.yaml +0 -1645
  240. package/src/business-developer.template.test.ts +0 -351
  241. package/src/business-developer.yaml +0 -860
  242. package/src/content-studio.yaml +0 -361
  243. package/src/dev-dashboard.yaml +0 -990
  244. package/src/outreach-comms.yaml +0 -486
  245. package/src/the-arch.yaml +0 -201
@@ -0,0 +1,227 @@
1
+ /**
2
+ * send_message and request_connection, as a pod app asks for them (CRM
3
+ * consolidation, plan 214f05bb, Lane 3; backend contract of Lane 2,
4
+ * feat/intent-send-message): the app sends ONE input `{ to: <entity id>, text,
5
+ * subject? }` (`request_connection`: `{ to, text? }`) and the pod resolves the
6
+ * recipient for each tool through `$to.<key>` (email, phone,
7
+ * linkedin.providerId, linkedin.publicIdentifier, chat, attendee). An action is
8
+ * reachable for a person when every `$to` path it reads resolves.
9
+ *
10
+ * Pinned here, where the maps are authored:
11
+ * - ONE send door (founder decision d6, 2026-11-02, session d00cd2ac): no
12
+ * Unipile verb serves send_message any more. An app's or automation's
13
+ * send_message resolves to the pod's own `messaging.send` (Synap Core),
14
+ * which files the message in the person's channel; `unipile_send_message`,
15
+ * `unipile_start_chat` and `linkedin_send_first_message` stay as agent
16
+ * tool calls, with no intent and no input map;
17
+ * - Unipile: one action per account `unipile_list_accounts` lists (a declared
18
+ * read, the pod refuses any other account source), LinkedIn AND Instagram,
19
+ * for `linkedin_send_invite` (request_connection);
20
+ * - LinkedIn by the person's PROFILE (2 Nov, session a638fbf2): no lead the
21
+ * pod holds carries a LinkedIn member id, only `linkedinUrl`, which the pod
22
+ * resolves as `linkedin.publicIdentifier`. `linkedin_send_first_message`
23
+ * (an agent tool call) and `linkedin_send_invite` look the person up (which gives the
24
+ * member id and the network distance) and refuse before sending what
25
+ * LinkedIn would refuse: a message to someone not connected, an invitation
26
+ * to someone connected or already invited, a note over 300 characters.
27
+ * Their code runs here against a fake Unipile;
28
+ * - Gmail: the person's email, the subject, the text as the body;
29
+ * - the new verb starts a 1to1 chat, never a group;
30
+ * - no Unipile skill carries an install-time `{{param}}` outside its
31
+ * providerSpec (the pod's reconcile refuses to re-apply such a template,
32
+ * "manual re-apply needed", so these maps could never reach an install).
33
+ *
34
+ * NOT covered: the pod's resolution of `$to` and its reach (backend); Unipile
35
+ * accepting the calls (checked against the v2 OpenAPI 2.62.0 reference, not a
36
+ * live account).
37
+ */
38
+ import { readFileSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { describe, expect, it } from "vitest";
41
+
42
+ type Skill = Record<string, any>;
43
+ const load = (file: string) =>
44
+ JSON.parse(readFileSync(join(import.meta.dirname, "..", "capabilities", file), "utf8")) as {
45
+ spec: { skills: Skill[]; provides?: string[] };
46
+ };
47
+ const unipile = load("unipile-linkedin.capability.json");
48
+ const google = load("nango-google.capability.json");
49
+ const skill = (cap: typeof unipile, name: string) => cap.spec.skills.find((s) => s.name === name)!;
50
+
51
+ /** The recipient keys the pod resolves (Lane 2 contract). */
52
+ const TO_KEYS = ["email", "phone", "linkedin.providerId", "linkedin.publicIdentifier", "chat", "attendee"];
53
+ const ACCOUNTS = { verb: "unipile_list_accounts", id: "id", label: "name", provider: "provider" };
54
+
55
+ describe("Unipile — what a pod app may ask it to send", () => {
56
+ it("the account source is a declared read listing every connected account", () => {
57
+ const list = skill(unipile, "unipile_list_accounts");
58
+ expect(list.metadata?.readOnly).toBe(true);
59
+ expect(list.providerSpec.pathTemplate).toBe("/accounts/");
60
+ // Every provider: LinkedIn and Instagram both get their own action (founder, 28 Oct, decision 34103d1b).
61
+ expect(list.providerSpec.query?.provider).toBeUndefined();
62
+ });
63
+
64
+ it.each(["unipile_send_message", "unipile_start_chat", "linkedin_send_first_message"])(
65
+ "%s serves no intent: send_message goes through the pod's send door (messaging.send)",
66
+ (name) => {
67
+ const verb = skill(unipile, name);
68
+ expect(verb, name).toBeDefined();
69
+ expect(verb.intent).toBeUndefined();
70
+ expect(verb.inputMap).toBeUndefined();
71
+ }
72
+ );
73
+
74
+ it("no Unipile verb serves send_message, and the capability does not provide it", () => {
75
+ expect(unipile.spec.skills.length).toBeGreaterThanOrEqual(14);
76
+ expect(unipile.spec.skills.filter((s) => s.intent === "send_message").map((s) => s.name)).toEqual([]);
77
+ expect(unipile.spec.provides).not.toContain("send_message");
78
+ // What it still serves for an app is untouched.
79
+ expect(unipile.spec.provides).toEqual(expect.arrayContaining(["request_connection", "publish_post"]));
80
+ });
81
+
82
+ it.each([
83
+ ["linkedin_send_invite", "request_connection", { providerId: "$to.linkedin.publicIdentifier", accountId: "$account.id", message: "$.text" }],
84
+ ])("%s serves %s, one action per Unipile account", (name, intent, params) => {
85
+ const verb = skill(unipile, name);
86
+ expect(verb.intent).toBe(intent);
87
+ expect(verb.inputMap).toEqual({ params, accounts: ACCOUNTS });
88
+ for (const param of Object.keys(params)) expect(Object.keys(verb.parameters)).toContain(param);
89
+ });
90
+
91
+ it("unipile_start_chat starts a 1to1 chat, LinkedIn through its CLASSIC_PRIMARY inbox", () => {
92
+ const code: string = skill(unipile, "unipile_start_chat").code;
93
+ expect(code).toContain("/inboxes/CLASSIC_PRIMARY/chats/send");
94
+ expect(code).toContain("/chats/send");
95
+ // users_ids as ONE string: an array, even of one id, asks Unipile for a group.
96
+ expect(code).toContain("users_ids: attendeeId");
97
+ expect(code).toMatch(/res\?\.proposed === true/);
98
+ });
99
+ });
100
+
101
+ /**
102
+ * Runs a verb's code as the pod's runtime does (`(async function(args, context){ <code> })` with
103
+ * callProvider and propose in scope) against a fake Unipile: the profile lookup answers `lookup`,
104
+ * every POST succeeds. Returns what it did, so a test reads the calls, never the code's text.
105
+ */
106
+ function runVerb(name: string, args: Record<string, unknown>, lookup: { status: number; body?: Record<string, unknown> }) {
107
+ const calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> = [];
108
+ const notes: Array<Record<string, unknown>> = [];
109
+ const callProvider = async (tool: string, method: string, path: string, body?: Record<string, unknown>) => {
110
+ expect(tool).toBe("unipile_linkedin");
111
+ calls.push({ method, path, body });
112
+ return method === "GET" ? { status: lookup.status, body: lookup.body ?? {} } : { status: 201, body: { chat_id: "chat_1", id: "rr_1" } };
113
+ };
114
+ const propose = { entity: async (e: Record<string, unknown>) => void notes.push(e) };
115
+ const fn = new Function("args", "context", "callProvider", "propose", `return (async function () {\n${skill(unipile, name).code}\n})();`);
116
+ return { done: fn(args, {}, callProvider, propose) as Promise<Record<string, unknown>>, calls, notes };
117
+ }
118
+
119
+ const ACCOUNT = "acc_li";
120
+ const lookupPath = (who: string) => `/${ACCOUNT}/users/${who}?variant=linkedin_classic`;
121
+ const connected = { status: 200, body: { provider_id: "ACoAAmember", public_identifier: "ada-lovelace", first_name: "Ada", last_name: "Lovelace", network_distance: "FIRST_DEGREE" } };
122
+
123
+ describe("linkedin_send_first_message — a message to one person, by their profile", () => {
124
+ const send = (lookup: { status: number; body?: Record<string, unknown> }) =>
125
+ runVerb("linkedin_send_first_message", { accountId: ACCOUNT, attendeeId: "ada-lovelace", text: "Hello Ada" }, lookup);
126
+
127
+ it("looks the person up, then sends to their member id as ONE id (an array asks for a group)", async () => {
128
+ const run = send(connected);
129
+ await expect(run.done).resolves.toMatchObject({ success: true, attendeeId: "ACoAAmember", chatId: "chat_1" });
130
+ expect(run.calls).toEqual([
131
+ { method: "GET", path: lookupPath("ada-lovelace"), body: undefined },
132
+ { method: "POST", path: `/${ACCOUNT}/inboxes/CLASSIC_PRIMARY/chats/send`, body: { text: "Hello Ada", users_ids: "ACoAAmember" } },
133
+ ]);
134
+ expect(run.notes).toHaveLength(1);
135
+ });
136
+
137
+ it("reads v2's `id` when the profile has no provider_id", async () => {
138
+ const run = send({ status: 200, body: { id: "ACoAAv2", network_distance: "FIRST_DEGREE" } });
139
+ await expect(run.done).resolves.toMatchObject({ success: true, attendeeId: "ACoAAv2" });
140
+ });
141
+
142
+ it.each(["SECOND_DEGREE", "THIRD_DEGREE", "OUT_OF_NETWORK"])("refuses someone %s before anything is sent", async (distance) => {
143
+ const run = send({ status: 200, body: { ...connected.body, network_distance: distance } });
144
+ await expect(run.done).rejects.toThrow(/Ada Lovelace is not one of your LinkedIn connections yet.*connection request first/);
145
+ expect(run.calls.map((c) => c.method)).toEqual(["GET"]);
146
+ expect(run.notes).toHaveLength(0);
147
+ });
148
+
149
+ it("an unknown distance never blocks", async () => {
150
+ const run = send({ status: 200, body: { provider_id: "ACoAAmember" } });
151
+ await expect(run.done).resolves.toMatchObject({ success: true });
152
+ });
153
+
154
+ it("a profile LinkedIn does not know sends nothing", async () => {
155
+ const run = send({ status: 404 });
156
+ await expect(run.done).rejects.toThrow(/no profile for ada-lovelace/);
157
+ expect(run.calls.map((c) => c.method)).toEqual(["GET"]);
158
+ });
159
+ });
160
+
161
+ describe("linkedin_send_invite — a connection request, by their profile", () => {
162
+ const invite = (lookup: { status: number; body?: Record<string, unknown> }, message?: string) =>
163
+ runVerb("linkedin_send_invite", { accountId: ACCOUNT, providerId: "ada-lovelace", message }, lookup);
164
+ const stranger = { status: 200, body: { ...connected.body, network_distance: "SECOND_DEGREE" } };
165
+
166
+ it("looks the person up, then invites their member id with the note", async () => {
167
+ const run = invite(stranger, "Hi Ada");
168
+ await expect(run.done).resolves.toMatchObject({ success: true, providerId: "ACoAAmember" });
169
+ expect(run.calls).toEqual([
170
+ { method: "GET", path: lookupPath("ada-lovelace"), body: undefined },
171
+ { method: "POST", path: `/${ACCOUNT}/users/me/relation-requests`, body: { user_id: "ACoAAmember", message: "Hi Ada" } },
172
+ ]);
173
+ });
174
+
175
+ it("refuses someone already connected", async () => {
176
+ const run = invite(connected);
177
+ await expect(run.done).rejects.toThrow(/already one of your LinkedIn connections/);
178
+ expect(run.calls.map((c) => c.method)).toEqual(["GET"]);
179
+ });
180
+
181
+ it("refuses a second request while one is pending", async () => {
182
+ const run = invite({ status: 200, body: { ...stranger.body, invitation: { type: "SENT", status: "PENDING" } } });
183
+ await expect(run.done).rejects.toThrow(/already pending/);
184
+ expect(run.calls.map((c) => c.method)).toEqual(["GET"]);
185
+ });
186
+
187
+ it("refuses a note over LinkedIn's 300 characters before calling anyone", async () => {
188
+ const run = invite(stranger, "x".repeat(301));
189
+ await expect(run.done).rejects.toThrow(/at most 300 characters/);
190
+ expect(run.calls).toEqual([]);
191
+ });
192
+ });
193
+
194
+ describe("Unipile can be updated on every pod", () => {
195
+ // backend `installTimePlaceholders`: skill name and every projected field but providerSpec.
196
+ const scanned = unipile.spec.skills.map((s) => [s.name, JSON.stringify({ ...s, providerSpec: undefined })] as const);
197
+
198
+ it("sees every skill (the scan is not empty)", () => {
199
+ expect(scanned.length).toBeGreaterThanOrEqual(14);
200
+ });
201
+
202
+ it.each(scanned)("%s carries no install-time placeholder", (_name, fields) => {
203
+ expect(fields).not.toMatch(/\{\{\s*\w+\s*\}\}/);
204
+ });
205
+
206
+ it("the only install param is the API key (the account is a call argument)", () => {
207
+ expect((unipile.spec as { params?: Array<{ name: string }> }).params?.map((p) => p.name)).toEqual(["unipileApiKey"]);
208
+ });
209
+ });
210
+
211
+ describe("Gmail — what a pod app may ask it to send", () => {
212
+ it("gmail_send writes to the person's email, with the subject, the text as its body", () => {
213
+ const verb = skill(google, "gmail_send");
214
+ expect(verb.intent).toBe("send_message");
215
+ expect(verb.inputMap).toEqual({ params: { to: "$to.email", subject: "$.subject", body: "$.text" } });
216
+ });
217
+ });
218
+
219
+ describe("every $to path in the catalog names a key the pod resolves", () => {
220
+ it("send and invite maps read only known recipient keys", () => {
221
+ const paths = [...unipile.spec.skills, ...google.spec.skills]
222
+ .flatMap((s) => Object.values((s.inputMap?.params ?? {}) as Record<string, unknown>))
223
+ .filter((v): v is string => typeof v === "string" && v.startsWith("$to."));
224
+ expect(paths.length).toBeGreaterThanOrEqual(2);
225
+ for (const p of paths) expect(TO_KEYS, p).toContain(p.slice("$to.".length));
226
+ });
227
+ });
@@ -124,9 +124,10 @@ describe("workspace-templates — shared ecosystem role SSOT (corpus)", () => {
124
124
  expect(foundation, "foundation template must exist in the corpus").toBeDefined();
125
125
  // `porteur` and `funder` join the relationship roles: a business model
126
126
  // rests on WHO carries it and WHO funds it, and both are revocable hats on
127
- // an existing person/company rather than kinds of their own.
127
+ // an existing person/company rather than kinds of their own. `churned` is
128
+ // NOT here: a state is never a role — it is `clientStatus: churned`.
128
129
  expect([...SHARED_ROLE_SLUGS].sort()).toEqual(
129
- ["churned", "client", "funder", "lead", "partner", "porteur", "sponsor"]
130
+ ["client", "funder", "lead", "partner", "porteur", "sponsor"]
130
131
  );
131
132
  });
132
133
 
@@ -268,4 +269,41 @@ describe("workspace-templates — shared ecosystem role SSOT (corpus)", () => {
268
269
  );
269
270
  expect(violations, violations.join("\n")).toEqual([]);
270
271
  });
272
+
273
+ // (e) A STATE IS NEVER A ROLE (work model, founder rule 2026-10-11). The
274
+ // `churned` role duplicated `clientStatus: churned`: one fact, two homes, and
275
+ // nothing kept them in step. A role slug that is also a status value of some
276
+ // role is that defect again. The status values are DERIVED from every role's
277
+ // status-like select (slug ending in status/stage) across the corpus, never
278
+ // hand-listed. Granularity: role slugs vs ROLE status values only — a kind's
279
+ // stage value (deal's `lead`) is deliberately not scanned, since a deal stage
280
+ // named after the lead role is a different (wave-1) question.
281
+ it("(e) no role slug is also a status value of a role", () => {
282
+ const STATUS_LIKE = /(status|stage)$/i;
283
+ const stateValues = new Map<string, string>();
284
+ const roleSlugs = new Map<string, string>();
285
+ for (const tpl of templates) {
286
+ for (const p of profilesOf(tpl)) {
287
+ if (normalizeKind(p.profileKind) !== "role") continue;
288
+ roleSlugs.set(p.slug, templateSlug(tpl));
289
+ for (const prop of p.properties ?? []) {
290
+ if (!STATUS_LIKE.test(prop.slug) || !Array.isArray(prop.enumValues)) continue;
291
+ for (const v of prop.enumValues) {
292
+ stateValues.set(String(v), `${templateSlug(tpl)}:${p.slug}.${prop.slug}`);
293
+ }
294
+ }
295
+ }
296
+ }
297
+ // Non-vacuity: the scan still sees the value that motivated it.
298
+ expect(stateValues.get("churned"), "scan no longer sees clientStatus: churned").toBeDefined();
299
+ expect(roleSlugs.size).toBeGreaterThan(10);
300
+ const violations = [...roleSlugs.entries()]
301
+ .filter(([slug]) => stateValues.has(slug))
302
+ .map(
303
+ ([slug, tpl]) =>
304
+ `${tpl} declares role '${slug}', which is also the status value ${stateValues.get(slug)}. ` +
305
+ `A state is never a role: make it an option of that status, not a facet.`
306
+ );
307
+ expect(violations, violations.join("\n")).toEqual([]);
308
+ });
271
309
  });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The dev loop as stored config (plan v5 T2): Builder's "Ship a change", the
3
+ * staged `ship-a-change-*` automations that move it on facts, and the
4
+ * `github-read` capability that reads the same facts on the person's token.
5
+ *
6
+ * What this guards:
7
+ * 1. the process FOLLOWS the feature's declared lifecycle: it names that
8
+ * property, and every status a stage writes or a worker reads is one of
9
+ * its options (never an invented one);
10
+ * 2. ONE human gate, at the lab deploy (the commitment); red checks send the
11
+ * work back to Build; the PR is a declared `pull_request` outcome;
12
+ * 3. REACHABILITY across files (derived from the automation files, not
13
+ * listed): every stage an automation moves a session to is a stage of
14
+ * the process, every evidence key an automation posts is one a criterion
15
+ * reads, and every playbook an automation runs exists in Builder; a
16
+ * merge acts only through a guard that it landed on the default branch
17
+ * (`intoDefault`, decision F3: a stacked merge is not delivered);
18
+ * 4. github-read stays read-only and bring-your-own: every declarative verb
19
+ * is a GET on its vaulted tool, a code verb (the merged CHECK) calls its
20
+ * tool with GET only and nothing else, and the token is the person's
21
+ * (never pod-wide).
22
+ *
23
+ * Not covered: whether the pod runs the staged automations (they need
24
+ * backend#286's verbs), and GitHub's real responses (NEEDS-DOGFOOD).
25
+ */
26
+ import { readFileSync, readdirSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { describe, expect, it } from "vitest";
30
+ import { getWorkspaceTemplate } from "./templates.js";
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url));
33
+ const builder = getWorkspaceTemplate("builder-workspace")!;
34
+ const pb = builder.playbooks!.find((p) => p.name === "Ship a change")! as unknown as {
35
+ subjectProfile: { profileSlug: string; statusProperty?: string };
36
+ activators?: { on: string; status?: string; mode?: string }[];
37
+ stages: {
38
+ key: string;
39
+ subjectStatus?: string;
40
+ gate?: { kind: string };
41
+ onFail?: { toStage: string };
42
+ criteria?: { key: string; check: { kind: string; evidenceKey?: string } }[];
43
+ expectedOutputs?: { kind: string }[];
44
+ }[];
45
+ };
46
+ const feature = builder.profiles.find((p) => p.slug === pb.subjectProfile.profileSlug)! as unknown as {
47
+ lifecycle: { property: string; options: { key: string }[] };
48
+ };
49
+
50
+ type FlowNode = { id: string; type: string; data: Record<string, any> };
51
+ type FlowEdge = { source: string; target: string };
52
+ const automationDir = join(HERE, "../automations");
53
+ const automations = readdirSync(automationDir)
54
+ .filter((f) => f.startsWith("ship-a-change-") && f.endsWith(".automation.json"))
55
+ .map((f) => {
56
+ const raw = JSON.parse(readFileSync(join(automationDir, f), "utf8"));
57
+ return {
58
+ file: f,
59
+ event: raw.triggerConfig?.eventPattern as string | undefined,
60
+ nodes: raw.flowDefinition.nodes as FlowNode[],
61
+ edges: raw.flowDefinition.edges as FlowEdge[],
62
+ };
63
+ });
64
+ const outputs = (type: string) =>
65
+ automations.flatMap((a) =>
66
+ a.nodes.filter((n) => n.type === "output" && n.data.outputType === type).map((n) => ({ file: a.file, n }))
67
+ );
68
+
69
+ describe("Ship a change — the process follows the feature's lifecycle", () => {
70
+ it("names the lifecycle property, and every status it writes or reads is an option of it", () => {
71
+ expect(pb.subjectProfile.statusProperty).toBe(feature.lifecycle.property);
72
+ const options = new Set(feature.lifecycle.options.map((o) => o.key));
73
+ const used = [
74
+ ...pb.stages.map((s) => s.subjectStatus).filter(Boolean),
75
+ ...(pb.activators ?? []).map((a) => a.status).filter(Boolean),
76
+ ];
77
+ expect(used.length).toBeGreaterThanOrEqual(5); // non-vacuity: spec, in_progress, in_review, done + the worker's spec
78
+ expect(used.filter((s) => !options.has(s!))).toEqual([]);
79
+ });
80
+
81
+ it("one human gate, at the lab deploy; red checks go back to Build; the PR is a pull_request outcome", () => {
82
+ expect(pb.stages.filter((s) => s.gate?.kind === "human").map((s) => s.key)).toEqual(["deploy"]);
83
+ const checks = pb.stages.find((s) => s.key === "checks")!;
84
+ expect(checks.gate).toEqual({ kind: "check" });
85
+ expect(checks.onFail).toEqual({ toStage: "build" });
86
+ const prStage = pb.stages.find((s) => s.key === "pr-opened")!;
87
+ expect(prStage.expectedOutputs?.map((o) => o.kind)).toContain("pull_request");
88
+ const keys = new Set(pb.stages.map((s) => s.key));
89
+ expect(pb.stages.flatMap((s) => (s.onFail ? [s.onFail.toStage] : [])).filter((k) => !keys.has(k))).toEqual([]);
90
+ });
91
+
92
+ it("a worker proposes (never runs) when a feature is shaped", () => {
93
+ expect(pb.activators).toContainEqual(expect.objectContaining({ on: "enters_status", status: "spec", mode: "propose" }));
94
+ expect((pb.activators ?? []).filter((a) => a.mode !== "propose")).toEqual([]);
95
+ });
96
+ });
97
+
98
+ describe("the staged ship-a-change automations reach the process", () => {
99
+ it("every stage an automation moves a session to is a stage of Ship a change", () => {
100
+ const moves = outputs("session_update");
101
+ expect(moves.length).toBeGreaterThanOrEqual(4); // non-vacuity: opened, green, red, merged
102
+ const keys = new Set(pb.stages.map((s) => s.key));
103
+ expect(moves.filter(({ n }) => !keys.has(n.data.config.currentStage)).map(({ file, n }) => `${file}: ${n.data.config.currentStage}`)).toEqual([]);
104
+ });
105
+
106
+ it("every evidence key an automation posts is one a criterion of the process reads", () => {
107
+ const posted = outputs("post_evidence").flatMap(({ file, n }) => Object.keys(n.data.config.evidence).map((k) => ({ file, k })));
108
+ expect(posted.length).toBeGreaterThanOrEqual(4);
109
+ const read = new Set(pb.stages.flatMap((s) => (s.criteria ?? []).map((c) => c.check.evidenceKey).filter(Boolean)));
110
+ expect(posted.filter(({ k }) => !read.has(k)).map(({ file, k }) => `${file}: ${k}`)).toEqual([]);
111
+ });
112
+
113
+ it("every playbook an automation runs is a Builder playbook", () => {
114
+ const runs = automations.flatMap((a) => a.nodes.filter((n) => n.type === "playbook_run").map((n) => n.data.playbookName));
115
+ expect(runs.length).toBeGreaterThanOrEqual(1);
116
+ const names = new Set(builder.playbooks!.map((p) => p.name));
117
+ expect(runs.filter((r) => !names.has(r))).toEqual([]);
118
+ });
119
+
120
+ it("a merge acts only after a guard that it landed on the default branch", () => {
121
+ const merged = automations.filter((a) => a.event === "dev.pr_merged");
122
+ expect(merged.length).toBeGreaterThanOrEqual(1);
123
+ // Reachability, not shape: drop every guard that checks intoDefault === true,
124
+ // then no output may still be reachable from the trigger.
125
+ const unguarded = merged.flatMap((a) => {
126
+ const blocks = new Set(
127
+ a.nodes
128
+ .filter((n) => n.type === "guard" && (n.data.checks ?? []).some((c: any) => c.path === "trigger.payload.data.intoDefault" && c.equals === true))
129
+ .map((n) => n.id)
130
+ );
131
+ const seen = new Set<string>();
132
+ const queue = a.nodes.filter((n) => n.type === "trigger").map((n) => n.id);
133
+ while (queue.length) {
134
+ const id = queue.shift()!;
135
+ if (seen.has(id) || blocks.has(id)) continue;
136
+ seen.add(id);
137
+ queue.push(...a.edges.filter((e) => e.source === id).map((e) => e.target));
138
+ }
139
+ return a.nodes.filter((n) => n.type !== "trigger" && n.type !== "guard" && seen.has(n.id)).map((n) => `${a.file}: ${n.id}`);
140
+ });
141
+ expect(unguarded).toEqual([]);
142
+ });
143
+ });
144
+
145
+ describe("github-read — read-only, bring your own token", () => {
146
+ const cap = JSON.parse(readFileSync(join(HERE, "../capabilities/github-read.capability.json"), "utf8")).spec;
147
+ const tools = new Set((cap.tools as { name: string }[]).map((t) => t.name));
148
+
149
+ it("every verb only reads: a declarative verb is a GET, a code verb calls its tool with GET only", () => {
150
+ expect(cap.skills.length).toBeGreaterThanOrEqual(5); // pr get, checks, release latest, compare + the merged check
151
+ const code = cap.skills.filter((s: { kind: string }) => s.kind === "code");
152
+ expect(code.length).toBeGreaterThanOrEqual(1); // non-vacuity: github_check_pr_merged
153
+ for (const s of cap.skills) {
154
+ expect(s.requires.every((t: string) => tools.has(t)), s.name).toBe(true);
155
+ expect(s.metadata?.readOnly, s.name).toBe(true);
156
+ if (s.kind === "declarative") {
157
+ expect(s.providerSpec.method, s.name).toBe("GET");
158
+ expect(tools.has(s.providerSpec.tool), s.name).toBe(true);
159
+ continue;
160
+ }
161
+ expect(s.kind, s.name).toBe("code");
162
+ const calls = [...String(s.code).matchAll(/callProvider\(\s*'([^']+)'\s*,\s*'([A-Z]+)'/g)];
163
+ expect(calls.length, s.name).toBeGreaterThanOrEqual(1);
164
+ for (const [, tool, method] of calls) {
165
+ expect(tools.has(tool), `${s.name} → ${tool}`).toBe(true);
166
+ expect(method, `${s.name} → ${tool}`).toBe("GET");
167
+ }
168
+ expect(String(s.code), s.name).not.toMatch(/host\.fetch|propose\./);
169
+ }
170
+ });
171
+
172
+ it("the token is the person's own vault secret, never pod-wide, and every tool uses it", () => {
173
+ const refs = new Set((cap.vault as { ref: string; podWide?: boolean }[]).map((v) => v.ref));
174
+ expect((cap.vault as { podWide?: boolean }[]).filter((v) => v.podWide)).toEqual([]);
175
+ for (const t of cap.tools) expect(refs.has(t.credentialRef), t.name).toBe(true);
176
+ });
177
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Space-linked skills — the DECLARATION side.
3
+ *
4
+ * A template declares `skills: [{ slug, when, mode }]`; the pod links each to
5
+ * the new space at install. This file proves three things a live pod depends on:
6
+ *
7
+ * 1. the SHAPE rule fires (bad slug, bad mode, duplicate) — the same
8
+ * `validateSpaceSkillDeclaration` the pod's applier runs, so author time
9
+ * and install time cannot drift;
10
+ * 2. a well-formed declaration REACHES THE WIRE via `toPackageDefinition`
11
+ * (asserted on the VALUE, not the key's existence — a declared-but-dropped
12
+ * field is the failure this whole ledger exists to prevent);
13
+ * 3. the workspace door does NOT carry it, which is the deliberate routing
14
+ * recorded in `WorkspaceDefinitionDropped` — if that ever flips, this test
15
+ * says so instead of the field silently landing nowhere.
16
+ */
17
+
18
+ import { describe, expect, it } from "vitest";
19
+ import { toPackageDefinition, toWorkspaceDefinition } from "./define.js";
20
+ import { validateTemplate } from "./validate.js";
21
+ import type { WorkspaceYaml } from "./types.js";
22
+
23
+ /** The smallest template that can carry a `skills:` declaration. */
24
+ function tplWith(skills: unknown): WorkspaceYaml {
25
+ return {
26
+ meta: {
27
+ slug: "skills-probe",
28
+ name: "Skills probe",
29
+ description: "d",
30
+ icon: "x",
31
+ color: "violet",
32
+ },
33
+ workspace: { name: "Skills probe", description: "d" },
34
+ profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
35
+ skills,
36
+ } as unknown as WorkspaceYaml;
37
+ }
38
+
39
+ const GOOD = [
40
+ {
41
+ slug: "system/synap/creative-director",
42
+ when: "any content ask — carousel, deck, video",
43
+ mode: "always",
44
+ },
45
+ ];
46
+
47
+ describe("skills — a template declares them and validation enforces the shape", () => {
48
+ it("accepts a well-formed declaration (the happy path is not vacuous)", () => {
49
+ const result = validateTemplate(tplWith(GOOD), {});
50
+ const skillErrors = result.errors.filter((e) => e.rule.startsWith("skill-"));
51
+ expect(
52
+ skillErrors,
53
+ `a valid declaration must raise no skill rule: ${JSON.stringify(skillErrors)}`
54
+ ).toEqual([]);
55
+ });
56
+
57
+ it("rejects a malformed slug with an actionable path", () => {
58
+ const result = validateTemplate(
59
+ tplWith([{ slug: "System/Synap/Creative-Director", mode: "always" }]),
60
+ {}
61
+ );
62
+ const bad = result.errors.find((e) => e.rule === "skill-invalid");
63
+ expect(bad, JSON.stringify(result.errors)).toBeDefined();
64
+ expect(bad!.path).toBe("skills.0");
65
+ expect(bad!.message).toMatch(/not a valid skill slug/);
66
+ });
67
+
68
+ it("rejects an unknown mode", () => {
69
+ const result = validateTemplate(
70
+ tplWith([{ slug: "system/synap/x", mode: "sometimes" }]),
71
+ {}
72
+ );
73
+ const bad = result.errors.find((e) => e.rule === "skill-invalid");
74
+ expect(bad, JSON.stringify(result.errors)).toBeDefined();
75
+ expect(bad!.message).toMatch(/`mode` must be one of/);
76
+ });
77
+
78
+ it("rejects the SAME skill declared twice — two modes is ambiguous, not a merge", () => {
79
+ const result = validateTemplate(
80
+ tplWith([
81
+ { slug: "system/synap/x", mode: "always" },
82
+ { slug: "system/synap/x", mode: "on-demand" },
83
+ ]),
84
+ {}
85
+ );
86
+ const dup = result.errors.find((e) => e.rule === "skill-duplicate");
87
+ expect(dup, JSON.stringify(result.errors)).toBeDefined();
88
+ expect(dup!.path).toBe("skills.1.slug");
89
+ });
90
+ });
91
+
92
+ describe("skills — a LINK needs an INSTALL (the silent failure, made loud)", () => {
93
+ /** The same probe, able to declare dependencies too. */
94
+ function tpl(skills: unknown, dependencies?: unknown): WorkspaceYaml {
95
+ return {
96
+ ...(tplWith(skills) as unknown as Record<string, unknown>),
97
+ ...(dependencies ? { dependencies } : {}),
98
+ } as unknown as WorkspaceYaml;
99
+ }
100
+ const LINK = [{ slug: "creative-director", mode: "on-demand" }];
101
+
102
+ it("REFUSES a link nothing installs — that is the silent one", () => {
103
+ // Without this rule the template publishes, installs cleanly, and the skill
104
+ // link does NOTHING: a missing dependency is non-fatal by design, so the
105
+ // only symptom is an agent that never gets the skill.
106
+ const result = validateTemplate(tpl(LINK), {});
107
+ const rule = result.errors.find(
108
+ (e) => e.rule === "skill-linked-without-install"
109
+ );
110
+ expect(rule, JSON.stringify(result.errors)).toBeDefined();
111
+ expect(rule!.path).toBe("skills.0.slug");
112
+ expect(rule!.message).toContain("dependencies");
113
+ });
114
+
115
+ it("accepts the same link once the dependency declares it", () => {
116
+ const result = validateTemplate(
117
+ tpl(LINK, [
118
+ { slug: "creative-director", kind: "skill", relation: "require" },
119
+ ]),
120
+ {}
121
+ );
122
+ expect(
123
+ result.errors.filter((e) => e.rule === "skill-linked-without-install")
124
+ ).toEqual([]);
125
+ });
126
+
127
+ it("accepts the seeded `system/…` namespace — present on every pod, no install needed", () => {
128
+ const result = validateTemplate(
129
+ tpl([{ slug: "system/synap/onboard", mode: "on-demand" }]),
130
+ {}
131
+ );
132
+ expect(
133
+ result.errors.filter((e) => e.rule === "skill-linked-without-install")
134
+ ).toEqual([]);
135
+ });
136
+
137
+ it("a WORKSPACE dependency does not satisfy a skill link", () => {
138
+ // The kinds are not interchangeable: `require`ing a workspace brings a space,
139
+ // not a skill row, and the link would still resolve to nothing.
140
+ const result = validateTemplate(
141
+ tpl(LINK, [{ slug: "creative-director", kind: "workspace" }]),
142
+ {}
143
+ );
144
+ expect(
145
+ result.errors.some((e) => e.rule === "skill-linked-without-install")
146
+ ).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe("skills — the declaration reaches the wire (and only the right door)", () => {
151
+ it("toPackageDefinition carries the declared skills, value for value", () => {
152
+ const pkg = toPackageDefinition(tplWith(GOOD));
153
+ // Value equality, not merely a present key: a ledger entry is a CLAIM about
154
+ // routing, and only the value arriving can check it.
155
+ expect(pkg.skills).toEqual(GOOD);
156
+ });
157
+
158
+ it("a template with no skills emits no `skills` key at all", () => {
159
+ const pkg = toPackageDefinition(tplWith(undefined));
160
+ expect("skills" in pkg).toBe(false);
161
+ });
162
+
163
+ it("the WORKSPACE door does NOT carry skills — the routing is deliberate", () => {
164
+ // Recorded in `WorkspaceDefinitionDropped`: the skill is linked post-workspace
165
+ // by the package apply, so the workspace door has no such field. If this ever
166
+ // starts passing `skills` through, the ledger comment is stale.
167
+ const result = toWorkspaceDefinition(tplWith(GOOD));
168
+ expect("skills" in result.definition).toBe(false);
169
+ });
170
+ });