@synap-core/workspace-templates 0.12.0 → 0.24.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.
- package/README.md +509 -0
- package/automations/bookmark-enrichment.automation.json +321 -0
- package/automations/github-pr-events.automation.json +505 -0
- package/automations/mail-feed.automation.json +86 -0
- package/automations/ship-a-change-checks-green.automation.json +106 -0
- package/automations/ship-a-change-checks-red.automation.json +106 -0
- package/automations/ship-a-change-deployed.automation.json +56 -0
- package/automations/ship-a-change-pr-merged.automation.json +110 -0
- package/automations/ship-a-change-pr-opened.automation.json +104 -0
- package/automations/url-bookmark-capture.automation.json +132 -0
- package/capabilities/agency-skills.capability.json +157 -0
- package/capabilities/apify.capability.json +265 -0
- package/capabilities/apollo.capability.json +120 -0
- package/capabilities/arch-backend.capability.json +97 -0
- package/capabilities/arch-client-intelligence.capability.json +401 -0
- package/capabilities/backblaze-b2.capability.json +205 -0
- package/capabilities/cal-com.capability.json +266 -0
- package/capabilities/canva.capability.json +274 -0
- package/capabilities/claude-code-routines.capability.json +172 -0
- package/capabilities/claude-managed-agents.capability.json +215 -0
- package/capabilities/client-comms-report.capability.json +246 -0
- package/capabilities/content-idea-to-production.capability.json +29 -0
- package/capabilities/datagouv.capability.json +315 -0
- package/capabilities/discord-bot.capability.json +157 -0
- package/capabilities/dropbox.capability.json +281 -0
- package/capabilities/exa.capability.json +109 -0
- package/capabilities/fal-ai.capability.json +83 -0
- package/capabilities/fal-media-lab.capability.json +158 -0
- package/capabilities/fireflies.capability.json +191 -0
- package/capabilities/freellmapi.capability.json +114 -0
- package/capabilities/generic-apikey.capability.json +92 -0
- package/capabilities/github-merge.capability.json +99 -0
- package/capabilities/github-read.capability.json +325 -0
- package/capabilities/governance-tighten-recommender.capability.json +81 -0
- package/capabilities/jev.capability.json +226 -0
- package/capabilities/lead-conversion-enrollment.capability.json +246 -0
- package/capabilities/mail-feed.capability.json +277 -0
- package/capabilities/mailgun.capability.json +52 -0
- package/capabilities/nango-google.capability.json +639 -0
- package/capabilities/notion.capability.json +417 -0
- package/capabilities/omniroute.capability.json +137 -0
- package/capabilities/posthog-analytics.capability.json +258 -0
- package/capabilities/postiz.capability.json +467 -0
- package/capabilities/proactive-digest.capability.json +190 -0
- package/capabilities/proton.capability.json +288 -0
- package/capabilities/replicate-rvm.capability.json +97 -0
- package/capabilities/research-methods.capability.json +57 -0
- package/capabilities/shopify.capability.json +395 -0
- package/capabilities/slack.capability.json +369 -0
- package/capabilities/stripe.capability.json +1496 -0
- package/capabilities/telegram-bridge.capability.json +56 -0
- package/capabilities/treg.capability.json +102 -0
- package/capabilities/unipile-linkedin.capability.json +494 -0
- package/capabilities/vercel.capability.json +424 -0
- package/dist/define-DqoItRiR.d.ts +1370 -0
- package/dist/index.d.ts +174 -900
- package/dist/index.js +26447 -24075
- package/dist/index.js.map +1 -1
- package/dist/wire.d.ts +222 -0
- package/dist/wire.js +77 -0
- package/dist/wire.js.map +1 -0
- package/package.json +27 -4
- package/retired.yaml +136 -0
- package/skills/app-factory-build-slice/SKILL.md +70 -0
- package/skills/app-factory-build-slice.catalog.json +17 -0
- package/skills/app-factory-design-canvas/SKILL.md +96 -0
- package/skills/app-factory-design-canvas.catalog.json +17 -0
- package/skills/app-factory-design-review/SKILL.md +105 -0
- package/skills/app-factory-design-review.catalog.json +18 -0
- package/skills/app-factory-research/SKILL.md +123 -0
- package/skills/app-factory-research.catalog.json +17 -0
- package/skills/bookmark-enrichment/SKILL.md +40 -0
- package/skills/carousel-format/carousel-format.skill.json +18 -0
- package/skills/collage-broll/collage-broll.skill.json +20 -0
- package/skills/content-taste/content-taste.skill.json +18 -0
- package/skills/creative-director/creative-director.skill.json +18 -0
- package/skills/design-critique/SKILL.md +138 -0
- package/skills/design-critique.catalog.json +12 -0
- package/skills/designing-with-real-components/SKILL.md +140 -0
- package/skills/designing-with-real-components.catalog.json +12 -0
- package/skills/edit-loop/edit-loop.skill.json +25 -0
- package/skills/facecam-edit/facecam-edit.skill.json +24 -0
- package/skills/mapping-the-server/SKILL.md +54 -0
- package/skills/math-explainer-manimgx/math-explainer-manimgx.skill.json +20 -0
- package/skills/motion-quality-gates/motion-quality-gates.skill.json +20 -0
- package/skills/open-design-edit/open-design-edit.skill.json +24 -0
- package/skills/reading-client-progress/SKILL.md +65 -0
- package/skills/routing-to-channels/SKILL.md +54 -0
- package/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
- package/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
- package/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
- package/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
- package/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
- package/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
- package/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
- package/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
- package/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
- package/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
- package/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
- package/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
- package/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
- package/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
- package/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
- package/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
- package/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
- package/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
- package/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
- package/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
- package/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
- package/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
- package/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
- package/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
- package/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
- package/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
- package/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
- package/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
- package/skills/video-direction/video-direction.skill.json +20 -0
- package/skills/video-style-vocabulary/video-style-vocabulary.skill.json +20 -0
- package/skills/video-toolbox/video-toolbox.skill.json +20 -0
- package/src/__fixtures__/lifecycle-presets.flat-before.json +205 -0
- package/src/agency-os.yaml +122 -0
- package/src/agent-os-skill-slugs.test.ts +65 -0
- package/src/apify-social-reader.test.ts +317 -0
- package/src/base.yaml +85 -39
- package/src/blockchain-ecosystem.yaml +1 -0
- package/src/brand-library.yaml +525 -14
- package/src/builder-dev-fold.template.test.ts +113 -0
- package/src/builder.yaml +779 -91
- package/src/business-model-catalog-description.test.ts +47 -0
- package/src/business-model-grp.template.test.ts +214 -0
- package/src/business-model.yaml +296 -49
- package/src/catalog-safety.test.ts +198 -0
- package/src/catalog.template-items.test.ts +75 -0
- package/src/catalog.ts +66 -2
- package/src/concept-guard.test.ts +28 -8
- package/src/concept-guard.ts +2 -2
- package/src/content-creation.template.test.ts +52 -0
- package/src/content-creation.yaml +192 -0
- package/src/content-os.edit-loop.template.test.ts +210 -0
- package/src/content-os.publish-due-posts.template.test.ts +261 -0
- package/src/content-os.template.test.ts +349 -0
- package/src/content-os.video-takes.template.test.ts +338 -0
- package/src/content-os.work-a-take.template.test.ts +125 -0
- package/src/content-os.yaml +1873 -39
- package/src/content-studio-retired.template.test.ts +82 -0
- package/src/corpus-flow-validity.test.ts +4 -5
- package/src/crm-absorbs-outreach.template.test.ts +117 -0
- package/src/crm-build-plan.template.test.ts +128 -0
- package/src/crm-lists.template.test.ts +96 -0
- package/src/crm-messages-cadence.template.test.ts +246 -0
- package/src/crm-one-send-door.template.test.ts +65 -0
- package/src/crm.yaml +1563 -181
- package/src/decisions-wave.template.test.ts +186 -0
- package/src/define.ts +146 -17
- package/src/design-to-implementation.template.test.ts +94 -0
- package/src/domain-template-type.test.ts +75 -0
- package/src/ecosystem.yaml +27 -66
- package/src/enterprise-os.yaml +10 -134
- package/src/facecam-edit.audio-studio.test.ts +188 -0
- package/src/fal-generate-image.test.ts +200 -0
- package/src/fal-media-lab-pod-files.test.ts +180 -0
- package/src/finance.yaml +254 -1
- package/src/foundation.yaml +78 -38
- package/src/frame-host-modules.d.mts +6 -0
- package/src/frame-host-modules.mjs +58 -0
- package/src/generate.mjs +9 -3
- package/src/github-merge.test.ts +210 -0
- package/src/grants.yaml +46 -17
- package/src/hr.yaml +39 -6
- package/src/index.ts +36 -1
- package/src/intent.test.ts +161 -0
- package/src/internal-runbook.yaml +19 -1
- package/src/jev.test.ts +356 -0
- package/src/legal.yaml +33 -1
- package/src/life-os.yaml +51 -240
- package/src/lifecycle-coverage.test.ts +124 -0
- package/src/lifecycle-declarations.test.ts +252 -0
- package/src/lifecycle-presets.test.ts +468 -0
- package/src/lifecycle-presets.ts +494 -0
- package/src/lifecycle-stuck.test.ts +46 -0
- package/src/lifecycle-waits.parity.test.ts +319 -0
- package/src/marketing.yaml +108 -4
- package/src/meetings-knowledge.yaml +18 -0
- package/src/networking.yaml +23 -2
- package/src/operations.yaml +304 -4
- package/src/pipeline-views.template.test.ts +88 -0
- package/src/playbook-goal-placeholders.test.ts +57 -0
- package/src/postiz-contract.test.ts +134 -0
- package/src/presentation-declarations.test.ts +174 -0
- package/src/process-contract.validate.test.ts +352 -0
- package/src/project-management.yaml +39 -740
- package/src/publish-body.d.mts +29 -0
- package/src/publish-body.mjs +74 -0
- package/src/publish-body.test.ts +74 -0
- package/src/publish-official-lib.test.ts +176 -0
- package/src/replicate-rvm.test.ts +504 -0
- package/src/research-base.yaml +119 -20
- package/src/research-decision.yaml +60 -3
- package/src/reserved-profile-slugs.test.ts +81 -0
- package/src/retired-goal-kinds.test.ts +52 -0
- package/src/role-reference-and-step-domain.test.ts +192 -0
- package/src/seed-obligation.test.ts +1 -6
- package/src/send-message-maps.test.ts +227 -0
- package/src/shared-role-ssot.test.ts +40 -2
- package/src/ship-a-change.template.test.ts +177 -0
- package/src/skills.test.ts +170 -0
- package/src/social.yaml +85 -27
- package/src/space-packages.freshness.test.ts +97 -0
- package/src/space-packages.generated.ts +40 -0
- package/src/space-packages.test.ts +125 -0
- package/src/space-packages.ts +73 -0
- package/src/start.test.ts +188 -0
- package/src/stripe-capability.test.ts +192 -0
- package/src/task-intents.test.ts +293 -0
- package/src/template-documents.test.ts +158 -0
- package/src/templates.ts +20907 -21464
- package/src/types.ts +475 -61
- package/src/unipile-linkedin-find-profile.test.ts +183 -0
- package/src/unipile-publish-post.test.ts +162 -0
- package/src/validate-flow.drift.test.ts +2 -3
- package/src/validate-flow.ts +22 -2
- package/src/validate-onboarding.test.ts +59 -0
- package/src/validate.test.ts +71 -4
- package/src/validate.ts +1416 -7
- package/src/wire-core.ts +231 -0
- package/src/wire.test.ts +159 -0
- package/src/wire.ts +107 -0
- package/views/bookmark-views-pack.view.json +39 -0
- package/views/event-views-pack.view.json +45 -0
- package/views/task-views-pack.view.json +53 -0
- package/src/agent-fleet.yaml +0 -1645
- package/src/business-developer.template.test.ts +0 -351
- package/src/business-developer.yaml +0 -860
- package/src/content-studio.yaml +0 -361
- package/src/dev-dashboard.yaml +0 -990
- package/src/outreach-comms.yaml +0 -486
- package/src/the-arch.yaml +0 -201
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `meta.start` — a template declares itself an onboarding start (A1).
|
|
3
|
+
*
|
|
4
|
+
* Pinned:
|
|
5
|
+
* - a well-formed start validates with zero `start-*` errors;
|
|
6
|
+
* - each `start-*` rule fires on a minimal broken fixture (and only it);
|
|
7
|
+
* - the package door forwards a TOP-LEVEL `start`, never inside `_meta`;
|
|
8
|
+
* - the catalog header accepts the shape and refuses it on a non-space kind.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { validateTemplate } from "./validate.js";
|
|
12
|
+
import { toPackageDefinition } from "./define.js";
|
|
13
|
+
import { INTENT_LABEL_MAX } from "./types.js";
|
|
14
|
+
import type { TemplateProfile, TemplateStart, WorkspaceYaml } from "./types.js";
|
|
15
|
+
import { manifestSchemaFor } from "../schema/index.ts";
|
|
16
|
+
import { listWorkspaceTemplates } from "./templates.js";
|
|
17
|
+
|
|
18
|
+
const deal: TemplateProfile = {
|
|
19
|
+
slug: "deal",
|
|
20
|
+
displayName: "Deal",
|
|
21
|
+
properties: [
|
|
22
|
+
{ slug: "commercialStage", label: "Stage", valueType: "string", inputType: "select", enumValues: ["lead", "negotiating", "won"] },
|
|
23
|
+
{ slug: "value", label: "Value", valueType: "number" },
|
|
24
|
+
],
|
|
25
|
+
} as TemplateProfile;
|
|
26
|
+
|
|
27
|
+
const goodStart = (over: Partial<TemplateStart> = {}): TemplateStart => ({
|
|
28
|
+
order: 1,
|
|
29
|
+
label: "Sales",
|
|
30
|
+
promise: "Leads and deals, each with a dated next step.",
|
|
31
|
+
suggestFor: ["founder"],
|
|
32
|
+
profile: "deal",
|
|
33
|
+
views: ["deal-pipeline", "deals-table"],
|
|
34
|
+
example: [1, 2, 3, 4].map((n) => ({ title: `Deal ${n}`, values: { commercialStage: "negotiating", value: n * 1000 } })),
|
|
35
|
+
firstSession: { title: "Your live deals", goal: "Add the deals you are moving now." },
|
|
36
|
+
starters: ["a", "b", "c"],
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const make = (slug: string, start?: Partial<TemplateStart> | null, extra: Partial<WorkspaceYaml> = {}): WorkspaceYaml =>
|
|
41
|
+
({
|
|
42
|
+
meta: { slug, name: slug, description: "d", icon: "box", color: "blue", ...(start === null ? {} : { start: goodStart(start) }) },
|
|
43
|
+
workspace: { name: slug, description: "d" },
|
|
44
|
+
profiles: [deal],
|
|
45
|
+
views: [
|
|
46
|
+
{ name: "Pipeline", slug: "deal-pipeline", type: "kanban", scopeProfileSlug: "deal" },
|
|
47
|
+
{ name: "Deals", slug: "deals-table", type: "table", scopeProfileSlugs: ["deal"] },
|
|
48
|
+
{ name: "Other", slug: "other-table", type: "table", scopeProfileSlug: "task" },
|
|
49
|
+
],
|
|
50
|
+
...extra,
|
|
51
|
+
}) as unknown as WorkspaceYaml;
|
|
52
|
+
|
|
53
|
+
const startErrors = (t: WorkspaceYaml, corpus?: WorkspaceYaml[]) =>
|
|
54
|
+
validateTemplate(t, { corpus }).errors.filter((e) => e.rule.startsWith("start-")).map((e) => e.rule);
|
|
55
|
+
|
|
56
|
+
describe("meta.start — validator rules", () => {
|
|
57
|
+
it("a well-formed start passes", () => {
|
|
58
|
+
expect(startErrors(make("t"))).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("no start → no start-* error", () => {
|
|
62
|
+
expect(startErrors(make("t", null))).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("start-shape: a missing field", () => {
|
|
66
|
+
const t = make("t");
|
|
67
|
+
delete (t.meta.start as Partial<TemplateStart>).promise;
|
|
68
|
+
expect(startErrors(t)).toEqual(["start-shape"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("start-no-workspace: an overlay owns no space", () => {
|
|
72
|
+
const t = make("t", {}, { dependencies: [{ slug: "base", relation: "compose" }] } as Partial<WorkspaceYaml>);
|
|
73
|
+
expect(startErrors(t)).toEqual(["start-no-workspace"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("start-label: longer than INTENT_LABEL_MAX", () => {
|
|
77
|
+
expect(startErrors(make("t", { label: "x".repeat(INTENT_LABEL_MAX + 1) }))).toEqual(["start-label"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("start-example-count and start-starters-count", () => {
|
|
81
|
+
expect(startErrors(make("t", { example: goodStart().example.slice(0, 3) }))).toEqual(["start-example-count"]);
|
|
82
|
+
expect(startErrors(make("t", { starters: ["a"] }))).toEqual(["start-starters-count"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("start-views-count: none, or more than three", () => {
|
|
86
|
+
expect(startErrors(make("t", { views: [] }))).toEqual(["start-views-count"]);
|
|
87
|
+
expect(startErrors(make("t", { views: ["deal-pipeline", "deals-table", "deal-pipeline", "deals-table"] }))).toEqual(["start-views-count"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("start-views-exist: a slug this template does not declare", () => {
|
|
91
|
+
expect(startErrors(make("t", { views: ["deal-pipeline", "nope"] }))).toEqual(["start-views-exist"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("start-views-scope: a view scoped to another kind", () => {
|
|
95
|
+
expect(startErrors(make("t", { views: ["other-table"] }))).toEqual(["start-views-scope"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("start-profile-unknown: a kind nobody declares", () => {
|
|
99
|
+
expect(startErrors(make("t", { profile: "ghost", views: [], example: goodStart().example.map((e) => ({ title: e.title })) }))).toContain("start-profile-unknown");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("start-profile-unknown: the kind may come from the dependency closure", () => {
|
|
103
|
+
const dep = { meta: { slug: "dep", name: "dep", description: "d", icon: "b", color: "c" }, workspace: { name: "d", description: "d" }, profiles: [deal] } as unknown as WorkspaceYaml;
|
|
104
|
+
const t = make("t", { views: [] }, { profiles: [], views: [], dependencies: [{ slug: "dep", relation: "require" }] } as Partial<WorkspaceYaml>);
|
|
105
|
+
expect(startErrors(t, [t, dep])).toEqual(["start-views-count"]); // profile resolves; only the empty views fire
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("start-example-property: a key the kind does not have", () => {
|
|
109
|
+
const example = goodStart().example.map((e) => ({ ...e, values: { ...e.values, mood: "x" } }));
|
|
110
|
+
expect(startErrors(make("t", { example }))).toEqual(Array(4).fill("start-example-property"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("start-example-enum: a select value outside enumValues", () => {
|
|
114
|
+
const example = goodStart().example.map((e) => ({ ...e, values: { commercialStage: "dancing" } }));
|
|
115
|
+
expect(startErrors(make("t", { example }))).toEqual(Array(4).fill("start-example-enum"));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("start-suggest-duplicate: an id twice in one start, and across starts", () => {
|
|
119
|
+
expect(startErrors(make("t", { suggestFor: ["founder", "founder"] }))).toEqual(["start-suggest-duplicate"]);
|
|
120
|
+
const a = make("a", { order: 1, suggestFor: ["founder"] });
|
|
121
|
+
const b = make("b", { order: 2, suggestFor: ["founder"] });
|
|
122
|
+
expect(startErrors(b, [a, b])).toEqual(["start-suggest-duplicate"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("start-order-duplicate: two starts on one card slot", () => {
|
|
126
|
+
const a = make("a", { order: 1, suggestFor: ["x"] });
|
|
127
|
+
const b = make("b", { order: 1, suggestFor: ["y"] });
|
|
128
|
+
expect(startErrors(b, [a, b])).toEqual(["start-order-duplicate"]);
|
|
129
|
+
expect(startErrors(make("b", { order: 2, suggestFor: ["y"] }), [a, make("b", { order: 2, suggestFor: ["y"] })])).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("meta.start — package door", () => {
|
|
134
|
+
it("carries a top-level start, never in _meta", () => {
|
|
135
|
+
const def = toPackageDefinition(make("t"));
|
|
136
|
+
expect(def.start).toEqual(goodStart());
|
|
137
|
+
expect("start" in def._meta).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("no start → no field", () => {
|
|
141
|
+
expect("start" in toPackageDefinition(make("t", null))).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("meta.start — catalog header", () => {
|
|
146
|
+
const manifest = (kind: "workspace" | "skill", meta: Record<string, unknown>) => ({
|
|
147
|
+
apiVersion: "synap/v1",
|
|
148
|
+
kind,
|
|
149
|
+
meta: { slug: "t", name: "T", ...meta },
|
|
150
|
+
spec: kind === "workspace" ? { workspace: { name: "T", description: "d" }, profiles: [] } : { name: "t", content: "x" },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("a workspace header accepts the shape and refuses an unknown key", () => {
|
|
154
|
+
expect(manifestSchemaFor("workspace").safeParse(manifest("workspace", { start: goodStart() })).success).toBe(true);
|
|
155
|
+
expect(manifestSchemaFor("workspace").safeParse(manifest("workspace", { start: { ...goodStart(), why: 1 } })).success).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("is refused on a kind that is not a workspace", () => {
|
|
159
|
+
const r = manifestSchemaFor("skill").safeParse(manifest("skill", { start: goodStart() }));
|
|
160
|
+
expect(r.success).toBe(false);
|
|
161
|
+
expect(r.error?.issues.some((i) => i.path.join(".") === "meta.start")).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("meta.start — the shipped catalog (A2)", () => {
|
|
166
|
+
const all = listWorkspaceTemplates();
|
|
167
|
+
const starts = all.filter((t) => t.meta.start);
|
|
168
|
+
|
|
169
|
+
it("declares exactly the four starts, card orders 1-4", () => {
|
|
170
|
+
expect(starts.map((t) => [t.meta.start!.order, t.meta.slug]).sort()).toEqual([
|
|
171
|
+
[1, "builder-workspace"],
|
|
172
|
+
[2, "content-os"],
|
|
173
|
+
[3, "crm"],
|
|
174
|
+
[4, "operations"],
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("every start passes the start-* rules against the whole corpus", () => {
|
|
179
|
+
for (const t of starts) expect(startErrors(t, all), t.meta.slug).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("CRM has three deal views scoped to deal", () => {
|
|
183
|
+
const crm = all.find((t) => t.meta.slug === "crm")!;
|
|
184
|
+
const dealViews = (crm.views ?? []).filter((v) => v.scopeProfileSlug === "deal").map((v) => v.slug);
|
|
185
|
+
expect(dealViews).toEqual(["deal-pipeline", "deals-table", "deals-list"]);
|
|
186
|
+
expect(crm.meta.start!.views).toEqual(dealViews);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stripe capability (session 92e83544, "Stripe capability: read-write,
|
|
3
|
+
* governed"): reads run, every write is reviewed, and the call can only ever
|
|
4
|
+
* reach https://api.stripe.com/v1/.
|
|
5
|
+
*
|
|
6
|
+
* Pinned here, where the verbs are authored:
|
|
7
|
+
* - a GET verb declares `metadata.readOnly: true` (the gate runs it at once);
|
|
8
|
+
* a verb that writes (POST, DELETE) never does, so it stays on the propose
|
|
9
|
+
* grant the pod seeds for every capability tool (`GRANT_DEFAULT_EXEC_MODE`)
|
|
10
|
+
* and its provider call re-checks the tool gate (`isReadMethod`);
|
|
11
|
+
* - a write verb declares no read intent (list_records / fetch_record), and
|
|
12
|
+
* its name or description carries a whole-word write signal, so the pod's
|
|
13
|
+
* `deriveVerbKind` files it as an `action`, never a `read`;
|
|
14
|
+
* - the tool's host is fixed (`baseUrl`), its bodies are form-encoded
|
|
15
|
+
* (`config.bodyEncoding: "form"`, Stripe v1 takes no JSON body);
|
|
16
|
+
* - every path is under `/v1/`; the generic verbs take their resource path
|
|
17
|
+
* through `encode: "path"` (the pod refuses "." / ".." / empty segments);
|
|
18
|
+
* - every Stripe-native field travels through `bodyFrom` (`fields` on a typed
|
|
19
|
+
* verb, `params` on a generic one), never a `body` template that would send
|
|
20
|
+
* an absent optional as an empty value (which Stripe reads as "unset");
|
|
21
|
+
* - the eight read verbs live pods already run are all still there.
|
|
22
|
+
*
|
|
23
|
+
* NOT covered: Stripe accepting the calls (checked against the API reference,
|
|
24
|
+
* docs.stripe.com/api, not the live account; no write is ever sent to it), and
|
|
25
|
+
* the pod's encoder and gate (backend: form-encode.test.ts,
|
|
26
|
+
* execute-provider-verb.body-from.test.ts, form-body.vault.test.ts).
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { dirname, join, resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { describe, expect, it } from "vitest";
|
|
32
|
+
|
|
33
|
+
const manifest = JSON.parse(
|
|
34
|
+
readFileSync(join(__dirname, "..", "capabilities", "stripe.capability.json"), "utf8"),
|
|
35
|
+
);
|
|
36
|
+
type Verb = {
|
|
37
|
+
name: string;
|
|
38
|
+
intent?: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
description: string;
|
|
41
|
+
requires: string[];
|
|
42
|
+
parameters?: Record<string, string>;
|
|
43
|
+
metadata?: { readOnly?: boolean };
|
|
44
|
+
providerSpec: {
|
|
45
|
+
method: string;
|
|
46
|
+
pathTemplate: string;
|
|
47
|
+
body?: unknown;
|
|
48
|
+
bodyFrom?: string;
|
|
49
|
+
paramMapping?: Record<string, { encode?: string; required?: boolean }>;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const verbs: Verb[] = manifest.spec.skills;
|
|
53
|
+
const isRead = (v: Verb) => /^(GET|HEAD)$/i.test(v.providerSpec.method);
|
|
54
|
+
const reads = verbs.filter(isRead);
|
|
55
|
+
const writes = verbs.filter((v) => !isRead(v));
|
|
56
|
+
|
|
57
|
+
// A mirror of the pod's own word lists (synap-backend create-from-definition.ts
|
|
58
|
+
// VERB_WRITE_SIGNALS / VERB_READ_SIGNALS): name first, then description,
|
|
59
|
+
// whole words, write signals before read signals. Held equal to the pod's
|
|
60
|
+
// source by the "mirror" describe below.
|
|
61
|
+
const WRITE_SIGNALS = ["send", "invite", "post", "create", "write", "update", "delete", "reply", "message", "email", "publish", "comment", "add", "remove", "set", "run", "execute"];
|
|
62
|
+
const READ_SIGNALS = ["search", "list", "get", "fetch", "read", "find", "lookup", "query", "pull"];
|
|
63
|
+
const words = (t: string) => new Set(t.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
64
|
+
const classify = (t: string) => {
|
|
65
|
+
const w = words(t);
|
|
66
|
+
if (WRITE_SIGNALS.some((s) => w.has(s))) return "action";
|
|
67
|
+
if (READ_SIGNALS.some((s) => w.has(s))) return "read";
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const derivedKind = (v: Verb) => classify(v.name) ?? classify(v.description) ?? "action";
|
|
71
|
+
|
|
72
|
+
describe("stripe capability: reads run, writes are reviewed", () => {
|
|
73
|
+
it("has reads and writes", () => {
|
|
74
|
+
expect(reads.length).toBeGreaterThan(10);
|
|
75
|
+
expect(writes.length).toBeGreaterThan(10);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("every GET verb is read-only (runs at once)", () => {
|
|
79
|
+
expect(reads.filter((v) => v.metadata?.readOnly !== true).map((v) => v.name)).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("no write verb is read-only: each stays on the propose grant", () => {
|
|
83
|
+
expect(writes.filter((v) => v.metadata?.readOnly === true).map((v) => v.name)).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a write declares no read intent and the pod files it as an action", () => {
|
|
87
|
+
expect(writes.filter((v) => v.intent === "list_records" || v.intent === "fetch_record").map((v) => v.name)).toEqual([]);
|
|
88
|
+
expect(writes.filter((v) => derivedKind(v) !== "action").map((v) => v.name)).toEqual([]);
|
|
89
|
+
expect(reads.filter((v) => derivedKind(v) !== "read").map((v) => v.name)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("covers what the founder asked for: tax settings, tax registrations, payment links, checkout, refunds, payouts", () => {
|
|
93
|
+
const has = (method: string, path: string) =>
|
|
94
|
+
writes.some((v) => v.providerSpec.method === method && v.providerSpec.pathTemplate === path);
|
|
95
|
+
expect(has("POST", "/v1/tax/settings")).toBe(true);
|
|
96
|
+
expect(has("POST", "/v1/tax/registrations")).toBe(true);
|
|
97
|
+
expect(has("POST", "/v1/payment_links")).toBe(true);
|
|
98
|
+
expect(has("POST", "/v1/checkout/sessions")).toBe(true);
|
|
99
|
+
expect(has("POST", "/v1/refunds")).toBe(true);
|
|
100
|
+
expect(has("POST", "/v1/payouts")).toBe(true);
|
|
101
|
+
expect(has("DELETE", "/v1/subscriptions/{{subscriptionId}}")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps the eight read verbs live pods already run", () => {
|
|
105
|
+
const names = new Set(reads.map((v) => v.name));
|
|
106
|
+
for (const n of ["stripe_list_customers", "stripe_get_customer", "stripe_list_charges", "stripe_list_invoices", "stripe_list_subscriptions", "stripe_list_products", "stripe_list_payouts", "stripe_get_balance"])
|
|
107
|
+
expect(names.has(n), n).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("stripe capability: one host, form bodies, paths under /v1/", () => {
|
|
112
|
+
const tool = manifest.spec.tools.find((t: { name: string }) => t.name === "stripe");
|
|
113
|
+
|
|
114
|
+
it("the tool's host is fixed and its bodies are form-encoded", () => {
|
|
115
|
+
expect(tool.config.baseUrl).toBe("https://api.stripe.com");
|
|
116
|
+
expect(tool.config.bodyEncoding).toBe("form");
|
|
117
|
+
expect(tool.credentialRef).toBe("stripe");
|
|
118
|
+
expect(verbs.every((v) => v.requires.length === 1 && v.requires[0] === "stripe")).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("every path starts with /v1/; every path parameter is encoded (uri for an id, path for a generic resource path)", () => {
|
|
122
|
+
for (const v of verbs) {
|
|
123
|
+
expect(v.providerSpec.pathTemplate.startsWith("/v1/"), v.name).toBe(true);
|
|
124
|
+
for (const [, name] of v.providerSpec.pathTemplate.matchAll(/\{\{(\w+)\}\}/g)) {
|
|
125
|
+
const m = v.providerSpec.paramMapping?.[name!];
|
|
126
|
+
expect(m?.required, `${v.name}.${name}`).toBe(true);
|
|
127
|
+
expect(["uri", "path"], `${v.name}.${name}`).toContain(m?.encode);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("the generic verbs are one per method, each taking a /v1/-relative path", () => {
|
|
133
|
+
const generic = verbs.filter((v) => v.providerSpec.pathTemplate === "/v1/{{path}}");
|
|
134
|
+
expect(generic.map((v) => `${v.name} ${v.providerSpec.method}`).sort()).toEqual([
|
|
135
|
+
"stripe_api_delete DELETE",
|
|
136
|
+
"stripe_api_read GET",
|
|
137
|
+
"stripe_api_write POST",
|
|
138
|
+
]);
|
|
139
|
+
for (const v of generic) {
|
|
140
|
+
expect(v.providerSpec.paramMapping?.path).toEqual({ required: true, encode: "path" });
|
|
141
|
+
expect(v.providerSpec.bodyFrom).toBe("params");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("Stripe's own fields travel only through bodyFrom (no body template sending an absent optional as empty)", () => {
|
|
146
|
+
for (const v of verbs) expect(v.providerSpec.body, v.name).toBeUndefined();
|
|
147
|
+
for (const v of writes) {
|
|
148
|
+
expect(v.providerSpec.bodyFrom, v.name).toBeDefined();
|
|
149
|
+
expect(v.parameters?.[v.providerSpec.bodyFrom!], v.name).toMatch(/^object\??$/);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("the connect copy names the restricted key and the permissions to grant", () => {
|
|
154
|
+
const param = manifest.spec.params.find((p: { name: string }) => p.name === "stripeApiKey");
|
|
155
|
+
expect(param.description).toMatch(/RESTRICTED/);
|
|
156
|
+
expect(param.description).toMatch(/Write only/);
|
|
157
|
+
expect(param.description).toMatch(/Tax Settings, Tax Registrations/);
|
|
158
|
+
expect(manifest.spec.description).toMatch(/ADAPTIVE PRICING/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── The mirror: the word lists above equal the pod's ─────────────────────────
|
|
163
|
+
// Read from synap-backend's source (a sibling checkout), so a word the pod
|
|
164
|
+
// adds or drops turns this red instead of the classification above drifting
|
|
165
|
+
// unseen. Skips, with the reason logged, when synap-backend is not a sibling.
|
|
166
|
+
const POD_SOURCE = resolve(
|
|
167
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
168
|
+
"../../../../synap-backend/packages/api/src/services/capabilities/create-from-definition.ts",
|
|
169
|
+
);
|
|
170
|
+
const hasPodSource = existsSync(POD_SOURCE);
|
|
171
|
+
if (!hasPodSource) console.warn(`[stripe signals mirror] SKIP: ${POD_SOURCE} is absent`);
|
|
172
|
+
|
|
173
|
+
/** The string literals of `const <name> = [ ... ]` in `src`, in order. */
|
|
174
|
+
function podList(src: string, name: string): string[] {
|
|
175
|
+
const m = new RegExp(`const ${name}\\s*=\\s*\\[([^\\]]*)\\]`).exec(src);
|
|
176
|
+
expect(m, `${name} is declared in create-from-definition.ts`).not.toBeNull();
|
|
177
|
+
return [...m![1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe.skipIf(!hasPodSource)("stripe capability: the word lists mirror the pod's", () => {
|
|
181
|
+
const src = hasPodSource ? readFileSync(POD_SOURCE, "utf8") : "";
|
|
182
|
+
it("WRITE_SIGNALS equals VERB_WRITE_SIGNALS", () => {
|
|
183
|
+
const pod = podList(src, "VERB_WRITE_SIGNALS");
|
|
184
|
+
expect(pod.length).toBeGreaterThan(5);
|
|
185
|
+
expect(WRITE_SIGNALS).toEqual(pod);
|
|
186
|
+
});
|
|
187
|
+
it("READ_SIGNALS equals VERB_READ_SIGNALS", () => {
|
|
188
|
+
const pod = podList(src, "VERB_READ_SIGNALS");
|
|
189
|
+
expect(pod.length).toBeGreaterThan(3);
|
|
190
|
+
expect(READ_SIGNALS).toEqual(pod);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRIPWIRE: `taskIntents` — the workspace's DECLARED task requirements.
|
|
3
|
+
*
|
|
4
|
+
* A template declares the intents (closed vocabulary) it needs to function, so
|
|
5
|
+
* an agent entering that space can be told what it is FOR without discovering
|
|
6
|
+
* it by failing. Three failure classes are guarded here, in increasing order of
|
|
7
|
+
* how quietly they would ship:
|
|
8
|
+
*
|
|
9
|
+
* 1. UNKNOWN SLUG — a `taskIntents` value outside the closed vocabulary. Caught
|
|
10
|
+
* at authoring time by `validateTemplate`; asserted here over the whole
|
|
11
|
+
* corpus so a seed added without ever running the validator still fails.
|
|
12
|
+
* 2. A TEMPLATE WITH NO DECLARATION — not an error by itself (a workspace
|
|
13
|
+
* that needs no external capability declares nothing), but asserted
|
|
14
|
+
* against a DERIVED set so a new template cannot silently ship undeclared.
|
|
15
|
+
* 3. CONTENT STUDIO SPECIFICALLY — the founder-specified reference list, and
|
|
16
|
+
* the one template whose exact declaration is pinned as data in this file.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT PROVES: that the declared set is (a) inside the closed vocabulary,
|
|
19
|
+
* (b) free of duplicates, (c) agreed on by `validateTemplate` — the gate
|
|
20
|
+
* `build.mjs`, the CP publish path and the CLI actually call — and (d) exactly
|
|
21
|
+
* the founder-specified list for Content Studio. It REPORTS (never gates) how
|
|
22
|
+
* much of each declaration is currently backed by an installed pack; see the
|
|
23
|
+
* long note on that describe block for why a gap is a fact, not an error.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT DOES NOT PROVE: that any declared intent is the RIGHT requirement for
|
|
26
|
+
* a workspace — that is a judgement, not a derivable fact.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ THE SCAN IS DERIVED, NOT HAND-LISTED. The template set comes from
|
|
29
|
+
* `readYamlTemplates()` (the same reader `build.mjs` uses), so a template added
|
|
30
|
+
* tomorrow joins this scan BY EXISTING. A hand-written array of slugs is how a
|
|
31
|
+
* guard ends up holding only the entries that were already correct.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ NON-VACUITY IS ASSERTED EXPLICITLY, twice: the corpus must be non-empty,
|
|
34
|
+
* and the vocabulary must be a real closed set. A glob that matches zero files
|
|
35
|
+
* passes every "no unknown intent" assertion there is.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, expect, it } from "vitest";
|
|
39
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
import { CAPABILITY_INTENTS } from "@synap-core/types/capability-intents";
|
|
44
|
+
import { validateTemplate } from "./validate.js";
|
|
45
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
46
|
+
|
|
47
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
// The reference list the founder specified for Content Studio, now carried by
|
|
49
|
+
// Content OS (content-studio retired into it, 2026-11-03). Pinned HERE as
|
|
50
|
+
// data so the test states what "correct" is rather than reading it from the
|
|
51
|
+
// file it is meant to police.
|
|
52
|
+
const CONTENT_OS_INTENTS = [
|
|
53
|
+
"generate_media",
|
|
54
|
+
"manage_file",
|
|
55
|
+
"capture_into_pod",
|
|
56
|
+
"search_external",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// The catalog's own capability manifests (the CP keeps a byte-checked mirror
|
|
60
|
+
// until its pin bump; see the CP's catalog-mirror.tripwire.test.ts).
|
|
61
|
+
const CAPABILITY_TEMPLATES_DIR = join(HERE, "../capabilities");
|
|
62
|
+
|
|
63
|
+
/** The closed vocabulary as a set, plus a readable unknown-value report. */
|
|
64
|
+
const VOCABULARY = new Set<string>(CAPABILITY_INTENTS);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The intents a capability pack actually provides, from its declared verb
|
|
68
|
+
* intents. Reads the catalog manifest (its `spec` is the CP seed JSON, kept
|
|
69
|
+
* identical by the mirror tripwire) — so "declared" here means what the seeder
|
|
70
|
+
* will really install.
|
|
71
|
+
*
|
|
72
|
+
* Returns null for a pack this checkout cannot read, which callers treat as
|
|
73
|
+
* "cannot check" rather than "provides nothing" — an unresolvable pack must not
|
|
74
|
+
* silently pass a subset assertion.
|
|
75
|
+
*/
|
|
76
|
+
function providedIntents(templateKey: string): Set<string> | null {
|
|
77
|
+
const file = join(CAPABILITY_TEMPLATES_DIR, `${templateKey}.capability.json`);
|
|
78
|
+
if (!existsSync(file)) return null;
|
|
79
|
+
const json = JSON.parse(readFileSync(file, "utf-8")) as unknown;
|
|
80
|
+
const intents = new Set<string>();
|
|
81
|
+
const walk = (node: unknown): void => {
|
|
82
|
+
if (Array.isArray(node)) {
|
|
83
|
+
node.forEach(walk);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (node && typeof node === "object") {
|
|
87
|
+
for (const [key, value] of Object.entries(node)) {
|
|
88
|
+
if (key === "intent" && typeof value === "string") intents.add(value);
|
|
89
|
+
walk(value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
walk(json);
|
|
94
|
+
return intents;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Load the corpus through the ONE reader `build.mjs` uses. */
|
|
98
|
+
function loadTemplates(): Array<{ file: string; slug: string; data: WorkspaceYaml }> {
|
|
99
|
+
// Imported lazily so this module's import cost is paid inside the tests, and
|
|
100
|
+
// so a YAML parse error surfaces as a test failure with context.
|
|
101
|
+
const { readYamlTemplates } = require("./generate.mjs") as {
|
|
102
|
+
readYamlTemplates: () => Array<{ file: string; slug: string; data: unknown }>;
|
|
103
|
+
};
|
|
104
|
+
return readYamlTemplates() as Array<{
|
|
105
|
+
file: string;
|
|
106
|
+
slug: string;
|
|
107
|
+
data: WorkspaceYaml;
|
|
108
|
+
}>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const TEMPLATES = loadTemplates();
|
|
112
|
+
const DECLARED = TEMPLATES.filter((t) => Array.isArray(t.data.taskIntents));
|
|
113
|
+
|
|
114
|
+
describe("taskIntents — every declared slug is a known intent", () => {
|
|
115
|
+
it("non-vacuity: the scan actually found templates and a real vocabulary", () => {
|
|
116
|
+
// Without these, "no unknown intent" passes on an empty set.
|
|
117
|
+
expect(TEMPLATES.length).toBeGreaterThanOrEqual(10);
|
|
118
|
+
expect(CAPABILITY_INTENTS.length).toBeGreaterThanOrEqual(13);
|
|
119
|
+
expect(CAPABILITY_INTENTS.length).toBe(new Set(CAPABILITY_INTENTS).size);
|
|
120
|
+
expect(DECLARED.length).toBeGreaterThanOrEqual(5);
|
|
121
|
+
// The scan can still see a literal it hunts: prove the reader picks up a
|
|
122
|
+
// value rather than normalising it away.
|
|
123
|
+
expect(CONTENT_OS_INTENTS.filter((i) => VOCABULARY.has(i))).toHaveLength(
|
|
124
|
+
CONTENT_OS_INTENTS.length
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("no template declares an intent outside the closed vocabulary", () => {
|
|
129
|
+
const offenders: string[] = [];
|
|
130
|
+
for (const t of DECLARED) {
|
|
131
|
+
for (const intent of t.data.taskIntents as string[]) {
|
|
132
|
+
if (!VOCABULARY.has(intent)) {
|
|
133
|
+
offenders.push(`${t.file}: "${intent}"`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
expect(
|
|
138
|
+
offenders,
|
|
139
|
+
`Unknown taskIntents (closed vocabulary: ${CAPABILITY_INTENTS.join(", ")}):`
|
|
140
|
+
).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("no template declares a duplicate intent", () => {
|
|
144
|
+
const offenders: string[] = [];
|
|
145
|
+
for (const t of DECLARED) {
|
|
146
|
+
const list = t.data.taskIntents as string[];
|
|
147
|
+
const dupes = list.filter((v, i) => list.indexOf(v) !== i);
|
|
148
|
+
if (dupes.length) offenders.push(`${t.file}: ${[...new Set(dupes)].join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
expect(offenders, "taskIntents must not repeat an intent").toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("validateTemplate agrees with this scan (the authoring-time gate fires)", () => {
|
|
154
|
+
// The scanner above reads raw YAML; the validator is what build.mjs, the
|
|
155
|
+
// CP publish path and the CLI actually call. Assert the two cannot diverge
|
|
156
|
+
// on the case that matters — an unknown slug must be an ERROR there, not
|
|
157
|
+
// merely a warning this file swallowed.
|
|
158
|
+
const withUnknown: WorkspaceYaml = {
|
|
159
|
+
...TEMPLATES[0].data,
|
|
160
|
+
taskIntents: ["generate_media", "definitely_not_an_intent"],
|
|
161
|
+
};
|
|
162
|
+
const result = validateTemplate(withUnknown);
|
|
163
|
+
const rule = result.errors.filter((e) => e.rule === "task-intent-unknown");
|
|
164
|
+
expect(rule).toHaveLength(1);
|
|
165
|
+
expect(rule[0].path).toContain("taskIntents");
|
|
166
|
+
// And a fully-valid list produces no taskIntents error at all.
|
|
167
|
+
const ok = validateTemplate({
|
|
168
|
+
...TEMPLATES[0].data,
|
|
169
|
+
taskIntents: [...CONTENT_OS_INTENTS],
|
|
170
|
+
});
|
|
171
|
+
expect(ok.errors.filter((e) => e.rule.startsWith("task-intent"))).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("taskIntents — declared requirements vs. what is installed today", () => {
|
|
176
|
+
/**
|
|
177
|
+
* ⚠️ THIS IS A REPORT, NOT A GATE — deliberately, and the reason is the point
|
|
178
|
+
* of the field.
|
|
179
|
+
*
|
|
180
|
+
* An earlier draft asserted `taskIntents ⊆ intents(provided by integrations)`
|
|
181
|
+
* as a hard failure. It went red immediately and was WRONG: the founder's
|
|
182
|
+
* reference declaration for Content Studio is
|
|
183
|
+
* `[generate_media, manage_file, capture_into_pod, search_external]`, while
|
|
184
|
+
* at the time its only installed pack (`remotion`, since retired) provided
|
|
185
|
+
* just `generate_media` — and today it ships NO pack at all (HyperFrames
|
|
186
|
+
* runs on the user's own agent, plan D9). A subset rule would have forbidden
|
|
187
|
+
* the specification itself.
|
|
188
|
+
*
|
|
189
|
+
* `taskIntents` is what a workspace NEEDS TO FUNCTION, not a snapshot of what
|
|
190
|
+
* it currently has. Declaring an intent ahead of the pack that serves it is
|
|
191
|
+
* the requirement doing its job — it is what a later install, or a Phase-3
|
|
192
|
+
* match, is checked against. So a gap is a FACT to surface, not an error to
|
|
193
|
+
* reject.
|
|
194
|
+
*
|
|
195
|
+
* What this therefore does NOT prove: that every requirement is satisfiable.
|
|
196
|
+
* A template could declare an intent nothing can ever provide, and this stays
|
|
197
|
+
* green. Only a human (or the Phase-3 resolver) can say a requirement is
|
|
198
|
+
* met; a structural check can only say whether it is currently backed.
|
|
199
|
+
*
|
|
200
|
+
* It DOES prove the reporting machinery works: the corpus is walked, packs are
|
|
201
|
+
* resolved for real, and an unresolvable pack is reported rather than counted
|
|
202
|
+
* as providing nothing — the "silently passing" failure this file exists to
|
|
203
|
+
* prevent.
|
|
204
|
+
*/
|
|
205
|
+
it("reports each declaration's backing and its gaps", () => {
|
|
206
|
+
interface Coverage {
|
|
207
|
+
file: string;
|
|
208
|
+
declared: number;
|
|
209
|
+
backed: number;
|
|
210
|
+
gaps: string[];
|
|
211
|
+
unreadable: string[];
|
|
212
|
+
}
|
|
213
|
+
const coverage: Coverage[] = [];
|
|
214
|
+
for (const t of DECLARED) {
|
|
215
|
+
const installed = t.data.integrations ?? [];
|
|
216
|
+
const provided = new Set<string>();
|
|
217
|
+
const unreadable: string[] = [];
|
|
218
|
+
for (const integration of installed) {
|
|
219
|
+
const intents = providedIntents(integration.templateKey);
|
|
220
|
+
if (intents === null) {
|
|
221
|
+
unreadable.push(integration.templateKey);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
intents.forEach((i) => provided.add(i));
|
|
225
|
+
}
|
|
226
|
+
const declared = t.data.taskIntents as string[];
|
|
227
|
+
coverage.push({
|
|
228
|
+
file: t.file,
|
|
229
|
+
declared: declared.length,
|
|
230
|
+
backed: declared.filter((i) => provided.has(i)).length,
|
|
231
|
+
gaps: declared.filter((i) => !provided.has(i)),
|
|
232
|
+
unreadable,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Non-vacuity: the report is real — every declared template was walked and
|
|
237
|
+
// at least one pack resolved, so the gap list is measured, not empty-by-
|
|
238
|
+
// construction.
|
|
239
|
+
expect(coverage.length).toBeGreaterThanOrEqual(5);
|
|
240
|
+
const resolvable = coverage.filter((c) => c.unreadable.length < (c.declared ? 1 : 0));
|
|
241
|
+
expect(
|
|
242
|
+
coverage.some((c) => c.backed > 0),
|
|
243
|
+
"no declared intent matched any installed pack — the derivation is broken"
|
|
244
|
+
).toBe(true);
|
|
245
|
+
|
|
246
|
+
// The only hard assertion: a template cannot claim MORE intents than it
|
|
247
|
+
// declared. (Trivially true by construction — kept as the shape check that
|
|
248
|
+
// fails if the coverage walk ever stops being total.)
|
|
249
|
+
for (const c of coverage) {
|
|
250
|
+
expect(c.backed).toBeLessThanOrEqual(c.declared);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (process.env.SYNAP_REPORT_TASK_INTENTS === "1") {
|
|
254
|
+
for (const c of coverage) {
|
|
255
|
+
console.warn(
|
|
256
|
+
`[taskIntents] ${c.file}: ${c.backed}/${c.declared} backed` +
|
|
257
|
+
(c.gaps.length ? ` · gaps: ${c.gaps.join(", ")}` : "") +
|
|
258
|
+
(c.unreadable.length
|
|
259
|
+
? ` · unreadable packs: ${c.unreadable.join(", ")}`
|
|
260
|
+
: "")
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Content OS carries the founder-specified intents over from the retired
|
|
268
|
+
// content-studio (retired.yaml, 2026-11-03), with the video pipeline they describe.
|
|
269
|
+
describe("taskIntents — Content OS is the reference declaration", () => {
|
|
270
|
+
it("declares exactly the founder-specified intents", () => {
|
|
271
|
+
const contentOs = TEMPLATES.find((t) => t.slug === "content-os");
|
|
272
|
+
expect(contentOs, "content-os template not found in src/*.yaml").toBeTruthy();
|
|
273
|
+
expect(contentOs?.data.taskIntents, "content-os must declare taskIntents")
|
|
274
|
+
.toEqual(CONTENT_OS_INTENTS);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("every one of them is a real intent", () => {
|
|
278
|
+
for (const intent of CONTENT_OS_INTENTS) {
|
|
279
|
+
expect(VOCABULARY.has(intent), `"${intent}" is not a known intent`).toBe(
|
|
280
|
+
true
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("and the template itself validates", () => {
|
|
286
|
+
const contentOs = TEMPLATES.find((t) => t.slug === "content-os");
|
|
287
|
+
const result = validateTemplate(contentOs!.data);
|
|
288
|
+
expect(
|
|
289
|
+
result.errors.filter((e) => e.rule.startsWith("task-intent")),
|
|
290
|
+
"content-os failed taskIntents validation"
|
|
291
|
+
).toEqual([]);
|
|
292
|
+
});
|
|
293
|
+
});
|