@synap-core/workspace-templates 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.d.ts +266 -8
  2. package/dist/index.js +2314 -1440
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/agent-fleet.yaml +666 -108
  6. package/src/blockchain-ecosystem.yaml +1 -0
  7. package/src/brand-library.yaml +127 -12
  8. package/src/builder.yaml +116 -12
  9. package/src/business-developer.yaml +110 -18
  10. package/src/catalog.test.ts +2 -2
  11. package/src/catalog.ts +13 -3
  12. package/src/communication.yaml +23 -453
  13. package/src/composition-graph.test.ts +28 -2
  14. package/src/concept-guard.test.ts +176 -0
  15. package/src/concept-guard.ts +219 -0
  16. package/src/content-os.yaml +103 -10
  17. package/src/content-studio.yaml +1 -0
  18. package/src/crm.yaml +1 -0
  19. package/src/define.ts +8 -8
  20. package/src/dev-dashboard.yaml +43 -9
  21. package/src/domains.test.ts +117 -0
  22. package/src/domains.ts +102 -0
  23. package/src/ecosystem.yaml +25 -5
  24. package/src/enterprise-os.yaml +1 -0
  25. package/src/finance.yaml +46 -5
  26. package/src/foundation.yaml +13 -1
  27. package/src/generate.d.mts +15 -0
  28. package/src/generate.mjs +86 -0
  29. package/src/grants.yaml +1 -0
  30. package/src/hr.yaml +61 -8
  31. package/src/index.ts +40 -0
  32. package/src/internal-runbook.yaml +36 -5
  33. package/src/legal.yaml +97 -12
  34. package/src/life-os.yaml +188 -27
  35. package/src/marketing.yaml +15 -2
  36. package/src/meetings-knowledge.yaml +143 -23
  37. package/src/operations.yaml +347 -0
  38. package/src/outreach-comms.yaml +11 -3
  39. package/src/project-management.yaml +840 -111
  40. package/src/research-base.yaml +516 -0
  41. package/src/research-decision.yaml +158 -0
  42. package/src/shared-role-ssot.test.ts +268 -0
  43. package/src/social.yaml +105 -37
  44. package/src/templates-freshness.test.ts +55 -0
  45. package/src/templates.ts +4620 -4278
  46. package/src/the-arch.yaml +1 -0
  47. package/src/types.ts +9 -0
  48. package/src/validate.test.ts +81 -0
  49. package/src/validate.ts +561 -0
  50. package/src/personal.yaml +0 -177
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Concept-guard tests.
3
+ *
4
+ * The first test is the gate: the SHIPPED corpus must be free of undeclared
5
+ * vocabulary forks. The rest prove the guard actually fires — a guard nobody
6
+ * has watched fail is decoration, so the failure cases are pinned here rather
7
+ * than demonstrated once by hand and forgotten.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import {
12
+ DECLARED_DIVERGENCES,
13
+ conceptTerm,
14
+ findConceptViolations,
15
+ formatConceptViolations,
16
+ } from "./concept-guard.js";
17
+ import { WORKSPACE_TEMPLATES } from "./templates.js";
18
+ import type { WorkspaceYaml } from "./types.js";
19
+
20
+ /** Minimal fixture corpus — only the fields the guard reads. */
21
+ function corpus(
22
+ templates: Record<string, Array<Record<string, unknown>>>
23
+ ): Record<string, WorkspaceYaml> {
24
+ return Object.fromEntries(
25
+ Object.entries(templates).map(([slug, profiles]) => [slug, { profiles }])
26
+ ) as unknown as Record<string, WorkspaceYaml>;
27
+ }
28
+
29
+ const role = (slug: string, extra: Record<string, unknown> = {}) => ({
30
+ slug,
31
+ displayName: slug,
32
+ profileKind: "role",
33
+ properties: [],
34
+ ...extra,
35
+ });
36
+
37
+ describe("concept guard — shipped corpus", () => {
38
+ it("has no undeclared cross-template concept forks", () => {
39
+ const violations = findConceptViolations();
40
+ expect(
41
+ violations,
42
+ violations.length ? `\n${formatConceptViolations(violations)}\n` : ""
43
+ ).toEqual([]);
44
+ });
45
+
46
+ it("still sees the two-tier CRM divergence — declared, not absent", () => {
47
+ // If business-developer's crm-lead/crm-client ever stopped colliding with
48
+ // foundation's lead/client, the allowlist would be stale and rule 3 would
49
+ // fire. This asserts the allowlist is load-bearing, not inert.
50
+ const withoutAllowlist = Object.keys(DECLARED_DIVERGENCES);
51
+ expect(withoutAllowlist).toEqual(["crm-lead|lead", "client|crm-client"]);
52
+ expect(WORKSPACE_TEMPLATES["business-developer"]?.profiles.find((p) => p.slug === "crm-lead")
53
+ ?.semanticSlug).toBe("crm.lead");
54
+ });
55
+ });
56
+
57
+ describe("conceptTerm", () => {
58
+ it("reduces a qualified concept identifier to its bare noun", () => {
59
+ expect(conceptTerm("lead")).toBe("lead");
60
+ expect(conceptTerm("crm.lead")).toBe("lead");
61
+ expect(conceptTerm("crm-lead")).toBe("lead");
62
+ expect(conceptTerm("marketing.contact")).toBe("contact");
63
+ });
64
+ });
65
+
66
+ describe("concept guard — fires on a real fork", () => {
67
+ it("fails when a second template renames an existing role concept", () => {
68
+ const violations = findConceptViolations(
69
+ corpus({
70
+ foundation: [role("partner", { scope: "shared", applicableKinds: ["company"] })],
71
+ "some-addon": [role("channel-partner", { scope: "WORKSPACE" })],
72
+ })
73
+ );
74
+ // Fixture corpora never match the real allowlist, so the stale-allowlist
75
+ // findings are expected noise here — the collision is what is under test.
76
+ const forks = violations.filter((v) => v.rule !== "stale-allowlist");
77
+ expect(forks).toHaveLength(1);
78
+ expect(forks[0]!.rule).toBe("concept-collision");
79
+ expect(forks[0]!.concept).toBe("partner");
80
+ expect(forks[0]!.message).toContain("channel-partner");
81
+ });
82
+
83
+ it("suppresses exactly the pair an author declared — and nothing else", () => {
84
+ // The SAME shape as the test above, but with the slugs the allowlist names.
85
+ // This is the whole safety property of the escape hatch: it is per-pair, not
86
+ // per-concept, so a THIRD `lead` spelling would still fail.
87
+ const declared = findConceptViolations(
88
+ corpus({
89
+ foundation: [role("lead", { scope: "shared" })],
90
+ "business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
91
+ })
92
+ );
93
+ expect(declared.filter((v) => v.rule === "concept-collision")).toEqual([]);
94
+
95
+ const third = findConceptViolations(
96
+ corpus({
97
+ foundation: [role("lead", { scope: "shared" })],
98
+ "business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
99
+ "another-addon": [role("sales-lead", { scope: "WORKSPACE" })],
100
+ })
101
+ );
102
+ expect(third.filter((v) => v.rule === "concept-collision")).toHaveLength(2);
103
+ });
104
+
105
+ it("fires even when the fork hides behind a namespaced semanticSlug", () => {
106
+ const violations = findConceptViolations(
107
+ corpus({
108
+ foundation: [role("lead")],
109
+ other: [role("pipeline-lead", { semanticSlug: "sales.lead" })],
110
+ })
111
+ );
112
+ expect(violations.filter((v) => v.rule !== "stale-allowlist").map((v) => v.rule)).toEqual([
113
+ "concept-collision",
114
+ ]);
115
+ });
116
+
117
+ it("fails when two aliased profiles disagree on shape", () => {
118
+ const violations = findConceptViolations(
119
+ corpus({
120
+ a: [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
121
+ b: [role("promo", { semanticSlug: "marketing.campaign", scope: "WORKSPACE" })],
122
+ })
123
+ );
124
+ expect(violations.map((v) => v.rule)).toContain("alias-shape-divergence");
125
+ });
126
+
127
+ it("fails when an allowlist entry stops matching anything", () => {
128
+ // DECLARED_DIVERGENCES is module-level, so an empty corpus makes every
129
+ // entry stale — exactly the state a resolved divergence would leave behind.
130
+ const violations = findConceptViolations(corpus({}));
131
+ expect(violations.every((v) => v.rule === "stale-allowlist")).toBe(true);
132
+ expect(violations).toHaveLength(Object.keys(DECLARED_DIVERGENCES).length);
133
+ });
134
+ });
135
+
136
+ describe("concept guard — stays green on legitimate patterns", () => {
137
+ it("allows the same slug re-declared across templates (overlay mechanism)", () => {
138
+ const violations = findConceptViolations(
139
+ corpus({
140
+ foundation: [role("lead", { scope: "shared", applicableKinds: ["person", "company"] })],
141
+ // A consumer legitimately OMITS row-level fields it does not own.
142
+ marketing: [role("lead", { scope: "shared" })],
143
+ })
144
+ );
145
+ expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
146
+ });
147
+
148
+ it("allows sibling roles authored in ONE template to share a noun", () => {
149
+ const violations = findConceptViolations(
150
+ corpus({
151
+ "blockchain-ecosystem": [role("marketing-agency"), role("paid-ads-agency")],
152
+ })
153
+ );
154
+ expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
155
+ });
156
+
157
+ it("allows an identical concept reused under one semanticSlug", () => {
158
+ const violations = findConceptViolations(
159
+ corpus({
160
+ "content-os": [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
161
+ marketing: [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
162
+ })
163
+ );
164
+ expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
165
+ });
166
+
167
+ it("does not police non-role profiles", () => {
168
+ const violations = findConceptViolations(
169
+ corpus({
170
+ a: [{ slug: "note", displayName: "Note", properties: [] }],
171
+ b: [{ slug: "meeting-note", displayName: "Meeting Note", properties: [] }],
172
+ })
173
+ );
174
+ expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
175
+ });
176
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Cross-template CONCEPT guard — catches vocabulary forks between templates.
3
+ *
4
+ * WHY THIS EXISTS (read before weakening or deleting it):
5
+ * `business-developer.yaml` introduced workspace-scoped `crm-lead`/`crm-client`
6
+ * for the same real-world concept that `foundation.yaml`/`crm.yaml` declare as
7
+ * pod-wide shared `lead`/`client`. Nothing failed. Three reasons:
8
+ *
9
+ * 1. `build.mjs`'s `validate()` runs PER FILE, inside the read loop, so it
10
+ * structurally cannot see that another template already owns `lead`.
11
+ * 2. The only vocabulary rule that exists (`template-shared-role-ssot.test.ts`
12
+ * in synap-backend) polices a HARDCODED five-slug set — and it FORBIDS
13
+ * declaring one of those slugs workspace-scoped, which is exactly what the
14
+ * two-tier design legitimately needed. So RENAMING was the path of least
15
+ * resistance: `crm-lead` is outside the set and passes silently. That rule
16
+ * polices SPELLING, not MEANING.
17
+ * 3. That test also cannot run in CI — it resolves this package across a
18
+ * sibling-repo relative path that CI never checks out. It is green on a dev
19
+ * machine and mute everywhere else.
20
+ *
21
+ * This guard lives HERE, in the repo that owns the template source, and runs in
22
+ * this repo's CI (`pnpm test`) and in `pnpm build` / `prepublishOnly` via
23
+ * `scripts/check-concepts.mjs`. It is deliberately NOT a hardcoded slug list —
24
+ * that is the pattern that failed.
25
+ *
26
+ * WHAT IT DOES NOT DO: it does not guess. There is no fuzzy name matching, no
27
+ * edit distance, no synonym table. A guesser nobody trusts is worse than no
28
+ * guard. It uses two exact, falsifiable signals only — see the rules below.
29
+ */
30
+
31
+ import { WORKSPACE_TEMPLATES } from "./templates.js";
32
+ import type { TemplateProfile, WorkspaceYaml } from "./types.js";
33
+
34
+ export interface ConceptViolation {
35
+ rule: "concept-collision" | "alias-shape-divergence" | "stale-allowlist";
36
+ /** The concept term or semantic slug the finding is about. */
37
+ concept: string;
38
+ message: string;
39
+ }
40
+
41
+ /**
42
+ * DECLARED, INTENTIONAL divergences — the escape hatch, and deliberately the
43
+ * SECONDARY mechanism. An entry here is an author saying "yes, two spellings of
44
+ * one concept, on purpose, and here is why". Keep it tiny; every entry must
45
+ * carry its WHY. A key that stops matching anything FAILS the guard, so this
46
+ * list cannot rot into a graveyard.
47
+ *
48
+ * Key = the two colliding profile slugs, sorted, joined by "|".
49
+ */
50
+ export const DECLARED_DIVERGENCES: Record<string, string> = {
51
+ // Two-tier lead vocabulary, documented at business-developer.yaml:1-16.
52
+ // Foundation's `lead` is a SHARED pod-wide role: being a lead is true
53
+ // everywhere. `crm-lead` is a WORKSPACE-scoped role so a person can be a lead
54
+ // inside one CRM workspace without becoming a lead across the whole pod. The
55
+ // tiers are intentionally separate rows; `semanticSlug: crm.lead` records the
56
+ // kinship so the two are discoverable as one concept via getBySemanticSlug.
57
+ "crm-lead|lead":
58
+ "business-developer: workspace-tier lead, deliberately NOT pod-wide foundation `lead` (two-tier CRM design)",
59
+ // Same reasoning for the post-sale side of the same lifecycle.
60
+ "client|crm-client":
61
+ "business-developer: workspace-tier client, deliberately NOT pod-wide foundation `client` (two-tier CRM design)",
62
+ };
63
+
64
+ /**
65
+ * A profile's concept identity. `semanticSlug` is the ONE field that can say
66
+ * "same concept, different spelling"; synap-backend's `profile-repository.ts`
67
+ * defaults it to the slug when unset, so this mirrors runtime truth exactly.
68
+ */
69
+ function effectiveSemanticSlug(profile: TemplateProfile): string {
70
+ return profile.semanticSlug ?? profile.slug;
71
+ }
72
+
73
+ /**
74
+ * The bare noun a concept identifier ends in: `crm.lead` → `lead`,
75
+ * `crm-lead` → `lead`, `lead` → `lead`.
76
+ *
77
+ * This is EXACT terminal-token equality on the author's own qualified name —
78
+ * not a similarity heuristic. `crm-lead` and `lead` collide because the author
79
+ * literally wrote the same noun; `prospect` and `lead` do not collide, and that
80
+ * is honest: no guard can know they mean the same thing.
81
+ */
82
+ export function conceptTerm(semanticSlug: string): string {
83
+ const tokens = semanticSlug.split(/[.\-_]/).filter(Boolean);
84
+ return (tokens[tokens.length - 1] ?? semanticSlug).toLowerCase();
85
+ }
86
+
87
+ /** `undefined`/`null` = the template makes NO claim, which cannot contradict one. */
88
+ function declaredValue(profile: TemplateProfile, field: string): string | null {
89
+ const raw = (profile as unknown as Record<string, unknown>)[field];
90
+ if (raw === undefined || raw === null) return null;
91
+ if (field === "applicableKinds") {
92
+ return Array.isArray(raw) ? JSON.stringify([...(raw as string[])].sort()) : null;
93
+ }
94
+ // `scope` is authored as both `shared` and `WORKSPACE` across the corpus.
95
+ return JSON.stringify(String(raw).toLowerCase());
96
+ }
97
+
98
+ function pairKey(a: string, b: string): string {
99
+ return [a, b].sort().join("|");
100
+ }
101
+
102
+ interface ProfileRef {
103
+ template: string;
104
+ profile: TemplateProfile;
105
+ }
106
+
107
+ export function findConceptViolations(
108
+ templates: Record<string, WorkspaceYaml> = WORKSPACE_TEMPLATES
109
+ ): ConceptViolation[] {
110
+ const violations: ConceptViolation[] = [];
111
+ const all: ProfileRef[] = [];
112
+ for (const [templateSlug, template] of Object.entries(templates)) {
113
+ for (const profile of template.profiles ?? []) {
114
+ all.push({ template: templateSlug, profile });
115
+ }
116
+ }
117
+
118
+ // ── RULE 1 — concept collision across templates ────────────────────────────
119
+ // Two ROLE profiles, in DIFFERENT templates, with DIFFERENT slugs, whose
120
+ // concept identifiers end in the same noun. Every qualifier is load-bearing:
121
+ // • ROLE only — roles are the facet vocabulary (the hat an entity wears),
122
+ // where a fork actually hurts: the same person is a lead under one
123
+ // spelling and not under the other. Kinds are things with their own life.
124
+ // • DIFFERENT templates — sibling roles authored in ONE file are a
125
+ // deliberate distinction the author had in front of them (blockchain-
126
+ // ecosystem's `marketing-agency` + `paid-ads-agency`). A fork is by
127
+ // definition cross-template drift. This qualifier is what takes the rule
128
+ // from 1 false positive to 0 on the current corpus.
129
+ // • DIFFERENT slugs — the SAME slug in several templates is the documented
130
+ // overlay/reuse mechanism (`lead` in foundation + crm + marketing), not a
131
+ // fork. That case is rule 2's business.
132
+ const roles = all.filter((r) => r.profile.profileKind === "role");
133
+ const usedAllowlistKeys = new Set<string>();
134
+ const reportedPairs = new Set<string>();
135
+
136
+ for (let i = 0; i < roles.length; i++) {
137
+ for (let j = i + 1; j < roles.length; j++) {
138
+ const a = roles[i]!;
139
+ const b = roles[j]!;
140
+ if (a.template === b.template) continue;
141
+ if (a.profile.slug === b.profile.slug) continue;
142
+ const term = conceptTerm(effectiveSemanticSlug(a.profile));
143
+ if (term !== conceptTerm(effectiveSemanticSlug(b.profile))) continue;
144
+
145
+ const key = pairKey(a.profile.slug, b.profile.slug);
146
+ if (key in DECLARED_DIVERGENCES) {
147
+ usedAllowlistKeys.add(key);
148
+ continue;
149
+ }
150
+ if (reportedPairs.has(key)) continue;
151
+ reportedPairs.add(key);
152
+ violations.push({
153
+ rule: "concept-collision",
154
+ concept: term,
155
+ message:
156
+ `two role profiles denote the concept "${term}" under different slugs: ` +
157
+ `"${a.profile.slug}" (${a.template}) vs "${b.profile.slug}" (${b.template}). ` +
158
+ `Either REUSE the existing slug, or — if the divergence is intentional — declare it: ` +
159
+ `set an explicit \`semanticSlug\` on the new profile and add "${key}" to ` +
160
+ `DECLARED_DIVERGENCES in src/concept-guard.ts with the reason.`,
161
+ });
162
+ }
163
+ }
164
+
165
+ // ── RULE 2 — declared alias, forked shape ──────────────────────────────────
166
+ // Two profiles with DIFFERENT slugs that claim the SAME `semanticSlug` are
167
+ // asserting "one concept". Then their row-level shape must agree, or the
168
+ // assertion is a lie and the field is decorative. Absence is not a conflict:
169
+ // a template that OMITS a row-level field makes no claim (that is exactly how
170
+ // marketing.yaml correctly stops restating what foundation owns).
171
+ // Same-slug re-declaration is NOT checked here — that is the overlay path.
172
+ const bySemantic = new Map<string, ProfileRef[]>();
173
+ for (const ref of all) {
174
+ const key = effectiveSemanticSlug(ref.profile);
175
+ const bucket = bySemantic.get(key);
176
+ if (bucket) bucket.push(ref);
177
+ else bySemantic.set(key, [ref]);
178
+ }
179
+ for (const [semantic, group] of bySemantic) {
180
+ if (new Set(group.map((r) => r.profile.slug)).size < 2) continue;
181
+ for (const field of ["profileKind", "scope", "applicableKinds"]) {
182
+ const claims = new Map<string, string>();
183
+ for (const ref of group) {
184
+ const value = declaredValue(ref.profile, field);
185
+ if (value === null) continue;
186
+ const existing = claims.get(value);
187
+ if (!existing) claims.set(value, `${ref.template}:${ref.profile.slug}`);
188
+ }
189
+ if (claims.size > 1) {
190
+ violations.push({
191
+ rule: "alias-shape-divergence",
192
+ concept: semantic,
193
+ message:
194
+ `profiles aliased to semanticSlug "${semantic}" disagree on \`${field}\`: ` +
195
+ [...claims].map(([v, who]) => `${who} declares ${v}`).join(", ") +
196
+ `. Aliased profiles assert one concept — make the shapes agree, or drop the alias.`,
197
+ });
198
+ }
199
+ }
200
+ }
201
+
202
+ // ── RULE 3 — the allowlist may not rot ─────────────────────────────────────
203
+ for (const key of Object.keys(DECLARED_DIVERGENCES)) {
204
+ if (usedAllowlistKeys.has(key)) continue;
205
+ violations.push({
206
+ rule: "stale-allowlist",
207
+ concept: key,
208
+ message:
209
+ `DECLARED_DIVERGENCES entry "${key}" no longer matches any collision — ` +
210
+ `the divergence was resolved or the profiles were renamed. Delete the entry.`,
211
+ });
212
+ }
213
+
214
+ return violations;
215
+ }
216
+
217
+ export function formatConceptViolations(violations: ConceptViolation[]): string {
218
+ return violations.map((v) => ` • [${v.rule}] ${v.message}`).join("\n");
219
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  meta:
10
10
  slug: content-os
11
+ domain: content
11
12
  name: Content OS
12
13
  description: "Your editorial operating system — pillars, posts, platforms, and newsletters. Turn raw thinking into a published content engine, all connected to your campaigns and brand voice."
13
14
  icon: pen-tool
@@ -71,17 +72,51 @@ profiles:
71
72
  label: Status
72
73
  valueType: string
73
74
  inputType: select
74
- enumValues: ["Idea", "Briefed", "In Draft", "Review", "Scheduled", "Published", "Archived"]
75
+ enumValues:
76
+ [
77
+ "Idea",
78
+ "Briefed",
79
+ "In Draft",
80
+ "Review",
81
+ "Scheduled",
82
+ "Published",
83
+ "Archived",
84
+ ]
75
85
  - slug: post-format
76
86
  label: Format
77
87
  valueType: string
78
88
  inputType: select
79
- enumValues: ["Article", "Thread", "Short Video", "Long Video", "Carousel", "Image Post", "Story", "Newsletter Section", "Podcast Episode", "Other"]
89
+ enumValues:
90
+ [
91
+ "Article",
92
+ "Thread",
93
+ "Short Video",
94
+ "Long Video",
95
+ "Carousel",
96
+ "Image Post",
97
+ "Story",
98
+ "Newsletter Section",
99
+ "Podcast Episode",
100
+ "Other",
101
+ ]
80
102
  - slug: post-platform
81
103
  label: Platform
82
104
  valueType: string
83
105
  inputType: select
84
- enumValues: ["LinkedIn", "Instagram", "TikTok", "YouTube", "Twitter / X", "Threads", "Bluesky", "Newsletter", "Podcast", "Blog", "Other"]
106
+ enumValues:
107
+ [
108
+ "LinkedIn",
109
+ "Instagram",
110
+ "TikTok",
111
+ "YouTube",
112
+ "Twitter / X",
113
+ "Threads",
114
+ "Bluesky",
115
+ "Newsletter",
116
+ "Podcast",
117
+ "Blog",
118
+ "Other",
119
+ ]
85
120
  - slug: post-pillar
86
121
  label: Pillar
87
122
  valueType: entity_id
@@ -160,7 +195,16 @@ profiles:
160
195
  label: Primary Formats
161
196
  valueType: string
162
197
  inputType: select
163
- enumValues: ["Articles + Threads", "Short Video", "Long Video", "Newsletter", "Podcast", "Carousel", "Mixed"]
198
+ enumValues:
199
+ [
200
+ "Articles + Threads",
201
+ "Short Video",
202
+ "Long Video",
203
+ "Newsletter",
204
+ "Podcast",
205
+ "Carousel",
206
+ "Mixed",
207
+ ]
164
208
  - slug: post-count-pillar
165
209
  label: Total Posts Published
166
210
  valueType: number
@@ -178,23 +222,61 @@ profiles:
178
222
  label: Target Frequency
179
223
  valueType: string
180
224
  inputType: select
181
- enumValues: ["Daily", "3x / week", "2x / week", "Weekly", "Bi-weekly", "Monthly", "Ad hoc"]
225
+ enumValues:
226
+ [
227
+ "Daily",
228
+ "3x / week",
229
+ "2x / week",
230
+ "Weekly",
231
+ "Bi-weekly",
232
+ "Monthly",
233
+ "Ad hoc",
234
+ ]
182
235
  - slug: pillar-tags
183
236
  label: Tags
184
237
  valueType: array
185
238
 
239
+ # `platform` is the ONE pod-wide publishing-account *vocabulary*. `scope: shared`
240
+ # makes it a single pod-wide profile row that a `require`-consumer (e.g.
241
+ # social.yaml) can adopt by RE-DECLARING this slug — the resolver writes the
242
+ # grant on declare, giving that workspace the same vocabulary instead of forking
243
+ # its own `channel` kind. (This shares the profile DEFINITION, not the account
244
+ # entities — `platform` has no `entityScope: pod`, so each workspace's platform
245
+ # rows stay its own.) This is the deterministic form of the campaign reuse
246
+ # pattern (there, a workspace kind is promoted to shared at apply; here the
247
+ # SSOT is shared from the start). The re-declared body in social must not
248
+ # conflict with this one — guarded locally by the shared-role SSOT check, not a
249
+ # CI gate.
186
250
  - slug: platform
187
251
  displayName: Platform
188
252
  icon: share-2
189
253
  color: "#06B6D4"
190
254
  description: "A social account, newsletter publication, or podcast channel. Each platform has its own audience metrics and publishing rhythm."
191
- scope: WORKSPACE
255
+ scope: shared
192
256
  properties:
193
257
  - slug: platform-type
194
258
  label: Type
195
259
  valueType: string
196
260
  inputType: select
197
- enumValues: ["LinkedIn", "Instagram", "TikTok", "YouTube", "Twitter / X", "Threads", "Bluesky", "Newsletter", "Podcast", "Blog / Website", "Other"]
261
+ enumValues:
262
+ [
263
+ "LinkedIn",
264
+ "Instagram",
265
+ "TikTok",
266
+ "YouTube",
267
+ "Twitter / X",
268
+ "Threads",
269
+ "Bluesky",
270
+ "Newsletter",
271
+ "Podcast",
272
+ "Blog / Website",
273
+ "Other",
274
+ ]
275
+ - slug: handle
276
+ label: Handle
277
+ valueType: string
278
+ inputType: text
279
+ placeholder: "@username"
198
280
  - slug: platform-status
199
281
  label: Status
200
282
  valueType: string
@@ -222,7 +304,16 @@ profiles:
222
304
  label: Posting Frequency
223
305
  valueType: string
224
306
  inputType: select
225
- enumValues: ["Daily", "3x / week", "2x / week", "Weekly", "Bi-weekly", "Monthly", "As Needed"]
307
+ enumValues:
308
+ [
309
+ "Daily",
310
+ "3x / week",
311
+ "2x / week",
312
+ "Weekly",
313
+ "Bi-weekly",
314
+ "Monthly",
315
+ "As Needed",
316
+ ]
226
317
  - slug: last-post-date
227
318
  label: Last Post Date
228
319
  valueType: date
@@ -246,7 +337,8 @@ profiles:
246
337
  label: Status
247
338
  valueType: string
248
339
  inputType: select
249
- enumValues: ["Idea", "Drafting", "Editing", "Scheduled", "Sent", "Archived"]
340
+ enumValues:
341
+ ["Idea", "Drafting", "Editing", "Scheduled", "Sent", "Archived"]
250
342
  - slug: newsletter-series
251
343
  label: Series
252
344
  valueType: string
@@ -327,7 +419,8 @@ profiles:
327
419
  label: Channel
328
420
  valueType: string
329
421
  inputType: select
330
- enumValues: [email, social, event, content, partnership, paid, organic, other]
422
+ enumValues:
423
+ [email, social, event, content, partnership, paid, organic, other]
331
424
  - slug: campaign-goal
332
425
  label: Goal
333
426
  valueType: string
@@ -3,6 +3,7 @@
3
3
 
4
4
  meta:
5
5
  slug: content-studio
6
+ domain: content
6
7
  name: Content Studio
7
8
  description: HyperFrames + Remotion video pipeline — script, design, compose, render, publish.
8
9
  icon: clapperboard
package/src/crm.yaml CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  meta:
8
8
  slug: crm
9
+ domain: revenue
9
10
  name: CRM
10
11
  description: Sales pipeline and contact management from first contact to handoff.
11
12
  icon: handshake
package/src/define.ts CHANGED
@@ -404,6 +404,7 @@ export interface PackageDefinitionOutput {
404
404
  slug: string;
405
405
  icon?: string;
406
406
  color?: string;
407
+ domain?: string;
407
408
  tags?: string[];
408
409
  isPublic?: boolean;
409
410
  requiredTier?: string | null;
@@ -442,13 +443,11 @@ export interface PackageDefinitionOutput {
442
443
  automations?: PackageAutomationWire[];
443
444
  /**
444
445
  * Entity-detail action placements — carried verbatim (authored form IS the
445
- * wire form). NOTE: the Hub `PackageApplySchema` (synap-backend
446
- * `hub-protocol/rest/packages.ts`) is a plain `z.object` and does not yet
447
- * declare `actionPlacements`, so on the CLI/Hub install path this field is
448
- * STRIPPED before `applyPackagePostWorkspace` sees it. The tRPC
449
- * `createFromDefinition` door DOES carry it. Named follow-up: add an
450
- * `ActionPlacementSchema` field to `PackageApplySchema` to close the Hub half
451
- * (out of the T2 fence — flagged in the handoff).
446
+ * wire form). The Hub `PackageApplySchema` (synap-backend
447
+ * `hub-protocol/rest/packages.ts`) now declares `actionPlacements`
448
+ * (`z.array(ActionPlacementSchema).optional()`), so BOTH the tRPC
449
+ * `createFromDefinition` door and the CLI/Hub install path carry it through to
450
+ * `applyPackagePostWorkspace` — the parity is closed.
452
451
  */
453
452
  actionPlacements?: WorkspaceYaml['actionPlacements'];
454
453
  onboarding?: WorkspaceYaml['onboarding'];
@@ -481,6 +480,7 @@ export function toPackageDefinition(
481
480
  slug: tpl.meta.slug,
482
481
  icon: tpl.meta.icon,
483
482
  color: tpl.meta.color,
483
+ domain: tpl.meta.domain,
484
484
  tags: tpl.meta.tags,
485
485
  isPublic: tpl.meta.isPublic ?? true,
486
486
  },
@@ -535,7 +535,7 @@ type PackageDefinitionForwarded =
535
535
  | 'integrations' // → capabilities
536
536
  | 'playbooks'
537
537
  | 'automations' // → mapped to the apply door's flowDefinition wire shape
538
- | 'actionPlacements' // → carried verbatim (Hub inbound schema decl is a follow-up)
538
+ | 'actionPlacements' // → carried verbatim (Hub inbound schema declares it — parity closed)
539
539
  | 'onboarding'
540
540
  | 'dependencies';
541
541