@synap-core/workspace-templates 0.6.3 → 0.7.0

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.
@@ -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
@@ -371,3 +371,96 @@ export function assembleCatalog(input: AssembleCatalogInput): MergeCatalogResult
371
371
  remoteStatus: input.remoteStatus,
372
372
  });
373
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/crm.yaml CHANGED
@@ -1,8 +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
- # campaigns, tasks, documents, or automations. It DOES seed funnel playbooks
5
- # (the action layer) so the pipeline is operable via sessions — see `playbooks:`.
4
+ # campaigns, tasks, documents, automations, or sales-specific playbooks. A
5
+ # use-case overlay (for example `business-developer`) may add those workflows.
6
6
 
7
7
  meta:
8
8
  slug: crm
@@ -17,6 +17,20 @@ meta:
17
17
  - contacts
18
18
  isPublic: true
19
19
 
20
+ # SSOT ordering guarantee — this template REFERENCES foundation's pod-wide
21
+ # shared `client`/`partner`/`lead`/`churned` roles below. The apply engine seeds
22
+ # a shared profile's pod-wide BASE property body from whichever template
23
+ # creates it FIRST, and adds a REUSING template's extra props as workspace
24
+ # OVERLAYS. Requiring foundation makes the resolver install it first
25
+ # (deps-first), so the base is always foundation's SSOT body and never this
26
+ # template's. `require`, not `compose`: foundation is a real workspace, not an
27
+ # overlay.
28
+ dependencies:
29
+ - slug: foundation
30
+ kind: workspace
31
+ relation: require
32
+ reason: "Provides the pod-wide client/partner/lead/churned role vocabulary this workspace reuses."
33
+
20
34
  workspace:
21
35
  name: CRM
22
36
  description: A focused pre-sale workspace for contacts, companies, and deals.
@@ -407,67 +421,33 @@ entityLinks:
407
421
  targetProfileSlug: deal
408
422
  type: linked_to_deal
409
423
 
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.
424
+ # Playbook — enriches a lead/prospect from public sources (company site,
425
+ # LinkedIn, firmographic signal) so ICP scoring and qualification start from
426
+ # real data instead of a blank record. Bound to `lead` so a newly captured
427
+ # lead can offer this as a session.
427
428
  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.
429
+ - name: Enrich Lead/Company
430
+ description: Enrich a lead or company from public sources — firmographics, company size, and recent signal — to sharpen ICP scoring and qualification.
430
431
  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.
432
+ Enrich {target} using public sources. Scrape their website and LinkedIn
433
+ presence (via Apify) for firmographics: company size, industry, location,
434
+ and any recent signal (funding, hiring, product launches). Fill in the
435
+ missing profile fields (industry, companySize, location, icpScore) with
436
+ what you find, and note anything that changes qualification. Do not
437
+ fabricate — leave a field blank rather than guess.
438
438
  subjectProfile:
439
439
  profileSlug: lead
440
440
  params:
441
- - { name: lead, type: entity, label: Lead, required: true }
441
+ - { name: target, type: entity, label: Lead or company, required: true }
442
442
  executor: is-agent
443
+ grants:
444
+ - apify_run_actor
445
+ - entity.create
443
446
  status: active
444
447
 
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
448
+ # Apify powers the enrichment playbook (scrape public sources for a lead's
449
+ # firmographics). Composed pod-wide at provisioning via the existing
450
+ # integrations door; the user supplies the Apify API key once on install — a
451
+ # missing key fails gracefully per-skill, not the whole workspace.
452
+ integrations:
453
+ - templateKey: apify
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toWorkspaceDefinition } from "./define.js";
3
+ import type { WorkspaceYaml } from "./types.js";
4
+
5
+ const workflowTemplate: WorkspaceYaml = {
6
+ meta: {
7
+ slug: "workflow-contract-test",
8
+ name: "Workflow contract test",
9
+ description: "Test template",
10
+ icon: "workflow",
11
+ color: "blue",
12
+ },
13
+ workspace: { name: "Workflow contract test", description: "Test" },
14
+ profiles: [],
15
+ automations: [
16
+ {
17
+ name: "A manual workflow",
18
+ triggerType: "manual",
19
+ flow: {
20
+ nodes: [{ id: "trigger", type: "trigger" }],
21
+ edges: [],
22
+ precondition: "trigger.payload.ready === true",
23
+ },
24
+ },
25
+ ],
26
+ actionPlacements: [
27
+ {
28
+ profileSlug: "person",
29
+ surface: "entity-detail",
30
+ kind: "automation",
31
+ ref: "A manual workflow",
32
+ label: "Run workflow",
33
+ when: {
34
+ requiredFacetSlugs: ["lead"],
35
+ propertyEquals: { stage: "ready" },
36
+ },
37
+ confirmation: { title: "Continue?", confirmLabel: "Run" },
38
+ },
39
+ ],
40
+ };
41
+
42
+ describe("toWorkspaceDefinition flow automations", () => {
43
+ it("keeps graph workflows distinct from legacy loop automations", () => {
44
+ const { definition } = toWorkspaceDefinition(workflowTemplate);
45
+
46
+ expect(definition.flowAutomations).toEqual([
47
+ expect.objectContaining({
48
+ name: "A manual workflow",
49
+ triggerType: "manual",
50
+ flowDefinition: expect.objectContaining({
51
+ precondition: "trigger.payload.ready === true",
52
+ }),
53
+ }),
54
+ ]);
55
+ expect("automations" in definition).toBe(false);
56
+ });
57
+
58
+ it("preserves action applicability and confirmation policy", () => {
59
+ const { definition } = toWorkspaceDefinition(workflowTemplate);
60
+ expect(definition.actionPlacements?.[0]).toMatchObject({
61
+ when: { requiredFacetSlugs: ["lead"], propertyEquals: { stage: "ready" } },
62
+ confirmation: { title: "Continue?", confirmLabel: "Run" },
63
+ });
64
+ });
65
+ });
package/src/define.ts CHANGED
@@ -10,9 +10,39 @@
10
10
  * shape never drifts per call-site.
11
11
  */
12
12
 
13
- import type { TemplateBentoBlock, WorkspaceYaml } from "./types.js";
13
+ import type { TemplateAutomation, TemplateBentoBlock, WorkspaceYaml } from "./types.js";
14
14
  import { getWorkspaceTemplate } from "./templates.js";
15
15
 
16
+ /**
17
+ * A `TemplateAutomation` as the apply door expects it: `flowDefinition`, not
18
+ * the authored `flow` (mirrors `PackagePostWorkspaceBody['automations'][number]`
19
+ * / the Hub `POST /packages/apply` `AutomationSchema`).
20
+ */
21
+ export interface PackageAutomationWire {
22
+ /** Stable template identity; names may be edited without orphaning the flow. */
23
+ key?: string;
24
+ name: string;
25
+ description?: string;
26
+ triggerType: TemplateAutomation['triggerType'];
27
+ triggerConfig?: Record<string, unknown>;
28
+ flowDefinition?: { nodes: unknown[]; edges: unknown[]; precondition?: string };
29
+ status?: TemplateAutomation['status'];
30
+ }
31
+
32
+ function toAutomationWire(
33
+ automations: TemplateAutomation[] | undefined,
34
+ ): PackageAutomationWire[] | undefined {
35
+ return automations?.map((a) => ({
36
+ key: a.key,
37
+ name: a.name,
38
+ description: a.description,
39
+ triggerType: a.triggerType,
40
+ triggerConfig: a.triggerConfig,
41
+ flowDefinition: a.flow,
42
+ status: a.status,
43
+ }));
44
+ }
45
+
16
46
  /** A widget block as the wire expects it: `bentoLayout[]`, keyed by `widgetType`. */
17
47
  export interface BentoWidgetBlockWire {
18
48
  widgetType: string;
@@ -147,6 +177,21 @@ export interface WorkspaceDefinitionInput {
147
177
  * tools/skills/automations silently dropped on that door.
148
178
  */
149
179
  capabilities?: WorkspaceYaml['integrations'];
180
+ /** Playbooks normalized to the workspace-create input contract. */
181
+ playbooks?: Array<Record<string, unknown>>;
182
+ /**
183
+ * Entity-detail action placements — forwarded verbatim to
184
+ * `applyPackagePostWorkspace`, which merges them into
185
+ * `settings.actionPlacements`. The tRPC door (`createFromDefinition`)
186
+ * declares `actionPlacements` in its zod input and carries them through the
187
+ * shared apply door. No shape transform: the authored form IS the wire form.
188
+ */
189
+ actionPlacements?: WorkspaceYaml['actionPlacements'];
190
+ /**
191
+ * Graph-based automations. Deliberately distinct from the legacy
192
+ * `automations` loop/playbook-trigger wire shape accepted by this door.
193
+ */
194
+ flowAutomations?: PackageAutomationWire[];
150
195
  // Workspace-level metadata (flattened from the `workspace` block)
151
196
  workspaceSubtype?: string;
152
197
  workspaceType?: NonNullable<WorkspaceYaml['workspace']>['workspaceType'];
@@ -177,6 +222,27 @@ export interface ToDefinitionResult {
177
222
  templateId: string;
178
223
  }
179
224
 
225
+ /**
226
+ * The workspace-create door persists a compact primitive input contract. A
227
+ * template's entity/choice parameter is still represented precisely by its
228
+ * `subjectProfile` or runtime prompt, while the persisted input value is an ID
229
+ * or selected value string. Keep this conversion at the shared template seam
230
+ * so browser creation and package application cannot silently disagree.
231
+ */
232
+ function toWorkspacePlaybooks(playbooks: WorkspaceYaml["playbooks"]): Array<Record<string, unknown>> | undefined {
233
+ return playbooks?.map((playbook) => ({
234
+ ...playbook,
235
+ params: playbook.params?.map((param) => ({
236
+ name: param.name,
237
+ type: param.type === "number" || param.type === "boolean" ? param.type : "string",
238
+ ...(param.defaultValue !== undefined ? { default: param.defaultValue } : {}),
239
+ ...(param.label
240
+ ? { description: param.label }
241
+ : {}),
242
+ })),
243
+ }));
244
+ }
245
+
180
246
  /**
181
247
  * Resolve a template (by slug or YAML object) into the createFromDefinition
182
248
  * payload. Pass `nameOverride` to rename the workspace at creation time.
@@ -228,6 +294,13 @@ export function toWorkspaceDefinition(
228
294
  workspaceCapabilities: ws.capabilities,
229
295
  sourceRoles: ws.sourceRoles,
230
296
  defaultSources: ws.defaultSources,
297
+ // Workspace creation persists its own compact playbook parameter shape.
298
+ // Map authored entity/choice inputs here instead of dropping playbooks.
299
+ playbooks: toWorkspacePlaybooks(tpl.playbooks),
300
+ // Entity-detail action placements — forwarded verbatim; the apply door
301
+ // resolves playbook/automation refs and merges into settings.actionPlacements.
302
+ actionPlacements: tpl.actionPlacements,
303
+ flowAutomations: toAutomationWire(tpl.automations),
231
304
  onboarding: tpl.onboarding,
232
305
  // Template-composition dependencies — forwarded so the tRPC/UI door gets the
233
306
  // SAME deps-first ordering the CLI/Hub door gets via `toPackageDefinition`.
@@ -281,6 +354,9 @@ type WorkspaceDefinitionForwarded =
281
354
  | 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
282
355
  | 'profileEntityBentoTemplates'
283
356
  | 'integrations' // → capabilities (installed via shared applyPackagePostWorkspace door)
357
+ | 'playbooks' // → normalized compact playbook contract
358
+ | 'actionPlacements' // → settings.actionPlacements via applyPackagePostWorkspace
359
+ | 'automations' // → flowAutomations → shared applyPackagePostWorkspace door
284
360
  | 'onboarding'
285
361
  | 'dependencies';
286
362
 
@@ -297,12 +373,7 @@ type WorkspaceDefinitionDropped =
297
373
  // The door DOES accept `extends`, but no template declares it today (0/21).
298
374
  // Forwarding is a no-op until one does — wire it then.
299
375
  | 'extends'
300
- // REAL GAP (agent-fleet, project-management declare playbooks). The door
301
- // accepts `playbooks`, but the shapes disagree: template param `type` allows
302
- // 'text'|'entity'|'choice', the tRPC enum accepts only string|number|boolean
303
- // (and defaultValue↔default, grants: string[]↔{kind,ref}[]). Raw forwarding =
304
- // zod BAD_REQUEST. Needs a param mapper — separate wave, not a silent drop.
305
- | 'playbooks';
376
+ ;
306
377
 
307
378
  type UnclassifiedWorkspaceDefinitionKey = Exclude<
308
379
  keyof WorkspaceYaml,
@@ -367,6 +438,19 @@ export interface PackageDefinitionOutput {
367
438
  layoutConfig?: WorkspaceYaml['layout'];
368
439
  capabilities?: WorkspaceYaml['integrations'];
369
440
  playbooks?: WorkspaceYaml['playbooks'];
441
+ /** Flow-based automations — mapped to the apply door's `flowDefinition` field. */
442
+ automations?: PackageAutomationWire[];
443
+ /**
444
+ * Entity-detail action placements — carried verbatim (authored form IS the
445
+ * wire form). NOTE: the Hub `PackageApplySchema` (synap-backend
446
+ * `hub-protocol/rest/packages.ts`) is a plain `z.object` and does not yet
447
+ * declare `actionPlacements`, so on the CLI/Hub install path this field is
448
+ * STRIPPED before `applyPackagePostWorkspace` sees it. The tRPC
449
+ * `createFromDefinition` door DOES carry it. Named follow-up: add an
450
+ * `ActionPlacementSchema` field to `PackageApplySchema` to close the Hub half
451
+ * (out of the T2 fence — flagged in the handoff).
452
+ */
453
+ actionPlacements?: WorkspaceYaml['actionPlacements'];
370
454
  onboarding?: WorkspaceYaml['onboarding'];
371
455
  /** Template-composition dependencies — resolved by the apply endpoint. */
372
456
  dependencies?: WorkspaceYaml['dependencies'];
@@ -421,6 +505,8 @@ export function toPackageDefinition(
421
505
  layoutConfig: tpl.layout,
422
506
  capabilities: tpl.integrations,
423
507
  playbooks: tpl.playbooks,
508
+ automations: toAutomationWire(tpl.automations),
509
+ actionPlacements: tpl.actionPlacements,
424
510
  onboarding: tpl.onboarding,
425
511
  dependencies: tpl.dependencies,
426
512
  };
@@ -448,6 +534,8 @@ type PackageDefinitionForwarded =
448
534
  | 'layout' // → layoutConfig
449
535
  | 'integrations' // → capabilities
450
536
  | 'playbooks'
537
+ | 'automations' // → mapped to the apply door's flowDefinition wire shape
538
+ | 'actionPlacements' // → carried verbatim (Hub inbound schema decl is a follow-up)
451
539
  | 'onboarding'
452
540
  | 'dependencies';
453
541
 
@@ -381,9 +381,11 @@ playbooks:
381
381
  goalTemplate: >-
382
382
  Research this competitor ({competitor}) and compare them to us. Investigate
383
383
  their {focus}: positioning, strengths, weaknesses, pricing, and recent
384
- moves. Use the provided company context where relevant: {companyContext}.
385
- Produce: {outputTypes}. Update the competitor entity with what you find and
386
- make clear where we win and where we are exposed.
384
+ moves. Use web search for news and public signal, and use the Apify scraper
385
+ for a deep scan of their site (pricing page, changelog, careers page) when
386
+ a URL is known. Use the provided company context where relevant:
387
+ {companyContext}. Produce: {outputTypes}. Update the competitor entity with
388
+ what you find and make clear where we win and where we are exposed.
387
389
  subjectProfile:
388
390
  profileSlug: competitor
389
391
  params:
@@ -398,8 +400,9 @@ playbooks:
398
400
  # starts paid AI runs — the user turns it on when they want the watch.
399
401
  schedule: { cron: "0 9 * * 1", enabled: false }
400
402
  grants:
401
- - Web Search
402
- - Entity Creator
403
+ - exa_search
404
+ - apify_run_actor
405
+ - entity.create
403
406
  status: active
404
407
 
405
408
  # A radar DISCOVERY run: the self-generating half. No subjectProfile → a single
@@ -409,28 +412,32 @@ playbooks:
409
412
  - name: Discover Competitors
410
413
  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
414
  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.
415
+ Find companies that compete with us in {ourSpace}. Fan out neural web
416
+ searches with the exa_search skill using category "company" — cover direct
417
+ competitors, adjacent tools that solve the same job a different way, and
418
+ emerging players, not just the obvious incumbents. For each genuinely NEW
419
+ one (not already a competitor in our ecosystem), propose it as a
420
+ `competitor` entity and ALWAYS include its `website` (its canonical URL) as
421
+ the dedup key — never create a company whose website already matches one we
422
+ track. Skip anything already known. Report what you added, the search
423
+ angles you covered, and why each new one matters.
420
424
  schedule: { cron: "0 9 * * 1", enabled: false }
421
425
  params:
422
426
  - { name: ourSpace, type: text, label: Our market / category, required: true }
423
427
  executor: is-agent
424
428
  grants:
425
429
  - exa_search
426
- - Entity Creator
430
+ - entity.create
427
431
  status: active
428
432
 
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.
433
+ # Exa powers the discovery radar (find companies we don't track yet); Apify
434
+ # powers the deep-scan half of Research Competitor (scrape a known competitor's
435
+ # site for pricing/changelog/careers signal). Composed pod-wide at provisioning
436
+ # via the existing integrations door; the user supplies each API key once on
437
+ # install — a missing key fails gracefully per-skill, not the whole workspace.
432
438
  integrations:
433
439
  - templateKey: exa
440
+ - templateKey: apify
434
441
 
435
442
  onboarding:
436
443
  goal: >
package/src/index.ts CHANGED
@@ -44,6 +44,8 @@ export type {
44
44
  TemplateOnboardingCollectTarget,
45
45
  TemplatePlaybook,
46
46
  TemplatePlaybookParam,
47
+ TemplateAutomation,
48
+ TemplateActionPlacement,
47
49
  } from "./types.js";
48
50
 
49
51
  // The dependency-kind vocabulary as a VALUE — so consumers (marketplace,
@@ -61,7 +63,13 @@ export {
61
63
 
62
64
  export type { WorkspaceTemplateSlug } from "./templates.js";
63
65
 
64
- export { mergeCatalog, rowToRemoteEntry, assembleCatalog } from "./catalog.js";
66
+ export {
67
+ mergeCatalog,
68
+ rowToRemoteEntry,
69
+ assembleCatalog,
70
+ computeTemplateUpdates,
71
+ isHashVersion,
72
+ } from "./catalog.js";
65
73
  export type {
66
74
  MergeCatalogInput,
67
75
  MergeCatalogResult,
@@ -73,6 +81,8 @@ export type {
73
81
  RemoteCatalogEntry,
74
82
  CpPackageRow,
75
83
  AssembleCatalogInput,
84
+ InstalledTemplate,
85
+ TemplateUpdateCheck,
76
86
  } from "./catalog.js";
77
87
 
78
88
  export {