@synap-core/workspace-templates 0.6.2 → 0.6.4
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 +114 -10
- package/dist/index.js +178 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/catalog.test.ts +99 -1
- package/src/catalog.ts +181 -0
- package/src/content-os.yaml +23 -2
- package/src/crm.yaml +67 -1
- package/src/define.ts +24 -16
- package/src/ecosystem.yaml +35 -0
- package/src/index.ts +11 -2
- package/src/templates.ts +116 -2
- package/src/types.ts +0 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/workspace-templates",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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",
|
package/src/catalog.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
|
-
import { mergeCatalog, type RemoteEntry } from "./catalog.js";
|
|
10
|
+
import { mergeCatalog, computeTemplateUpdates, type RemoteEntry } from "./catalog.js";
|
|
11
11
|
import type { WorkspaceYaml } from "./types.js";
|
|
12
12
|
|
|
13
13
|
const BUNDLE_VERSION = "0.6.0";
|
|
@@ -154,3 +154,101 @@ test("maps a bundled template's meta fields onto the entry", () => {
|
|
|
154
154
|
assert.equal(e.category, "workspace");
|
|
155
155
|
assert.equal(e.isPrivate, true, "isPublic:false ⇒ isPrivate");
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
// ── computeTemplateUpdates ───────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
test("computeTemplateUpdates: no update when installed equals catalog latest (semver)", () => {
|
|
161
|
+
const { entries } = mergeCatalog({
|
|
162
|
+
bundled: [bundled("crm")],
|
|
163
|
+
remote: [remote("crm", { version: "0.7.0" })],
|
|
164
|
+
bundleVersion: BUNDLE_VERSION, // remote strictly newer → remote wins, carries "0.7.0"
|
|
165
|
+
});
|
|
166
|
+
const [check] = computeTemplateUpdates([{ slug: "crm", version: "0.7.0" }], entries);
|
|
167
|
+
assert.equal(check.latestVersion, "0.7.0");
|
|
168
|
+
assert.equal(check.updateAvailable, false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("computeTemplateUpdates: update available when installed is older (semver)", () => {
|
|
172
|
+
const { entries } = mergeCatalog({
|
|
173
|
+
bundled: [bundled("crm")],
|
|
174
|
+
remote: [remote("crm", { version: "0.7.0" })],
|
|
175
|
+
bundleVersion: BUNDLE_VERSION,
|
|
176
|
+
});
|
|
177
|
+
const [check] = computeTemplateUpdates([{ slug: "crm", version: "0.5.0" }], entries);
|
|
178
|
+
assert.equal(check.latestVersion, "0.7.0");
|
|
179
|
+
assert.equal(check.updateAvailable, true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// NOTE: uses a non-colliding slug so the remote row is kept as a DISTINCT entry
|
|
183
|
+
// regardless of version — a hash string never parses as semver, so on a slug
|
|
184
|
+
// COLLISION `remoteIsStrictlyNewer` would (correctly, per mergeCatalog's existing
|
|
185
|
+
// squatting-defense semantics) keep the bundle winning and never surface the hash.
|
|
186
|
+
test("computeTemplateUpdates: hash-vs-semver — any mismatch with a hash version signals an update", () => {
|
|
187
|
+
const { entries } = mergeCatalog({
|
|
188
|
+
bundled: [bundled("crm")],
|
|
189
|
+
remote: [remote("hash-template", { version: "h-abc123" })],
|
|
190
|
+
bundleVersion: BUNDLE_VERSION,
|
|
191
|
+
});
|
|
192
|
+
const [staleSemver] = computeTemplateUpdates(
|
|
193
|
+
[{ slug: "hash-template", version: "0.5.0" }],
|
|
194
|
+
entries,
|
|
195
|
+
);
|
|
196
|
+
assert.equal(staleSemver.latestVersion, "h-abc123");
|
|
197
|
+
assert.equal(staleSemver.updateAvailable, true, "semver installed vs hash latest ⇒ differ ⇒ update");
|
|
198
|
+
|
|
199
|
+
const [sameHash] = computeTemplateUpdates(
|
|
200
|
+
[{ slug: "hash-template", version: "h-abc123" }],
|
|
201
|
+
entries,
|
|
202
|
+
);
|
|
203
|
+
assert.equal(sameHash.updateAvailable, false, "identical hash ⇒ no update");
|
|
204
|
+
|
|
205
|
+
const [differentHash] = computeTemplateUpdates(
|
|
206
|
+
[{ slug: "hash-template", version: "h-old999" }],
|
|
207
|
+
entries,
|
|
208
|
+
);
|
|
209
|
+
assert.equal(differentHash.updateAvailable, true, "different hash ⇒ update");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("computeTemplateUpdates: slug missing from catalog ⇒ no update, latestVersion undefined", () => {
|
|
213
|
+
const { entries } = mergeCatalog({
|
|
214
|
+
bundled: [bundled("crm")],
|
|
215
|
+
remote: [],
|
|
216
|
+
bundleVersion: BUNDLE_VERSION,
|
|
217
|
+
});
|
|
218
|
+
const [check] = computeTemplateUpdates([{ slug: "ghost-template", version: "1.0.0" }], entries);
|
|
219
|
+
assert.equal(check.latestVersion, undefined);
|
|
220
|
+
assert.equal(check.updateAvailable, false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("computeTemplateUpdates: bundled winner compares against bundleVersion when passed", () => {
|
|
224
|
+
const { entries } = mergeCatalog({
|
|
225
|
+
bundled: [bundled("crm")],
|
|
226
|
+
remote: [],
|
|
227
|
+
bundleVersion: BUNDLE_VERSION,
|
|
228
|
+
});
|
|
229
|
+
const stale = computeTemplateUpdates(
|
|
230
|
+
[{ slug: "crm", version: "0.1.0" }],
|
|
231
|
+
entries,
|
|
232
|
+
BUNDLE_VERSION,
|
|
233
|
+
);
|
|
234
|
+
assert.equal(stale[0].latestVersion, BUNDLE_VERSION);
|
|
235
|
+
assert.equal(stale[0].updateAvailable, true);
|
|
236
|
+
|
|
237
|
+
const noBundleVersion = computeTemplateUpdates([{ slug: "crm", version: "0.1.0" }], entries);
|
|
238
|
+
assert.equal(noBundleVersion[0].latestVersion, undefined, "no bundleVersion ⇒ nothing to compare");
|
|
239
|
+
assert.equal(noBundleVersion[0].updateAvailable, false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("computeTemplateUpdates: passes isPrivate through from the winning entry", () => {
|
|
243
|
+
const { entries } = mergeCatalog({
|
|
244
|
+
bundled: [bundled("crm")],
|
|
245
|
+
remote: [remote("private-pack", { isPrivate: true, version: "1.0.0" })],
|
|
246
|
+
bundleVersion: BUNDLE_VERSION,
|
|
247
|
+
});
|
|
248
|
+
const [check] = computeTemplateUpdates(
|
|
249
|
+
[{ slug: "private-pack", version: "0.9.0" }],
|
|
250
|
+
entries,
|
|
251
|
+
);
|
|
252
|
+
assert.equal(check.isPrivate, true);
|
|
253
|
+
assert.equal(check.updateAvailable, true);
|
|
254
|
+
});
|
package/src/catalog.ts
CHANGED
|
@@ -101,6 +101,59 @@ export interface MergeCatalogInput {
|
|
|
101
101
|
remoteStatus?: RemoteSourceStatus;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A control-plane package LIST row, as returned by `GET /api/packages` and
|
|
106
|
+
* `GET /api/packages/mine`. This is the raw JSON shape BEFORE it is projected
|
|
107
|
+
* into a `RemoteEntry`. Both list routes STRIP `definition`, so it is absent
|
|
108
|
+
* here (the install path fetches the full row per-slug on demand).
|
|
109
|
+
*
|
|
110
|
+
* `isPublic` is present ONLY on `/mine` rows (`false` ⇒ private). Public browse
|
|
111
|
+
* rows omit it BY CONSTRUCTION (the list route hard-filters `WHERE is_public =
|
|
112
|
+
* true`), so its absence means "public", never "unknown".
|
|
113
|
+
*
|
|
114
|
+
* Consumers used to hand-copy this shape + `rowToRemoteEntry` (browser
|
|
115
|
+
* `useMarketplaceCatalog`, CLI `cp-packages`); this is the ONE canonical copy.
|
|
116
|
+
*/
|
|
117
|
+
export interface CpPackageRow {
|
|
118
|
+
slug: string;
|
|
119
|
+
displayName: string;
|
|
120
|
+
description?: string | null;
|
|
121
|
+
/** CP package category / type (e.g. `"workspace"`, `"view"`). */
|
|
122
|
+
category?: string;
|
|
123
|
+
tags?: string[] | null;
|
|
124
|
+
icon?: string | null;
|
|
125
|
+
/** Present only on `/mine` rows. `false` ⇒ private. Browse rows omit it. */
|
|
126
|
+
isPublic?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Map a CP list row into the merge door's `RemoteEntry`. THE canonical mapping —
|
|
131
|
+
* browser + CLI import this instead of hand-copying it (the two copies had
|
|
132
|
+
* already drifted in `description`/`isPrivate` handling).
|
|
133
|
+
*
|
|
134
|
+
* `isPrivate` is passed EXPLICITLY by the caller because it depends on WHICH
|
|
135
|
+
* route the row came from: browse rows are all-public (pass `undefined`), `/mine`
|
|
136
|
+
* rows carry the real flag (pass `row.isPublic === false`). Keeping it a param —
|
|
137
|
+
* not derived here — lets a caller that fetches the two routes separately control
|
|
138
|
+
* it per-source; `assembleCatalog` derives it from the row for the common case.
|
|
139
|
+
*/
|
|
140
|
+
export function rowToRemoteEntry(
|
|
141
|
+
row: CpPackageRow,
|
|
142
|
+
isPrivate?: boolean,
|
|
143
|
+
): RemoteEntry {
|
|
144
|
+
return {
|
|
145
|
+
slug: row.slug,
|
|
146
|
+
name: row.displayName,
|
|
147
|
+
description: row.description ?? undefined,
|
|
148
|
+
category: row.category,
|
|
149
|
+
tags: row.tags ?? [],
|
|
150
|
+
icon: row.icon ?? null,
|
|
151
|
+
isPrivate,
|
|
152
|
+
// Both list routes strip `definition`; absence ⇒ bundle wins a slug conflict,
|
|
153
|
+
// and the install path fetches the full definition per-slug when needed.
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
104
157
|
// ============================================================================
|
|
105
158
|
// Output
|
|
106
159
|
// ============================================================================
|
|
@@ -283,3 +336,131 @@ export function mergeCatalog(input: MergeCatalogInput): MergeCatalogResult {
|
|
|
283
336
|
sources: { bundled: "ok", remote: remoteStatus },
|
|
284
337
|
};
|
|
285
338
|
}
|
|
339
|
+
|
|
340
|
+
export interface AssembleCatalogInput {
|
|
341
|
+
/** Bundled templates (this package). Always in-process. */
|
|
342
|
+
bundled: BundledEntry[];
|
|
343
|
+
/**
|
|
344
|
+
* Raw CP list rows (browse and/or `/mine`, concatenated). Mapped to
|
|
345
|
+
* `RemoteEntry` via `rowToRemoteEntry`; `isPrivate` is DERIVED per-row from
|
|
346
|
+
* `isPublic` (`false` ⇒ private; absent/`true` ⇒ public), which handles both
|
|
347
|
+
* browse rows (no `isPublic`) and `/mine` rows uniformly.
|
|
348
|
+
*/
|
|
349
|
+
remoteRows?: CpPackageRow[];
|
|
350
|
+
/** This client's bundled-package version (the winner signal vs each row). */
|
|
351
|
+
bundleVersion: string;
|
|
352
|
+
/** The transport's verdict on the remote fetch. Default `"ok"`. */
|
|
353
|
+
remoteStatus?: RemoteSourceStatus;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Convenience wrapper over `mergeCatalog`: maps raw CP rows through the canonical
|
|
358
|
+
* `rowToRemoteEntry` and merges them with the bundled templates. Still PURE — the
|
|
359
|
+
* caller fetches the rows (its own transport/auth) and passes them in; this only
|
|
360
|
+
* maps + merges. Use `mergeCatalog` + `rowToRemoteEntry` directly when you need
|
|
361
|
+
* per-source `isPrivate` control (e.g. keeping browse and `/mine` distinct).
|
|
362
|
+
*/
|
|
363
|
+
export function assembleCatalog(input: AssembleCatalogInput): MergeCatalogResult {
|
|
364
|
+
const remote = (input.remoteRows ?? []).map((row) =>
|
|
365
|
+
rowToRemoteEntry(row, row.isPublic === false ? true : undefined),
|
|
366
|
+
);
|
|
367
|
+
return mergeCatalog({
|
|
368
|
+
bundled: input.bundled,
|
|
369
|
+
remote,
|
|
370
|
+
bundleVersion: input.bundleVersion,
|
|
371
|
+
remoteStatus: input.remoteStatus,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ============================================================================
|
|
376
|
+
// Installed-vs-catalog update check
|
|
377
|
+
// ============================================================================
|
|
378
|
+
|
|
379
|
+
/** A template a pod has installed — the door reads `settings.packageVersion` off the workspace row. */
|
|
380
|
+
export interface InstalledTemplate {
|
|
381
|
+
slug: string;
|
|
382
|
+
version: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Per-slug verdict: is a newer catalog version available than what's installed? */
|
|
386
|
+
export interface TemplateUpdateCheck {
|
|
387
|
+
slug: string;
|
|
388
|
+
installedVersion: string;
|
|
389
|
+
/** `undefined` when the slug has no matching catalog entry, or a bundled winner and no `bundleVersion` was passed. */
|
|
390
|
+
latestVersion: string | undefined;
|
|
391
|
+
updateAvailable: boolean;
|
|
392
|
+
/** Passed through from the winning catalog entry, so callers can route private updates through the authed path. */
|
|
393
|
+
isPrivate?: boolean;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `true` when `version` is a CP content-hash stamp (`"h-<hash>"`) rather than semver.
|
|
398
|
+
* Content-hash versions are NOT semver-comparable — see `computeTemplateUpdates`.
|
|
399
|
+
*/
|
|
400
|
+
export function isHashVersion(version: string | undefined): boolean {
|
|
401
|
+
return typeof version === "string" && version.startsWith("h-");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Compare an installed version against a catalog's latest — reusing
|
|
406
|
+
* `remoteIsStrictlyNewer` for the semver path. Either side being a content-hash
|
|
407
|
+
* (`"h-..."`) makes the pair NOT semver-comparable, so an update is signalled
|
|
408
|
+
* whenever the two raw strings simply differ (equal ⇒ no update, by construction
|
|
409
|
+
* the CP only reissues a new hash when the template content actually changed).
|
|
410
|
+
*/
|
|
411
|
+
function isNewerVersion(installedVersion: string, latestVersion: string | undefined): boolean {
|
|
412
|
+
if (latestVersion === undefined) return false;
|
|
413
|
+
if (isHashVersion(installedVersion) || isHashVersion(latestVersion)) {
|
|
414
|
+
return installedVersion !== latestVersion;
|
|
415
|
+
}
|
|
416
|
+
return remoteIsStrictlyNewer(latestVersion, installedVersion);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Compute, per installed template, whether the CATALOG (bundled + remote,
|
|
421
|
+
* already merged by `mergeCatalog`/`assembleCatalog`) carries a newer version
|
|
422
|
+
* than what the pod installed — the shared "update available" check all three
|
|
423
|
+
* doors (browser, synap-app, CLI) reuse instead of re-deriving it.
|
|
424
|
+
*
|
|
425
|
+
* `installed` and `catalog` are BOTH already-fetched/computed by the caller —
|
|
426
|
+
* this stays PURE, same as the rest of this module (no fetch, no auth).
|
|
427
|
+
*
|
|
428
|
+
* The catalog's winning entry carries the latest version signal two ways:
|
|
429
|
+
* • `source: "remote"` → `remote.definition?.sourcePackage?.version` (semver
|
|
430
|
+
* OR, going forward, a CP content-hash `"h-<hash>"` stamp).
|
|
431
|
+
* • `source: "bundled"` → bundled entries carry no version field at all
|
|
432
|
+
* (`TemplateMeta` has none); pass `bundleVersion` (this client's compiled
|
|
433
|
+
* package version, the same value fed to `mergeCatalog`) to compare against
|
|
434
|
+
* it. Omit `bundleVersion` and bundled-backed slugs report `latestVersion:
|
|
435
|
+
* undefined, updateAvailable: false` (nothing comparable).
|
|
436
|
+
*
|
|
437
|
+
* A slug with no matching catalog entry ⇒ `updateAvailable: false`,
|
|
438
|
+
* `latestVersion: undefined` — there is nothing to update TO.
|
|
439
|
+
*/
|
|
440
|
+
export function computeTemplateUpdates(
|
|
441
|
+
installed: InstalledTemplate[],
|
|
442
|
+
catalog: CatalogEntry[],
|
|
443
|
+
bundleVersion?: string,
|
|
444
|
+
): TemplateUpdateCheck[] {
|
|
445
|
+
const bySlug = new Map(catalog.map((entry) => [entry.slug, entry]));
|
|
446
|
+
|
|
447
|
+
return installed.map(({ slug, version: installedVersion }) => {
|
|
448
|
+
const entry = bySlug.get(slug);
|
|
449
|
+
if (!entry) {
|
|
450
|
+
return { slug, installedVersion, latestVersion: undefined, updateAvailable: false };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const latestVersion =
|
|
454
|
+
entry.source === "remote"
|
|
455
|
+
? entry.remote.definition?.sourcePackage?.version
|
|
456
|
+
: bundleVersion;
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
slug,
|
|
460
|
+
installedVersion,
|
|
461
|
+
latestVersion,
|
|
462
|
+
updateAvailable: isNewerVersion(installedVersion, latestVersion),
|
|
463
|
+
isPrivate: entry.isPrivate,
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
}
|
package/src/content-os.yaml
CHANGED
|
@@ -402,6 +402,20 @@ views:
|
|
|
402
402
|
scopeProfileSlug: post
|
|
403
403
|
groupBy: post-status
|
|
404
404
|
description: Content posts by status.
|
|
405
|
+
# The editorial calendar — the "it lands on a calendar" promise. Posts render
|
|
406
|
+
# on a month grid keyed on their Publish Date. The calendar renderer resolves
|
|
407
|
+
# the event date via getProperty(entity, dateField) against the entity's nested
|
|
408
|
+
# `properties` object (CalendarView default is `properties.date`), so the
|
|
409
|
+
# dateField must be the `properties.`-prefixed path, NOT a bare slug — a bare
|
|
410
|
+
# slug only anchors the visible month, it does not map events.
|
|
411
|
+
# `publish-date` is valueType:date, so values render as all-day chips.
|
|
412
|
+
- name: Content Calendar
|
|
413
|
+
slug: content-calendar
|
|
414
|
+
type: calendar
|
|
415
|
+
scopeProfileSlug: post
|
|
416
|
+
description: Your editorial calendar — every post on the day it publishes.
|
|
417
|
+
config:
|
|
418
|
+
dateField: properties.publish-date
|
|
405
419
|
- name: Pillars
|
|
406
420
|
slug: content-pillars
|
|
407
421
|
type: list
|
|
@@ -434,6 +448,10 @@ layout:
|
|
|
434
448
|
viewName: Posts
|
|
435
449
|
label: Posts
|
|
436
450
|
icon: file-edit
|
|
451
|
+
- kind: view
|
|
452
|
+
viewName: Content Calendar
|
|
453
|
+
label: Calendar
|
|
454
|
+
icon: calendar
|
|
437
455
|
- kind: view
|
|
438
456
|
viewName: Pillars
|
|
439
457
|
label: Pillars
|
|
@@ -451,12 +469,15 @@ layout:
|
|
|
451
469
|
bento:
|
|
452
470
|
viewName: Posts
|
|
453
471
|
blocks:
|
|
472
|
+
- kind: view
|
|
473
|
+
viewName: Content Calendar
|
|
474
|
+
pos: { x: 0, y: 0, w: 12, h: 6 }
|
|
454
475
|
- kind: view
|
|
455
476
|
viewName: Pillars
|
|
456
|
-
pos: { x: 0, y:
|
|
477
|
+
pos: { x: 0, y: 6, w: 6, h: 5 }
|
|
457
478
|
- kind: view
|
|
458
479
|
viewName: Newsletters
|
|
459
|
-
pos: { x: 6, y:
|
|
480
|
+
pos: { x: 6, y: 6, w: 6, h: 5 }
|
|
460
481
|
|
|
461
482
|
# Editorial graph: connect posts to campaign / pillar / sponsor
|
|
462
483
|
entityLinks:
|
package/src/crm.yaml
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# CRM workspace template
|
|
2
2
|
# The CRM owns pre-sale work only. A won deal is handed to the operating
|
|
3
3
|
# workspace; this template deliberately does not provision client delivery,
|
|
4
|
-
#
|
|
4
|
+
# campaigns, tasks, documents, or automations. It DOES seed funnel playbooks
|
|
5
|
+
# (the action layer) so the pipeline is operable via sessions — see `playbooks:`.
|
|
5
6
|
|
|
6
7
|
meta:
|
|
7
8
|
slug: crm
|
|
@@ -405,3 +406,68 @@ entityLinks:
|
|
|
405
406
|
- sourceProfileSlug: company
|
|
406
407
|
targetProfileSlug: deal
|
|
407
408
|
type: linked_to_deal
|
|
409
|
+
|
|
410
|
+
# Playbooks — session templates seeded with the workspace. The CRM's action layer.
|
|
411
|
+
# `subjectProfile` binds each playbook to a funnel subject so the Capture → Session
|
|
412
|
+
# matcher (`playbooks.matchForEntity`, filtering `subject_profile->>'profileSlug'`)
|
|
413
|
+
# can offer it when that subject is captured — a captured lead surfaces "Enrich this
|
|
414
|
+
# lead" / "Qualify this lead"; a deal surfaces "Work this deal". The subject entity
|
|
415
|
+
# is passed as the matching entity param and substituted into the goalTemplate.
|
|
416
|
+
#
|
|
417
|
+
# Two subjects are used: `lead` (a shared ROLE facet on a person/company — the same
|
|
418
|
+
# facet-role subject pattern ecosystem.yaml uses for `competitor`) and `deal` (a
|
|
419
|
+
# primary CRM kind). The matcher keys purely on the profileSlug string, so a role
|
|
420
|
+
# facet and a kind are matched identically.
|
|
421
|
+
#
|
|
422
|
+
# No `grants:` on "Enrich this lead": enrichment tools (Apify/Unipile-LinkedIn) are
|
|
423
|
+
# workspace-conditional and may not be granted, so the goalTemplate context-injects
|
|
424
|
+
# the intent and the session agent uses whatever enrichment capability the workspace
|
|
425
|
+
# actually has — mirroring how "Produce Content" (content-os) grants nothing and
|
|
426
|
+
# leans on the workspace's tools, rather than hardcoding a tool that might be absent.
|
|
427
|
+
playbooks:
|
|
428
|
+
- name: Enrich this lead
|
|
429
|
+
description: Take a thin, freshly-captured lead and fill it out — company, title, LinkedIn, and the relationships around it.
|
|
430
|
+
goalTemplate: >-
|
|
431
|
+
Enrich this lead ({lead}). Starting from whatever is known, fill out the
|
|
432
|
+
missing detail — company, job title, LinkedIn URL, location, and the people
|
|
433
|
+
and companies around them — and record it on the lead. Draw on whatever
|
|
434
|
+
enrichment capability this workspace has connected (e.g. an Apify or
|
|
435
|
+
Unipile/LinkedIn tool); if none is available, enrich from the public web and
|
|
436
|
+
what is already in the pod. Link the lead to its company and any related
|
|
437
|
+
contacts, and note where information is still missing.
|
|
438
|
+
subjectProfile:
|
|
439
|
+
profileSlug: lead
|
|
440
|
+
params:
|
|
441
|
+
- { name: lead, type: entity, label: Lead, required: true }
|
|
442
|
+
executor: is-agent
|
|
443
|
+
status: active
|
|
444
|
+
|
|
445
|
+
- name: Qualify this lead
|
|
446
|
+
description: Assess a lead against the ICP, score the fit, and decide the next step — advance, nurture, or disqualify.
|
|
447
|
+
goalTemplate: >-
|
|
448
|
+
Qualify this lead ({lead}). Assess how well they fit our ideal customer
|
|
449
|
+
profile — company, role, industry, size, and the signals of intent or need —
|
|
450
|
+
and set an ICP score. Decide the next step: advance them toward a deal,
|
|
451
|
+
keep nurturing, or disqualify. Write the reasoning and the decision back onto
|
|
452
|
+
the lead (its stage, score, and next step) so the call is auditable.
|
|
453
|
+
subjectProfile:
|
|
454
|
+
profileSlug: lead
|
|
455
|
+
params:
|
|
456
|
+
- { name: lead, type: entity, label: Lead, required: true }
|
|
457
|
+
executor: is-agent
|
|
458
|
+
status: active
|
|
459
|
+
|
|
460
|
+
- name: Work this deal
|
|
461
|
+
description: Move a deal toward Won — read the current stage, find the blocker, and propose the next step and owner.
|
|
462
|
+
goalTemplate: >-
|
|
463
|
+
Work this deal ({deal}) toward Won. Assess where it stands — its current
|
|
464
|
+
stage, value, and close date — and identify the single thing blocking it from
|
|
465
|
+
advancing. Propose the concrete next step, who owns it, and by when, and
|
|
466
|
+
update the deal's next step accordingly. If the stage should change, say so
|
|
467
|
+
and why.
|
|
468
|
+
subjectProfile:
|
|
469
|
+
profileSlug: deal
|
|
470
|
+
params:
|
|
471
|
+
- { name: deal, type: entity, label: Deal, required: true }
|
|
472
|
+
executor: is-agent
|
|
473
|
+
status: active
|
package/src/define.ts
CHANGED
|
@@ -135,6 +135,18 @@ export interface WorkspaceDefinitionInput {
|
|
|
135
135
|
/** VIEW half of the authored bento union — resolved against the viewMap. */
|
|
136
136
|
bentoViewBlocks?: BentoViewBlockWire[];
|
|
137
137
|
profileEntityBentoTemplates?: WorkspaceYaml['profileEntityBentoTemplates'];
|
|
138
|
+
/**
|
|
139
|
+
* Capability integrations (→ tools + skills + vault + capability-embedded
|
|
140
|
+
* automations/playbooks). Emitted from the template's `integrations:` list —
|
|
141
|
+
* IDENTICAL to what `toPackageDefinition` puts in its `capabilities` field. The
|
|
142
|
+
* tRPC door (`workspaces.createFromDefinition`) now declares `capabilities` in
|
|
143
|
+
* its zod input and installs them via the SHARED `applyPackagePostWorkspace`
|
|
144
|
+
* door (same as Hub `/packages/apply`). It was missing from this interface, so
|
|
145
|
+
* `toWorkspaceDefinition` had nowhere to put integrations and the browser
|
|
146
|
+
* BUNDLED install path carried NO capabilities — every capability's
|
|
147
|
+
* tools/skills/automations silently dropped on that door.
|
|
148
|
+
*/
|
|
149
|
+
capabilities?: WorkspaceYaml['integrations'];
|
|
138
150
|
// Workspace-level metadata (flattened from the `workspace` block)
|
|
139
151
|
workspaceSubtype?: string;
|
|
140
152
|
workspaceType?: NonNullable<WorkspaceYaml['workspace']>['workspaceType'];
|
|
@@ -203,6 +215,13 @@ export function toWorkspaceDefinition(
|
|
|
203
215
|
bentoLayout: bento.bentoLayout,
|
|
204
216
|
bentoViewBlocks: bento.bentoViewBlocks,
|
|
205
217
|
profileEntityBentoTemplates: tpl.profileEntityBentoTemplates,
|
|
218
|
+
// Capability integrations — forwarded so the tRPC/UI (bundled) install door
|
|
219
|
+
// seeds the SAME operational payload (tools + skills + capability-embedded
|
|
220
|
+
// automations/playbooks) the CLI/Hub door gets via `toPackageDefinition`.
|
|
221
|
+
// `workspaces.createFromDefinition` declares `capabilities` in its zod input
|
|
222
|
+
// and routes it through the shared `applyPackagePostWorkspace` door. Omitting
|
|
223
|
+
// it here made the browser bundled-install path carry no capabilities at all.
|
|
224
|
+
capabilities: tpl.integrations,
|
|
206
225
|
workspaceSubtype: ws.subtype,
|
|
207
226
|
workspaceType: ws.workspaceType,
|
|
208
227
|
workspaceVisibility: ws.visibility,
|
|
@@ -261,6 +280,7 @@ type WorkspaceDefinitionForwarded =
|
|
|
261
280
|
| 'layout' // → layoutConfig
|
|
262
281
|
| 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
|
|
263
282
|
| 'profileEntityBentoTemplates'
|
|
283
|
+
| 'integrations' // → capabilities (installed via shared applyPackagePostWorkspace door)
|
|
264
284
|
| 'onboarding'
|
|
265
285
|
| 'dependencies';
|
|
266
286
|
|
|
@@ -277,16 +297,6 @@ type WorkspaceDefinitionDropped =
|
|
|
277
297
|
// The door DOES accept `extends`, but no template declares it today (0/21).
|
|
278
298
|
// Forwarding is a no-op until one does — wire it then.
|
|
279
299
|
| 'extends'
|
|
280
|
-
// The door DOES accept `automations`, but no template declares it today
|
|
281
|
-
// (0/21), and its zod shape ({trigger,action:{playbook_run}}) is not
|
|
282
|
-
// `TemplateAutomation`. Needs a mapper if a template ever uses it.
|
|
283
|
-
// The browser install path installs automations via its own createAutomation door.
|
|
284
|
-
| 'automations'
|
|
285
|
-
// The door has no `integrations` field. Its `tools` field is a DIFFERENT
|
|
286
|
-
// shape ({name,kind,inputSchema} vs {templateKey,params,mode}). Capability
|
|
287
|
-
// templates are seeded via the packages/apply path, not createFromDefinition
|
|
288
|
-
// — see the note on `TemplateIntegration` in types.ts. Forwarding = no-op.
|
|
289
|
-
| 'integrations'
|
|
290
300
|
// REAL GAP (agent-fleet, project-management declare playbooks). The door
|
|
291
301
|
// accepts `playbooks`, but the shapes disagree: template param `type` allows
|
|
292
302
|
// 'text'|'entity'|'choice', the tRPC enum accepts only string|number|boolean
|
|
@@ -446,12 +456,10 @@ type PackageDefinitionDropped =
|
|
|
446
456
|
// UI-only, same as the definition door: `PackageDefinition` has no `app`.
|
|
447
457
|
| 'app'
|
|
448
458
|
// REAL GAP: backend `PackageDefinition` DOES declare `extends`
|
|
449
|
-
// (package-definition.ts:262)
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
| 'extends'
|
|
454
|
-
| 'automations';
|
|
459
|
+
// (package-definition.ts:262), but `PackageDefinitionOutput` omits it. No
|
|
460
|
+
// template declares it today (0/21), so this is latent, not live — wire it
|
|
461
|
+
// when a template first uses one.
|
|
462
|
+
| 'extends';
|
|
455
463
|
|
|
456
464
|
type UnclassifiedPackageDefinitionKey = Exclude<
|
|
457
465
|
keyof WorkspaceYaml,
|
package/src/ecosystem.yaml
CHANGED
|
@@ -392,11 +392,46 @@ playbooks:
|
|
|
392
392
|
- { name: companyContext, type: text, label: Our company context }
|
|
393
393
|
- { name: outputTypes, type: text, label: Outputs, defaultValue: "a head-to-head comparison summary and an updated competitor profile" }
|
|
394
394
|
executor: is-agent
|
|
395
|
+
# A radar MONITOR: bound to the competitor KIND + a schedule → the backing
|
|
396
|
+
# cron automation fans out (query→loop→playbook_run) and runs this once per
|
|
397
|
+
# competitor. Disabled by default so installing the workspace never silently
|
|
398
|
+
# starts paid AI runs — the user turns it on when they want the watch.
|
|
399
|
+
schedule: { cron: "0 9 * * 1", enabled: false }
|
|
395
400
|
grants:
|
|
396
401
|
- Web Search
|
|
397
402
|
- Entity Creator
|
|
398
403
|
status: active
|
|
399
404
|
|
|
405
|
+
# A radar DISCOVERY run: the self-generating half. No subjectProfile → a single
|
|
406
|
+
# scheduled run (not per-existing-entity) that finds competitors we don't track
|
|
407
|
+
# yet and adds them — deduplicated by website — so the knowledge base grows
|
|
408
|
+
# itself. Requires the Exa capability (integrations below).
|
|
409
|
+
- name: Discover Competitors
|
|
410
|
+
description: Scan the market for competitors we don't track yet and add them to the ecosystem — the self-generating half of the radar.
|
|
411
|
+
goalTemplate: >-
|
|
412
|
+
Find companies that compete with us in {ourSpace}. Use neural web search
|
|
413
|
+
(the exa_search skill with category "company") to surface real, named
|
|
414
|
+
companies — direct competitors, adjacent tools that solve the same job a
|
|
415
|
+
different way, and emerging players. For each genuinely NEW one (not already
|
|
416
|
+
a competitor in our ecosystem), propose it as a `competitor` entity and
|
|
417
|
+
ALWAYS include its `website` (its canonical URL) so it de-duplicates against
|
|
418
|
+
what we already track — never create a company that already exists. Skip
|
|
419
|
+
anything already known. Report what you added and why each one matters.
|
|
420
|
+
schedule: { cron: "0 9 * * 1", enabled: false }
|
|
421
|
+
params:
|
|
422
|
+
- { name: ourSpace, type: text, label: Our market / category, required: true }
|
|
423
|
+
executor: is-agent
|
|
424
|
+
grants:
|
|
425
|
+
- exa_search
|
|
426
|
+
- Entity Creator
|
|
427
|
+
status: active
|
|
428
|
+
|
|
429
|
+
# The Exa search capability powers the discovery radar (find companies we don't
|
|
430
|
+
# track yet). Composed pod-wide at provisioning via the existing integrations
|
|
431
|
+
# door; the user supplies the Exa API key once on install.
|
|
432
|
+
integrations:
|
|
433
|
+
- templateKey: exa
|
|
434
|
+
|
|
400
435
|
onboarding:
|
|
401
436
|
goal: >
|
|
402
437
|
Map the real ecosystem — the key players (companies and people), who partners
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,6 @@ export type {
|
|
|
34
34
|
TemplateSuggestedRelation,
|
|
35
35
|
TemplateDisplayTemplate,
|
|
36
36
|
TemplateIntegration,
|
|
37
|
-
TemplateAutomation,
|
|
38
37
|
TemplateExtendsRef,
|
|
39
38
|
TemplateDependency,
|
|
40
39
|
PackageDependencyKind,
|
|
@@ -62,7 +61,13 @@ export {
|
|
|
62
61
|
|
|
63
62
|
export type { WorkspaceTemplateSlug } from "./templates.js";
|
|
64
63
|
|
|
65
|
-
export {
|
|
64
|
+
export {
|
|
65
|
+
mergeCatalog,
|
|
66
|
+
rowToRemoteEntry,
|
|
67
|
+
assembleCatalog,
|
|
68
|
+
computeTemplateUpdates,
|
|
69
|
+
isHashVersion,
|
|
70
|
+
} from "./catalog.js";
|
|
66
71
|
export type {
|
|
67
72
|
MergeCatalogInput,
|
|
68
73
|
MergeCatalogResult,
|
|
@@ -72,6 +77,10 @@ export type {
|
|
|
72
77
|
CatalogEntry,
|
|
73
78
|
BundledCatalogEntry,
|
|
74
79
|
RemoteCatalogEntry,
|
|
80
|
+
CpPackageRow,
|
|
81
|
+
AssembleCatalogInput,
|
|
82
|
+
InstalledTemplate,
|
|
83
|
+
TemplateUpdateCheck,
|
|
75
84
|
} from "./catalog.js";
|
|
76
85
|
|
|
77
86
|
export {
|