@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.
- package/README.md +483 -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 +420 -0
- package/capabilities/telegram-bridge.capability.json +56 -0
- package/capabilities/treg.capability.json +102 -0
- package/capabilities/unipile-linkedin.capability.json +471 -0
- package/capabilities/vercel.capability.json +424 -0
- package/dist/define-CPS0n8BV.d.ts +1331 -0
- package/dist/index.d.ts +189 -900
- package/dist/index.js +24654 -21639
- 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 +41 -27
- package/packs/bookmark-views-pack.json +33 -0
- package/packs/crm-pack.json +76 -0
- package/packs/customer-profile.json +28 -0
- package/packs/deep-research.playbook.json +30 -0
- package/packs/event-views-pack.json +33 -0
- package/packs/habit-profile.json +27 -0
- package/packs/hr-pack.json +54 -0
- package/packs/invoice-profile.json +28 -0
- package/packs/knowledge-base-pack.json +56 -0
- package/packs/meeting-profile.json +29 -0
- package/packs/product-profile.json +30 -0
- package/packs/project-management-pack.json +49 -0
- package/packs/recipe-profile.json +29 -0
- package/packs/task-views-pack.json +39 -0
- 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 +99 -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 +135 -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.template.test.ts +25 -19
- package/src/base.yaml +95 -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 +798 -92
- 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 +598 -0
- package/src/catalog-safety.test.ts +198 -0
- package/src/catalog.template-items.test.ts +75 -0
- package/src/catalog.ts +85 -2
- package/src/civic-france.yaml +102 -0
- 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 +322 -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 +1830 -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 +164 -0
- package/src/crm-one-send-door.template.test.ts +65 -0
- package/src/crm.yaml +1456 -181
- package/src/decisions-wave.template.test.ts +186 -0
- package/src/define.ts +130 -17
- package/src/design-to-implementation.template.test.ts +94 -0
- package/src/dist-freshness.test.ts +97 -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 +130 -28
- 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 +57 -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 +65 -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 +83 -0
- package/src/retired-goal-kinds.test.ts +58 -0
- package/src/role-reference-and-step-domain.test.ts +192 -0
- package/src/seed-obligation.test.ts +176 -0
- package/src/send-message-maps.test.ts +227 -0
- package/src/shared-role-ssot.test.ts +42 -1
- 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/task-intents.test.ts +293 -0
- package/src/templates.ts +21639 -21522
- package/src/types.ts +443 -60
- package/src/unipile-publish-post.test.ts +162 -0
- package/src/validate-flow.drift.test.ts +2 -3
- package/src/validate-flow.ts +32 -2
- package/src/validate-onboarding.test.ts +59 -0
- package/src/validate.test.ts +156 -3
- package/src/validate.ts +1384 -7
- package/src/wire-core.ts +231 -0
- package/src/wire.test.ts +159 -0
- package/src/wire.ts +107 -0
- package/src/__fixtures__/report-automation-flow.json +0 -393
- 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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static asset edit loop, W5 (build plan pod document ab3e0a5a §4, founder D0-D1, 2 Nov 2026).
|
|
3
|
+
* Pinned against the REAL YAML corpus and the real tombstone registry:
|
|
4
|
+
*
|
|
5
|
+
* 1. `content-studio` is no longer live, and `retired.yaml` points it at `content-os`, which
|
|
6
|
+
* declares every kind it held (content-composition, content-asset, render-job, the
|
|
7
|
+
* brand-template grant). No live template depends on it, and no standard targets it.
|
|
8
|
+
* 2. `brand-template`'s Format gains `layered` and `open-design` (the words content-os
|
|
9
|
+
* `render-engine` uses), additively, in Brand Library and in Content OS's mirror.
|
|
10
|
+
*
|
|
11
|
+
* Does NOT cover: the pod side (the resolver refuses a retired install and leaves an
|
|
12
|
+
* installed space alone, synap-backend resolve-workspace-template.ts), nor the CP row,
|
|
13
|
+
* which changes only when the catalog is published.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "fs";
|
|
17
|
+
import { dirname, join } from "path";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
21
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
22
|
+
import { loadRetired } from "../scripts/catalog-lib.mjs";
|
|
23
|
+
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
26
|
+
const template = (slug: string) => corpus.find((t) => t.meta.slug === slug);
|
|
27
|
+
const prop = (tpl: WorkspaceYaml | undefined, profile: string, slug: string) =>
|
|
28
|
+
tpl?.profiles.find((p) => p.slug === profile)?.properties.find((d) => d.slug === slug) as
|
|
29
|
+
| { enumValues?: string[] }
|
|
30
|
+
| undefined;
|
|
31
|
+
|
|
32
|
+
const FORMATS_BEFORE = ["tldraw", "html", "json", "markdown", "image", "mixed", "hyperframes"];
|
|
33
|
+
|
|
34
|
+
describe("content-studio is retired into content-os", () => {
|
|
35
|
+
it("non-vacuity: the corpus is read and content-os is live", () => {
|
|
36
|
+
expect(corpus.length).toBeGreaterThanOrEqual(25);
|
|
37
|
+
expect(template("content-os")).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("is no longer live, and retired.yaml points it at content-os", async () => {
|
|
41
|
+
expect(corpus.map((t) => t.meta.slug)).not.toContain("content-studio");
|
|
42
|
+
const { retired, problems } = await loadRetired();
|
|
43
|
+
expect(problems).toEqual([]);
|
|
44
|
+
expect(retired.find((t) => t.slug === "content-studio")).toMatchObject({
|
|
45
|
+
kind: "workspace",
|
|
46
|
+
replacedBy: "content-os",
|
|
47
|
+
since: "2026-11-03",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("content-os declares every kind content-studio held", () => {
|
|
52
|
+
const slugs = (template("content-os")?.profiles ?? []).map((p) => p.slug);
|
|
53
|
+
for (const kind of ["content-composition", "content-asset", "render-job", "brand-template"]) {
|
|
54
|
+
expect(slugs, `content-os must declare ${kind}`).toContain(kind);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("nothing live depends on it, and no standard targets it", () => {
|
|
59
|
+
const dependents = corpus.filter((t) =>
|
|
60
|
+
((t as { dependencies?: Array<{ slug: string }> }).dependencies ?? []).some((d) => d.slug === "content-studio")
|
|
61
|
+
);
|
|
62
|
+
expect(dependents.map((t) => t.meta.slug)).toEqual([]);
|
|
63
|
+
expect(existsSync(join(here, "../standards/content-studio.standard.yaml"))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("brand-template gains the layered and open-design formats", () => {
|
|
68
|
+
it("uses the words content-os render-engine uses", () => {
|
|
69
|
+
const engines = prop(template("content-os"), "content-composition", "render-engine")?.enumValues ?? [];
|
|
70
|
+
expect(engines).toEqual(expect.arrayContaining(["layered", "open-design"]));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("adds them without dropping a value, in Brand Library and Content OS's mirror", () => {
|
|
74
|
+
for (const slug of ["brand-library", "content-os"]) {
|
|
75
|
+
expect(prop(template(slug), "brand-template", "template-format")?.enumValues, slug).toEqual([
|
|
76
|
+
...FORMATS_BEFORE,
|
|
77
|
+
"layered",
|
|
78
|
+
"open-design",
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -85,10 +85,9 @@ test("corpus: every shipped automation flow passes validateFlowDefinition", () =
|
|
|
85
85
|
// These seeds ship automations verbatim into user pods, so they are held to the
|
|
86
86
|
// same contract. Skips when the CP checkout is absent, like the drift guards.
|
|
87
87
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
);
|
|
88
|
+
// The catalog's capability manifests (`spec` = the CP seed JSON; the CP keeps a
|
|
89
|
+
// byte-checked mirror until its pin bump). Always present: no skip.
|
|
90
|
+
const CP_SEEDS = join(HERE, "../capabilities");
|
|
92
91
|
|
|
93
92
|
test("corpus: every CP capability seed's automation flow passes validateFlowDefinition", (t) => {
|
|
94
93
|
if (!existsSync(CP_SEEDS)) {
|
|
@@ -99,7 +98,7 @@ test("corpus: every CP capability seed's automation flow passes validateFlowDefi
|
|
|
99
98
|
let flowsChecked = 0;
|
|
100
99
|
|
|
101
100
|
for (const file of readdirSync(CP_SEEDS).filter((f) => f.endsWith(".json"))) {
|
|
102
|
-
const seed = JSON.parse(readFileSync(join(CP_SEEDS, file), "utf8")) as {
|
|
101
|
+
const seed = (JSON.parse(readFileSync(join(CP_SEEDS, file), "utf8")) as { spec: unknown }).spec as {
|
|
103
102
|
automations?: Array<{ key?: string; name?: string; flowDefinition?: unknown }>;
|
|
104
103
|
};
|
|
105
104
|
for (const automation of seed.automations ?? []) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM absorbs Outreach & Comms (founder decision 2026-10-23: one space per
|
|
3
|
+
* domain; for a founder-led company outreach is part of the commercial job).
|
|
4
|
+
* Pinned against the REAL YAML corpus and the real tombstone registry:
|
|
5
|
+
*
|
|
6
|
+
* 1. `outreach-comms` is no longer live, and `retired.yaml` points it at `crm`;
|
|
7
|
+
* no live template still depends on it.
|
|
8
|
+
* 2. CRM owns `outreach-touch` with the SAME slug and lifecycle keys the CRM
|
|
9
|
+
* app reads, and `sent` is a person's move (the agent drafts, a person sends).
|
|
10
|
+
* 3. No twin: no template declares `outreach-campaign`; an outreach campaign is
|
|
11
|
+
* CRM's `campaign` (campaign-type outreach) carrying the angle's offer
|
|
12
|
+
* fields. Touch edges `sent_to` → person and `part_of` → campaign resolve.
|
|
13
|
+
* 4. No CRM process can send (no send grant, no send intent required) and none
|
|
14
|
+
* opens a deal from a reply; the reply reader proposes the estimate or a
|
|
15
|
+
* meeting and starts when a person marked the message sent (a touch from
|
|
16
|
+
* before the channels; a channel's replies are read by the follow-up).
|
|
17
|
+
*
|
|
18
|
+
* Does NOT cover: what an agent does at run time (the goals are prose), nor the
|
|
19
|
+
* pod applying the fold (no pod had outreach-comms installed; nothing migrates).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
24
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
25
|
+
import { loadRetired } from "../scripts/catalog-lib.mjs";
|
|
26
|
+
|
|
27
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
28
|
+
const crm = corpus.find((t) => t.meta.slug === "crm")!;
|
|
29
|
+
const profileOf = (slug: string) => crm.profiles.find((p) => p.slug === slug);
|
|
30
|
+
const playbookOf = (name: string) => {
|
|
31
|
+
const pb = crm.playbooks?.find((p) => p.name === name);
|
|
32
|
+
if (!pb) throw new Error(`playbook "${name}" not in crm`);
|
|
33
|
+
return pb;
|
|
34
|
+
};
|
|
35
|
+
const outputsOf = (pb: ReturnType<typeof playbookOf>) => [
|
|
36
|
+
...(pb.expectedOutputs ?? []),
|
|
37
|
+
...(pb.stages ?? []).flatMap((s) => s.expectedOutputs ?? []),
|
|
38
|
+
];
|
|
39
|
+
const SEND_GRANTS = ["unipile_send_message", "gmail_send", "linkedin_send_invite"];
|
|
40
|
+
|
|
41
|
+
describe("CRM absorbs Outreach & Comms", () => {
|
|
42
|
+
it("non-vacuity: the corpus is read and CRM has processes", () => {
|
|
43
|
+
expect(corpus.length).toBeGreaterThanOrEqual(25);
|
|
44
|
+
expect(crm).toBeDefined();
|
|
45
|
+
expect((crm.playbooks ?? []).length).toBeGreaterThanOrEqual(5);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("outreach-comms is retired to crm, and nothing live depends on it", async () => {
|
|
49
|
+
expect(corpus.map((t) => t.meta.slug)).not.toContain("outreach-comms");
|
|
50
|
+
const { retired, problems } = await loadRetired();
|
|
51
|
+
expect(problems).toEqual([]);
|
|
52
|
+
expect(retired.find((t) => t.slug === "outreach-comms")).toMatchObject({
|
|
53
|
+
kind: "workspace",
|
|
54
|
+
replacedBy: "crm",
|
|
55
|
+
since: "2026-10-23",
|
|
56
|
+
});
|
|
57
|
+
const dependents = corpus.filter((t) =>
|
|
58
|
+
((t as { dependencies?: Array<{ slug: string }> }).dependencies ?? []).some((d) => d.slug === "outreach-comms")
|
|
59
|
+
);
|
|
60
|
+
expect(dependents.map((t) => t.meta.slug)).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("CRM owns outreach-touch with the same lifecycle keys, and sending is a person's move", () => {
|
|
64
|
+
const touch = profileOf("outreach-touch");
|
|
65
|
+
expect(touch?.lifecycle?.property).toBe("status");
|
|
66
|
+
expect(touch?.lifecycle?.options.map((o) => [o.key, o.category, o.humanOnly ?? false])).toEqual([
|
|
67
|
+
["drafted", "open", false],
|
|
68
|
+
["sent", "active", true],
|
|
69
|
+
["opened", "active", false],
|
|
70
|
+
["replied", "done", false],
|
|
71
|
+
["bounced", "lost", false],
|
|
72
|
+
["no_response", "lost", false],
|
|
73
|
+
]);
|
|
74
|
+
expect(crm.meta.intent?.produces?.map((p) => p.profile)).toContain("outreach-touch");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("no outreach-campaign twin: the angle is CRM's campaign, and the touch edges resolve", () => {
|
|
78
|
+
for (const t of corpus) expect(t.profiles.map((p) => p.slug), t.meta.slug).not.toContain("outreach-campaign");
|
|
79
|
+
const campaign = profileOf("campaign");
|
|
80
|
+
const props = (campaign?.properties ?? []).map((p) => p.slug);
|
|
81
|
+
expect(props).toEqual(expect.arrayContaining(["campaign-value-prop", "campaign-call-to-action"]));
|
|
82
|
+
expect(campaign?.properties.find((p) => p.slug === "campaign-type")?.enumValues).toContain("outreach");
|
|
83
|
+
const touchLinks = (crm.entityLinks ?? []).filter((l) => l.sourceProfileSlug === "outreach-touch");
|
|
84
|
+
expect(touchLinks.map((l) => [l.type, l.targetProfileSlug])).toEqual([
|
|
85
|
+
["sent_to", "person"],
|
|
86
|
+
["part_of", "campaign"],
|
|
87
|
+
// Whose message it is: the lead, a person or a company (required, lane E2).
|
|
88
|
+
["relates_to", "person"],
|
|
89
|
+
["relates_to", "company"],
|
|
90
|
+
]);
|
|
91
|
+
// A campaign reaches people through member_of, not a second `targets` edge.
|
|
92
|
+
expect((crm.entityLinks ?? []).filter((l) => l.sourceProfileSlug === "campaign")).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("no CRM process sends, and none opens a deal from a reply", () => {
|
|
96
|
+
for (const pb of crm.playbooks ?? []) {
|
|
97
|
+
const grants = (pb as { grants?: string[] }).grants ?? [];
|
|
98
|
+
expect(grants.filter((g) => SEND_GRANTS.includes(g)), pb.name).toEqual([]);
|
|
99
|
+
expect(pb.requiredIntents ?? [], pb.name).not.toContain("send_message");
|
|
100
|
+
}
|
|
101
|
+
const reply = playbookOf("Read the reply to an outreach message");
|
|
102
|
+
expect(reply.subjectProfile?.profileSlug).toBe("outreach-touch");
|
|
103
|
+
expect(reply.activators).toEqual([{ on: "enters_status", status: "sent", mode: "run" }]);
|
|
104
|
+
expect(outputsOf(reply).map((o) => o.kind)).not.toContain("deal");
|
|
105
|
+
expect(reply.goalTemplate).toMatch(/estimate/);
|
|
106
|
+
expect(reply.goalTemplate).toMatch(/meeting/);
|
|
107
|
+
expect(reply.goalTemplate).not.toMatch(/(?<!never )open a deal/i);
|
|
108
|
+
// The run starts at `sent`: no stage may move the message back to drafted or sent.
|
|
109
|
+
expect((reply.stages ?? []).map((s) => s.subjectStatus).filter(Boolean)).toEqual(["replied", "bounced", "no_response"]);
|
|
110
|
+
|
|
111
|
+
const outreach = playbookOf("Personalized Outreach");
|
|
112
|
+
expect(outreach.subjectProfile).toMatchObject({ profileSlug: "campaign", statusProperty: "campaign-status" });
|
|
113
|
+
expect(outputsOf(outreach)).toEqual([
|
|
114
|
+
{ kind: "messaging", label: "Proposed first messages", relationToSubject: "none" },
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CRM build plan's catalog changes (pod document a684720a, section 3A,
|
|
3
|
+
* founder-approved 2026-10-22), pinned against the REAL YAML corpus and the
|
|
4
|
+
* real validator:
|
|
5
|
+
*
|
|
6
|
+
* A1 the lead role's leadSource names warm-network, research, import, inbound-form;
|
|
7
|
+
* A2 the deal's first stages read "Meeting booked" / "Proposal sent", and every
|
|
8
|
+
* stage still open is stuck after 14 days;
|
|
9
|
+
* A3 the deal carries the booked meeting's date;
|
|
10
|
+
* A5 "Build a prospect list from our foundation": ONE review per batch, and the
|
|
11
|
+
* next batch reads back the candidates a person dropped;
|
|
12
|
+
* A6 "Follow up on leads in outreach" is the one follow-up process and drafts
|
|
13
|
+
* proposed messages through the pod's send door (2026-11-02; outreach
|
|
14
|
+
* touches before); no outreach process opens a deal (Outreach & Comms'
|
|
15
|
+
* "Send an outreach message" is CRM's "Read the reply to an outreach
|
|
16
|
+
* message" since the fold of 2026-10-23, pinned in
|
|
17
|
+
* crm-absorbs-outreach.template.test.ts);
|
|
18
|
+
* A7 "Prepare a pre-sale estimate": the owner approves, then a SENT step records
|
|
19
|
+
* date and channel on the lead;
|
|
20
|
+
* A8 a lead's segment is a `targets` edge to an audience.
|
|
21
|
+
* A4 ("Qualify a lead" opens no deal) is pinned in decisions-wave.template.test.ts.
|
|
22
|
+
*
|
|
23
|
+
* Does NOT cover: what an agent does at run time (the goals are prose), and A9
|
|
24
|
+
* (CRM referencing `contact`), which waits on the pod granting a system role.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, expect, it } from "vitest";
|
|
28
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
29
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
30
|
+
import { validateTemplate } from "./validate.js";
|
|
31
|
+
|
|
32
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
33
|
+
const tpl = (slug: string) => {
|
|
34
|
+
const t = corpus.find((x) => x.meta.slug === slug);
|
|
35
|
+
if (!t) throw new Error(`template ${slug} not in the corpus`);
|
|
36
|
+
return t;
|
|
37
|
+
};
|
|
38
|
+
const profileOf = (t: WorkspaceYaml, slug: string) => t.profiles.find((p) => p.slug === slug);
|
|
39
|
+
const propOf = (t: WorkspaceYaml, profile: string, prop: string) =>
|
|
40
|
+
profileOf(t, profile)?.properties.find((p) => p.slug === prop);
|
|
41
|
+
const playbookOf = (t: WorkspaceYaml, name: string) => {
|
|
42
|
+
const pb = t.playbooks?.find((p) => p.name === name);
|
|
43
|
+
if (!pb) throw new Error(`playbook "${name}" not in ${t.meta.slug}`);
|
|
44
|
+
return pb;
|
|
45
|
+
};
|
|
46
|
+
const outputsOf = (pb: ReturnType<typeof playbookOf>) => [
|
|
47
|
+
...(pb.expectedOutputs ?? []),
|
|
48
|
+
...(pb.stages ?? []).flatMap((s) => s.expectedOutputs ?? []),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const crm = tpl("crm");
|
|
52
|
+
|
|
53
|
+
describe("CRM build plan — catalog changes", () => {
|
|
54
|
+
it("crm validates against the corpus", () => {
|
|
55
|
+
expect(validateTemplate(crm, { corpus }).errors).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("A1: the lead role's leadSource names the four new sources", () => {
|
|
59
|
+
const values = propOf(crm, "lead", "leadSource")?.enumValues ?? [];
|
|
60
|
+
for (const v of ["warm-network", "research", "import", "inbound-form"]) expect(values).toContain(v);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("A2: deal stages read Meeting booked / Proposal sent, and every unfinished stage is stuck after 14 days", () => {
|
|
64
|
+
const options = profileOf(crm, "deal")?.lifecycle?.options ?? [];
|
|
65
|
+
expect(options.map((o) => [o.key, o.label, o.stuckAfterDays])).toEqual([
|
|
66
|
+
["draft", "Meeting booked", 14],
|
|
67
|
+
["proposed", "Proposal sent", 14],
|
|
68
|
+
["negotiating", undefined, 14],
|
|
69
|
+
["won", undefined, undefined],
|
|
70
|
+
["lost", undefined, undefined],
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("A3: the deal records the booked meeting's date", () => {
|
|
75
|
+
expect(propOf(crm, "deal", "meetingDate")).toMatchObject({ valueType: "date", inputType: "date" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("A5: the prospect list is one review per batch, reads back dropped candidates, and creates leads only there", () => {
|
|
79
|
+
const pb = playbookOf(crm, "Build a prospect list from our foundation");
|
|
80
|
+
expect(pb.subjectProfile?.profileSlug).toBe("audience");
|
|
81
|
+
// No stage gate asks the person a second time: the batch's proposal is the review.
|
|
82
|
+
expect((pb.stages ?? []).filter((s) => s.gate)).toEqual([]);
|
|
83
|
+
expect(pb.goalTemplate).toMatch(/ONE proposal/);
|
|
84
|
+
expect(pb.goalTemplate).toMatch(/dropped/);
|
|
85
|
+
expect(pb.goalTemplate).toMatch(/lead source\s+research/);
|
|
86
|
+
expect(outputsOf(pb)).toEqual([{ kind: "company", label: "Prospect accounts", relationToSubject: "targets" }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("A6: one follow-up process drafts proposed messages on weekdays; no outreach process opens a deal", () => {
|
|
90
|
+
const pb = playbookOf(crm, "Follow up on leads in outreach");
|
|
91
|
+
expect(pb.subjectProfile).toMatchObject({ profileSlug: "lead", statusProperty: "leadStage" });
|
|
92
|
+
expect(pb.activators).toContainEqual({ on: "schedule", cron: "0 7 * * 1-5", status: "outreach", mode: "propose" });
|
|
93
|
+
expect(outputsOf(pb).map((o) => o.kind)).toEqual(["messaging"]);
|
|
94
|
+
expect(pb.stages?.at(-1)).toMatchObject({ key: "send", gate: { kind: "human" } });
|
|
95
|
+
|
|
96
|
+
const followUps = corpus.flatMap((t) => (t.playbooks ?? []).filter((p) => /follow.?up/i.test(p.name)));
|
|
97
|
+
expect(followUps.map((p) => p.name)).toEqual(["Follow up on leads in outreach"]);
|
|
98
|
+
|
|
99
|
+
const reply = playbookOf(crm, "Read the reply to an outreach message");
|
|
100
|
+
expect(outputsOf(reply).map((o) => o.kind)).not.toContain("deal");
|
|
101
|
+
expect(reply.goalTemplate).not.toMatch(/(?<!never )open a deal/i);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("A7: the estimate is approved by the owner, then a SENT step records date and channel on the lead", () => {
|
|
105
|
+
const pb = playbookOf(crm, "Prepare a pre-sale estimate");
|
|
106
|
+
expect(pb.subjectProfile?.profileSlug).toBe("lead");
|
|
107
|
+
expect((pb.stages ?? []).map((s) => [s.key, s.gate?.kind])).toEqual([
|
|
108
|
+
["research", undefined],
|
|
109
|
+
["draft", undefined],
|
|
110
|
+
["approve", "human"],
|
|
111
|
+
["sent", "human"],
|
|
112
|
+
]);
|
|
113
|
+
// A reader: an estimate never moves the lead's stage.
|
|
114
|
+
expect((pb.stages ?? []).filter((s) => s.subjectStatus)).toEqual([]);
|
|
115
|
+
expect(propOf(crm, "lead", "estimateSentAt")).toMatchObject({ valueType: "date" });
|
|
116
|
+
expect(propOf(crm, "lead", "estimateSentChannel")?.inputType).toBe("select");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("A8: a lead's segment is a targets edge from the person or company to an audience", () => {
|
|
120
|
+
const links = (crm.entityLinks ?? []).filter((l) => l.targetProfileSlug === "audience");
|
|
121
|
+
expect(links.map((l) => [l.sourceProfileSlug, l.type])).toEqual([
|
|
122
|
+
["person", "targets"],
|
|
123
|
+
["company", "targets"],
|
|
124
|
+
]);
|
|
125
|
+
// A reference to foundation's shared kind, so the CRM space is granted it.
|
|
126
|
+
expect(profileOf(crm, "audience")).toMatchObject({ scope: "shared", entityScope: "pod", properties: [] });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM lists, signal-first scores, Claude Code drafts (contract of 2026-10-27,
|
|
3
|
+
* founder decisions in pod session ba6f39c8), pinned against the REAL YAML
|
|
4
|
+
* corpus and the real validator. The CRM app codes against these names:
|
|
5
|
+
*
|
|
6
|
+
* - the lead role carries buyingSignal / buyingSignalSource / scoredAt, and
|
|
7
|
+
* icpScore reads 1–5;
|
|
8
|
+
* - an outreach touch can be a first_message;
|
|
9
|
+
* - "Score a list" (subject audience) is the ONE home of the rubric;
|
|
10
|
+
* - "Draft a first message" (subject lead) drafts one touch and never sends;
|
|
11
|
+
* - "Build a prospect list from our foundation" takes pool public|network.
|
|
12
|
+
* "Qualify a lead" being manual only is pinned in decisions-wave.template.test.ts.
|
|
13
|
+
*
|
|
14
|
+
* Does NOT cover what an agent does at run time (the goals are prose).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
19
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
20
|
+
import { validateTemplate } from "./validate.js";
|
|
21
|
+
|
|
22
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
23
|
+
const crm = corpus.find((x) => x.meta.slug === "crm")!;
|
|
24
|
+
const profileOf = (slug: string) => crm.profiles.find((p) => p.slug === slug);
|
|
25
|
+
const propOf = (profile: string, prop: string) => profileOf(profile)?.properties.find((p) => p.slug === prop);
|
|
26
|
+
const playbookOf = (name: string) => {
|
|
27
|
+
const pb = crm.playbooks?.find((p) => p.name === name);
|
|
28
|
+
if (!pb) throw new Error(`playbook "${name}" not in crm`);
|
|
29
|
+
return pb;
|
|
30
|
+
};
|
|
31
|
+
const paramsOf = (name: string) =>
|
|
32
|
+
(playbookOf(name).params ?? []).map((p) => [p.name, p.type, p.required ?? false, (p as { defaultValue?: unknown }).defaultValue]);
|
|
33
|
+
|
|
34
|
+
describe("CRM lists — catalog contract", () => {
|
|
35
|
+
it("crm validates against the corpus", () => {
|
|
36
|
+
expect(validateTemplate(crm, { corpus }).errors).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("the lead role carries the signal fields, and icpScore reads 1–5", () => {
|
|
40
|
+
expect(propOf("lead", "buyingSignal")).toMatchObject({ valueType: "string", inputType: "select", enumValues: ["hiring", "asking", "tooling", "none"] });
|
|
41
|
+
expect(propOf("lead", "buyingSignalSource")).toMatchObject({ valueType: "string", inputType: "url" });
|
|
42
|
+
expect(propOf("lead", "scoredAt")).toMatchObject({ valueType: "date", inputType: "date" });
|
|
43
|
+
expect(propOf("lead", "icpScore")?.label).toMatch(/1–5/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("an outreach touch can be a first message", () => {
|
|
47
|
+
expect(propOf("outreach-touch", "touchType")?.enumValues).toEqual(["first_message", "connection_request", "follow_up", "intro_email", "reply"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("'Score a list' scores one audience's leads in one manual run and is the rubric's one home", () => {
|
|
51
|
+
const pb = playbookOf("Score a list");
|
|
52
|
+
expect(pb.subjectProfile).toEqual({ profileSlug: "audience" });
|
|
53
|
+
expect(pb.activators).toEqual([{ on: "manual", mode: "propose" }]);
|
|
54
|
+
expect(paramsOf("Score a list")).toEqual([
|
|
55
|
+
["audience", "entity", true, undefined],
|
|
56
|
+
["limit", "number", false, 50],
|
|
57
|
+
["rescoreAfterDays", "number", false, 30],
|
|
58
|
+
]);
|
|
59
|
+
// The audience has no lifecycle: no stage moves anything.
|
|
60
|
+
expect((pb.stages ?? []).filter((s) => s.subjectStatus || s.gate)).toEqual([]);
|
|
61
|
+
for (const part of [/SIGNAL, 0 to\s+2/, /FIT, 0\s+to 2/, /WARMTH, 0 or 1/, /within the last 90 days/])
|
|
62
|
+
expect(pb.goalTemplate).toMatch(part);
|
|
63
|
+
// ONE home: no other playbook restates the rubric.
|
|
64
|
+
const restating = corpus.flatMap((t) => (t.playbooks ?? []).filter((p) => p !== pb && /WARMTH, 0 or 1/.test(p.goalTemplate)));
|
|
65
|
+
expect(restating.map((p) => p.name)).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("'Draft a first message' drafts one proposed message through the send door for a person to approve, and reads the lead's lifecycle", () => {
|
|
69
|
+
const pb = playbookOf("Draft a first message");
|
|
70
|
+
expect(pb.subjectProfile).toEqual({ profileSlug: "lead" });
|
|
71
|
+
expect(pb.activators).toEqual([{ on: "manual", mode: "propose" }]);
|
|
72
|
+
expect(paramsOf("Draft a first message")).toEqual([
|
|
73
|
+
["lead", "entity", true, undefined],
|
|
74
|
+
["campaign", "entity", false, undefined],
|
|
75
|
+
["channel", "choice", false, "linkedin"],
|
|
76
|
+
]);
|
|
77
|
+
expect(pb.grants).toEqual(["entity.create"]);
|
|
78
|
+
expect((pb.stages ?? []).flatMap((s) => s.expectedOutputs ?? [])).toEqual([
|
|
79
|
+
{ kind: "messaging", label: "Proposed first message", relationToSubject: "none" },
|
|
80
|
+
]);
|
|
81
|
+
// A reader: no stage moves the lead; the person approves the send behind a human gate.
|
|
82
|
+
expect((pb.stages ?? []).filter((s) => s.subjectStatus)).toEqual([]);
|
|
83
|
+
expect(pb.stages?.at(-1)).toMatchObject({ key: "send", gate: { kind: "human" } });
|
|
84
|
+
expect(pb.goalTemplate).toMatch(/run messaging\.send with to = that person's entity id/);
|
|
85
|
+
expect(pb.goalTemplate).toMatch(/Never send\./);
|
|
86
|
+
expect(pb.goalTemplate).toMatch(/"Send the first message to\s+<name> on LinkedIn"/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("'Build a prospect list from our foundation' takes a pool, and the network pool makes warm-network leads", () => {
|
|
90
|
+
const pb = playbookOf("Build a prospect list from our foundation");
|
|
91
|
+
expect(pb.params?.find((p) => p.name === "pool")).toMatchObject({ type: "choice", options: ["public", "network"], defaultValue: "public" });
|
|
92
|
+
expect(pb.goalTemplate).toMatch(/\{pool\}/);
|
|
93
|
+
expect(pb.goalTemplate).toMatch(/lead source warm-network/);
|
|
94
|
+
expect(pb.goalTemplate).toMatch(/"Score\s+a list" process/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
|
|
32
|
+
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
33
|
+
const crm = corpus.find((x) => x.meta.slug === "crm")!;
|
|
34
|
+
const playbookOf = (name: string) => {
|
|
35
|
+
const pb = crm.playbooks?.find((p) => p.name === name);
|
|
36
|
+
if (!pb) throw new Error(`playbook "${name}" not in crm`);
|
|
37
|
+
return pb;
|
|
38
|
+
};
|
|
39
|
+
const flat = (s: string | undefined) => (s ?? "").replace(/\s+/g, " ");
|
|
40
|
+
const goalOf = (name: string) => flat(playbookOf(name).goalTemplate);
|
|
41
|
+
const stageOf = (name: string, key: string) => {
|
|
42
|
+
const st = playbookOf(name).stages?.find((s) => s.key === key);
|
|
43
|
+
if (!st) throw new Error(`stage "${key}" not in "${name}"`);
|
|
44
|
+
return st;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const CADENCE =
|
|
48
|
+
'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.';
|
|
49
|
+
|
|
50
|
+
const DRAFT_RULES =
|
|
51
|
+
"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.";
|
|
52
|
+
|
|
53
|
+
/** What a drafter yields: a `messaging.external.send` proposal (target type `messaging`). */
|
|
54
|
+
const PROPOSED_MESSAGE = "messaging";
|
|
55
|
+
const outputsOf = (p: NonNullable<WorkspaceYaml["playbooks"]>[number]) => [
|
|
56
|
+
...(p.expectedOutputs ?? []),
|
|
57
|
+
...(p.stages ?? []).flatMap((st) => st.expectedOutputs ?? []),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const SENDERS = ["Draft a first message", "Follow up on leads in outreach", "Personalized Outreach"];
|
|
61
|
+
const DRAFTERS = ["Draft a first message", "Follow up on leads in outreach", "Personalized Outreach"];
|
|
62
|
+
|
|
63
|
+
describe("CRM messages and cadence — catalog contract", () => {
|
|
64
|
+
it("one CADENCE paragraph, word for word, in every process that sets the follow-up after a send", () => {
|
|
65
|
+
for (const name of SENDERS) expect(goalOf(name), name).toContain(CADENCE);
|
|
66
|
+
// Only these: a fourth copy would be a second wording to keep in step.
|
|
67
|
+
const holders = (crm.playbooks ?? []).filter((p) => flat(p.goalTemplate).includes("CADENCE:")).map((p) => p.name);
|
|
68
|
+
expect(holders.sort()).toEqual([...SENDERS].sort());
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("no CRM process keeps the old weekly cadence or the 14-day silence default", () => {
|
|
72
|
+
const texts = (crm.playbooks ?? []).flatMap((p) => [
|
|
73
|
+
[p.name, flat(p.goalTemplate)],
|
|
74
|
+
...(p.stages ?? []).flatMap((s) => [[`${p.name} › ${s.key}`, flat(s.goal)], ...(s.criteria ?? []).map((c) => [`${p.name} › ${c.key}`, flat(c.statement)])]),
|
|
75
|
+
]);
|
|
76
|
+
const stale = texts.filter(([, t]) => /a week later|a week forward|14 days when it has none|polite last message/i.test(t!));
|
|
77
|
+
expect(stale.map(([w]) => w)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
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", () => {
|
|
81
|
+
for (const name of DRAFTERS) expect(goalOf(name), name).toContain(DRAFT_RULES);
|
|
82
|
+
// Every process that drafts a proposed message is a drafter, and holds it.
|
|
83
|
+
const drafting = (crm.playbooks ?? []).filter((p) => outputsOf(p).some((o) => o.kind === PROPOSED_MESSAGE)).map((p) => p.name);
|
|
84
|
+
expect(drafting.sort()).toEqual([...DRAFTERS].sort());
|
|
85
|
+
const holders = (crm.playbooks ?? []).filter((p) => flat(p.goalTemplate).includes("DRAFT RULES:")).map((p) => p.name);
|
|
86
|
+
expect(holders.sort()).toEqual([...DRAFTERS].sort());
|
|
87
|
+
for (const part of [
|
|
88
|
+
"filed through the pod's send door: run messaging.send with to = that person's entity id",
|
|
89
|
+
"it goes out only once they approve it",
|
|
90
|
+
"Never call a tool's own send verb",
|
|
91
|
+
"never create an outreach touch",
|
|
92
|
+
"never use synap_capture",
|
|
93
|
+
"never the company's website, domain or email",
|
|
94
|
+
])
|
|
95
|
+
expect(DRAFT_RULES).toContain(part);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("'Draft a first message': send it to <name> today, then follow up 3 days after the send", () => {
|
|
99
|
+
const name = "Draft a first message";
|
|
100
|
+
expect(goalOf(name)).toContain('"Send the first message to <name> on LinkedIn"');
|
|
101
|
+
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\)/);
|
|
102
|
+
const criteria = (stageOf(name, "draft").criteria ?? []).map((c) => [c.key, flat(c.statement)] as const);
|
|
103
|
+
expect(criteria.length).toBeLessThanOrEqual(5);
|
|
104
|
+
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/);
|
|
105
|
+
expect(Object.fromEntries(criteria)["first-message-no-capture"]).toMatch(/send verb.*outreach touch.*synap_capture.*never the company's website, domain or email/);
|
|
106
|
+
expect(Object.fromEntries(criteria)["first-message-next-action"]).toContain('"Send the first message to <name>"');
|
|
107
|
+
expect(flat(stageOf(name, "send").goal)).toContain('nextAction "Follow up with <name> if no reply" and nextActionAt the sent day + 3 days');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("'Follow up on leads in outreach': at most two follow-ups, 7 days after each, never a third", () => {
|
|
111
|
+
const name = "Follow up on leads in outreach";
|
|
112
|
+
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");
|
|
113
|
+
expect(flat(stageOf(name, "draft").goal)).toMatch(/or was already sent two follow-ups, gets none/);
|
|
114
|
+
const criteria = Object.fromEntries((stageOf(name, "draft").criteria ?? []).map((c) => [c.key, flat(c.statement)]));
|
|
115
|
+
expect(Object.keys(criteria).length).toBeLessThanOrEqual(5);
|
|
116
|
+
expect(criteria["followup-no-draft-after-reply"]).toMatch(/nor a third follow-up/);
|
|
117
|
+
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/);
|
|
118
|
+
const send = flat(stageOf(name, "send").goal);
|
|
119
|
+
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');
|
|
120
|
+
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');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("CADENCE counts a lead's messages in its channels, never in outreach touches", () => {
|
|
124
|
+
expect(CADENCE).toContain("count the messages sent to the lead (the outbound messages in its channels");
|
|
125
|
+
expect(CADENCE).toContain("a reply is an inbound message after our last outbound one in the channel");
|
|
126
|
+
// The old rules (touches with a sent date; sent to the lead or its decider; a
|
|
127
|
+
// person marking a message sent) are gone from every CRM text.
|
|
128
|
+
const texts = (crm.playbooks ?? []).flatMap((p) => [flat(p.goalTemplate), ...(p.stages ?? []).map((s) => flat(s.goal))]);
|
|
129
|
+
expect(texts.length).toBeGreaterThan(20);
|
|
130
|
+
expect(texts.filter((t) => /sent to it or its decider|outreach touches with a sent date|person marks a message sent/.test(t))).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("the touch kind declares relates_to a lead (person or company) as its REQUIRED link, and only that", () => {
|
|
134
|
+
const links = crm.entityLinks ?? [];
|
|
135
|
+
const touchLinks = links.filter((l) => l.sourceProfileSlug === "outreach-touch");
|
|
136
|
+
// Non-vacuity: the touch's other links (sent_to, part_of) are seen too.
|
|
137
|
+
expect(touchLinks.map((l) => l.type).sort()).toEqual(["part_of", "relates_to", "relates_to", "sent_to"]);
|
|
138
|
+
const required = links.filter((l) => l.required === true).map((l) => `${l.sourceProfileSlug} -${l.type}-> ${l.targetProfileSlug}`);
|
|
139
|
+
expect(required.sort()).toEqual(["outreach-touch -relates_to-> company", "outreach-touch -relates_to-> person"]);
|
|
140
|
+
// The lead role is worn by a person or a company: the rule's targets are both.
|
|
141
|
+
for (const l of touchLinks.filter((x) => x.type === "relates_to")) expect(l.required).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("every drafter yields proposed messages, which write no edge, and none yields an outreach touch", () => {
|
|
145
|
+
for (const name of DRAFTERS) {
|
|
146
|
+
const outputs = outputsOf(playbookOf(name));
|
|
147
|
+
expect(outputs.length, name).toBeGreaterThan(0);
|
|
148
|
+
// The channel is bound to the person already: a proposed message relates to nothing else.
|
|
149
|
+
for (const o of outputs) expect([o.kind, o.relationToSubject], `${name} › ${o.label}`).toEqual([PROPOSED_MESSAGE, "none"]);
|
|
150
|
+
}
|
|
151
|
+
// Decision d5: no CRM process drafts an outreach touch any more.
|
|
152
|
+
const touchMakers = (crm.playbooks ?? []).filter((p) => outputsOf(p).some((o) => o.kind === "outreach-touch")).map((p) => p.name);
|
|
153
|
+
expect(touchMakers).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("'Read the reply to an outreach message': silence is read by the cadence's dates, and nurturing follows the second follow-up", () => {
|
|
157
|
+
const name = "Read the reply to an outreach message";
|
|
158
|
+
const goal = goalOf(name);
|
|
159
|
+
expect(goal).toContain("the sent day + 3 days after the first message, the sent day + 7 days after each follow-up");
|
|
160
|
+
expect(goal).toMatch(/When this message is the second follow-up .* mark it no response and propose moving the lead to nurturing/);
|
|
161
|
+
expect(goal).not.toMatch(/14 days/);
|
|
162
|
+
expect(flat(stageOf(name, "no-response").goal)).toMatch(/after the second follow-up propose moving the lead to nurturing/);
|
|
163
|
+
});
|
|
164
|
+
});
|