@synap-core/workspace-templates 0.6.0 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
@@ -18,10 +18,11 @@
18
18
  "scripts": {
19
19
  "type-check": "tsc --noEmit",
20
20
  "build:yaml": "node build.mjs",
21
- "build": "node build.mjs && tsup",
21
+ "check:converters": "node scripts/check-converters.mjs",
22
+ "build": "node build.mjs && tsup && node scripts/check-converters.mjs",
22
23
  "provision": "node scripts/provision.mjs",
23
24
  "eval": "node scripts/eval-standard.mjs",
24
- "prepublishOnly": "node build.mjs && tsup"
25
+ "prepublishOnly": "node build.mjs && tsup && node scripts/check-converters.mjs"
25
26
  },
26
27
  "dependencies": {
27
28
  "zod": "^3.0.0",
@@ -895,28 +895,36 @@ displayTemplates:
895
895
  bento:
896
896
  viewName: "Agent Fleet Home"
897
897
  blocks:
898
- - widgetType: welcome-header
898
+ - kind: widget
899
+ widgetType: welcome-header
899
900
  pos: { x: 0, y: 0, w: 12, h: 2 }
900
901
  config: { title: "Agent Fleet", subtitle: "Your sovereign control plane for the agent fleet." }
901
- - widgetType: entity-count
902
+ - kind: widget
903
+ widgetType: entity-count
902
904
  pos: { x: 0, y: 2, w: 3, h: 2 }
903
905
  config: { profileSlug: agent, label: Agents, icon: bot }
904
- - widgetType: entity-count
906
+ - kind: widget
907
+ widgetType: entity-count
905
908
  pos: { x: 3, y: 2, w: 3, h: 2 }
906
909
  config: { profileSlug: task, label: Tasks, icon: check-square }
907
- - widgetType: entity-count
910
+ - kind: widget
911
+ widgetType: entity-count
908
912
  pos: { x: 6, y: 2, w: 3, h: 2 }
909
913
  config: { profileSlug: skill, label: Skills, icon: zap }
910
- - widgetType: entity-count
914
+ - kind: widget
915
+ widgetType: entity-count
911
916
  pos: { x: 9, y: 2, w: 3, h: 2 }
912
917
  config: { profileSlug: provider, label: Providers, icon: database }
913
- - widgetType: entity-spotlight
918
+ - kind: widget
919
+ widgetType: entity-spotlight
914
920
  pos: { x: 0, y: 4, w: 4, h: 4 }
915
921
  config: { profileSlug: agent, seed: random, layout: detail, subtitleField: status, color: "#1A3A5C" }
916
- - widgetType: entity-list
922
+ - kind: widget
923
+ widgetType: entity-list
917
924
  pos: { x: 4, y: 4, w: 4, h: 4 }
918
925
  config: { profileSlug: task, title: "Active Tasks", limit: 6, variant: compact }
919
- - widgetType: entity-list
926
+ - kind: widget
927
+ widgetType: entity-list
920
928
  pos: { x: 8, y: 4, w: 4, h: 4 }
921
929
  config: { profileSlug: skill, title: "Top Skills", limit: 6, variant: compact }
922
930
  - kind: view
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Unit tests for the mergeCatalog door. Run: `npx tsx --test src/catalog.test.ts`
3
+ *
4
+ * These test the SOURCE (`./catalog.js` → catalog.ts), never `dist/`, so no build
5
+ * is required and there is no stale-generated-output trap.
6
+ */
7
+
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mergeCatalog, type RemoteEntry } from "./catalog.js";
11
+ import type { WorkspaceYaml } from "./types.js";
12
+
13
+ const BUNDLE_VERSION = "0.6.0";
14
+
15
+ /** Minimal bundled template — only the fields the door reads. */
16
+ function bundled(slug: string, extra?: Partial<WorkspaceYaml["meta"]>): WorkspaceYaml {
17
+ return {
18
+ meta: { slug, name: slug, description: `${slug} desc`, icon: "layers", color: "#fff", ...extra },
19
+ workspace: { name: slug, description: "" },
20
+ profiles: [],
21
+ } as WorkspaceYaml;
22
+ }
23
+
24
+ /** Minimal CP row. `version` (when given) lands at definition.sourcePackage.version. */
25
+ function remote(slug: string, opts?: { version?: string; isPrivate?: boolean }): RemoteEntry {
26
+ return {
27
+ slug,
28
+ name: slug,
29
+ description: `${slug} remote`,
30
+ category: "workspace",
31
+ tags: [],
32
+ isPrivate: opts?.isPrivate,
33
+ definition:
34
+ opts?.version !== undefined
35
+ ? { sourcePackage: { name: "@synap-core/workspace-templates", version: opts.version } }
36
+ : undefined,
37
+ };
38
+ }
39
+
40
+ // ── Dedupe by slug ──────────────────────────────────────────────────────────
41
+ // MUTATION PROOF: change the dedupe key to `slug@version` and this count goes to
42
+ // 2 (bundled "1.0.0"-implied vs remote) → RED.
43
+ test("dedupes a slug present in both sources into ONE entry", () => {
44
+ const { entries } = mergeCatalog({
45
+ bundled: [bundled("crm"), bundled("legal")],
46
+ remote: [remote("crm", { version: "0.5.0" }), remote("legal", { version: "0.5.0" })],
47
+ bundleVersion: BUNDLE_VERSION,
48
+ });
49
+ assert.equal(entries.length, 2, "two slugs, deduped");
50
+ assert.equal(entries.filter((e) => e.slug === "crm").length, 1);
51
+ });
52
+
53
+ // ── Winner rule ─────────────────────────────────────────────────────────────
54
+ test("bundle wins when remote version EQUALS the bundle", () => {
55
+ const { entries } = mergeCatalog({
56
+ bundled: [bundled("crm")],
57
+ remote: [remote("crm", { version: BUNDLE_VERSION })],
58
+ bundleVersion: BUNDLE_VERSION,
59
+ });
60
+ assert.equal(entries[0].source, "bundled");
61
+ });
62
+
63
+ test("bundle wins when remote version is OLDER", () => {
64
+ const { entries } = mergeCatalog({
65
+ bundled: [bundled("crm")],
66
+ remote: [remote("crm", { version: "0.5.0" })],
67
+ bundleVersion: BUNDLE_VERSION,
68
+ });
69
+ assert.equal(entries[0].source, "bundled");
70
+ });
71
+
72
+ // MUTATION PROOF: flip `remoteIsStrictlyNewer`'s comparison (`>` → `<`, or return
73
+ // false) and this assertion (source === "remote") goes RED.
74
+ test("remote wins when it is STRICTLY newer (this client is stale)", () => {
75
+ const { entries } = mergeCatalog({
76
+ bundled: [bundled("crm")],
77
+ remote: [remote("crm", { version: "0.7.0" })],
78
+ bundleVersion: BUNDLE_VERSION,
79
+ });
80
+ assert.equal(entries[0].source, "remote");
81
+ });
82
+
83
+ // ── Third-party & squatting ─────────────────────────────────────────────────
84
+ test("a non-colliding remote slug is kept as a DISTINCT entry", () => {
85
+ const { entries } = mergeCatalog({
86
+ bundled: [bundled("crm")],
87
+ remote: [remote("acme-widget")], // no sourcePackage, no bundled twin
88
+ bundleVersion: BUNDLE_VERSION,
89
+ });
90
+ assert.equal(entries.length, 2);
91
+ const acme = entries.find((e) => e.slug === "acme-widget");
92
+ assert.equal(acme?.source, "remote");
93
+ assert.equal(acme?.isPrivate, false, "browse rows default public");
94
+ });
95
+
96
+ test("a third-party row SQUATTING an official slug (no sourcePackage) loses to the bundle", () => {
97
+ const { entries } = mergeCatalog({
98
+ bundled: [bundled("crm")],
99
+ remote: [remote("crm")], // slug collision, NO sourcePackage → squatting
100
+ bundleVersion: BUNDLE_VERSION,
101
+ });
102
+ assert.equal(entries.length, 1);
103
+ assert.equal(entries[0].source, "bundled");
104
+ });
105
+
106
+ // ── sources degradation (load-bearing) ──────────────────────────────────────
107
+ test("remote unreachable → returns bundled only + honest source status", () => {
108
+ const { entries, sources } = mergeCatalog({
109
+ bundled: [bundled("crm"), bundled("legal")],
110
+ remote: [], // fetch failed → empty
111
+ bundleVersion: BUNDLE_VERSION,
112
+ remoteStatus: "unreachable",
113
+ });
114
+ assert.equal(entries.length, 2, "still shows all bundled");
115
+ assert.equal(sources.bundled, "ok");
116
+ assert.equal(sources.remote, "unreachable");
117
+ });
118
+
119
+ test("remote unauthenticated → status surfaced; any stray remote rows are ignored", () => {
120
+ const { entries, sources } = mergeCatalog({
121
+ bundled: [bundled("crm")],
122
+ remote: [remote("private-pack", { isPrivate: true })], // must NOT be merged
123
+ bundleVersion: BUNDLE_VERSION,
124
+ remoteStatus: "unauthenticated",
125
+ });
126
+ assert.equal(entries.length, 1);
127
+ assert.equal(entries[0].slug, "crm");
128
+ assert.equal(sources.remote, "unauthenticated");
129
+ });
130
+
131
+ test("private remote packages merge when reachable and carry isPrivate", () => {
132
+ const { entries } = mergeCatalog({
133
+ bundled: [bundled("crm")],
134
+ remote: [remote("private-pack", { isPrivate: true })],
135
+ bundleVersion: BUNDLE_VERSION,
136
+ remoteStatus: "ok",
137
+ });
138
+ const priv = entries.find((e) => e.slug === "private-pack");
139
+ assert.equal(priv?.source, "remote");
140
+ assert.equal(priv?.isPrivate, true);
141
+ });
142
+
143
+ // ── Real bundled data shape (integration-ish) ───────────────────────────────
144
+ test("maps a bundled template's meta fields onto the entry", () => {
145
+ const { entries } = mergeCatalog({
146
+ bundled: [bundled("crm", { icon: "handshake", tags: ["crm", "sales"], isPublic: false })],
147
+ remote: [],
148
+ bundleVersion: BUNDLE_VERSION,
149
+ });
150
+ const e = entries[0];
151
+ assert.equal(e.source, "bundled");
152
+ assert.equal(e.icon, "handshake");
153
+ assert.deepEqual(e.tags, ["crm", "sales"]);
154
+ assert.equal(e.category, "workspace");
155
+ assert.equal(e.isPrivate, true, "isPublic:false ⇒ isPrivate");
156
+ });
package/src/catalog.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * mergeCatalog — the ONE merge door for the workspace-template catalog.
3
+ * ====================================================================
4
+ *
5
+ * Both marketplace entry points (browser + CLI) and the pod need to show ONE
6
+ * list of installable workspace templates drawn from TWO sources:
7
+ *
8
+ * 1. BUNDLED — the canonical `@synap-core/workspace-templates` package that
9
+ * ships inside every client (this package). Always present, zero network.
10
+ * 2. REMOTE — rows in the control-plane package registry (`GET /api/packages`),
11
+ * which includes both the official templates re-published by
12
+ * `seed-templates.ts` AND private / third-party packages that never shipped
13
+ * in the bundle.
14
+ *
15
+ * The two overlap: every official template exists in BOTH sources (once bundled,
16
+ * once seeded to the CP). Naively concatenating them shows 21 duplicate cards.
17
+ * `mergeCatalog` de-dupes them into ONE catalog with a deterministic winner.
18
+ *
19
+ * WHY THIS LIVES HERE (and is PURE):
20
+ * This is the ONLY module the root `pnpm-workspace.yaml` lets `synap-cli`,
21
+ * `browser`, `synap-control-plane-api`, AND `synap-backend/packages/api` all
22
+ * resolve. `@synap-core/marketplace` pulls React and is `private: true`, so the
23
+ * Node CLI cannot import it. Keep this file pure data — no fetch, no auth, no
24
+ * React, no Node-only APIs. Each entry point has a DIFFERENT transport (browser
25
+ * cookie/`cpFetch`, CLI Bearer, pod anonymous); a door that fetched would force
26
+ * ONE auth model on all three and become the next lossy seam. Transport lives
27
+ * in the consumers; this door only merges arrays they already fetched.
28
+ */
29
+
30
+ import type { WorkspaceYaml } from "./types.js";
31
+
32
+ // ============================================================================
33
+ // Inputs
34
+ // ============================================================================
35
+
36
+ /**
37
+ * A bundled template — the canonical `WorkspaceYaml` this package ships. Callers
38
+ * pass `listPublicTemplates()` (18) or `listWorkspaceTemplates()` (21); the door
39
+ * merges whatever it is given and does NOT filter (see the publicity note below).
40
+ */
41
+ export type BundledEntry = WorkspaceYaml;
42
+
43
+ /**
44
+ * A control-plane registry row, mapped into the minimal shape the door needs.
45
+ * Consumers build this from a `MarketplacePackage` (browser) / the CP
46
+ * `GET /api/packages` JSON (CLI) — a thin, transport-specific projection.
47
+ *
48
+ * `definition` is carried OPAQUE: it is both (a) the install payload the consumer
49
+ * feeds back to the pod, and (b) the home of the ONE comparable version signal,
50
+ * `definition.sourcePackage.version` — the real npm semver of the published
51
+ * `@synap-core/workspace-templates` build that generated an official row
52
+ * (`seed-templates.ts` stamps it). The door only reads that one nested field; the
53
+ * rest of `definition` is passed through untouched.
54
+ */
55
+ export interface RemoteEntry {
56
+ slug: string;
57
+ name: string;
58
+ description?: string;
59
+ /** CP package category / type (e.g. `"workspace"`, `"view"`). */
60
+ category?: string;
61
+ tags?: string[];
62
+ icon?: string | null;
63
+ /**
64
+ * `true` when the CP row is not publicly listed (`isPublic === false`). Browse
65
+ * rows leave this `undefined` BY CONSTRUCTION — the browse route hard-filters
66
+ * `WHERE is_public = true`, so every browse row is public; `undefined` ⇒ public,
67
+ * never "unknown-so-hide".
68
+ */
69
+ isPrivate?: boolean;
70
+ /**
71
+ * The CP `definition` jsonb — opaque install payload. The door reads only
72
+ * `definition.sourcePackage.version` (the winner signal). Absent on browse rows
73
+ * (the list route strips `definition`), which is fine: absence ⇒ bundle wins.
74
+ */
75
+ definition?: {
76
+ sourcePackage?: { name?: string; version?: string };
77
+ [k: string]: unknown;
78
+ };
79
+ }
80
+
81
+ /** Whether the caller could reach / authenticate against the remote source. */
82
+ export type RemoteSourceStatus = "ok" | "unauthenticated" | "unreachable";
83
+
84
+ export interface MergeCatalogInput {
85
+ /** Bundled templates (this package). Always "ok" — it is in-process data. */
86
+ bundled: BundledEntry[];
87
+ /** Already-fetched CP rows. Empty/omitted when the fetch failed. */
88
+ remote?: RemoteEntry[];
89
+ /**
90
+ * This client's bundled-package version (`package.json` `version`, live `0.6.0`).
91
+ * Compared against each remote row's `sourcePackage.version` to decide whether
92
+ * THIS client's bundle is stale. Callers pass the compiled-in package version.
93
+ */
94
+ bundleVersion: string;
95
+ /**
96
+ * The transport's verdict on the remote fetch. `"ok"` (default) ⇒ merge the
97
+ * `remote` array. `"unauthenticated"` / `"unreachable"` ⇒ ignore `remote` and
98
+ * surface the degradation in `sources.remote`, so the consumer can render
99
+ * "18 available — private ones need login" instead of FAILING OPEN EMPTY.
100
+ */
101
+ remoteStatus?: RemoteSourceStatus;
102
+ }
103
+
104
+ // ============================================================================
105
+ // Output
106
+ // ============================================================================
107
+
108
+ interface CatalogEntryBase {
109
+ slug: string;
110
+ name: string;
111
+ description?: string;
112
+ /** Package category / type. Bundled templates are always `"workspace"`. */
113
+ category?: string;
114
+ tags: string[];
115
+ icon?: string | null;
116
+ /** `true` ⇒ not publicly listed (internal bundled template, or private CP row). */
117
+ isPrivate: boolean;
118
+ }
119
+
120
+ /** A catalog entry backed by a bundled template. Install via `toWorkspaceDefinition()` / `toPackageDefinition()` on `template`. */
121
+ export interface BundledCatalogEntry extends CatalogEntryBase {
122
+ source: "bundled";
123
+ template: WorkspaceYaml;
124
+ }
125
+
126
+ /** A catalog entry backed by a CP registry row. Install from `remote.definition`. */
127
+ export interface RemoteCatalogEntry extends CatalogEntryBase {
128
+ source: "remote";
129
+ remote: RemoteEntry;
130
+ }
131
+
132
+ /** One merged card. `source` discriminates the install path; common fields feed a picker. */
133
+ export type CatalogEntry = BundledCatalogEntry | RemoteCatalogEntry;
134
+
135
+ export interface MergeCatalogResult {
136
+ entries: CatalogEntry[];
137
+ /**
138
+ * Per-source health. LOAD-BEARING, not decoration: it is what lets a consumer
139
+ * distinguish "remote returned nothing" from "remote unreachable" and avoid the
140
+ * FAILS-OPEN-EMPTY trap (agent sees empty catalog → thinks pod is empty → dupes).
141
+ */
142
+ sources: { bundled: "ok"; remote: RemoteSourceStatus };
143
+ }
144
+
145
+ // ============================================================================
146
+ // Winner signal
147
+ // ============================================================================
148
+
149
+ function parseSemver(v: string | undefined): [number, number, number] | null {
150
+ if (!v) return null;
151
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
152
+ if (!m) return null; // e.g. a range string ("range:>=0.6.0 <1.0.0") — not a version
153
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
154
+ }
155
+
156
+ /**
157
+ * Is the remote row STRICTLY newer than this client's bundle? Only then does the
158
+ * remote win a slug conflict — it means THIS client is stale and the CP has a
159
+ * fresher official build. Every other case (equal, older, unparseable, or no
160
+ * `sourcePackage` at all) ⇒ `false` ⇒ BUNDLE WINS. That default is correct four
161
+ * ways, strongest last: (1) the CP is provably staler in the common case; (2) the
162
+ * CP row is a lossy re-encode of the bundle; (3) zero network; (4) the pod's boot
163
+ * reconcile already converges every workspace to the BUNDLED shape — bundle-wins
164
+ * agrees with the reconciler; CP-wins would fight it every boot.
165
+ */
166
+ function remoteIsStrictlyNewer(
167
+ remoteVersion: string | undefined,
168
+ bundleVersion: string,
169
+ ): boolean {
170
+ const r = parseSemver(remoteVersion);
171
+ const b = parseSemver(bundleVersion);
172
+ if (!r || !b) return false;
173
+ for (let i = 0; i < 3; i++) {
174
+ if (r[i] > b[i]) return true;
175
+ if (r[i] < b[i]) return false;
176
+ }
177
+ return false; // equal ⇒ not strictly newer ⇒ bundle wins
178
+ }
179
+
180
+ // ============================================================================
181
+ // Mapping
182
+ // ============================================================================
183
+
184
+ function bundledToEntry(tpl: WorkspaceYaml): BundledCatalogEntry {
185
+ return {
186
+ source: "bundled",
187
+ slug: tpl.meta.slug,
188
+ name: tpl.meta.name,
189
+ description: tpl.meta.description,
190
+ // Every bundled template is a full workspace — the CP calls this category
191
+ // "workspace" (PACKAGE_TYPES). Finer display grouping is derived from `tags`
192
+ // by the consumer (browser `categoryFor`), not baked in here.
193
+ category: "workspace",
194
+ tags: tpl.meta.tags ?? [],
195
+ icon: tpl.meta.icon,
196
+ isPrivate: tpl.meta.isPublic === false,
197
+ template: tpl,
198
+ };
199
+ }
200
+
201
+ function remoteToEntry(row: RemoteEntry): RemoteCatalogEntry {
202
+ return {
203
+ source: "remote",
204
+ slug: row.slug,
205
+ name: row.name,
206
+ description: row.description,
207
+ category: row.category,
208
+ tags: row.tags ?? [],
209
+ icon: row.icon ?? null,
210
+ isPrivate: row.isPrivate ?? false,
211
+ remote: row,
212
+ };
213
+ }
214
+
215
+ // ============================================================================
216
+ // The door
217
+ // ============================================================================
218
+
219
+ /**
220
+ * Merge bundled + remote template catalogs into ONE de-duplicated list.
221
+ *
222
+ * DEDUPE KEY = `slug` — NOT `slug@version`. This is forced by the live data, not
223
+ * a preference: every CP flagship row is hardcoded `version: "1.0.0"`
224
+ * (`seed-templates.ts`) and `TemplateMeta` carries NO version field at all, so a
225
+ * `slug@version` key would collide on nothing → 21 duplicate cards. The real
226
+ * per-build version lives only in `definition.sourcePackage.version`, which is
227
+ * the WINNER signal, never the identity key.
228
+ *
229
+ * CONFLICT RESOLUTION (remote slug already claimed by a bundled template):
230
+ * • remote `sourcePackage.version` strictly > `bundleVersion` → remote wins
231
+ * (this client is stale — the CP has a fresher official build).
232
+ * • otherwise (equal / older / no `sourcePackage`) → BUNDLE wins. The
233
+ * no-`sourcePackage` case is a third-party package SQUATTING an official slug;
234
+ * the bundle wins unconditionally (squatting defense).
235
+ *
236
+ * A remote slug that does NOT collide with any bundled template is a DISTINCT
237
+ * entry (a private or third-party package) and is always kept.
238
+ */
239
+ export function mergeCatalog(input: MergeCatalogInput): MergeCatalogResult {
240
+ const { bundled, bundleVersion } = input;
241
+ const remoteStatus: RemoteSourceStatus = input.remoteStatus ?? "ok";
242
+ const remote = input.remote ?? [];
243
+
244
+ // Insertion-ordered map keyed by slug. Bundled entries seed it first, so they
245
+ // are the default winner; a qualifying remote row overwrites in place.
246
+ const bySlug = new Map<string, CatalogEntry>();
247
+
248
+ for (const tpl of bundled) {
249
+ bySlug.set(tpl.meta.slug, bundledToEntry(tpl));
250
+ }
251
+
252
+ // Only fold in remote rows when the transport actually reached the source.
253
+ // A failed fetch must NOT silently look like "the CP has no templates".
254
+ if (remoteStatus === "ok") {
255
+ for (const row of remote) {
256
+ const existing = bySlug.get(row.slug);
257
+
258
+ if (!existing) {
259
+ // No bundled template owns this slug → distinct (private / third-party).
260
+ bySlug.set(row.slug, remoteToEntry(row));
261
+ continue;
262
+ }
263
+
264
+ if (existing.source !== "bundled") {
265
+ // Two remote rows with the same slug (shouldn't happen — CP slugs are
266
+ // unique) — keep the first, defensively.
267
+ continue;
268
+ }
269
+
270
+ // Slug conflict with an official bundled template. Remote wins ONLY if it
271
+ // is a strictly-newer official build; else the bundle stays (older, equal,
272
+ // or a squatting third-party with no sourcePackage).
273
+ const remoteVersion = row.definition?.sourcePackage?.version;
274
+ if (remoteIsStrictlyNewer(remoteVersion, bundleVersion)) {
275
+ bySlug.set(row.slug, remoteToEntry(row));
276
+ }
277
+ // else: no-op — bundled entry already in place wins.
278
+ }
279
+ }
280
+
281
+ return {
282
+ entries: [...bySlug.values()],
283
+ sources: { bundled: "ok", remote: remoteStatus },
284
+ };
285
+ }
@@ -28,8 +28,35 @@ workspace:
28
28
  - brand.templates
29
29
  - brand.components
30
30
  - narrative.positioning
31
+ # Communication CONSUMES brand and PROVIDES narrative.
32
+ #
33
+ # `brand: provider` here was a LIVE bug, not dead metadata. brand-library.yaml
34
+ # also declares `brand: provider`, and the IS reads this to pick which
35
+ # workspace supplies brand for EVERY agent's system prompt
36
+ # (`resolveWorkspaceForCapability` in agents/utils/context-manager.ts): it
37
+ # filters candidates by the `brand.library` capability, then takes the FIRST
38
+ # workspace whose sourceRoles.brand is a provider. With both installed, "first"
39
+ # is whatever order listWorkspaces returned — an arbitrary, non-deterministic
40
+ # brand voice across turns. brand-library is the canonical brand source (this
41
+ # file's brand profiles are, by its own admission below, verbatim from it);
42
+ # Communication is the narrative/voice layer that reads from it.
43
+ #
44
+ # Every other domain in this template set has exactly ONE provider and names
45
+ # its consumers (content-studio: `brand: consumer`; marketing: `brand: consumer`).
46
+ # `brand` was the only domain with two. This restores that invariant.
47
+ #
48
+ # Expressed as a source ROLE, not `defaultSources.brand`, because a
49
+ # TemplateDefaultSource requires a concrete `workspaceId` (types.ts) that does
50
+ # not exist until provisioning — a template cannot know it. finance.yaml makes
51
+ # the same call for `strategy` and says so. `defaultSources` remains the
52
+ # correct RUNTIME override (`synap_declare_workspace_source`).
53
+ #
54
+ # The `brand.library` capability above is deliberately KEPT: it is what the
55
+ # resolver filters candidates on, and it makes this workspace a graceful
56
+ # fallback (step 3) when brand-library is not installed — in which case the
57
+ # local brand profiles below are exactly what the agent should read.
31
58
  sourceRoles:
32
- brand: provider
59
+ brand: consumer
33
60
  narrative: provider
34
61
 
35
62
  profiles: