@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
@@ -7,6 +7,7 @@
7
7
 
8
8
  meta:
9
9
  slug: dev-dashboard
10
+ domain: build
10
11
  name: "Synap Dev — Infrastructure Command Center"
11
12
  description: "Internal DevOps dashboard for monitoring Synap infrastructure. Tracks services, vendors, and environments with health status, architecture flow map, and quick access to admin panels."
12
13
  icon: server
@@ -123,7 +124,8 @@ profiles:
123
124
  label: Vendor Type
124
125
  valueType: string
125
126
  inputType: select
126
- enumValues: [Hosting, AI, Auth, Database, Monitoring, CI/CD, DNS, Email, Other]
127
+ enumValues:
128
+ [Hosting, AI, Auth, Database, Monitoring, CI/CD, DNS, Email, Other]
127
129
  - slug: vendor-url
128
130
  label: Vendor URL
129
131
  valueType: string
@@ -271,7 +273,8 @@ profiles:
271
273
  label: Type
272
274
  valueType: string
273
275
  inputType: select
274
- enumValues: [api_key, ssh_key, password, token, certificate, env_variable, other]
276
+ enumValues:
277
+ [api_key, ssh_key, password, token, certificate, env_variable, other]
275
278
  placeholder: "Select credential type"
276
279
  - slug: service-name
277
280
  label: Service
@@ -418,7 +421,14 @@ views:
418
421
  type: table
419
422
  scopeProfileSlug: service
420
423
  config:
421
- visibleColumns: [service-type, environment, deployment-branch, last-deploy, health-status]
424
+ visibleColumns:
425
+ [
426
+ service-type,
427
+ environment,
428
+ deployment-branch,
429
+ last-deploy,
430
+ health-status,
431
+ ]
422
432
  sortBy: last-deploy
423
433
  sortDirection: desc
424
434
 
@@ -854,12 +864,36 @@ bento:
854
864
  config:
855
865
  title: Quick Links
856
866
  links:
857
- - { label: GitHub, url: "https://github.com/synapsh", color: "#24292E" }
858
- - { label: Hetzner Console, url: "https://console.hetzner.cloud", color: "#D50C2D" }
859
- - { label: Vercel Dashboard, url: "https://vercel.com/dashboard", color: "#000000" }
860
- - { label: Ory Console, url: "https://console.ory.sh", color: "#5528FF" }
861
- - { label: Cloudflare, url: "https://dash.cloudflare.com", color: "#F38020" }
862
- - { label: OpenRouter, url: "https://openrouter.ai/dashboard", color: "#6366F1" }
867
+ - {
868
+ label: GitHub,
869
+ url: "https://github.com/synapsh",
870
+ color: "#24292E",
871
+ }
872
+ - {
873
+ label: Hetzner Console,
874
+ url: "https://console.hetzner.cloud",
875
+ color: "#D50C2D",
876
+ }
877
+ - {
878
+ label: Vercel Dashboard,
879
+ url: "https://vercel.com/dashboard",
880
+ color: "#000000",
881
+ }
882
+ - {
883
+ label: Ory Console,
884
+ url: "https://console.ory.sh",
885
+ color: "#5528FF",
886
+ }
887
+ - {
888
+ label: Cloudflare,
889
+ url: "https://dash.cloudflare.com",
890
+ color: "#F38020",
891
+ }
892
+ - {
893
+ label: OpenRouter,
894
+ url: "https://openrouter.ai/dashboard",
895
+ color: "#6366F1",
896
+ }
863
897
  - kind: widget
864
898
  widgetType: entity-list
865
899
  pos: { x: 0, y: 7, w: 6, h: 5 }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Unit tests for the domain taxonomy — the "works for any template" contract.
3
+ * Run: `npx vitest run packages/workspace-templates` (from the repo root).
4
+ */
5
+
6
+ import { describe, expect, it } from "vitest";
7
+ import {
8
+ groupByDomain,
9
+ domainLabel,
10
+ TEMPLATE_DOMAINS,
11
+ OTHER_DOMAIN_ID,
12
+ isKnownDomain,
13
+ } from "./domains.js";
14
+
15
+ type Row = { slug: string; domain?: string };
16
+ const domainOf = (r: Row) => r.domain;
17
+
18
+ describe("domainLabel", () => {
19
+ it("returns the registry label for a known domain", () => {
20
+ expect(domainLabel("revenue")).toBe("Sales & Marketing");
21
+ });
22
+ it("returns 'More' for a missing domain", () => {
23
+ expect(domainLabel(undefined)).toBe("More");
24
+ expect(domainLabel(null)).toBe("More");
25
+ expect(domainLabel("")).toBe("More");
26
+ });
27
+ it("title-cases an unknown-but-declared domain so it still reads", () => {
28
+ expect(domainLabel("supply-chain")).toBe("Supply Chain");
29
+ expect(domainLabel("bizops")).toBe("Bizops");
30
+ });
31
+ });
32
+
33
+ describe("groupByDomain — works for ANY template", () => {
34
+ it("orders known domains by the registry, never alphabetically", () => {
35
+ const rows: Row[] = [
36
+ { slug: "a", domain: "knowledge" }, // registry order 5
37
+ { slug: "b", domain: "strategy" }, // registry order 0
38
+ { slug: "c", domain: "content" }, // registry order 2
39
+ ];
40
+ const groups = groupByDomain(rows, domainOf);
41
+ expect(groups.map((g) => g.id)).toEqual(["strategy", "content", "knowledge"]);
42
+ });
43
+
44
+ it("preserves input order WITHIN a section", () => {
45
+ const rows: Row[] = [
46
+ { slug: "z", domain: "content" },
47
+ { slug: "a", domain: "content" },
48
+ ];
49
+ const [content] = groupByDomain(rows, domainOf);
50
+ expect(content.items.map((r) => r.slug)).toEqual(["z", "a"]);
51
+ });
52
+
53
+ it("places an unknown-but-declared domain AFTER all known ones", () => {
54
+ const rows: Row[] = [
55
+ { slug: "x", domain: "supply-chain" }, // unknown
56
+ { slug: "y", domain: "build" }, // known
57
+ ];
58
+ const groups = groupByDomain(rows, domainOf);
59
+ expect(groups.map((g) => g.id)).toEqual(["build", "supply-chain"]);
60
+ expect(groups[1].label).toBe("Supply Chain");
61
+ });
62
+
63
+ it("sorts multiple unknown domains alphabetically, before 'other'", () => {
64
+ const rows: Row[] = [
65
+ { slug: "1", domain: "zeta" },
66
+ { slug: "2" }, // missing → other
67
+ { slug: "3", domain: "alpha" },
68
+ { slug: "4", domain: "revenue" }, // known
69
+ ];
70
+ const groups = groupByDomain(rows, domainOf);
71
+ expect(groups.map((g) => g.id)).toEqual([
72
+ "revenue",
73
+ "alpha",
74
+ "zeta",
75
+ OTHER_DOMAIN_ID,
76
+ ]);
77
+ });
78
+
79
+ it("routes a MISSING domain into the 'More' bucket, always LAST", () => {
80
+ const rows: Row[] = [
81
+ { slug: "1" },
82
+ { slug: "2", domain: "strategy" },
83
+ ];
84
+ const groups = groupByDomain(rows, domainOf);
85
+ expect(groups[groups.length - 1].id).toBe(OTHER_DOMAIN_ID);
86
+ expect(groups[groups.length - 1].label).toBe("More");
87
+ });
88
+
89
+ it("NEVER drops an item — count in equals count out", () => {
90
+ const rows: Row[] = [
91
+ { slug: "1", domain: "build" },
92
+ { slug: "2", domain: "made-up" },
93
+ { slug: "3" },
94
+ { slug: "4", domain: "build" },
95
+ ];
96
+ const total = groupByDomain(rows, domainOf).reduce(
97
+ (n, g) => n + g.items.length,
98
+ 0
99
+ );
100
+ expect(total).toBe(rows.length);
101
+ });
102
+
103
+ it("omits empty sections", () => {
104
+ const groups = groupByDomain([{ slug: "1", domain: "build" }], domainOf);
105
+ expect(groups).toHaveLength(1);
106
+ expect(groups[0].id).toBe("build");
107
+ });
108
+ });
109
+
110
+ describe("isKnownDomain", () => {
111
+ it("recognises every registry id and rejects the rest", () => {
112
+ for (const d of TEMPLATE_DOMAINS) expect(isKnownDomain(d.id)).toBe(true);
113
+ expect(isKnownDomain("made-up")).toBe(false);
114
+ expect(isKnownDomain(undefined)).toBe(false);
115
+ expect(isKnownDomain(OTHER_DOMAIN_ID)).toBe(false);
116
+ });
117
+ });
package/src/domains.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Template DOMAIN taxonomy — the authored, cross-surface grouping that sections
3
+ * the marketplace (CLI `market list`, browser New Space + catalog, marketplace).
4
+ *
5
+ * WHY A FIELD, NOT DERIVED FROM TAGS. Grouping is a property of the template, so
6
+ * it lives ON the template (`meta.domain`) — authored once, read by every
7
+ * surface. The alternative was a hardcoded slug→group map (or a tag heuristic)
8
+ * inside each consumer; that map drifts the instant a template is added, and no
9
+ * one authoring a template can see it. Same lesson as the vocabulary work: a
10
+ * declared relationship should live where it can be verified, not re-guessed.
11
+ *
12
+ * NOT `category`. The catalog already uses `category` for the PACKAGE TYPE
13
+ * (`workspace`/`capability`/`skill`/…). `domain` is the human subject area.
14
+ *
15
+ * WORKS FOR ANY TEMPLATE. A template may declare a domain absent from this
16
+ * registry, or none at all — `groupByDomain` never drops one. Known domains
17
+ * render first in registry order; an unknown-but-declared domain renders after
18
+ * them (its id title-cased); a missing domain falls into the `other` bucket,
19
+ * always last. So a NEW template appears correctly without touching this file —
20
+ * adding it here only controls a domain's ORDER and LABEL.
21
+ */
22
+
23
+ export interface TemplateDomain {
24
+ id: string;
25
+ label: string;
26
+ }
27
+
28
+ export const OTHER_DOMAIN_ID = "other";
29
+ export const OTHER_DOMAIN_LABEL = "More";
30
+
31
+ /** Known domains, in display order (the order sections appear in a listing). */
32
+ export const TEMPLATE_DOMAINS: readonly TemplateDomain[] = [
33
+ { id: "strategy", label: "Strategy & Market" },
34
+ { id: "revenue", label: "Sales & Marketing" },
35
+ { id: "content", label: "Content & Social" },
36
+ { id: "company", label: "Company & Operations" },
37
+ { id: "build", label: "Build & Operate" },
38
+ { id: "knowledge", label: "Knowledge & Research" },
39
+ ];
40
+
41
+ const KNOWN = new Map(
42
+ TEMPLATE_DOMAINS.map((d, i) => [d.id, { label: d.label, order: i }] as const)
43
+ );
44
+
45
+ /** Every valid domain id the registry knows about (excludes `other`). */
46
+ export function isKnownDomain(id: string | undefined | null): boolean {
47
+ return id != null && KNOWN.has(id);
48
+ }
49
+
50
+ /** Display label for a domain id. Missing → "More"; unknown-declared → title-cased id. */
51
+ export function domainLabel(id: string | undefined | null): string {
52
+ if (!id) return OTHER_DOMAIN_LABEL;
53
+ const known = KNOWN.get(id);
54
+ if (known) return known.label;
55
+ return id
56
+ .replace(/[-_]/g, " ")
57
+ .replace(/\b\w/g, (c) => c.toUpperCase());
58
+ }
59
+
60
+ export interface DomainGroup<T> {
61
+ id: string;
62
+ label: string;
63
+ items: T[];
64
+ }
65
+
66
+ /**
67
+ * Partition `items` into ordered domain sections: known domains first (registry
68
+ * order), then unknown-but-declared domains (alphabetical), then the missing
69
+ * "More" bucket, always last. Empty sections are omitted; nothing is dropped.
70
+ * Item order WITHIN a section is preserved from the input.
71
+ */
72
+ export function groupByDomain<T>(
73
+ items: readonly T[],
74
+ domainOf: (item: T) => string | undefined | null
75
+ ): DomainGroup<T>[] {
76
+ const buckets = new Map<string, T[]>();
77
+ for (const item of items) {
78
+ const id = domainOf(item) || OTHER_DOMAIN_ID;
79
+ const bucket = buckets.get(id);
80
+ if (bucket) bucket.push(item);
81
+ else buckets.set(id, [item]);
82
+ }
83
+
84
+ const rank = (id: string): number => {
85
+ if (id === OTHER_DOMAIN_ID) return Number.MAX_SAFE_INTEGER;
86
+ const known = KNOWN.get(id);
87
+ return known ? known.order : 1_000_000; // unknown-declared: after known, before "other"
88
+ };
89
+
90
+ return [...buckets.entries()]
91
+ .sort(([a], [b]) => {
92
+ const ra = rank(a);
93
+ const rb = rank(b);
94
+ if (ra !== rb) return ra - rb;
95
+ return a < b ? -1 : a > b ? 1 : 0;
96
+ })
97
+ .map(([id, groupItems]) => ({
98
+ id,
99
+ label: id === OTHER_DOMAIN_ID ? OTHER_DOMAIN_LABEL : domainLabel(id),
100
+ items: groupItems,
101
+ }));
102
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  meta:
10
10
  slug: ecosystem
11
+ domain: strategy
11
12
  name: Ecosystem
12
13
  description: Map the market you operate in — the partners, competitors, and players, the segments and trends, and the relationships between them.
13
14
  icon: radar
@@ -170,7 +171,8 @@ profiles:
170
171
  label: Type
171
172
  valueType: string
172
173
  inputType: select
173
- enumValues: [raise, partnership, launch, acquisition, listing, conference, other]
174
+ enumValues:
175
+ [raise, partnership, launch, acquisition, listing, conference, other]
174
176
  - slug: event-date
175
177
  label: Date
176
178
  valueType: date
@@ -390,9 +392,19 @@ playbooks:
390
392
  profileSlug: competitor
391
393
  params:
392
394
  - { name: competitor, type: entity, label: Competitor, required: true }
393
- - { name: focus, type: text, label: Focus area, defaultValue: "product, pricing, and positioning" }
395
+ - {
396
+ name: focus,
397
+ type: text,
398
+ label: Focus area,
399
+ defaultValue: "product, pricing, and positioning",
400
+ }
394
401
  - { name: companyContext, type: text, label: Our company context }
395
- - { name: outputTypes, type: text, label: Outputs, defaultValue: "a head-to-head comparison summary and an updated competitor profile" }
402
+ - {
403
+ name: outputTypes,
404
+ type: text,
405
+ label: Outputs,
406
+ defaultValue: "a head-to-head comparison summary and an updated competitor profile",
407
+ }
396
408
  executor: is-agent
397
409
  # A radar MONITOR: bound to the competitor KIND + a schedule → the backing
398
410
  # cron automation fans out (query→loop→playbook_run) and runs this once per
@@ -423,7 +435,12 @@ playbooks:
423
435
  angles you covered, and why each new one matters.
424
436
  schedule: { cron: "0 9 * * 1", enabled: false }
425
437
  params:
426
- - { name: ourSpace, type: text, label: Our market / category, required: true }
438
+ - {
439
+ name: ourSpace,
440
+ type: text,
441
+ label: Our market / category,
442
+ required: true,
443
+ }
427
444
  executor: is-agent
428
445
  grants:
429
446
  - exa_search
@@ -536,7 +553,10 @@ layout:
536
553
  icon: database
537
554
 
538
555
  bento:
539
- viewName: Ecosystem Map
556
+ # Distinct from the "Ecosystem Map" graph view it embeds — the bento is created
557
+ # as its OWN view row, so reusing an embedded view's name puts two identically
558
+ # named views in the switcher.
559
+ viewName: Ecosystem Home
540
560
  blocks:
541
561
  - kind: view
542
562
  viewName: Ecosystem Map
@@ -20,6 +20,7 @@
20
20
 
21
21
  meta:
22
22
  slug: enterprise-os
23
+ domain: company
23
24
  name: Enterprise OS
24
25
  description: Stand up a company's public operating core in one install — strategy, market, operations, and pipeline — plus a command tower to track objectives across them.
25
26
  icon: layout-dashboard
package/src/finance.yaml CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  meta:
7
7
  slug: finance
8
+ domain: company
8
9
  name: Finance
9
10
  description: The company's money model — accounts, receivables, spend, revenue streams, budgets, and runway.
10
11
  icon: banknote
@@ -123,7 +124,17 @@ profiles:
123
124
  label: Category
124
125
  valueType: string
125
126
  inputType: select
126
- enumValues: [payroll, software, marketing, rent, travel, contractors, taxes, other]
127
+ enumValues:
128
+ [
129
+ payroll,
130
+ software,
131
+ marketing,
132
+ rent,
133
+ travel,
134
+ contractors,
135
+ taxes,
136
+ other,
137
+ ]
127
138
  constraints: { defaultValue: other }
128
139
  - slug: status
129
140
  label: Status
@@ -245,7 +256,18 @@ profiles:
245
256
  label: Metric
246
257
  valueType: string
247
258
  inputType: select
248
- enumValues: [runway, burn-rate, mrr, arr, cash-balance, gross-margin, cac, ltv, other]
259
+ enumValues:
260
+ [
261
+ runway,
262
+ burn-rate,
263
+ mrr,
264
+ arr,
265
+ cash-balance,
266
+ gross-margin,
267
+ cac,
268
+ ltv,
269
+ other,
270
+ ]
249
271
  constraints: { defaultValue: runway }
250
272
  - slug: value
251
273
  label: Value
@@ -292,7 +314,8 @@ views:
292
314
  defaultView: true
293
315
  description: Accounts receivable — who owes us, how much, and when it's due.
294
316
  config:
295
- columns: [title, invoice-number, status, amount, customer, due-date, paid-date]
317
+ columns:
318
+ [title, invoice-number, status, amount, customer, due-date, paid-date]
296
319
  defaultSort:
297
320
  field: due-date
298
321
  direction: asc
@@ -311,7 +334,16 @@ views:
311
334
  scopeProfileSlug: revenue-stream
312
335
  description: Revenue streams with MRR/ARR and billing cadence.
313
336
  config:
314
- columns: [title, stream-type, status, mrr, arr, billing-cadence, customer-segment]
337
+ columns:
338
+ [
339
+ title,
340
+ stream-type,
341
+ status,
342
+ mrr,
343
+ arr,
344
+ billing-cadence,
345
+ customer-segment,
346
+ ]
315
347
  defaultSort:
316
348
  field: mrr
317
349
  direction: desc
@@ -321,7 +353,16 @@ views:
321
353
  scopeProfileSlug: budget
322
354
  description: Planned spend against actuals, by category and period.
323
355
  config:
324
- columns: [title, category, period, fiscal-year, budgeted-amount, actual-amount, status]
356
+ columns:
357
+ [
358
+ title,
359
+ category,
360
+ period,
361
+ fiscal-year,
362
+ budgeted-amount,
363
+ actual-amount,
364
+ status,
365
+ ]
325
366
  - name: Runway & Metrics
326
367
  slug: runway-metrics
327
368
  type: table
@@ -6,6 +6,7 @@
6
6
 
7
7
  meta:
8
8
  slug: foundation
9
+ domain: strategy
9
10
  name: Foundation
10
11
  description: The strategic DNA — mission, audience, positioning, and principles — that every workspace inherits.
11
12
  icon: compass
@@ -455,7 +456,18 @@ views:
455
456
  - name: Strategy Map
456
457
  slug: strategy-map
457
458
  type: graph
458
- scopeProfileSlugs: [mission, vision, value, audience, pain-point, positioning, narrative, differentiator, non-goal]
459
+ scopeProfileSlugs:
460
+ [
461
+ mission,
462
+ vision,
463
+ value,
464
+ audience,
465
+ pain-point,
466
+ positioning,
467
+ narrative,
468
+ differentiator,
469
+ non-goal,
470
+ ]
459
471
  description: The strategic DNA as a connected graph.
460
472
 
461
473
  # Schema-level relations — give the Strategy Map and the relation picker
@@ -0,0 +1,15 @@
1
+ /** Types for `generate.mjs` — the shared renderer for `src/templates.ts`. */
2
+
3
+ export interface RawTemplateEntry {
4
+ /** Source file name, e.g. `business-developer.yaml`. */
5
+ file: string;
6
+ /** `meta.slug` as declared in the YAML. */
7
+ slug: string;
8
+ /** The parsed YAML document, untyped on purpose (build-time shape). */
9
+ data: unknown;
10
+ }
11
+
12
+ export declare const SRC_DIR: string;
13
+ export declare const TEMPLATES_FILE: string;
14
+ export declare function readYamlTemplates(): RawTemplateEntry[];
15
+ export declare function renderTemplatesModule(templates: RawTemplateEntry[]): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The ONE renderer for the generated `src/templates.ts` corpus.
3
+ *
4
+ * WHY THIS FILE EXISTS (read before inlining it back into build.mjs):
5
+ * `src/templates.ts` is auto-generated from `src/*.yaml` AND committed to git
6
+ * (it has to be — `browser/tsconfig.json` aliases this package straight at
7
+ * `src/`, with no build step, and synap-backend's
8
+ * `__tripwires__/template-shared-role-ssot.test.ts` reads the file by path).
9
+ * A committed artifact can go stale: edit a YAML, run `pnpm test`, and every
10
+ * template-corpus check — including `concept-guard` — passes against
11
+ * vocabulary that no longer exists on disk. Only `pnpm build` regenerated.
12
+ *
13
+ * So the render logic lives HERE, and two callers share it:
14
+ * • `build.mjs` — validates, renders, WRITES the file.
15
+ * • `src/templates-freshness.test.ts` — renders in memory and asserts the
16
+ * committed file matches byte-for-byte. Runs in `pnpm test`, i.e. in CI.
17
+ *
18
+ * Duplicating the template string into the test would recreate the same class
19
+ * of drift one level up, so don't.
20
+ */
21
+
22
+ import { readFileSync, readdirSync } from 'fs';
23
+ import { dirname, join } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import yaml from 'js-yaml';
26
+
27
+ export const SRC_DIR = dirname(fileURLToPath(import.meta.url));
28
+ export const TEMPLATES_FILE = join(SRC_DIR, 'templates.ts');
29
+
30
+ /**
31
+ * Read + parse every `src/*.yaml`, in the same (readdir) order build.mjs used.
32
+ * No validation here — validation stays in build.mjs, which is the writer.
33
+ */
34
+ export function readYamlTemplates() {
35
+ return readdirSync(SRC_DIR)
36
+ .filter((f) => f.endsWith('.yaml'))
37
+ .map((file) => {
38
+ const parsed = yaml.load(readFileSync(join(SRC_DIR, file), 'utf-8'));
39
+ return { file, slug: parsed?.meta?.slug, data: parsed };
40
+ });
41
+ }
42
+
43
+ /** Render the full text of `src/templates.ts` for a corpus. Pure. */
44
+ export function renderTemplatesModule(templates) {
45
+ const data = JSON.stringify(
46
+ Object.fromEntries(templates.map((t) => [t.slug, t.data])),
47
+ null,
48
+ 2,
49
+ );
50
+
51
+ return `// ═══════════════════════════════════════════════════════════════════════
52
+ // AUTO-GENERATED from YAML source files in src/*.yaml
53
+ // DO NOT EDIT — run \`node build.mjs\` or \`pnpm build\` to regenerate
54
+ // ═══════════════════════════════════════════════════════════════════════
55
+
56
+ import type { WorkspaceYaml } from './types.js';
57
+
58
+ const DATA = ${data} as unknown as Record<string, WorkspaceYaml>;
59
+
60
+ export const WORKSPACE_TEMPLATES: Record<string, WorkspaceYaml> = DATA;
61
+
62
+ export type WorkspaceTemplateSlug = keyof typeof WORKSPACE_TEMPLATES;
63
+
64
+ export function getWorkspaceTemplate(slug: string): WorkspaceYaml | undefined {
65
+ return WORKSPACE_TEMPLATES[slug];
66
+ }
67
+
68
+ export function listWorkspaceTemplates(): WorkspaceYaml[] {
69
+ return Object.values(WORKSPACE_TEMPLATES);
70
+ }
71
+
72
+ /**
73
+ * Public templates only (\`meta.isPublic !== false\`). This is a CONVENIENCE
74
+ * filter for the default picker — NOT a security boundary: the private YAML
75
+ * still ships in the published tarball, so anything that must not leak cannot
76
+ * rely on this. \`listWorkspaceTemplates()\` returns all of them (CLI, seeder,
77
+ * tripwire need the full set). Publicity is also expressed per-entry as
78
+ * \`CatalogEntry.isPrivate\` by \`mergeCatalog()\`, so a caller can pass ALL
79
+ * templates and filter at the presentation layer instead of choosing a list
80
+ * function up front — the merge door itself never filters (it stays pure).
81
+ */
82
+ export function listPublicTemplates(): WorkspaceYaml[] {
83
+ return Object.values(WORKSPACE_TEMPLATES).filter(t => t.meta?.isPublic !== false);
84
+ }
85
+ `;
86
+ }
package/src/grants.yaml CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  meta:
14
14
  slug: grants
15
+ domain: revenue
15
16
  name: Grants Pipeline
16
17
  description: "Grant-agency operations — submissions, rounds, analyses. Overlays the Operations workspace."
17
18
  icon: award