@synap-core/workspace-templates 0.23.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 +50 -24
- package/capabilities/stripe.capability.json +1163 -87
- package/capabilities/unipile-linkedin.capability.json +30 -7
- package/dist/{define-CPS0n8BV.d.ts → define-DqoItRiR.d.ts} +40 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +159 -4
- package/dist/index.js.map +1 -1
- package/dist/wire.d.ts +1 -1
- package/package.json +3 -3
- package/skills/app-factory-design-review/SKILL.md +8 -2
- package/skills/design-critique/SKILL.md +3 -0
- package/src/catalog-safety.test.ts +1 -1
- package/src/content-os.template.test.ts +27 -0
- package/src/content-os.yaml +43 -0
- package/src/crm-messages-cadence.template.test.ts +83 -1
- package/src/crm.yaml +111 -4
- package/src/define.ts +16 -0
- package/src/reserved-profile-slugs.test.ts +8 -10
- package/src/retired-goal-kinds.test.ts +6 -12
- package/src/stripe-capability.test.ts +192 -0
- package/src/template-documents.test.ts +158 -0
- package/src/templates.ts +92 -4
- package/src/types.ts +32 -1
- package/src/unipile-linkedin-find-profile.test.ts +183 -0
- package/src/validate.ts +81 -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/packs/bookmark-views-pack.json +0 -33
- package/packs/crm-pack.json +0 -76
- package/packs/customer-profile.json +0 -28
- package/packs/deep-research.playbook.json +0 -30
- package/packs/event-views-pack.json +0 -33
- package/packs/habit-profile.json +0 -27
- package/packs/hr-pack.json +0 -54
- package/packs/invoice-profile.json +0 -28
- package/packs/knowledge-base-pack.json +0 -56
- package/packs/meeting-profile.json +0 -29
- package/packs/product-profile.json +0 -30
- package/packs/project-management-pack.json +0 -49
- package/packs/recipe-profile.json +0 -29
- package/packs/task-views-pack.json +0 -39
package/dist/wire.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { g as PackageDefinitionOutput, X as WorkspaceDefinitionInput } from './define-
|
|
1
|
+
import { g as PackageDefinitionOutput, X as WorkspaceDefinitionInput } from './define-DqoItRiR.js';
|
|
2
2
|
import '@synap-core/types/presentation';
|
|
3
3
|
import '@synap-core/types/space-brief';
|
|
4
4
|
import '@synap-core/types/capability-intents';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/workspace-templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
|
|
6
6
|
"repository": {
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"./capabilities/*": "./capabilities/*",
|
|
21
21
|
"./skills/*": "./skills/*",
|
|
22
|
-
"./packs/*": "./packs/*",
|
|
23
22
|
"./automations/*": "./automations/*",
|
|
23
|
+
"./views/*": "./views/*",
|
|
24
24
|
"./package.json": "./package.json"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"src",
|
|
29
29
|
"capabilities",
|
|
30
30
|
"skills",
|
|
31
|
-
"packs",
|
|
32
31
|
"automations",
|
|
32
|
+
"views",
|
|
33
33
|
"retired.yaml"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
@@ -42,7 +42,13 @@ Prompt (fill the braces):
|
|
|
42
42
|
> phone: navigation, thumb reach, what is missing that the person needs on the phone; (11) boards that must exist and
|
|
43
43
|
> do not (a state, a detail page a door points to, a first run, an island), split P0 (needed to approve) and P1;
|
|
44
44
|
> (12) the sample world: dates, names and counts that disagree between boards, and any words attributed to the person
|
|
45
|
-
> that are not verbatim from the plan's real data
|
|
45
|
+
> that are not verbatim from the plan's real data; (13) every filter drawn as a row of chips instead of one chip
|
|
46
|
+
> showing its value that opens a menu, and every page whose filters, tabs and search take more than one row; (14)
|
|
47
|
+
> every panel that shows all its options at once instead of the current choice and one door, every later step drawn
|
|
48
|
+
> before the earlier one is made, every option that cannot apply to this object, and every default pre-selected
|
|
49
|
+
> without a declared one; (15) anything that contradicts the object's status (a made thing offering to be made, a
|
|
50
|
+
> done step offered again, a primary that is not the next move for the status). End with the three changes that
|
|
51
|
+
> would most improve the design.
|
|
46
52
|
|
|
47
53
|
## Lens 2 — component fidelity, contrast and responsive
|
|
48
54
|
|
|
@@ -89,7 +95,7 @@ Prompt:
|
|
|
89
95
|
across boards · boards to add (P0, P1) · fidelity (the measured numbers and token mapping) · real data (the one
|
|
90
96
|
sample world) · process notes. Content Studio's brief is the reference shape.
|
|
91
97
|
5. Apply it yourself, as the only writer: edit `design-kit/src`, `pnpm design:build && pnpm design:check`, `pnpm
|
|
92
|
-
|
|
98
|
+
design:preview`, commit `src` + `out`, republish the canvas, and list every board changed or added.
|
|
93
99
|
|
|
94
100
|
## Verify pass
|
|
95
101
|
|
|
@@ -87,6 +87,9 @@ make it explicit, and lower `confidence` when the class is ambiguous.
|
|
|
87
87
|
| `pat.creation` | object creation → `minimum then detail page` · `wizard` | a multi-step wizard before the object exists is a defect | ui-rules "Cardinal rules" (object creation) |
|
|
88
88
|
| `pat.raw-token` | each label naming a kind, action or status → `words` · `raw token` | a raw token (`entity.create`, `in_review`) shown to a person is a defect | vocabulary.md "Cardinal rules" |
|
|
89
89
|
| `pat.states-covered` | screen states → which of `empty` · `loading` · `failed` · `full` · `long` the screen has, and which the design lays out | a state the screen has and the design omits is a defect | "Design to implementation", Generate |
|
|
90
|
+
| `pat.filter-row` | each filter → `one chip + menu` · `row of chips`; the controls → `one row` · `several rows` | a filter drawn as a row of chips (one per value), or filters, tabs and search spread over more than one row, is a defect | page-grammar §9 (decision afab5911) |
|
|
91
|
+
| `pat.disclosure` | each choice → `current choice + one door` · `every option shown`; each default → `declared` · `first item` | every option shown at once, a later step drawn before its earlier one is made, an option that cannot apply offered, or a default nobody declared is a defect | ui-composition §12 |
|
|
92
|
+
| `pat.status-follows` | each action, label, section → `fits the status` · `contradicts the status` | anything that contradicts the object's status (offers to make what exists, offers a step already done, primary not the next move) is a defect | ui-composition §13 |
|
|
90
93
|
|
|
91
94
|
A `pattern` failure is `major` by default; it is a `blocker` when it hides a failure from the reader
|
|
92
95
|
(`pat.empty-answer` folding a failed read into empty) or leaves the reader no way to act
|
|
@@ -172,7 +172,7 @@ describe("(b) every skill reference resolves", () => {
|
|
|
172
172
|
|
|
173
173
|
// ── (c) the content directories ship, the build reads none of them ─────────
|
|
174
174
|
|
|
175
|
-
const CONTENT_DIRS = ["capabilities", "skills", "
|
|
175
|
+
const CONTENT_DIRS = ["capabilities", "skills", "automations", "views"];
|
|
176
176
|
|
|
177
177
|
describe("(c) catalog content ships in the npm bundle; the build reads only src/*.yaml", () => {
|
|
178
178
|
const pkg = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8"));
|
|
@@ -320,3 +320,30 @@ describe("Content OS Repurpose proposes once per original, never for a copy", ()
|
|
|
320
320
|
expect(post?.properties.map((d) => d.slug)).toContain("post-copy-of");
|
|
321
321
|
});
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
// "Write the caption" (founder, 5 Nov): Content Studio's "Write it for me" runs it on ONE post. It drafts, never
|
|
325
|
+
// decides: it writes the script's "## Caption draft" section, never the "## Caption" on its own; the person's yes to ONE
|
|
326
|
+
// owed confirm ask (or their Save in the app) makes it the caption. It binds no stage and moves no status.
|
|
327
|
+
describe("Content OS Write the caption drafts one post's caption, never the person's", () => {
|
|
328
|
+
const playbooks = (template.playbooks ?? []) as TemplatePlaybook[];
|
|
329
|
+
const caption = playbooks.find((p) => p.name === "Write the caption");
|
|
330
|
+
|
|
331
|
+
it("is a post's process, run by hand on one post (non-vacuity)", () => {
|
|
332
|
+
expect(caption?.subjectProfile?.profileSlug).toBe("post");
|
|
333
|
+
expect(caption?.params).toEqual([expect.objectContaining({ name: "post", type: "entity", required: true })]);
|
|
334
|
+
expect(caption?.goalTemplate).toContain("{post}");
|
|
335
|
+
expect(caption?.status).toBe("active");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("writes the draft section only, never the saved caption, and never moves the status", () => {
|
|
339
|
+
expect(caption?.goalTemplate).toMatch(/"## Caption draft" section/);
|
|
340
|
+
expect(caption?.goalTemplate).toMatch(/Never write or change the "## Caption" section on\s+your own/);
|
|
341
|
+
// The person's approval is ONE owed confirm ask; only their yes makes the draft the caption.
|
|
342
|
+
expect(caption?.goalTemplate).toMatch(/ONE confirm ask on this session/);
|
|
343
|
+
expect(caption?.goalTemplate).toMatch(/On yes, put the draft in\s+the "## Caption" section/);
|
|
344
|
+
expect(caption?.goalTemplate).toMatch(/Never move the post's status/);
|
|
345
|
+
expect(caption?.stages ?? []).toEqual([]);
|
|
346
|
+
expect(caption?.activators ?? []).toEqual([]);
|
|
347
|
+
expect(caption?.expectedOutputs).toEqual([expect.objectContaining({ kind: "document", label: "Caption draft", relationToSubject: "none" })]);
|
|
348
|
+
});
|
|
349
|
+
});
|
package/src/content-os.yaml
CHANGED
|
@@ -2397,6 +2397,49 @@ playbooks:
|
|
|
2397
2397
|
executor: is-agent
|
|
2398
2398
|
status: active
|
|
2399
2399
|
|
|
2400
|
+
# One post's caption, on request (founder, 5 Nov: "something that writes the
|
|
2401
|
+
# caption so I don't have to"). Content Studio's "Write it for me" on a post's
|
|
2402
|
+
# Caption box runs it for that post. It writes a DRAFT, the script document's
|
|
2403
|
+
# "## Caption draft" section, never the "## Caption" the person saved; the
|
|
2404
|
+
# person approves it through ONE owed confirm ask (yes: the draft becomes the
|
|
2405
|
+
# caption) or uses it in the app's Caption box and saves it there. It works
|
|
2406
|
+
# one post but binds no stage and never moves the status.
|
|
2407
|
+
- name: Write the caption
|
|
2408
|
+
description: Draft the caption of one post from its brief and its script, as a draft in its script document that the person edits and saves.
|
|
2409
|
+
goalTemplate: >-
|
|
2410
|
+
Draft the caption of this post ({post}). Read the post: its hook
|
|
2411
|
+
(post-hook), its call to action (post-cta), its pillar, its journey
|
|
2412
|
+
stage, its platform (post-platform) and its format. Read its script, the
|
|
2413
|
+
post's own document: its slides (the S1, S2 lines or the slide table)
|
|
2414
|
+
and any caption already written under "## Caption". Write ONE caption in
|
|
2415
|
+
the brand voice (the pod's brand kit) that says what the slides leave
|
|
2416
|
+
out: a first line that stands on its own before the platform cuts it
|
|
2417
|
+
(Instagram shows about 125 characters before "more", LinkedIn about 210
|
|
2418
|
+
before "see more"), then two or three short lines, and the call to action
|
|
2419
|
+
last. No hashtag wall, no emoji run, no claim the script does not make,
|
|
2420
|
+
and no line that starts with "##". Put it in the script document as its
|
|
2421
|
+
"## Caption draft" section: replace that section if it is there, else add
|
|
2422
|
+
it at the end, and change no other line (synap_update_document with the
|
|
2423
|
+
revision you read). Never write or change the "## Caption" section on
|
|
2424
|
+
your own: it is the person's. Then ask the person to approve your draft:
|
|
2425
|
+
ONE confirm ask on this session (owner the person), its prompt the
|
|
2426
|
+
caption itself, "Use this caption" recommended. On yes, put the draft in
|
|
2427
|
+
the "## Caption" section (replace its words, or add it) and remove the
|
|
2428
|
+
"## Caption draft" section, nothing else. On no, leave the draft where it
|
|
2429
|
+
is (they may edit it and save it on the post's page in Content Studio,
|
|
2430
|
+
which also makes it the caption). Never move the post's status. Close
|
|
2431
|
+
this session once the person answered.
|
|
2432
|
+
subjectProfile:
|
|
2433
|
+
profileSlug: post
|
|
2434
|
+
# Typed output: the draft lives in the post's own script document (a
|
|
2435
|
+
# section of it), so nothing new is created and there is no edge to add.
|
|
2436
|
+
expectedOutputs:
|
|
2437
|
+
- { kind: document, label: Caption draft, relationToSubject: none }
|
|
2438
|
+
params:
|
|
2439
|
+
- { name: post, type: entity, label: Post, required: true }
|
|
2440
|
+
executor: is-agent
|
|
2441
|
+
status: active
|
|
2442
|
+
|
|
2400
2443
|
# Once a post is Published, propose turning it into its siblings (thread →
|
|
2401
2444
|
# video → newsletter section). It READS the post lifecycle (its activator) but
|
|
2402
2445
|
# never moves it — the new pieces are new posts, linked `derived_from` (the
|
|
@@ -28,7 +28,12 @@
|
|
|
28
28
|
import { describe, expect, it } from "vitest";
|
|
29
29
|
import { readYamlTemplates } from "./generate.mjs";
|
|
30
30
|
import type { WorkspaceYaml } from "./types.js";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
31
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 }> } };
|
|
32
37
|
const corpus = readYamlTemplates().map((t) => t.data as WorkspaceYaml);
|
|
33
38
|
const crm = corpus.find((x) => x.meta.slug === "crm")!;
|
|
34
39
|
const playbookOf = (name: string) => {
|
|
@@ -47,8 +52,22 @@ const stageOf = (name: string, key: string) => {
|
|
|
47
52
|
const CADENCE =
|
|
48
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.';
|
|
49
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
|
+
|
|
50
68
|
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."
|
|
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;
|
|
52
71
|
|
|
53
72
|
/** What a drafter yields: a `messaging.external.send` proposal (target type `messaging`). */
|
|
54
73
|
const PROPOSED_MESSAGE = "messaging";
|
|
@@ -161,4 +180,67 @@ describe("CRM messages and cadence — catalog contract", () => {
|
|
|
161
180
|
expect(goal).not.toMatch(/14 days/);
|
|
162
181
|
expect(flat(stageOf(name, "no-response").goal)).toMatch(/after the second follow-up propose moving the lead to nurturing/);
|
|
163
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
|
+
});
|
|
164
246
|
});
|
package/src/crm.yaml
CHANGED
|
@@ -1398,7 +1398,24 @@ playbooks:
|
|
|
1398
1398
|
pod files it for a person to review in that channel; it goes out only
|
|
1399
1399
|
once they approve it. Never call a tool's own send verb (Unipile,
|
|
1400
1400
|
Gmail), never create an outreach touch, a note or a task holding the
|
|
1401
|
-
message, and never use synap_capture for it. Never send.
|
|
1401
|
+
message, and never use synap_capture for it. Never send.
|
|
1402
|
+
A LinkedIn message is drafted whatever their reach: the pod holds a
|
|
1403
|
+
draft to someone who is not a connection yet, shows it with the next
|
|
1404
|
+
step and lets it go out once they connect, so never hold a draft back
|
|
1405
|
+
for want of a connection. Draft it, then, in this order: (1) a person
|
|
1406
|
+
with no linkedinUrl: find their profile with linkedin_find_profile
|
|
1407
|
+
(their full name and company); on a match, propose its linkedinUrl and
|
|
1408
|
+
linkedinProviderId on the person for a person to approve; on none or
|
|
1409
|
+
several, ask the person to choose among its candidates (the process
|
|
1410
|
+
"Find their LinkedIn" does this for one lead). (2) Read their
|
|
1411
|
+
linkedinConnection, which only the pod writes (never set it or
|
|
1412
|
+
linkedinInvitedAt): connected, nothing more; none or not set, ask to
|
|
1413
|
+
connect with linkedin_send_invite (it refuses someone already connected
|
|
1414
|
+
or already invited: then nothing more); pending, the draft waits for
|
|
1415
|
+
them; ignored, draft by email instead when they have an email address.
|
|
1416
|
+
While the draft waits for them to connect, set the lead's nextAction to
|
|
1417
|
+
"Wait for <name> to accept the LinkedIn request" and its nextActionAt 7
|
|
1418
|
+
days later. An email message needs none of this. Its message
|
|
1402
1419
|
is personal: it cites the buying signal and what it showed, or a past
|
|
1403
1420
|
exchange, never a generic template; short; in the person's language,
|
|
1404
1421
|
French by default, and in French it says vous. It offers one useful
|
|
@@ -1452,7 +1469,7 @@ playbooks:
|
|
|
1452
1469
|
# A judge: no core or catalog verb reads a session's proposals and
|
|
1453
1470
|
# answers {passed}, so the pod has no exact check for this yet.
|
|
1454
1471
|
- key: first-message-one-draft
|
|
1455
|
-
statement: One proposed message, filed through messaging.send to the person written to (for a company, its decider), waits for review in that person's channel, holds the message text, and follows the campaign's angle when one was given.
|
|
1472
|
+
statement: One proposed message, filed through messaging.send to the person written to (for a company, its decider), waits for review in that person's channel, holds the message text, and follows the campaign's angle when one was given, whatever the person's LinkedIn reach; on LinkedIn to someone not yet a connection, the pod holds it, and their profile is found and the connection asked for or awaited, in the DRAFT RULES order.
|
|
1456
1473
|
check: { kind: judge }
|
|
1457
1474
|
- key: first-message-no-capture
|
|
1458
1475
|
statement: No tool's own send verb was called, no outreach touch, note or task holds the message, no record was made with synap_capture, and a decider created for a company lead carries only the person's own fields, never the company's website, domain or email.
|
|
@@ -1473,6 +1490,61 @@ playbooks:
|
|
|
1473
1490
|
gate: { kind: human }
|
|
1474
1491
|
onFail: { toStage: draft }
|
|
1475
1492
|
|
|
1493
|
+
# ── Find their LinkedIn — one lead's LinkedIn profile ───────────────────
|
|
1494
|
+
# CRM Messages contract (decision b603d75e, build map ca310f27): the first of
|
|
1495
|
+
# the steps a LinkedIn lead walks (Find their LinkedIn, Ask to connect,
|
|
1496
|
+
# Waiting, Send). linkedin_find_profile is a read with no inputMap, so the
|
|
1497
|
+
# actions door cannot run it for the CRM app: the app starts this process
|
|
1498
|
+
# for one lead (`params.lead`, as "Draft a first message"). On a strict match
|
|
1499
|
+
# it PROPOSES linkedinUrl and linkedinProviderId on the person; on none or
|
|
1500
|
+
# several it hands the person up to 3 candidates. The reach fields stay the
|
|
1501
|
+
# pod's (backend#786). A lifecycle READER of leadStage: no stage moves.
|
|
1502
|
+
- name: Find their LinkedIn
|
|
1503
|
+
description: Find one lead's LinkedIn profile by their full name and company and propose its URL and member id on the person for a person to approve, or offer up to 3 candidates to choose from; nothing is sent.
|
|
1504
|
+
goalTemplate: >-
|
|
1505
|
+
Find the LinkedIn profile of the lead {lead}. WHO: a person lead is
|
|
1506
|
+
looked up themselves; for a company lead, the person who decides there,
|
|
1507
|
+
from the pod; when no decider is known, ask the person who it is. When
|
|
1508
|
+
that person already has a linkedinUrl, say so and stop. FIND: run
|
|
1509
|
+
linkedin_find_profile with their full name, their company and the
|
|
1510
|
+
LinkedIn account unipile_list_accounts lists (when several are
|
|
1511
|
+
connected, ask the person which one reads). On a match, propose its
|
|
1512
|
+
linkedinUrl and linkedinProviderId on the person through the entity
|
|
1513
|
+
door, for a person to approve; never write them directly, and never set
|
|
1514
|
+
linkedinConnection or linkedinInvitedAt, which only the pod writes. On
|
|
1515
|
+
none, several or more results than it read, ask the person to choose
|
|
1516
|
+
among its candidates (at most 3, each with its name, headline and
|
|
1517
|
+
profile URL) or to give the URL themselves, then propose the one chosen
|
|
1518
|
+
the same way. Never send a message or a connection request, never draft
|
|
1519
|
+
one, and never move the lead's stage.
|
|
1520
|
+
subjectProfile:
|
|
1521
|
+
profileSlug: lead
|
|
1522
|
+
activators:
|
|
1523
|
+
- { on: manual, mode: propose }
|
|
1524
|
+
params:
|
|
1525
|
+
- { name: lead, type: entity, label: Lead, required: true }
|
|
1526
|
+
executor: is-agent
|
|
1527
|
+
status: active
|
|
1528
|
+
requiredIntents: [enrich_entity]
|
|
1529
|
+
stages:
|
|
1530
|
+
- key: find
|
|
1531
|
+
name: Find the profile
|
|
1532
|
+
category: started
|
|
1533
|
+
goal: Run linkedin_find_profile for the lead (a company lead's decider) with their full name and company; on a match propose linkedinUrl and linkedinProviderId on the person, otherwise ask the person to choose among up to 3 candidates or give the URL.
|
|
1534
|
+
criteria:
|
|
1535
|
+
- key: find-linkedin-proposed
|
|
1536
|
+
statement: The person's linkedinUrl and linkedinProviderId are proposed on them for a person to approve (a strict match, or the candidate the person chose), or the person was asked to choose among at most 3 candidates; nothing was written directly.
|
|
1537
|
+
check: { kind: judge }
|
|
1538
|
+
- key: find-linkedin-reader-only
|
|
1539
|
+
statement: No message or connection request was sent or drafted, linkedinConnection and linkedinInvitedAt were not set, and the lead's stage was not moved.
|
|
1540
|
+
check: { kind: judge }
|
|
1541
|
+
- key: confirm
|
|
1542
|
+
name: Person confirms
|
|
1543
|
+
category: completed
|
|
1544
|
+
goal: The person approves the proposed profile, or picks a candidate or gives the URL, and the pod stores linkedinUrl and linkedinProviderId on the person; the lead's next step is then to ask to connect.
|
|
1545
|
+
gate: { kind: human }
|
|
1546
|
+
onFail: { toStage: find }
|
|
1547
|
+
|
|
1476
1548
|
# ── Follow up on leads in outreach — THE one follow-up process ───────────
|
|
1477
1549
|
# CRM build plan A6 (founder Q4). A batch worker: each weekday it reads the
|
|
1478
1550
|
# channels of the leads in outreach. Due with no reply, it drafts one message
|
|
@@ -1534,7 +1606,24 @@ playbooks:
|
|
|
1534
1606
|
files it for a person to review in that channel; it goes out only once
|
|
1535
1607
|
they approve it. Never call a tool's own send verb (Unipile, Gmail),
|
|
1536
1608
|
never create an outreach touch, a note or a task holding the message,
|
|
1537
|
-
and never use synap_capture for it. Never send.
|
|
1609
|
+
and never use synap_capture for it. Never send.
|
|
1610
|
+
A LinkedIn message is drafted whatever their reach: the pod holds a
|
|
1611
|
+
draft to someone who is not a connection yet, shows it with the next
|
|
1612
|
+
step and lets it go out once they connect, so never hold a draft back
|
|
1613
|
+
for want of a connection. Draft it, then, in this order: (1) a person
|
|
1614
|
+
with no linkedinUrl: find their profile with linkedin_find_profile
|
|
1615
|
+
(their full name and company); on a match, propose its linkedinUrl and
|
|
1616
|
+
linkedinProviderId on the person for a person to approve; on none or
|
|
1617
|
+
several, ask the person to choose among its candidates (the process
|
|
1618
|
+
"Find their LinkedIn" does this for one lead). (2) Read their
|
|
1619
|
+
linkedinConnection, which only the pod writes (never set it or
|
|
1620
|
+
linkedinInvitedAt): connected, nothing more; none or not set, ask to
|
|
1621
|
+
connect with linkedin_send_invite (it refuses someone already connected
|
|
1622
|
+
or already invited: then nothing more); pending, the draft waits for
|
|
1623
|
+
them; ignored, draft by email instead when they have an email address.
|
|
1624
|
+
While the draft waits for them to connect, set the lead's nextAction to
|
|
1625
|
+
"Wait for <name> to accept the LinkedIn request" and its nextActionAt 7
|
|
1626
|
+
days later. An email message needs none of this. For each draft, set
|
|
1538
1627
|
the lead's `nextAction` to send it (e.g. "Send the drafted message to
|
|
1539
1628
|
Ana on LinkedIn") and `nextActionAt` to today. The person reviews each
|
|
1540
1629
|
proposed message in its channel, edits it if needed and approves it;
|
|
@@ -1635,7 +1724,25 @@ playbooks:
|
|
|
1635
1724
|
channel; it goes out only once they approve it. Never call a tool's own
|
|
1636
1725
|
send verb (Unipile, Gmail), never create an outreach touch, a note or a
|
|
1637
1726
|
task holding the message, and never use synap_capture for it. Never
|
|
1638
|
-
send.
|
|
1727
|
+
send.
|
|
1728
|
+
A LinkedIn message is drafted whatever their reach: the pod holds a
|
|
1729
|
+
draft to someone who is not a connection yet, shows it with the next
|
|
1730
|
+
step and lets it go out once they connect, so never hold a draft back
|
|
1731
|
+
for want of a connection. Draft it, then, in this order: (1) a person
|
|
1732
|
+
with no linkedinUrl: find their profile with linkedin_find_profile
|
|
1733
|
+
(their full name and company); on a match, propose its linkedinUrl and
|
|
1734
|
+
linkedinProviderId on the person for a person to approve; on none or
|
|
1735
|
+
several, ask the person to choose among its candidates (the process
|
|
1736
|
+
"Find their LinkedIn" does this for one lead). (2) Read their
|
|
1737
|
+
linkedinConnection, which only the pod writes (never set it or
|
|
1738
|
+
linkedinInvitedAt): connected, nothing more; none or not set, ask to
|
|
1739
|
+
connect with linkedin_send_invite (it refuses someone already connected
|
|
1740
|
+
or already invited: then nothing more); pending, the draft waits for
|
|
1741
|
+
them; ignored, draft by email instead when they have an email address.
|
|
1742
|
+
While the draft waits for them to connect, set the lead's nextAction to
|
|
1743
|
+
"Wait for <name> to accept the LinkedIn request" and its nextActionAt 7
|
|
1744
|
+
days later. An email message needs none of this. START: a person approves the audience, the offer and
|
|
1745
|
+
the list,
|
|
1639
1746
|
then reviews each proposed message in its channel and approves it;
|
|
1640
1747
|
nothing goes out before they approve it, and the agent never sends.
|
|
1641
1748
|
Once a message is sent, follow the cadence, so "Follow up on leads in
|
package/src/define.ts
CHANGED
|
@@ -272,6 +272,8 @@ export interface WorkspaceDefinitionInput {
|
|
|
272
272
|
proposalId?: string;
|
|
273
273
|
profiles: WorkspaceYaml['profiles'];
|
|
274
274
|
views: NonNullable<WorkspaceYaml['views']>;
|
|
275
|
+
/** Template documents — created by the pod with the views (`utils/template-documents.ts`). */
|
|
276
|
+
documents?: WorkspaceYaml['documents'];
|
|
275
277
|
entityLinks: NonNullable<WorkspaceYaml['entityLinks']>;
|
|
276
278
|
seedEntities: NonNullable<WorkspaceYaml['seedEntities']>;
|
|
277
279
|
suggestedRelations?: WorkspaceYaml['suggestedRelations'];
|
|
@@ -444,6 +446,9 @@ export function toWorkspaceDefinition(
|
|
|
444
446
|
proposalId,
|
|
445
447
|
profiles: tpl.profiles,
|
|
446
448
|
views: tpl.views ?? [],
|
|
449
|
+
// Absent when the template has none, so a template without documents
|
|
450
|
+
// compiles to the same definition (and content hash) as before.
|
|
451
|
+
...(tpl.documents?.length ? { documents: tpl.documents } : {}),
|
|
447
452
|
entityLinks: tpl.entityLinks ?? [],
|
|
448
453
|
seedEntities: tpl.seedEntities ?? [],
|
|
449
454
|
suggestedRelations: tpl.suggestedRelations,
|
|
@@ -528,6 +533,7 @@ type WorkspaceDefinitionForwarded =
|
|
|
528
533
|
| 'workspace' // flattened into workspaceName/description/workspaceType/…
|
|
529
534
|
| 'profiles'
|
|
530
535
|
| 'views'
|
|
536
|
+
| 'documents' // → carried verbatim; created with the views by the pod
|
|
531
537
|
| 'entityLinks'
|
|
532
538
|
| 'seedEntities'
|
|
533
539
|
| 'suggestedRelations'
|
|
@@ -644,6 +650,14 @@ export interface PackageDefinitionOutput {
|
|
|
644
650
|
defaultSources?: WorkspaceYaml['workspace']['defaultSources'];
|
|
645
651
|
profiles: WorkspaceYaml['profiles'];
|
|
646
652
|
views: NonNullable<WorkspaceYaml['views']>;
|
|
653
|
+
/**
|
|
654
|
+
* Template documents — created by the pod with the views
|
|
655
|
+
* (`utils/template-documents.ts`); the Hub `PackageApplySchema` slots them.
|
|
656
|
+
* The CP publish door does NOT yet (it strips the field and refuses a
|
|
657
|
+
* `kind: "document"` sidebar item), so no official template may declare
|
|
658
|
+
* documents until it does.
|
|
659
|
+
*/
|
|
660
|
+
documents?: WorkspaceYaml['documents'];
|
|
647
661
|
suggestedEntities: NonNullable<WorkspaceYaml['seedEntities']>;
|
|
648
662
|
suggestedRelations?: WorkspaceYaml['suggestedRelations'];
|
|
649
663
|
displayTemplates?: WorkspaceYaml['displayTemplates'];
|
|
@@ -744,6 +758,7 @@ export function toPackageDefinition(
|
|
|
744
758
|
defaultSources: ws.defaultSources,
|
|
745
759
|
profiles: tpl.profiles,
|
|
746
760
|
views: tpl.views ?? [],
|
|
761
|
+
...(tpl.documents?.length ? { documents: tpl.documents } : {}),
|
|
747
762
|
suggestedEntities: tpl.seedEntities ?? [],
|
|
748
763
|
suggestedRelations: tpl.suggestedRelations,
|
|
749
764
|
displayTemplates: tpl.displayTemplates,
|
|
@@ -780,6 +795,7 @@ type PackageDefinitionForwarded =
|
|
|
780
795
|
| 'workspace' // flattened into workspaceName/description/workspaceType/…
|
|
781
796
|
| 'profiles'
|
|
782
797
|
| 'views'
|
|
798
|
+
| 'documents' // → carried verbatim (Hub PackageApplySchema slots it)
|
|
783
799
|
| 'seedEntities' // → suggestedEntities
|
|
784
800
|
| 'suggestedRelations'
|
|
785
801
|
| 'displayTemplates'
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* synap-backend checkout and skips, with the reason logged, when it is absent.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync
|
|
16
|
-
import { dirname,
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { dirname, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { describe, expect, it } from "vitest";
|
|
19
19
|
import { RESERVED_PROFILE_SLUGS, validateTemplate } from "./validate.js";
|
|
@@ -54,18 +54,16 @@ describe("profile-slug-reserved", () => {
|
|
|
54
54
|
expect(errors).toEqual([]);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.map((f) => ({ file: `packs/${f}`, data: JSON.parse(readFileSync(join(PKG, "packs", f), "utf8")) }));
|
|
57
|
+
// Profiles are declared only by workspace YAMLs: the CP pack fragments that
|
|
58
|
+
// also declared them were retired (catalog-lib refuses a `_meta` fragment).
|
|
59
|
+
it("no shipped template declares a reserved slug", () => {
|
|
60
|
+
const yamls = readYamlTemplates().map((t) => ({ file: `src/${t.file}`, data: t.data as WorkspaceYaml }));
|
|
62
61
|
// Non-vacuity: the scan sees the corpus, and the corpus declares profiles.
|
|
63
62
|
expect(yamls.length).toBeGreaterThan(20);
|
|
64
|
-
|
|
65
|
-
const declared = [...yamls, ...packs].flatMap((t) => (t.data?.profiles ?? []).map((p: { slug: string }) => p.slug));
|
|
63
|
+
const declared = yamls.flatMap((t) => (t.data?.profiles ?? []).map((p: { slug: string }) => p.slug));
|
|
66
64
|
expect(declared.length).toBeGreaterThan(100);
|
|
67
65
|
|
|
68
|
-
const offenders =
|
|
66
|
+
const offenders = yamls.flatMap((t) =>
|
|
69
67
|
reservedErrors(t.data as WorkspaceYaml).map((e) => `${t.file}: ${e.path}`)
|
|
70
68
|
);
|
|
71
69
|
expect(offenders).toEqual([]);
|
|
@@ -13,14 +13,9 @@
|
|
|
13
13
|
* goal links to the pod's goals (tracks); it never re-declares the kind.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
17
|
-
import { dirname, join, resolve } from "node:path";
|
|
18
|
-
import { fileURLToPath } from "node:url";
|
|
19
16
|
import { describe, expect, it } from "vitest";
|
|
20
17
|
import { readYamlTemplates } from "./generate.mjs";
|
|
21
18
|
|
|
22
|
-
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
-
|
|
24
19
|
const RETIRED_GOAL_KINDS = ["goal", "objective", "okr"] as const;
|
|
25
20
|
|
|
26
21
|
type Declared = { slug?: string };
|
|
@@ -43,16 +38,15 @@ describe("retired goal-like kinds", () => {
|
|
|
43
38
|
expect(retiredKindsDeclared(probe)).toEqual(["probe.yaml: OKR ", "probe2.yaml: goal", "probe2.yaml: objective"]);
|
|
44
39
|
});
|
|
45
40
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.map((f) => ({ file: `packs/${f}`, data: JSON.parse(readFileSync(join(PKG, "packs", f), "utf8")) }));
|
|
41
|
+
// Profiles are declared only by workspace YAMLs: the CP pack fragments that
|
|
42
|
+
// also declared them were retired (catalog-lib refuses a `_meta` fragment).
|
|
43
|
+
it("no shipped workspace template declares one", () => {
|
|
44
|
+
const yamls: Corpus[] = readYamlTemplates().map((t) => ({ file: `src/${t.file}`, data: t.data as Corpus["data"] }));
|
|
51
45
|
// Non-vacuity: the scan reads the corpus, and the corpus declares profiles.
|
|
52
46
|
expect(yamls.length).toBeGreaterThan(20);
|
|
53
|
-
const declared =
|
|
47
|
+
const declared = yamls.flatMap((t) => t.data?.profiles ?? []);
|
|
54
48
|
expect(declared.length).toBeGreaterThan(100);
|
|
55
49
|
|
|
56
|
-
expect(retiredKindsDeclared(
|
|
50
|
+
expect(retiredKindsDeclared(yamls)).toEqual([]);
|
|
57
51
|
});
|
|
58
52
|
});
|