@synap-core/workspace-templates 0.12.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +509 -0
- package/automations/bookmark-enrichment.automation.json +321 -0
- package/automations/github-pr-events.automation.json +505 -0
- package/automations/mail-feed.automation.json +86 -0
- package/automations/ship-a-change-checks-green.automation.json +106 -0
- package/automations/ship-a-change-checks-red.automation.json +106 -0
- package/automations/ship-a-change-deployed.automation.json +56 -0
- package/automations/ship-a-change-pr-merged.automation.json +110 -0
- package/automations/ship-a-change-pr-opened.automation.json +104 -0
- package/automations/url-bookmark-capture.automation.json +132 -0
- package/capabilities/agency-skills.capability.json +157 -0
- package/capabilities/apify.capability.json +265 -0
- package/capabilities/apollo.capability.json +120 -0
- package/capabilities/arch-backend.capability.json +97 -0
- package/capabilities/arch-client-intelligence.capability.json +401 -0
- package/capabilities/backblaze-b2.capability.json +205 -0
- package/capabilities/cal-com.capability.json +266 -0
- package/capabilities/canva.capability.json +274 -0
- package/capabilities/claude-code-routines.capability.json +172 -0
- package/capabilities/claude-managed-agents.capability.json +215 -0
- package/capabilities/client-comms-report.capability.json +246 -0
- package/capabilities/content-idea-to-production.capability.json +29 -0
- package/capabilities/datagouv.capability.json +315 -0
- package/capabilities/discord-bot.capability.json +157 -0
- package/capabilities/dropbox.capability.json +281 -0
- package/capabilities/exa.capability.json +109 -0
- package/capabilities/fal-ai.capability.json +83 -0
- package/capabilities/fal-media-lab.capability.json +158 -0
- package/capabilities/fireflies.capability.json +191 -0
- package/capabilities/freellmapi.capability.json +114 -0
- package/capabilities/generic-apikey.capability.json +92 -0
- package/capabilities/github-merge.capability.json +99 -0
- package/capabilities/github-read.capability.json +325 -0
- package/capabilities/governance-tighten-recommender.capability.json +81 -0
- package/capabilities/jev.capability.json +226 -0
- package/capabilities/lead-conversion-enrollment.capability.json +246 -0
- package/capabilities/mail-feed.capability.json +277 -0
- package/capabilities/mailgun.capability.json +52 -0
- package/capabilities/nango-google.capability.json +639 -0
- package/capabilities/notion.capability.json +417 -0
- package/capabilities/omniroute.capability.json +137 -0
- package/capabilities/posthog-analytics.capability.json +258 -0
- package/capabilities/postiz.capability.json +467 -0
- package/capabilities/proactive-digest.capability.json +190 -0
- package/capabilities/proton.capability.json +288 -0
- package/capabilities/replicate-rvm.capability.json +97 -0
- package/capabilities/research-methods.capability.json +57 -0
- package/capabilities/shopify.capability.json +395 -0
- package/capabilities/slack.capability.json +369 -0
- package/capabilities/stripe.capability.json +1496 -0
- package/capabilities/telegram-bridge.capability.json +56 -0
- package/capabilities/treg.capability.json +102 -0
- package/capabilities/unipile-linkedin.capability.json +494 -0
- package/capabilities/vercel.capability.json +424 -0
- package/dist/define-DqoItRiR.d.ts +1370 -0
- package/dist/index.d.ts +174 -900
- package/dist/index.js +26447 -24075
- package/dist/index.js.map +1 -1
- package/dist/wire.d.ts +222 -0
- package/dist/wire.js +77 -0
- package/dist/wire.js.map +1 -0
- package/package.json +27 -4
- package/retired.yaml +136 -0
- package/skills/app-factory-build-slice/SKILL.md +70 -0
- package/skills/app-factory-build-slice.catalog.json +17 -0
- package/skills/app-factory-design-canvas/SKILL.md +96 -0
- package/skills/app-factory-design-canvas.catalog.json +17 -0
- package/skills/app-factory-design-review/SKILL.md +105 -0
- package/skills/app-factory-design-review.catalog.json +18 -0
- package/skills/app-factory-research/SKILL.md +123 -0
- package/skills/app-factory-research.catalog.json +17 -0
- package/skills/bookmark-enrichment/SKILL.md +40 -0
- package/skills/carousel-format/carousel-format.skill.json +18 -0
- package/skills/collage-broll/collage-broll.skill.json +20 -0
- package/skills/content-taste/content-taste.skill.json +18 -0
- package/skills/creative-director/creative-director.skill.json +18 -0
- package/skills/design-critique/SKILL.md +138 -0
- package/skills/design-critique.catalog.json +12 -0
- package/skills/designing-with-real-components/SKILL.md +140 -0
- package/skills/designing-with-real-components.catalog.json +12 -0
- package/skills/edit-loop/edit-loop.skill.json +25 -0
- package/skills/facecam-edit/facecam-edit.skill.json +24 -0
- package/skills/mapping-the-server/SKILL.md +54 -0
- package/skills/math-explainer-manimgx/math-explainer-manimgx.skill.json +20 -0
- package/skills/motion-quality-gates/motion-quality-gates.skill.json +20 -0
- package/skills/open-design-edit/open-design-edit.skill.json +24 -0
- package/skills/reading-client-progress/SKILL.md +65 -0
- package/skills/routing-to-channels/SKILL.md +54 -0
- package/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
- package/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
- package/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
- package/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
- package/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
- package/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
- package/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
- package/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
- package/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
- package/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
- package/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
- package/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
- package/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
- package/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
- package/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
- package/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
- package/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
- package/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
- package/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
- package/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
- package/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
- package/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
- package/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
- package/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
- package/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
- package/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
- package/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
- package/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
- package/skills/video-direction/video-direction.skill.json +20 -0
- package/skills/video-style-vocabulary/video-style-vocabulary.skill.json +20 -0
- package/skills/video-toolbox/video-toolbox.skill.json +20 -0
- package/src/__fixtures__/lifecycle-presets.flat-before.json +205 -0
- package/src/agency-os.yaml +122 -0
- package/src/agent-os-skill-slugs.test.ts +65 -0
- package/src/apify-social-reader.test.ts +317 -0
- package/src/base.yaml +85 -39
- package/src/blockchain-ecosystem.yaml +1 -0
- package/src/brand-library.yaml +525 -14
- package/src/builder-dev-fold.template.test.ts +113 -0
- package/src/builder.yaml +779 -91
- package/src/business-model-catalog-description.test.ts +47 -0
- package/src/business-model-grp.template.test.ts +214 -0
- package/src/business-model.yaml +296 -49
- package/src/catalog-safety.test.ts +198 -0
- package/src/catalog.template-items.test.ts +75 -0
- package/src/catalog.ts +66 -2
- package/src/concept-guard.test.ts +28 -8
- package/src/concept-guard.ts +2 -2
- package/src/content-creation.template.test.ts +52 -0
- package/src/content-creation.yaml +192 -0
- package/src/content-os.edit-loop.template.test.ts +210 -0
- package/src/content-os.publish-due-posts.template.test.ts +261 -0
- package/src/content-os.template.test.ts +349 -0
- package/src/content-os.video-takes.template.test.ts +338 -0
- package/src/content-os.work-a-take.template.test.ts +125 -0
- package/src/content-os.yaml +1873 -39
- package/src/content-studio-retired.template.test.ts +82 -0
- package/src/corpus-flow-validity.test.ts +4 -5
- package/src/crm-absorbs-outreach.template.test.ts +117 -0
- package/src/crm-build-plan.template.test.ts +128 -0
- package/src/crm-lists.template.test.ts +96 -0
- package/src/crm-messages-cadence.template.test.ts +246 -0
- package/src/crm-one-send-door.template.test.ts +65 -0
- package/src/crm.yaml +1563 -181
- package/src/decisions-wave.template.test.ts +186 -0
- package/src/define.ts +146 -17
- package/src/design-to-implementation.template.test.ts +94 -0
- package/src/domain-template-type.test.ts +75 -0
- package/src/ecosystem.yaml +27 -66
- package/src/enterprise-os.yaml +10 -134
- package/src/facecam-edit.audio-studio.test.ts +188 -0
- package/src/fal-generate-image.test.ts +200 -0
- package/src/fal-media-lab-pod-files.test.ts +180 -0
- package/src/finance.yaml +254 -1
- package/src/foundation.yaml +78 -38
- package/src/frame-host-modules.d.mts +6 -0
- package/src/frame-host-modules.mjs +58 -0
- package/src/generate.mjs +9 -3
- package/src/github-merge.test.ts +210 -0
- package/src/grants.yaml +46 -17
- package/src/hr.yaml +39 -6
- package/src/index.ts +36 -1
- package/src/intent.test.ts +161 -0
- package/src/internal-runbook.yaml +19 -1
- package/src/jev.test.ts +356 -0
- package/src/legal.yaml +33 -1
- package/src/life-os.yaml +51 -240
- package/src/lifecycle-coverage.test.ts +124 -0
- package/src/lifecycle-declarations.test.ts +252 -0
- package/src/lifecycle-presets.test.ts +468 -0
- package/src/lifecycle-presets.ts +494 -0
- package/src/lifecycle-stuck.test.ts +46 -0
- package/src/lifecycle-waits.parity.test.ts +319 -0
- package/src/marketing.yaml +108 -4
- package/src/meetings-knowledge.yaml +18 -0
- package/src/networking.yaml +23 -2
- package/src/operations.yaml +304 -4
- package/src/pipeline-views.template.test.ts +88 -0
- package/src/playbook-goal-placeholders.test.ts +57 -0
- package/src/postiz-contract.test.ts +134 -0
- package/src/presentation-declarations.test.ts +174 -0
- package/src/process-contract.validate.test.ts +352 -0
- package/src/project-management.yaml +39 -740
- package/src/publish-body.d.mts +29 -0
- package/src/publish-body.mjs +74 -0
- package/src/publish-body.test.ts +74 -0
- package/src/publish-official-lib.test.ts +176 -0
- package/src/replicate-rvm.test.ts +504 -0
- package/src/research-base.yaml +119 -20
- package/src/research-decision.yaml +60 -3
- package/src/reserved-profile-slugs.test.ts +81 -0
- package/src/retired-goal-kinds.test.ts +52 -0
- package/src/role-reference-and-step-domain.test.ts +192 -0
- package/src/seed-obligation.test.ts +1 -6
- package/src/send-message-maps.test.ts +227 -0
- package/src/shared-role-ssot.test.ts +40 -2
- package/src/ship-a-change.template.test.ts +177 -0
- package/src/skills.test.ts +170 -0
- package/src/social.yaml +85 -27
- package/src/space-packages.freshness.test.ts +97 -0
- package/src/space-packages.generated.ts +40 -0
- package/src/space-packages.test.ts +125 -0
- package/src/space-packages.ts +73 -0
- package/src/start.test.ts +188 -0
- package/src/stripe-capability.test.ts +192 -0
- package/src/task-intents.test.ts +293 -0
- package/src/template-documents.test.ts +158 -0
- package/src/templates.ts +20907 -21464
- package/src/types.ts +475 -61
- package/src/unipile-linkedin-find-profile.test.ts +183 -0
- package/src/unipile-publish-post.test.ts +162 -0
- package/src/validate-flow.drift.test.ts +2 -3
- package/src/validate-flow.ts +22 -2
- package/src/validate-onboarding.test.ts +59 -0
- package/src/validate.test.ts +71 -4
- package/src/validate.ts +1416 -7
- package/src/wire-core.ts +231 -0
- package/src/wire.test.ts +159 -0
- package/src/wire.ts +107 -0
- package/views/bookmark-views-pack.view.json +39 -0
- package/views/event-views-pack.view.json +45 -0
- package/views/task-views-pack.view.json +53 -0
- package/src/agent-fleet.yaml +0 -1645
- package/src/business-developer.template.test.ts +0 -351
- package/src/business-developer.yaml +0 -860
- package/src/content-studio.yaml +0 -361
- package/src/dev-dashboard.yaml +0 -990
- package/src/outreach-comms.yaml +0 -486
- package/src/the-arch.yaml +0 -201
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM messages and follow-up cadence (founder decisions of 2026-10-27, contract
|
|
3
|
+
* lanes/_briefs/crm-messages-cadence-2026-10-27.md, pod session ba6f39c8),
|
|
4
|
+
* pinned against the REAL YAML corpus. The CRM app shows the same words:
|
|
5
|
+
*
|
|
6
|
+
* - CADENCE: 3 days after the first message, 7 days after each follow-up, at
|
|
7
|
+
* most two follow-ups, never a third; ONE paragraph, word for word the same
|
|
8
|
+
* in every process that sets the next follow-up after a send;
|
|
9
|
+
* - DRAFT RULES (one send door, founder decisions d1/d5/d6 of 2026-11-02,
|
|
10
|
+
* session d00cd2ac): a draft is a proposed message in the person's
|
|
11
|
+
* channel, filed through the pod's `messaging.send` with `to` = their
|
|
12
|
+
* entity id; never a tool's own send verb, never an outreach touch, never
|
|
13
|
+
* synap_capture; ONE paragraph, word for word, in every drafter;
|
|
14
|
+
* - the CADENCE counts the outbound messages in the lead's channels, and a
|
|
15
|
+
* reply (an inbound message after our last outbound one) is read by
|
|
16
|
+
* "Follow up on leads in outreach";
|
|
17
|
+
* - "Read the reply to an outreach message" (the touches marked sent before
|
|
18
|
+
* the channels) reads silence by the same dates and proposes nurturing
|
|
19
|
+
* after the second follow-up;
|
|
20
|
+
* - WHOSE MESSAGE (founder D3.1, wave 2 lane E2): a touch still belongs to
|
|
21
|
+
* the lead it `relates_to` (REQUIRED link) until the touches are moved
|
|
22
|
+
* into their channels; a proposed message belongs to the channel of the
|
|
23
|
+
* person written to, so a drafter's output writes no edge.
|
|
24
|
+
*
|
|
25
|
+
* Does NOT cover what an agent does at run time (the goals are prose).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it } from "vitest";
|
|
29
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
30
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
|
|
34
|
+
const unipile = JSON.parse(
|
|
35
|
+
readFileSync(join(import.meta.dirname, "..", "capabilities", "unipile-linkedin.capability.json"), "utf8"),
|
|
36
|
+
) as { spec: { skills: Array<{ name: string }> } };
|
|
37
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
38
|
+
const crm = corpus.find((x) => x.meta.slug === "crm")!;
|
|
39
|
+
const playbookOf = (name: string) => {
|
|
40
|
+
const pb = crm.playbooks?.find((p) => p.name === name);
|
|
41
|
+
if (!pb) throw new Error(`playbook "${name}" not in crm`);
|
|
42
|
+
return pb;
|
|
43
|
+
};
|
|
44
|
+
const flat = (s: string | undefined) => (s ?? "").replace(/\s+/g, " ");
|
|
45
|
+
const goalOf = (name: string) => flat(playbookOf(name).goalTemplate);
|
|
46
|
+
const stageOf = (name: string, key: string) => {
|
|
47
|
+
const st = playbookOf(name).stages?.find((s) => s.key === key);
|
|
48
|
+
if (!st) throw new Error(`stage "${key}" not in "${name}"`);
|
|
49
|
+
return st;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const CADENCE =
|
|
53
|
+
'CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead\'s lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead\'s next action: after the first message, nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days, and when that date passes with no reply, "Follow up on leads in outreach" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and "Follow up on leads in outreach" reads it and proposes the next step.';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The LinkedIn order (CRM wave 2, S4b, build map 6f6c197f; changed by the CRM
|
|
57
|
+
* Messages contract b4f8c2cd, decision b603d75e, build map ca310f27): a
|
|
58
|
+
* LinkedIn message is drafted WHATEVER the person's reach. The pod holds a
|
|
59
|
+
* first message to someone not connected when it is filed (backend#818,
|
|
60
|
+
* `data.held`) instead of refusing it (backend#786), so a drafter that held a
|
|
61
|
+
* draft back left the person with nothing to see. Find the profile and ask to
|
|
62
|
+
* connect still go alongside the draft. Part of the DRAFT RULES paragraph, so
|
|
63
|
+
* it is word for word the same in every drafter.
|
|
64
|
+
*/
|
|
65
|
+
const LINKEDIN_ORDER =
|
|
66
|
+
'A LinkedIn message is drafted whatever their reach: the pod holds a draft to someone who is not a connection yet, shows it with the next step and lets it go out once they connect, so never hold a draft back for want of a connection. Draft it, then, in this order: (1) a person with no linkedinUrl: find their profile with linkedin_find_profile (their full name and company); on a match, propose its linkedinUrl and linkedinProviderId on the person for a person to approve; on none or several, ask the person to choose among its candidates (the process "Find their LinkedIn" does this for one lead). (2) Read their linkedinConnection, which only the pod writes (never set it or linkedinInvitedAt): connected, nothing more; none or not set, ask to connect with linkedin_send_invite (it refuses someone already connected or already invited: then nothing more); pending, the draft waits for them; ignored, draft by email instead when they have an email address. While the draft waits for them to connect, set the lead\'s nextAction to "Wait for <name> to accept the LinkedIn request" and its nextActionAt 7 days later. An email message needs none of this.';
|
|
67
|
+
|
|
68
|
+
const DRAFT_RULES =
|
|
69
|
+
"DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. " +
|
|
70
|
+
LINKEDIN_ORDER;
|
|
71
|
+
|
|
72
|
+
/** What a drafter yields: a `messaging.external.send` proposal (target type `messaging`). */
|
|
73
|
+
const PROPOSED_MESSAGE = "messaging";
|
|
74
|
+
const outputsOf = (p: NonNullable<WorkspaceYaml["playbooks"]>[number]) => [
|
|
75
|
+
...(p.expectedOutputs ?? []),
|
|
76
|
+
...(p.stages ?? []).flatMap((st) => st.expectedOutputs ?? []),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const SENDERS = ["Draft a first message", "Follow up on leads in outreach", "Personalized Outreach"];
|
|
80
|
+
const DRAFTERS = ["Draft a first message", "Follow up on leads in outreach", "Personalized Outreach"];
|
|
81
|
+
|
|
82
|
+
describe("CRM messages and cadence — catalog contract", () => {
|
|
83
|
+
it("one CADENCE paragraph, word for word, in every process that sets the follow-up after a send", () => {
|
|
84
|
+
for (const name of SENDERS) expect(goalOf(name), name).toContain(CADENCE);
|
|
85
|
+
// Only these: a fourth copy would be a second wording to keep in step.
|
|
86
|
+
const holders = (crm.playbooks ?? []).filter((p) => flat(p.goalTemplate).includes("CADENCE:")).map((p) => p.name);
|
|
87
|
+
expect(holders.sort()).toEqual([...SENDERS].sort());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("no CRM process keeps the old weekly cadence or the 14-day silence default", () => {
|
|
91
|
+
const texts = (crm.playbooks ?? []).flatMap((p) => [
|
|
92
|
+
[p.name, flat(p.goalTemplate)],
|
|
93
|
+
...(p.stages ?? []).flatMap((s) => [[`${p.name} › ${s.key}`, flat(s.goal)], ...(s.criteria ?? []).map((c) => [`${p.name} › ${c.key}`, flat(c.statement)])]),
|
|
94
|
+
]);
|
|
95
|
+
const stale = texts.filter(([, t]) => /a week later|a week forward|14 days when it has none|polite last message/i.test(t!));
|
|
96
|
+
expect(stale.map(([w]) => w)).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("one DRAFT RULES paragraph in every drafter: messaging.send to the person, never a tool's send verb, an outreach touch or synap_capture", () => {
|
|
100
|
+
for (const name of DRAFTERS) expect(goalOf(name), name).toContain(DRAFT_RULES);
|
|
101
|
+
// Every process that drafts a proposed message is a drafter, and holds it.
|
|
102
|
+
const drafting = (crm.playbooks ?? []).filter((p) => outputsOf(p).some((o) => o.kind === PROPOSED_MESSAGE)).map((p) => p.name);
|
|
103
|
+
expect(drafting.sort()).toEqual([...DRAFTERS].sort());
|
|
104
|
+
const holders = (crm.playbooks ?? []).filter((p) => flat(p.goalTemplate).includes("DRAFT RULES:")).map((p) => p.name);
|
|
105
|
+
expect(holders.sort()).toEqual([...DRAFTERS].sort());
|
|
106
|
+
for (const part of [
|
|
107
|
+
"filed through the pod's send door: run messaging.send with to = that person's entity id",
|
|
108
|
+
"it goes out only once they approve it",
|
|
109
|
+
"Never call a tool's own send verb",
|
|
110
|
+
"never create an outreach touch",
|
|
111
|
+
"never use synap_capture",
|
|
112
|
+
"never the company's website, domain or email",
|
|
113
|
+
])
|
|
114
|
+
expect(DRAFT_RULES).toContain(part);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("'Draft a first message': send it to <name> today, then follow up 3 days after the send", () => {
|
|
118
|
+
const name = "Draft a first message";
|
|
119
|
+
expect(goalOf(name)).toContain('"Send the first message to <name> on LinkedIn"');
|
|
120
|
+
expect(flat(stageOf(name, "draft").goal)).toMatch(/as a proposed message in their channel through messaging\.send \(to = their entity id, never a tool's own send verb, never an outreach touch or synap_capture\)/);
|
|
121
|
+
const criteria = (stageOf(name, "draft").criteria ?? []).map((c) => [c.key, flat(c.statement)] as const);
|
|
122
|
+
expect(criteria.length).toBeLessThanOrEqual(5);
|
|
123
|
+
expect(Object.fromEntries(criteria)["first-message-one-draft"]).toMatch(/filed through messaging\.send to the person written to .* waits for review in that person's channel/);
|
|
124
|
+
expect(Object.fromEntries(criteria)["first-message-no-capture"]).toMatch(/send verb.*outreach touch.*synap_capture.*never the company's website, domain or email/);
|
|
125
|
+
expect(Object.fromEntries(criteria)["first-message-next-action"]).toContain('"Send the first message to <name>"');
|
|
126
|
+
expect(flat(stageOf(name, "send").goal)).toContain('nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("'Follow up on leads in outreach': at most two follow-ups, 7 days after each, never a third", () => {
|
|
130
|
+
const name = "Follow up on leads in outreach";
|
|
131
|
+
expect(goalOf(name)).toContain("Draft at most two follow-ups per lead: a lead already sent two follow-ups gets no draft, never a third follow-up");
|
|
132
|
+
expect(flat(stageOf(name, "draft").goal)).toMatch(/or was already sent two follow-ups, gets none/);
|
|
133
|
+
const criteria = Object.fromEntries((stageOf(name, "draft").criteria ?? []).map((c) => [c.key, flat(c.statement)]));
|
|
134
|
+
expect(Object.keys(criteria).length).toBeLessThanOrEqual(5);
|
|
135
|
+
expect(criteria["followup-no-draft-after-reply"]).toMatch(/nor a third follow-up/);
|
|
136
|
+
expect(criteria["followup-draft-rules"]).toMatch(/proposed message filed through messaging\.send to the person written to .*no tool's own send verb .*outreach touch.*synap_capture/);
|
|
137
|
+
const send = flat(stageOf(name, "send").goal);
|
|
138
|
+
expect(send).toContain('after the first follow-up, nextAction "Follow up with <name> a last time if no reply" and nextActionAt the sent day + 7 days');
|
|
139
|
+
expect(send).toContain('after the second follow-up, nextAction "No reply after two follow-ups: nurture or drop <name>" and nextActionAt the sent day + 7 days');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("CADENCE counts a lead's messages in its channels, never in outreach touches", () => {
|
|
143
|
+
expect(CADENCE).toContain("count the messages sent to the lead (the outbound messages in its channels");
|
|
144
|
+
expect(CADENCE).toContain("a reply is an inbound message after our last outbound one in the channel");
|
|
145
|
+
// The old rules (touches with a sent date; sent to the lead or its decider; a
|
|
146
|
+
// person marking a message sent) are gone from every CRM text.
|
|
147
|
+
const texts = (crm.playbooks ?? []).flatMap((p) => [flat(p.goalTemplate), ...(p.stages ?? []).map((s) => flat(s.goal))]);
|
|
148
|
+
expect(texts.length).toBeGreaterThan(20);
|
|
149
|
+
expect(texts.filter((t) => /sent to it or its decider|outreach touches with a sent date|person marks a message sent/.test(t))).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("the touch kind declares relates_to a lead (person or company) as its REQUIRED link, and only that", () => {
|
|
153
|
+
const links = crm.entityLinks ?? [];
|
|
154
|
+
const touchLinks = links.filter((l) => l.sourceProfileSlug === "outreach-touch");
|
|
155
|
+
// Non-vacuity: the touch's other links (sent_to, part_of) are seen too.
|
|
156
|
+
expect(touchLinks.map((l) => l.type).sort()).toEqual(["part_of", "relates_to", "relates_to", "sent_to"]);
|
|
157
|
+
const required = links.filter((l) => l.required === true).map((l) => `${l.sourceProfileSlug} -${l.type}-> ${l.targetProfileSlug}`);
|
|
158
|
+
expect(required.sort()).toEqual(["outreach-touch -relates_to-> company", "outreach-touch -relates_to-> person"]);
|
|
159
|
+
// The lead role is worn by a person or a company: the rule's targets are both.
|
|
160
|
+
for (const l of touchLinks.filter((x) => x.type === "relates_to")) expect(l.required).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("every drafter yields proposed messages, which write no edge, and none yields an outreach touch", () => {
|
|
164
|
+
for (const name of DRAFTERS) {
|
|
165
|
+
const outputs = outputsOf(playbookOf(name));
|
|
166
|
+
expect(outputs.length, name).toBeGreaterThan(0);
|
|
167
|
+
// The channel is bound to the person already: a proposed message relates to nothing else.
|
|
168
|
+
for (const o of outputs) expect([o.kind, o.relationToSubject], `${name} › ${o.label}`).toEqual([PROPOSED_MESSAGE, "none"]);
|
|
169
|
+
}
|
|
170
|
+
// Decision d5: no CRM process drafts an outreach touch any more.
|
|
171
|
+
const touchMakers = (crm.playbooks ?? []).filter((p) => outputsOf(p).some((o) => o.kind === "outreach-touch")).map((p) => p.name);
|
|
172
|
+
expect(touchMakers).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("'Read the reply to an outreach message': silence is read by the cadence's dates, and nurturing follows the second follow-up", () => {
|
|
176
|
+
const name = "Read the reply to an outreach message";
|
|
177
|
+
const goal = goalOf(name);
|
|
178
|
+
expect(goal).toContain("the sent day + 3 days after the first message, the sent day + 7 days after each follow-up");
|
|
179
|
+
expect(goal).toMatch(/When this message is the second follow-up .* mark it no response and propose moving the lead to nurturing/);
|
|
180
|
+
expect(goal).not.toMatch(/14 days/);
|
|
181
|
+
expect(flat(stageOf(name, "no-response").goal)).toMatch(/after the second follow-up propose moving the lead to nurturing/);
|
|
182
|
+
});
|
|
183
|
+
it("the LinkedIn order: draft whatever their reach (the pod holds it), find the profile, ask to connect; the reach fields stay the pod's", () => {
|
|
184
|
+
for (const part of [
|
|
185
|
+
"A LinkedIn message is drafted whatever their reach",
|
|
186
|
+
"never hold a draft back for want of a connection",
|
|
187
|
+
"find their profile with linkedin_find_profile",
|
|
188
|
+
"propose its linkedinUrl and linkedinProviderId on the person for a person to approve",
|
|
189
|
+
"ask the person to choose among its candidates",
|
|
190
|
+
"never set it or linkedinInvitedAt",
|
|
191
|
+
"ask to connect with linkedin_send_invite",
|
|
192
|
+
"pending, the draft waits for them",
|
|
193
|
+
"ignored, draft by email instead",
|
|
194
|
+
"An email message needs none of this.",
|
|
195
|
+
])
|
|
196
|
+
expect(LINKEDIN_ORDER).toContain(part);
|
|
197
|
+
for (const name of DRAFTERS) {
|
|
198
|
+
expect(goalOf(name), name).toContain(LINKEDIN_ORDER);
|
|
199
|
+
// Decision b603d75e (B): a draft is never held back for want of a connection.
|
|
200
|
+
// (A reply still gets no draft: "With a reply, draft nothing" stays, it is not about reach.)
|
|
201
|
+
for (const gone of ["goes only to a connection", "linkedin_send_invite and draft nothing", "wait and draft nothing", "candidates, or stop"])
|
|
202
|
+
expect(goalOf(name), `${name}: ${gone}`).not.toContain(gone);
|
|
203
|
+
}
|
|
204
|
+
// Every catalog verb the order names is a verb of the Unipile capability:
|
|
205
|
+
// a renamed or removed verb would leave the drafters calling nothing.
|
|
206
|
+
const named = [...LINKEDIN_ORDER.matchAll(/\b(linkedin_[a-z_]+)\b/g)].map((m) => m[1]);
|
|
207
|
+
expect(named.sort()).toEqual(["linkedin_find_profile", "linkedin_send_invite"]);
|
|
208
|
+
const verbs = unipile.spec.skills.map((s) => s.name);
|
|
209
|
+
for (const v of named) expect(verbs, v).toContain(v);
|
|
210
|
+
// The system person kind owns the reach fields (backend#786, machine-written):
|
|
211
|
+
// the CRM never declares them, so no CRM editor offers them.
|
|
212
|
+
const person = crm.profiles?.find((p) => p.slug === "person");
|
|
213
|
+
expect(person, "crm declares the person overlay").toBeTruthy();
|
|
214
|
+
const slugs = (person?.properties ?? []).map((p) => p.slug);
|
|
215
|
+
expect(slugs).toContain("linkedinUrl");
|
|
216
|
+
expect(slugs).not.toContain("linkedinConnection");
|
|
217
|
+
expect(slugs).not.toContain("linkedinInvitedAt");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("'Find their LinkedIn': one lead, linkedin_find_profile, a proposal or up to 3 candidates, nothing sent", () => {
|
|
221
|
+
const name = "Find their LinkedIn";
|
|
222
|
+
const pb = playbookOf(name);
|
|
223
|
+
expect(pb.subjectProfile?.profileSlug).toBe("lead");
|
|
224
|
+
// The CRM app starts it for one lead, as "Draft a first message": params.lead.
|
|
225
|
+
expect(pb.params?.find((p) => p.name === "lead")).toMatchObject({ type: "entity", required: true });
|
|
226
|
+
expect(pb.params?.find((p) => p.name === "lead")).toEqual(playbookOf("Draft a first message").params?.find((p) => p.name === "lead"));
|
|
227
|
+
expect(pb.requiredIntents).toEqual(["enrich_entity"]);
|
|
228
|
+
const goal = goalOf(name);
|
|
229
|
+
expect(goal).toContain("run linkedin_find_profile with their full name, their company");
|
|
230
|
+
expect(goal).toContain("propose its linkedinUrl and linkedinProviderId on the person");
|
|
231
|
+
expect(goal).toContain("at most 3");
|
|
232
|
+
expect(goal).toContain("never set linkedinConnection or linkedinInvitedAt");
|
|
233
|
+
// A reader: it never asks to connect nor drafts (those are the other steps).
|
|
234
|
+
const named = [...goal.matchAll(/\b((?:linkedin|unipile)_[a-z_]+)\b/g)].map((m) => m[1]);
|
|
235
|
+
expect(named.sort()).toEqual(["linkedin_find_profile", "unipile_list_accounts"]);
|
|
236
|
+
const verbs = unipile.spec.skills.map((s) => s.name);
|
|
237
|
+
for (const v of named) expect(verbs, v).toContain(v);
|
|
238
|
+
// The verb it runs answers the intent it requires.
|
|
239
|
+
const find = (unipile.spec.skills as Array<{ name: string; intent?: string }>).find((s) => s.name === "linkedin_find_profile");
|
|
240
|
+
expect(find?.intent).toBe("enrich_entity");
|
|
241
|
+
// The person decides what lands on the person.
|
|
242
|
+
expect(stageOf(name, "confirm").gate).toEqual({ kind: "human" });
|
|
243
|
+
// The drafters point at it by its exact name.
|
|
244
|
+
expect(LINKEDIN_ORDER).toContain(`the process "${name}"`);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One send door (founder decisions d1, d5, d6 of 2026-11-02, pod session
|
|
3
|
+
* d00cd2ac), pinned against the REAL YAML corpus:
|
|
4
|
+
*
|
|
5
|
+
* - every CRM process that writes to a person files a proposed message
|
|
6
|
+
* through the pod's `messaging.send` (a `messaging.external.send` proposal
|
|
7
|
+
* on the person's channel), and none names a tool's own send verb;
|
|
8
|
+
* - the CRM's "LinkedIn conversation ingest" automation is gone: the pod's
|
|
9
|
+
* messaging webhook and its history import record LinkedIn messages in the
|
|
10
|
+
* channels (backend channels release). It also filtered accounts on
|
|
11
|
+
* `item.type == 'LINKEDIN'`, which Unipile v2 rows never carry.
|
|
12
|
+
*
|
|
13
|
+
* The paragraphs themselves (DRAFT RULES, CADENCE) are pinned word for word in
|
|
14
|
+
* crm-messages-cadence.template.test.ts; the Unipile verbs' intents in
|
|
15
|
+
* send-message-maps.test.ts.
|
|
16
|
+
*
|
|
17
|
+
* Does NOT cover what an agent does at run time (the goals are prose), nor the
|
|
18
|
+
* pod serving `messaging.send` (backend).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
23
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
24
|
+
|
|
25
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
26
|
+
const crm = corpus.find((x) => x.meta.slug === "crm")!;
|
|
27
|
+
const flat = (s: string | undefined) => (s ?? "").replace(/\s+/g, " ");
|
|
28
|
+
/** Every text an agent reads in a playbook: goal, description, stage goals, criteria. */
|
|
29
|
+
const textsOf = (p: NonNullable<WorkspaceYaml["playbooks"]>[number]) => [
|
|
30
|
+
flat(p.goalTemplate),
|
|
31
|
+
flat(p.description),
|
|
32
|
+
...(p.stages ?? []).flatMap((s) => [flat(s.goal), ...(s.criteria ?? []).map((c) => flat(c.statement))]),
|
|
33
|
+
];
|
|
34
|
+
/** The catalog's own send verbs (Unipile, Gmail, Slack): agent tool calls, never a process's door. */
|
|
35
|
+
const TOOL_SEND_VERBS = /\b(unipile_send_message|unipile_start_chat|linkedin_send_first_message|gmail_send|slack_send_message)\b/;
|
|
36
|
+
const automationKeys = (t: WorkspaceYaml) =>
|
|
37
|
+
((t as { automations?: Array<{ key?: string }> }).automations ?? []).map((a) => a.key);
|
|
38
|
+
|
|
39
|
+
describe("CRM — one send door", () => {
|
|
40
|
+
it("non-vacuity: the corpus and CRM's processes are read", () => {
|
|
41
|
+
expect(corpus.length).toBeGreaterThanOrEqual(25);
|
|
42
|
+
expect((crm.playbooks ?? []).length).toBeGreaterThanOrEqual(8);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("every CRM process that drafts a message files it through messaging.send", () => {
|
|
46
|
+
const drafters = (crm.playbooks ?? []).filter((p) => flat(p.goalTemplate).includes("DRAFT RULES:"));
|
|
47
|
+
expect(drafters.map((p) => p.name).sort()).toEqual(["Draft a first message", "Follow up on leads in outreach", "Personalized Outreach"]);
|
|
48
|
+
for (const p of drafters) expect(flat(p.goalTemplate), p.name).toMatch(/run messaging\.send with to = that person's entity id/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("no process in any template names a tool's own send verb", () => {
|
|
52
|
+
const naming = corpus.flatMap((t) =>
|
|
53
|
+
(t.playbooks ?? []).filter((p) => textsOf(p).some((x) => TOOL_SEND_VERBS.test(x))).map((p) => `${t.meta.slug} › ${p.name}`)
|
|
54
|
+
);
|
|
55
|
+
expect(naming).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("the LinkedIn ingest automation is gone from every template", () => {
|
|
59
|
+
expect(automationKeys(crm)).not.toContain("linkedin-conversation-ingest");
|
|
60
|
+
const carrying = corpus.filter((t) => automationKeys(t).includes("linkedin-conversation-ingest")).map((t) => t.meta.slug);
|
|
61
|
+
expect(carrying).toEqual([]);
|
|
62
|
+
// Nothing else in the CRM still drives the old ingest verb.
|
|
63
|
+
expect(JSON.stringify(crm)).not.toMatch(/channel\.ingest|unipile_list_conversations|unipile_list_messages/);
|
|
64
|
+
});
|
|
65
|
+
});
|