@synap-core/workspace-templates 0.7.0 → 0.7.2
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/dist/index.d.ts +172 -1
- package/dist/index.js +370 -28
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/business-developer.yaml +8 -0
- package/src/catalog.test.ts +2 -2
- package/src/composition-graph.test.ts +135 -0
- package/src/composition-graph.ts +298 -0
- package/src/concept-guard.test.ts +176 -0
- package/src/concept-guard.ts +219 -0
- package/src/ecosystem.yaml +4 -1
- package/src/generate.d.mts +15 -0
- package/src/generate.mjs +86 -0
- package/src/index.ts +24 -0
- package/src/meetings-knowledge.yaml +136 -22
- package/src/outreach-comms.yaml +5 -2
- package/src/templates-freshness.test.ts +55 -0
- package/src/templates.ts +126 -28
|
@@ -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
|
+
}
|
package/src/ecosystem.yaml
CHANGED
|
@@ -536,7 +536,10 @@ layout:
|
|
|
536
536
|
icon: database
|
|
537
537
|
|
|
538
538
|
bento:
|
|
539
|
-
|
|
539
|
+
# Distinct from the "Ecosystem Map" graph view it embeds — the bento is created
|
|
540
|
+
# as its OWN view row, so reusing an embedded view's name puts two identically
|
|
541
|
+
# named views in the switcher.
|
|
542
|
+
viewName: Ecosystem Home
|
|
540
543
|
blocks:
|
|
541
544
|
- kind: view
|
|
542
545
|
viewName: Ecosystem Map
|
|
@@ -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;
|
package/src/generate.mjs
ADDED
|
@@ -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/index.ts
CHANGED
|
@@ -85,6 +85,30 @@ export type {
|
|
|
85
85
|
TemplateUpdateCheck,
|
|
86
86
|
} from "./catalog.js";
|
|
87
87
|
|
|
88
|
+
export {
|
|
89
|
+
layerTemplateGraph,
|
|
90
|
+
bundledEdgesOf,
|
|
91
|
+
} from "./composition-graph.js";
|
|
92
|
+
export type {
|
|
93
|
+
CompositionInstalledEntry,
|
|
94
|
+
CompositionOverlay,
|
|
95
|
+
CompositionEdge,
|
|
96
|
+
CompositionNode,
|
|
97
|
+
CompositionGraph,
|
|
98
|
+
LayerTemplateGraphInput,
|
|
99
|
+
} from "./composition-graph.js";
|
|
100
|
+
|
|
101
|
+
// Cross-template vocabulary-fork guard. Exported because the build/publish gate
|
|
102
|
+
// (`scripts/check-concepts.mjs`) runs it off `dist/` — one implementation, two
|
|
103
|
+
// enforcement points (CI `pnpm test` + `pnpm build`).
|
|
104
|
+
export {
|
|
105
|
+
findConceptViolations,
|
|
106
|
+
formatConceptViolations,
|
|
107
|
+
conceptTerm,
|
|
108
|
+
DECLARED_DIVERGENCES,
|
|
109
|
+
} from "./concept-guard.js";
|
|
110
|
+
export type { ConceptViolation } from "./concept-guard.js";
|
|
111
|
+
|
|
88
112
|
export {
|
|
89
113
|
toWorkspaceDefinition,
|
|
90
114
|
toPackageDefinition,
|
|
@@ -25,26 +25,92 @@ workspace:
|
|
|
25
25
|
meetings: provider
|
|
26
26
|
|
|
27
27
|
profiles:
|
|
28
|
-
# ──
|
|
28
|
+
# ── Reused pod-wide kinds ────────────────────────────────────────────────
|
|
29
|
+
# Declared minimally because the workspace-definition validator resolves view
|
|
30
|
+
# scopes and entityLink endpoints ONLY against the slugs declared here — a
|
|
31
|
+
# referenced-but-undeclared slug throws at install time, it does not fall
|
|
32
|
+
# through to the pod-wide kind. Kept light on purpose: these kinds are owned
|
|
33
|
+
# pod-wide (identity + full schema live there), this is just the local grant.
|
|
34
|
+
- slug: person
|
|
35
|
+
displayName: Person
|
|
36
|
+
icon: user
|
|
37
|
+
color: amber
|
|
38
|
+
description: A meeting attendee.
|
|
39
|
+
properties:
|
|
40
|
+
- slug: email
|
|
41
|
+
label: Email
|
|
42
|
+
valueType: string
|
|
43
|
+
inputType: email
|
|
44
|
+
|
|
45
|
+
- slug: task
|
|
46
|
+
displayName: Task
|
|
47
|
+
icon: check-square
|
|
48
|
+
color: blue
|
|
49
|
+
description: An action item that came out of a meeting.
|
|
50
|
+
properties:
|
|
51
|
+
# NOTE: the pod-wide `task` already ships `status` and `dueDate`, and the
|
|
52
|
+
# applier SKIPS properties that already exist — so these are not what makes
|
|
53
|
+
# the kanban work. They are restated only so the SLUG is declarable here
|
|
54
|
+
# (the validator resolves view scopes against this template's `profiles`).
|
|
55
|
+
# The enum mirrors the live pod-wide one exactly; diverging would be
|
|
56
|
+
# misleading since the declaration is skipped and the real lanes win.
|
|
57
|
+
- slug: status
|
|
58
|
+
label: Status
|
|
59
|
+
valueType: string
|
|
60
|
+
inputType: select
|
|
61
|
+
enumValues: [todo, in-progress, done, cancelled]
|
|
62
|
+
constraints: { defaultValue: todo }
|
|
63
|
+
- slug: dueDate
|
|
64
|
+
label: Due
|
|
65
|
+
valueType: date
|
|
66
|
+
inputType: date
|
|
67
|
+
|
|
68
|
+
- slug: note
|
|
69
|
+
displayName: Note
|
|
70
|
+
icon: file-text
|
|
71
|
+
color: violet
|
|
72
|
+
description: A note or summary captured from a meeting.
|
|
73
|
+
properties:
|
|
74
|
+
- slug: content
|
|
75
|
+
label: Content
|
|
76
|
+
valueType: string
|
|
77
|
+
inputType: textarea
|
|
78
|
+
|
|
79
|
+
# ── Event (reused pod-wide kind — the meeting's IDENTITY) ────────────────
|
|
80
|
+
# The pod already seeds `event`, and the live Google Calendar sync writes
|
|
81
|
+
# `entities.type = "event"`. A parallel `meeting` KIND would therefore sit
|
|
82
|
+
# empty beside a synced calendar AND mint a second entity for the same
|
|
83
|
+
# real-world meeting. Identity lives here; the meeting-ness is a role below.
|
|
84
|
+
- slug: event
|
|
85
|
+
displayName: Event
|
|
86
|
+
icon: calendar
|
|
87
|
+
color: sky
|
|
88
|
+
description: A calendar event — the thing a meeting actually is.
|
|
89
|
+
properties:
|
|
90
|
+
- slug: startDate
|
|
91
|
+
label: Start
|
|
92
|
+
valueType: date
|
|
93
|
+
inputType: date
|
|
94
|
+
|
|
95
|
+
# ── Meeting (a ROLE the event plays — NOT a second entity) ───────────────
|
|
96
|
+
# Kind-vs-Facets rule: a role an entity PLAYS is a facet attached to the real
|
|
97
|
+
# thing, never its own kind. Date/attendees/location come from the `event`;
|
|
98
|
+
# only these four are genuinely new. The import playbook ATTACHES this role to
|
|
99
|
+
# the already-synced event rather than creating a duplicate.
|
|
29
100
|
- slug: meeting
|
|
30
101
|
displayName: Meeting
|
|
31
102
|
icon: video
|
|
32
103
|
color: violet
|
|
33
|
-
|
|
104
|
+
scope: shared
|
|
105
|
+
profileKind: role
|
|
106
|
+
applicableKinds: [event]
|
|
107
|
+
description: The meeting hat an event wears — platform, transcript, and what came out of it.
|
|
34
108
|
properties:
|
|
35
|
-
- slug: meetingDate
|
|
36
|
-
label: Date
|
|
37
|
-
valueType: date
|
|
38
|
-
inputType: date
|
|
39
109
|
- slug: platform
|
|
40
110
|
label: Platform
|
|
41
111
|
valueType: string
|
|
42
112
|
inputType: select
|
|
43
113
|
enumValues: [zoom, google-meet, teams, in-person, other]
|
|
44
|
-
- slug: attendees
|
|
45
|
-
label: Attendees
|
|
46
|
-
valueType: array
|
|
47
|
-
inputType: text
|
|
48
114
|
- slug: status
|
|
49
115
|
label: Status
|
|
50
116
|
valueType: string
|
|
@@ -67,13 +133,37 @@ views:
|
|
|
67
133
|
scopeProfileSlug: meeting
|
|
68
134
|
description: Every meeting, its platform, and whether it's been processed.
|
|
69
135
|
config:
|
|
70
|
-
columns: [title,
|
|
136
|
+
columns: [title, startDate, platform, status]
|
|
137
|
+
# The workspace's daily question: which meetings happened but haven't been
|
|
138
|
+
# imported+summarized yet. This is the one action the workspace exists to
|
|
139
|
+
# prompt, so it is the landing view — not the full table.
|
|
140
|
+
- name: Needs Processing
|
|
141
|
+
slug: meetings-unprocessed
|
|
142
|
+
type: list
|
|
143
|
+
scopeProfileSlug: meeting
|
|
144
|
+
description: Meetings that happened but haven't been imported and summarized yet.
|
|
145
|
+
config:
|
|
146
|
+
filterBy: { field: status, operator: is, value: completed }
|
|
71
147
|
defaultView: true
|
|
148
|
+
# Summaries the playbook writes land here — without a view they're invisible.
|
|
149
|
+
- name: Meeting Notes
|
|
150
|
+
slug: meeting-notes
|
|
151
|
+
type: list
|
|
152
|
+
scopeProfileSlug: note
|
|
153
|
+
description: Summaries and notes produced from meetings.
|
|
72
154
|
- name: Meetings Calendar
|
|
73
155
|
slug: meetings-calendar
|
|
74
156
|
type: calendar
|
|
75
157
|
scopeProfileSlug: meeting
|
|
76
158
|
description: Meetings on a calendar, keyed on their date.
|
|
159
|
+
# The date comes from the underlying `event` (startDate), not the meeting
|
|
160
|
+
# role. NOTE the `properties.` prefix is REQUIRED: the calendar resolves
|
|
161
|
+
# dateField with a dotted-path walker against the entity, so a bare
|
|
162
|
+
# `startDate` reads `entity.startDate` (undefined) and only renders at all
|
|
163
|
+
# by falling through the hardcoded dueDate→startDate→startTime chain.
|
|
164
|
+
config:
|
|
165
|
+
dateField: properties.startDate
|
|
166
|
+
colorBy: status
|
|
77
167
|
- name: Action Items
|
|
78
168
|
slug: meeting-action-items
|
|
79
169
|
type: kanban
|
|
@@ -152,20 +242,41 @@ onboarding:
|
|
|
152
242
|
Recurring meetings (standups, 1:1s) get skipped because they feel
|
|
153
243
|
routine — but their action items are exactly what falls through the
|
|
154
244
|
cracks. Don't let "it's just a standup" excuse skipping capture.
|
|
245
|
+
- >
|
|
246
|
+
"Attendees" as a list of names is not attendee capture — it's a string.
|
|
247
|
+
If the summary says "Sarah will send the deck" and Sarah isn't a tracked
|
|
248
|
+
person linked to that task, the next meeting with Sarah starts from zero
|
|
249
|
+
again. Resolve every attendee to a real person, or say out loud that
|
|
250
|
+
you're choosing not to.
|
|
251
|
+
- >
|
|
252
|
+
A summary nobody re-reads is theatre. The test for a good meeting note is
|
|
253
|
+
whether someone who missed the call could act from it alone — if it needs
|
|
254
|
+
the transcript to make sense, it isn't a summary yet.
|
|
155
255
|
bar: >
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
256
|
+
The PIPE is live, not the rows: transcript source and calendar connected,
|
|
257
|
+
the Import & Summarize playbook run once end-to-end on a real recent
|
|
258
|
+
meeting, and the output inspected together — a note a human would actually
|
|
259
|
+
keep, tasks that are genuinely actionable, and attendees resolved to
|
|
260
|
+
tracked people rather than name strings. If the first import produces vague
|
|
261
|
+
tasks ("follow up"), fix the extraction bar now, not after fifty meetings.
|
|
159
262
|
collect:
|
|
263
|
+
# Deliberately NOT a backfill. The import playbook fills date, summary,
|
|
264
|
+
# attendees and action items — asking the human to type them here would
|
|
265
|
+
# invert the product's whole loop (capture → AI structures it). The only
|
|
266
|
+
# thing the agent can't infer is which meetings deserve the pipe.
|
|
160
267
|
- profileSlug: meeting
|
|
161
|
-
what:
|
|
268
|
+
what: >
|
|
269
|
+
Which recurring meetings are worth importing every time — the standing
|
|
270
|
+
1:1s, the weekly ops call. A watchlist, not a backlog.
|
|
162
271
|
cardinality: few
|
|
163
|
-
keyFields: [
|
|
272
|
+
keyFields: [platform]
|
|
164
273
|
openingQuestions:
|
|
165
|
-
- "
|
|
166
|
-
- "
|
|
167
|
-
- "
|
|
168
|
-
doneWhen:
|
|
274
|
+
- "Name a meeting from last week whose outcome you can't remember. What did that cost you?"
|
|
275
|
+
- "When a meeting produces an action item, where does it go today — and does it survive the week?"
|
|
276
|
+
- "Which recurring meeting would you cancel if you could see its action-item completion rate?"
|
|
277
|
+
doneWhen: >
|
|
278
|
+
Transcript source connected and one real meeting imported end-to-end —
|
|
279
|
+
summary note, at least one action item, and attendees resolved to people.
|
|
169
280
|
|
|
170
281
|
app:
|
|
171
282
|
kind: stored
|
|
@@ -174,7 +285,7 @@ app:
|
|
|
174
285
|
layout:
|
|
175
286
|
defaultView: Meetings
|
|
176
287
|
defaultApp: null
|
|
177
|
-
pinnedApps: [dashboard, data]
|
|
288
|
+
pinnedApps: [dashboard, data, chat]
|
|
178
289
|
sidebarItems:
|
|
179
290
|
- kind: view
|
|
180
291
|
viewName: Meetings
|
|
@@ -194,7 +305,10 @@ layout:
|
|
|
194
305
|
icon: database
|
|
195
306
|
|
|
196
307
|
bento:
|
|
197
|
-
|
|
308
|
+
# Distinct from the "Meetings" table it embeds — the bento is created as its
|
|
309
|
+
# OWN view row, so reusing an embedded view's name puts two identically-named
|
|
310
|
+
# views in the switcher and makes layout.defaultView ambiguous.
|
|
311
|
+
viewName: Meetings Home
|
|
198
312
|
blocks:
|
|
199
313
|
- kind: view
|
|
200
314
|
viewName: Meetings
|
package/src/outreach-comms.yaml
CHANGED
|
@@ -256,7 +256,7 @@ app:
|
|
|
256
256
|
layout:
|
|
257
257
|
defaultView: Campaigns
|
|
258
258
|
defaultApp: null
|
|
259
|
-
pinnedApps: [dashboard, data]
|
|
259
|
+
pinnedApps: [dashboard, data, chat]
|
|
260
260
|
sidebarItems:
|
|
261
261
|
- kind: view
|
|
262
262
|
viewName: Campaigns
|
|
@@ -272,7 +272,10 @@ layout:
|
|
|
272
272
|
icon: database
|
|
273
273
|
|
|
274
274
|
bento:
|
|
275
|
-
|
|
275
|
+
# Distinct from the "Campaigns" table it embeds — see meetings-knowledge:
|
|
276
|
+
# the bento becomes its own view row, so a shared name collides in the
|
|
277
|
+
# switcher and makes layout.defaultView ambiguous.
|
|
278
|
+
viewName: Outreach Home
|
|
276
279
|
blocks:
|
|
277
280
|
- kind: view
|
|
278
281
|
viewName: Campaigns
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FRESHNESS GATE for the generated-and-committed `src/templates.ts`.
|
|
3
|
+
*
|
|
4
|
+
* WHY (read before deleting):
|
|
5
|
+
* `src/templates.ts` is generated from `src/*.yaml` by `build.mjs` and is
|
|
6
|
+
* COMMITTED to git. Every other check in this package — `concept-guard`,
|
|
7
|
+
* `catalog`, `composition-graph`, the backend's shared-role tripwire — reads
|
|
8
|
+
* that committed snapshot, NOT the YAML. Root `pnpm test` does not regenerate
|
|
9
|
+
* it. So before this test, an author could edit a template YAML, run
|
|
10
|
+
* `pnpm test`, and get a green guard against vocabulary that no longer exists
|
|
11
|
+
* on disk. A guard that can pass against a stale artifact is not a guard.
|
|
12
|
+
*
|
|
13
|
+
* This test closes that hole in the cheapest possible place: the existing
|
|
14
|
+
* `pnpm test` CI step. It re-renders the corpus IN MEMORY with the SAME
|
|
15
|
+
* function `build.mjs` uses (`src/generate.mjs`) and asserts the committed file
|
|
16
|
+
* matches byte-for-byte. No build dependency, no extra CI step, ~tens of ms.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
import {
|
|
22
|
+
readYamlTemplates,
|
|
23
|
+
renderTemplatesModule,
|
|
24
|
+
TEMPLATES_FILE,
|
|
25
|
+
} from "./generate.mjs";
|
|
26
|
+
|
|
27
|
+
describe("templates.ts freshness", () => {
|
|
28
|
+
it("matches what build.mjs would generate from src/*.yaml", () => {
|
|
29
|
+
const expected = renderTemplatesModule(readYamlTemplates());
|
|
30
|
+
const actual = readFileSync(TEMPLATES_FILE, "utf-8");
|
|
31
|
+
|
|
32
|
+
if (actual !== expected) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
[
|
|
35
|
+
"",
|
|
36
|
+
"❌ src/templates.ts is STALE — it no longer matches src/*.yaml.",
|
|
37
|
+
"",
|
|
38
|
+
" Every template check (concept-guard, catalog, composition-graph, and",
|
|
39
|
+
" synap-backend's shared-role tripwire) reads this generated file, so a",
|
|
40
|
+
" stale snapshot makes them all green against vocabulary that is not on",
|
|
41
|
+
" disk anymore.",
|
|
42
|
+
"",
|
|
43
|
+
" FIX — regenerate and commit the result:",
|
|
44
|
+
" cd synap-app/packages/workspace-templates && pnpm build:yaml",
|
|
45
|
+
"",
|
|
46
|
+
" (`pnpm build` also regenerates, and additionally runs tsup + the",
|
|
47
|
+
" converter/concept guards.)",
|
|
48
|
+
"",
|
|
49
|
+
].join("\n"),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
expect(actual).toBe(expected);
|
|
54
|
+
});
|
|
55
|
+
});
|