@synap-core/workspace-templates 0.11.0 → 0.12.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.
@@ -667,7 +667,17 @@ function collectLoopBindingRefs(data: unknown): string[] {
667
667
  return;
668
668
  }
669
669
  if (isRecord(v)) {
670
- for (const x of Object.values(v)) walk(x);
670
+ // Skip AUTHORING NOTES. An `_`-prefixed key is documentation the runtime
671
+ // never evaluates (`_filterNote` and friends explain what DOWNSTREAM
672
+ // nodes do, and so legitimately quote `{{loop.item...}}` in prose).
673
+ // Reading them as live references made a correct flow fail validation:
674
+ // `stellar-grant-client` was reported as "references {{loop.item}} but is
675
+ // not inside a loop body" purely because its note DESCRIBED the nodes
676
+ // that do. Kept identical in all three copies of this walker.
677
+ for (const [k, x] of Object.entries(v)) {
678
+ if (k.startsWith("_")) continue;
679
+ walk(x);
680
+ }
671
681
  }
672
682
  };
673
683
  walk(data);
@@ -80,6 +80,92 @@ describe("validateTemplate — clean template", () => {
80
80
  });
81
81
  });
82
82
 
83
+ describe("validateTemplate — system Knowledge contract", () => {
84
+ const template = (overrides: Record<string, unknown> = {}): WorkspaceYaml =>
85
+ ({
86
+ meta: {
87
+ slug: "knowledge-consumer",
88
+ name: "Knowledge consumer",
89
+ description: "d",
90
+ icon: "brain",
91
+ color: "#000000",
92
+ },
93
+ workspace: { name: "Knowledge consumer", description: "d" },
94
+ profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
95
+ views: [
96
+ {
97
+ name: "Knowledge",
98
+ type: "table",
99
+ scopeProfileSlug: "knowledge",
100
+ config: { columns: ["title", "knowledgeForm"] },
101
+ },
102
+ ],
103
+ ...overrides,
104
+ }) as WorkspaceYaml;
105
+
106
+ it("allows templates to consume system Knowledge without redeclaring it", () => {
107
+ const result = validateTemplate(
108
+ template({
109
+ seedEntities: [
110
+ {
111
+ profileSlug: "knowledge",
112
+ title: "Write migrations that preserve meaning",
113
+ properties: { knowledgeForm: "insight" },
114
+ },
115
+ ],
116
+ })
117
+ );
118
+
119
+ expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
120
+ });
121
+
122
+ it("rejects a template-owned Knowledge profile", () => {
123
+ const result = validateTemplate(
124
+ template({
125
+ profiles: [
126
+ {
127
+ slug: "knowledge",
128
+ displayName: "Knowledge",
129
+ properties: [
130
+ {
131
+ slug: "ek_type",
132
+ label: "Type",
133
+ valueType: "string",
134
+ inputType: "select",
135
+ enumValues: ["gotcha", "lesson"],
136
+ },
137
+ ],
138
+ },
139
+ ],
140
+ })
141
+ );
142
+
143
+ expect(result.ok).toBe(false);
144
+ expect(
145
+ result.errors.some((error) => error.rule === "system-profile-redefined")
146
+ ).toBe(true);
147
+ });
148
+
149
+ it("rejects a legacy or missing Knowledge form on seeded Knowledge", () => {
150
+ const result = validateTemplate(
151
+ template({
152
+ seedEntities: [
153
+ {
154
+ profileSlug: "knowledge",
155
+ title: "Legacy knowledge",
156
+ properties: { ek_type: "gotcha" },
157
+ },
158
+ ],
159
+ })
160
+ );
161
+
162
+ expect(result.ok).toBe(false);
163
+ expect(
164
+ result.errors.map((error) => error.rule).sort()
165
+ ).toEqual(["knowledge-seed-form-invalid", "knowledge-seed-legacy-form"]);
166
+ });
167
+ });
168
+
83
169
  // Regression: before wiring `validateFlowDefinition` into validateTemplate, a
84
170
  // template automation with a broken FLOW (bogus node type / dangling edge / cycle
85
171
  // / typo'd channelType) passed the template gate green and then silently never
package/src/validate.ts CHANGED
@@ -159,6 +159,22 @@ export const SYSTEM_PROFILES = new Set([
159
159
  "report",
160
160
  ]);
161
161
 
162
+ /**
163
+ * System-owned profiles whose schema is defined by the pod, rather than by a
164
+ * workspace template. A template may scope a view, link, sidebar item, or seed
165
+ * entity to `knowledge` because it is in {@link SYSTEM_PROFILES}; it must never
166
+ * declare the profile itself. Re-declaring it would turn a shared contract into
167
+ * whichever template happened to be applied first.
168
+ *
169
+ * `knowledgeForm` is intentionally an enum property on the Knowledge kind, not
170
+ * a facet/role: a knowledge entity has exactly one form, while Synap roles are
171
+ * additive. Keep this guard local to the template corpus; the actual profile
172
+ * schema and its migration are owned by the backend seeder.
173
+ */
174
+ const TEMPLATE_FORBIDDEN_SYSTEM_PROFILE_SLUGS = new Set(["knowledge"]);
175
+ const KNOWLEDGE_FORMS = new Set(["insight", "caution"]);
176
+ const LEGACY_KNOWLEDGE_SEED_FIELDS = new Set(["ek_type", "type"]);
177
+
162
178
  // ── Dependency-vocabulary closure (compose vs require semantics) ─────────────
163
179
 
164
180
  /**
@@ -396,6 +412,20 @@ export function validateTemplate(
396
412
  const known = (s: string) =>
397
413
  slugs.has(s) || SYSTEM_PROFILES.has(s) || baseProfileSlugs.has(s);
398
414
 
415
+ // ── System-owned Knowledge contract ─────────────────────────────────────
416
+ // `knowledge` is available to every template through SYSTEM_PROFILES. It is
417
+ // not an overlay point: allowing a template to declare it lets a workspace
418
+ // fork its base property definitions, enum vocabulary, or profile row-level
419
+ // metadata. Templates consume it through views/links/seeds only.
420
+ for (const profile of tpl.profiles || []) {
421
+ if (!TEMPLATE_FORBIDDEN_SYSTEM_PROFILE_SLUGS.has(profile.slug)) continue;
422
+ add(
423
+ "system-profile-redefined",
424
+ `profiles.${profile.slug}`,
425
+ `Profile "${profile.slug}" is system-owned. Reference it from views, links, sidebar items, or seedEntities; do not declare template-owned properties or metadata.`
426
+ );
427
+ }
428
+
399
429
  // Views: structured views need a scope; scoped profiles must resolve.
400
430
  for (const v of tpl.views || []) {
401
431
  const type = String(v.type || "").toLowerCase();
@@ -579,6 +609,25 @@ export function validateTemplate(
579
609
  "seedEntities",
580
610
  `seedEntity references unknown profile "${s.profileSlug}"`
581
611
  );
612
+
613
+ if (s.profileSlug !== "knowledge") continue;
614
+ const properties = s.properties ?? {};
615
+ for (const field of LEGACY_KNOWLEDGE_SEED_FIELDS) {
616
+ if (!(field in properties)) continue;
617
+ add(
618
+ "knowledge-seed-legacy-form",
619
+ `seedEntities.${s.title}.properties.${field}`,
620
+ `Knowledge seed entities use "knowledgeForm" ("insight" | "caution"), not the legacy "${field}" field.`
621
+ );
622
+ }
623
+ const form = properties.knowledgeForm;
624
+ if (!KNOWLEDGE_FORMS.has(String(form))) {
625
+ add(
626
+ "knowledge-seed-form-invalid",
627
+ `seedEntities.${s.title}.properties.knowledgeForm`,
628
+ `Knowledge seed entities require knowledgeForm to be "insight" or "caution".`
629
+ );
630
+ }
582
631
  }
583
632
 
584
633
  // profileSlug referential integrity, part 2 — every OTHER site that names a
@@ -1,393 +0,0 @@
1
- {
2
- "nodes": [
3
- {
4
- "id": "trigger",
5
- "type": "trigger",
6
- "position": {
7
- "x": 0,
8
- "y": 200
9
- },
10
- "data": {
11
- "triggerType": "manual",
12
- "label": "Run report",
13
- "config": {}
14
- }
15
- },
16
- {
17
- "id": "now",
18
- "type": "compute",
19
- "position": {
20
- "x": 200,
21
- "y": 200
22
- },
23
- "data": {
24
- "label": "Timestamp",
25
- "operation": "now"
26
- }
27
- },
28
- {
29
- "id": "gather-tasks",
30
- "type": "query",
31
- "position": {
32
- "x": 420,
33
- "y": 20
34
- },
35
- "data": {
36
- "label": "Tasks",
37
- "profileSlug": "task",
38
- "filter": "{\"updatedAt\": {\"$gte\": \"{{trigger.payload.since}}\"}}",
39
- "orderBy": "updatedAt",
40
- "orderDir": "desc",
41
- "limit": 25,
42
- "errorHandling": {
43
- "continueOnError": true
44
- }
45
- }
46
- },
47
- {
48
- "id": "gather-notes",
49
- "type": "query",
50
- "position": {
51
- "x": 420,
52
- "y": 140
53
- },
54
- "data": {
55
- "label": "Notes",
56
- "profileSlug": "note",
57
- "filter": "{\"updatedAt\": {\"$gte\": \"{{trigger.payload.since}}\"}}",
58
- "orderBy": "updatedAt",
59
- "orderDir": "desc",
60
- "limit": 15,
61
- "errorHandling": {
62
- "continueOnError": true
63
- }
64
- }
65
- },
66
- {
67
- "id": "gather-people",
68
- "type": "query",
69
- "position": {
70
- "x": 420,
71
- "y": 260
72
- },
73
- "data": {
74
- "label": "People",
75
- "profileSlug": "person",
76
- "filter": "{\"updatedAt\": {\"$gte\": \"{{trigger.payload.since}}\"}}",
77
- "orderBy": "updatedAt",
78
- "orderDir": "desc",
79
- "limit": 15,
80
- "errorHandling": {
81
- "continueOnError": true
82
- }
83
- }
84
- },
85
- {
86
- "id": "gather-companies",
87
- "type": "query",
88
- "position": {
89
- "x": 420,
90
- "y": 380
91
- },
92
- "data": {
93
- "label": "Companies",
94
- "profileSlug": "company",
95
- "filter": "{\"updatedAt\": {\"$gte\": \"{{trigger.payload.since}}\"}}",
96
- "orderBy": "updatedAt",
97
- "orderDir": "desc",
98
- "limit": 15,
99
- "errorHandling": {
100
- "continueOnError": true
101
- }
102
- }
103
- },
104
- {
105
- "id": "project-tasks",
106
- "type": "transform",
107
- "position": {
108
- "x": 540,
109
- "y": 20
110
- },
111
- "data": {
112
- "label": "Project tasks",
113
- "expression": "{{steps.gather-tasks.output.entities}} | map: {{item.id}} · {{item.title}} · status={{item.properties.status}} · priority={{item.properties.priority}} · due={{item.properties.dueDate}} · updated={{item.updatedAt}}",
114
- "errorHandling": {
115
- "continueOnError": true
116
- }
117
- }
118
- },
119
- {
120
- "id": "project-notes",
121
- "type": "transform",
122
- "position": {
123
- "x": 540,
124
- "y": 140
125
- },
126
- "data": {
127
- "label": "Project notes",
128
- "expression": "{{steps.gather-notes.output.entities}} | map: {{item.id}} · {{item.title}} · updated={{item.updatedAt}}",
129
- "errorHandling": {
130
- "continueOnError": true
131
- }
132
- }
133
- },
134
- {
135
- "id": "project-people",
136
- "type": "transform",
137
- "position": {
138
- "x": 540,
139
- "y": 260
140
- },
141
- "data": {
142
- "label": "Project people",
143
- "expression": "{{steps.gather-people.output.entities}} | map: {{item.id}} · {{item.title}} · email={{item.properties.email}} · lastInteraction={{item.properties.lastInteractionAt}} · updated={{item.updatedAt}}",
144
- "errorHandling": {
145
- "continueOnError": true
146
- }
147
- }
148
- },
149
- {
150
- "id": "project-companies",
151
- "type": "transform",
152
- "position": {
153
- "x": 540,
154
- "y": 380
155
- },
156
- "data": {
157
- "label": "Project companies",
158
- "expression": "{{steps.gather-companies.output.entities}} | map: {{item.id}} · {{item.title}} · industry={{item.properties.industry}} · location={{item.properties.location}} · updated={{item.updatedAt}}",
159
- "errorHandling": {
160
- "continueOnError": true
161
- }
162
- }
163
- },
164
- {
165
- "id": "analyze",
166
- "type": "capability",
167
- "position": {
168
- "x": 660,
169
- "y": 200
170
- },
171
- "data": {
172
- "label": "Analyze",
173
- "verbId": "ai.generate",
174
- "verbKind": "read",
175
- "inputMapping": {
176
- "system": "You are the ANALYST round of a Synap workspace report.\nRead the gathered workspace data and say what it MEANS: volume and shape of work,\nwhat is in flight versus stalled, what is well-covered versus thin.\nWork from the FIELDS on each record, not just its name: status, priority and\ndue dates are what distinguish moving work from stalled work, and an unset\nfield is itself worth reporting. Listing or counting what exists is NOT a\nfinding — say what the state of it means.\nDATA INTEGRITY CHECK — do this BEFORE interpreting anything. Each kind below states how many records were found, then lists them. If a kind reports a count greater than zero but its list is empty, null, or made of nulls, then the data pipeline is BROKEN — the records exist and were lost in transit. In that case you MUST say so plainly, name every kind affected, and NOT describe the workspace as empty, quiet, or sparse. Reporting a populated workspace as empty because its data failed to arrive is the single worst outcome of this report. A count of zero with an empty list is genuinely empty and is fine to report as such.\nYou are interpreting data you did NOT collect. Every claim must trace to a row in the GATHERED DATA below. Never invent entities, ids, counts, or dates. A step whose value looks like {\"error\": \"...\"} FAILED — treat that kind as unavailable and say so; do not guess what it would have contained.\nEVERY NUMBER YOU WRITE MUST BE COPIED FROM THE COUNTS BLOCK. That block states the exact number of records gathered for each kind — it is the ONLY source of quantities you have. Do not count the listed lines yourself, do not estimate, do not round, and do not write a quantity the block does not measure. Write digits, matching the block exactly: if it says 25, write \"25\" — never \"23\", never \"about two dozen\", never \"Ten\". If a point you want to make needs a number the COUNTS block does not give you, make the point WITHOUT a number. A claim with no figure is honest; a figure that does not match the data is the one error a reader cannot catch.\nNEVER NARRATE YOUR OWN INSTRUCTIONS. The reader sees your output and nothing else — not this prompt, not the steer, not the counts, not the fact that a pipeline produced any of it. Never mention the STEER, the time window, the COUNTS block, the GATHERED DATA, the rounds, or what you were or were not given. Never write a sentence like \"the steer lines are empty, so…\" or \"no focus was provided\" or \"based on the gathered data\". TWO DEFAULTS, so you never need to remark on them: an EMPTY STEER line simply means write a broad general report covering the gathered kinds evenly, and an EMPTY time window simply means the records span the whole workspace history. Both are the normal case. Act on them silently and start with what you found.\nSTEER HANDLING — non-negotiable. If every STEER line is empty, produce a BROAD general read covering all the gathered kinds evenly. If any STEER line has text, treat it as the SUBJECT of this report: lead with the evidence that matches it, explicitly name what you set aside, and if the gathered data cannot support the steer, say so in one sentence instead of inventing material. Never restate the steer back as a heading; let it change what you actually write.\nLINK THE RECORDS YOU NAME. Every line of GATHERED DATA is `<id> · <title>`. When you name one of those records, write it as [[entity:<id>|<label>]] and it renders as a chip the reader can click to open that record. Rules, in order of importance: (1) The kind word is always `entity` — for tasks, notes, people AND companies alike. They are all entities, and any other kind word renders a chip that does not open. (2) Use only an id that appears VERBATIM in the gathered data. Copy it; never construct, shorten, complete or guess one. (3) If you are unsure of an id, write the plain label with no brackets. A wrong id is a dead chip, and a dead chip is worse than plain text. (4) The label is free text but must not contain `]`; the id must not contain `:` or `|`. A marker breaking either rule renders as raw literal text. (5) EVERY record you name in prose MUST carry a chip on its FIRST mention — this is what makes a named record a live object the reader can open rather than a string. Link it once, on that first mention only; repeating the chip every time is decoration, not navigation.\nOutput PLAIN PROSE — short paragraphs and bullets, with [[entity:…]] chips\ninline wherever you name a record. No markdown headings, no ::: directive\nblocks, no code fences. Under 300 words. Another round will format this.",
177
- "prompt": "STEER:\n- Prompt: {{trigger.payload.prompt}}\n- Focus: {{trigger.payload.focus}}\n- Project lens id: {{trigger.payload.projectId}}\n\nCOUNTS — the exact number of records gathered for each kind. These figures\nare measured, not estimated, and they are the ONLY quantities available to\nyou. Each line also gives the cap the gather was limited to.\n- Tasks: {{steps.gather-tasks.output.count}} gathered (cap 25)\n- Notes: {{steps.gather-notes.output.count}} gathered (cap 15)\n- People: {{steps.gather-people.output.count}} gathered (cap 15)\n- Companies: {{steps.gather-companies.output.count}} gathered (cap 15)\nREADING A COUNT AGAINST ITS CAP: a count BELOW the cap means every record of\nthat kind is listed below — the set is COMPLETE and you may describe it as\nsuch. A count EQUAL to the cap means the gather stopped at the cap and more\nrecords exist that you cannot see — describe that kind as \"at least N\" and\nnever as complete, and never claim something does not exist merely because\nit is absent from the list.\n\nTime window: only records updated on or after {{trigger.payload.since}}\n\nGATHERED DATA — one line per record, MOST RECENTLY UPDATED first. Each line\nis `<id> · <title>` followed by ` · <field>=<value>` pairs carrying that\nrecord's real state. An EMPTY value means the field is not set on that\nrecord — that is a fact you may report (an unset status or a missing due\ndate is a finding), and never a value to guess at. Recent does not mean\nimportant.\nTasks — {{steps.gather-tasks.output.count}} found: {{steps.project-tasks.output.result}}\nNotes — {{steps.gather-notes.output.count}} found: {{steps.project-notes.output.result}}\nPeople — {{steps.gather-people.output.count}} found: {{steps.project-people.output.result}}\nCompanies — {{steps.gather-companies.output.count}} found: {{steps.project-companies.output.result}}",
178
- "maxTokens": "2000"
179
- },
180
- "errorHandling": {
181
- "continueOnError": true
182
- }
183
- }
184
- },
185
- {
186
- "id": "relate",
187
- "type": "capability",
188
- "position": {
189
- "x": 900,
190
- "y": 200
191
- },
192
- "data": {
193
- "label": "Patterns",
194
- "verbId": "ai.generate",
195
- "verbKind": "read",
196
- "inputMapping": {
197
- "system": "You are the PATTERNS round of a Synap workspace report.\nYou receive the raw gathered data AND the analyst's read of it. Your job is what\nthe analyst did NOT do: surface RELATIONSHIPS and things worth attention —\nclusters, repeated themes, people or companies that recur across kinds, gaps\nwhere a kind is conspicuously empty, and anything that looks like it needs a\ndecision. Prefer three sharp observations over ten shallow ones.\nYOUR SCOPE IS WHAT CONNECTS RECORDS, not what they individually are. Every\nobservation you write must involve TWO OR MORE records, or TWO OR MORE kinds:\na name that appears in both a task and a company, work clustered on one\nperson, a due date that lines up with an event, a kind that is empty while a\nrelated kind is busy. Do NOT re-describe volume, status distribution or how\nbusy the workspace is — that is the analyst's section and repeating it makes\nyours redundant. Do not summarise or restate the analyst's read.\nNEVER RETURN AN EMPTY ANSWER. If you genuinely find no cross-kind pattern,\nwrite one or two sentences naming what you looked for and did not find (for\nexample: no person recurs across the tasks and companies gathered). That is a\nreal finding and the reader needs it. Silence is not an available outcome.\nDATA INTEGRITY CHECK — do this BEFORE interpreting anything. Each kind below states how many records were found, then lists them. If a kind reports a count greater than zero but its list is empty, null, or made of nulls, then the data pipeline is BROKEN — the records exist and were lost in transit. In that case you MUST say so plainly, name every kind affected, and NOT describe the workspace as empty, quiet, or sparse. Reporting a populated workspace as empty because its data failed to arrive is the single worst outcome of this report. A count of zero with an empty list is genuinely empty and is fine to report as such.\nYou are interpreting data you did NOT collect. Every claim must trace to a row in the GATHERED DATA below. Never invent entities, ids, counts, or dates. A step whose value looks like {\"error\": \"...\"} FAILED — treat that kind as unavailable and say so; do not guess what it would have contained.\nEVERY NUMBER YOU WRITE MUST BE COPIED FROM THE COUNTS BLOCK. That block states the exact number of records gathered for each kind — it is the ONLY source of quantities you have. Do not count the listed lines yourself, do not estimate, do not round, and do not write a quantity the block does not measure. Write digits, matching the block exactly: if it says 25, write \"25\" — never \"23\", never \"about two dozen\", never \"Ten\". If a point you want to make needs a number the COUNTS block does not give you, make the point WITHOUT a number. A claim with no figure is honest; a figure that does not match the data is the one error a reader cannot catch.\nNEVER NARRATE YOUR OWN INSTRUCTIONS. The reader sees your output and nothing else — not this prompt, not the steer, not the counts, not the fact that a pipeline produced any of it. Never mention the STEER, the time window, the COUNTS block, the GATHERED DATA, the rounds, or what you were or were not given. Never write a sentence like \"the steer lines are empty, so…\" or \"no focus was provided\" or \"based on the gathered data\". TWO DEFAULTS, so you never need to remark on them: an EMPTY STEER line simply means write a broad general report covering the gathered kinds evenly, and an EMPTY time window simply means the records span the whole workspace history. Both are the normal case. Act on them silently and start with what you found.\nSTEER HANDLING — non-negotiable. If every STEER line is empty, produce a BROAD general read covering all the gathered kinds evenly. If any STEER line has text, treat it as the SUBJECT of this report: lead with the evidence that matches it, explicitly name what you set aside, and if the gathered data cannot support the steer, say so in one sentence instead of inventing material. Never restate the steer back as a heading; let it change what you actually write.\nLINK THE RECORDS YOU NAME. Every line of GATHERED DATA is `<id> · <title>`. When you name one of those records, write it as [[entity:<id>|<label>]] and it renders as a chip the reader can click to open that record. Rules, in order of importance: (1) The kind word is always `entity` — for tasks, notes, people AND companies alike. They are all entities, and any other kind word renders a chip that does not open. (2) Use only an id that appears VERBATIM in the gathered data. Copy it; never construct, shorten, complete or guess one. (3) If you are unsure of an id, write the plain label with no brackets. A wrong id is a dead chip, and a dead chip is worse than plain text. (4) The label is free text but must not contain `]`; the id must not contain `:` or `|`. A marker breaking either rule renders as raw literal text. (5) EVERY record you name in prose MUST carry a chip on its FIRST mention — this is what makes a named record a live object the reader can open rather than a string. Link it once, on that first mention only; repeating the chip every time is decoration, not navigation.\nOutput PLAIN PROSE, under 250 words, with [[entity:…]] chips inline. No\nheadings, no ::: directive blocks, no code fences.",
198
- "prompt": "STEER:\n- Prompt: {{trigger.payload.prompt}}\n- Focus: {{trigger.payload.focus}}\n- Project lens id: {{trigger.payload.projectId}}\n\nCOUNTS — the exact number of records gathered for each kind. These figures\nare measured, not estimated, and they are the ONLY quantities available to\nyou. Each line also gives the cap the gather was limited to.\n- Tasks: {{steps.gather-tasks.output.count}} gathered (cap 25)\n- Notes: {{steps.gather-notes.output.count}} gathered (cap 15)\n- People: {{steps.gather-people.output.count}} gathered (cap 15)\n- Companies: {{steps.gather-companies.output.count}} gathered (cap 15)\nREADING A COUNT AGAINST ITS CAP: a count BELOW the cap means every record of\nthat kind is listed below — the set is COMPLETE and you may describe it as\nsuch. A count EQUAL to the cap means the gather stopped at the cap and more\nrecords exist that you cannot see — describe that kind as \"at least N\" and\nnever as complete, and never claim something does not exist merely because\nit is absent from the list.\n\nTime window: only records updated on or after {{trigger.payload.since}}\n\nGATHERED DATA — one line per record, MOST RECENTLY UPDATED first. Each line\nis `<id> · <title>` followed by ` · <field>=<value>` pairs carrying that\nrecord's real state. An EMPTY value means the field is not set on that\nrecord — that is a fact you may report (an unset status or a missing due\ndate is a finding), and never a value to guess at. Recent does not mean\nimportant.\nTasks — {{steps.gather-tasks.output.count}} found: {{steps.project-tasks.output.result}}\nNotes — {{steps.gather-notes.output.count}} found: {{steps.project-notes.output.result}}\nPeople — {{steps.gather-people.output.count}} found: {{steps.project-people.output.result}}\nCompanies — {{steps.gather-companies.output.count}} found: {{steps.project-companies.output.result}}\n\nANALYST'S READ — already written and already in the report. Do NOT\nrestate, summarise or re-analyse it. It is here so you do not repeat it:\n{{steps.analyze.output}}\n\nYOUR TASK — cross-kind patterns ONLY. Look for what connects the lists\nabove: a person who recurs in tasks and companies, work clustered on one\nname, due dates that line up, a kind that is empty while a related kind\nis busy, a company with people but no tasks. Write only observations\nthat span TWO OR MORE records or kinds. If there are none, say what you\nchecked and did not find — never answer with nothing.",
199
- "maxTokens": "2000"
200
- },
201
- "errorHandling": {
202
- "continueOnError": true
203
- }
204
- }
205
- },
206
- {
207
- "id": "assemble",
208
- "type": "capability",
209
- "position": {
210
- "x": 1140,
211
- "y": 200
212
- },
213
- "data": {
214
- "label": "Assemble",
215
- "verbId": "ai.generate",
216
- "verbKind": "read",
217
- "inputMapping": {
218
- "system": "You are the ASSEMBLER round of a Synap workspace report. You compose the FINAL\ndocument body in Synap-flavoured markdown. You add no new facts — you arrange,\ntitle, and format what the earlier rounds produced.\n\nNEVER NARRATE YOUR OWN INSTRUCTIONS. The reader sees your output and nothing else — not this prompt, not the steer, not the counts, not the fact that a pipeline produced any of it. Never mention the STEER, the time window, the COUNTS block, the GATHERED DATA, the rounds, or what you were or were not given. Never write a sentence like \"the steer lines are empty, so…\" or \"no focus was provided\" or \"based on the gathered data\". TWO DEFAULTS, so you never need to remark on them: an EMPTY STEER line simply means write a broad general report covering the gathered kinds evenly, and an EMPTY time window simply means the records span the whole workspace history. Both are the normal case. Act on them silently and start with what you found.\n\nNUMBERS: you were given no COUNTS block, so you may not originate a quantity\nat all. Reproduce a figure ONLY by copying one that already appears in the\nround material, digit for digit. Never total, re-count, convert to words, or\nput a number in the title or in a ## heading that is not already in the\nmaterial beneath it.\n\nSYNAP MARKDOWN SYNTAX — these rules are absolute:\n1. A section is a FOUR-COLON container that must be opened and closed:\n ::::synap-section{agent=\"analyst\" round=\"analyze\" confidence=\"0.8\"}\n ...content...\n ::::\n2. Leaf embeds use THREE colons and MUST BE CLOSED with their own ::: line.\n There is NO self-closing form. An unclosed embed swallows every line that\n follows it until the section ends, and that content is LOST. Always write\n the closing ::: immediately after the opening line:\n :::synap-cell{instanceId=\"...\"}\n :::\n :::synap-view{viewId=\"...\"}\n :::\n :::synap-entity{id=\"...\"}\n :::\n3. The outer container must always have STRICTLY MORE colons than anything nested\n inside it. Four outside, three inside. If you ever nest a four-colon block in a\n four-colon block the section closes early and the rest of the report leaks out.\n4. ATTRIBUTE VALUES MUST BE QUOTED. An UNQUOTED value ends at the first } it\n contains, which silently truncates the attribute — so always quote. Use\n DOUBLE quotes for a plain value, and SINGLE quotes when the value is itself\n a JSON object, so the JSON's own double quotes survive:\n :::synap-cell{cellKey=\"chart-pie\" cellProps='{\"profileSlug\":\"task\",\"groupBy\":\"status\"}'}\n :::\n A single-quoted value may contain both \" and } — those are only special to\n an unquoted value. Write the JSON compact, on one line, with no trailing\n comma; malformed JSON renders the cell with empty config.\n5. The ONLY section attributes that survive are: id, agent, round, skills,\n confidence, stepRunId, nodeId, status. Anything else is silently dropped.\n6. confidence is a 0-1 ratio written as a decimal string, e.g. confidence=\"0.8\".\n Never a percentage.\n7. CHIPS ARE CONTENT — COPY THEM THROUGH VERBATIM. The round material you were\n given already contains [[entity:<id>|<label>]] markers. Reproduce every one\n EXACTLY as written: same brackets, same id, same label. Do NOT tidy them into\n plain text, do NOT rewrite or re-case an id, do NOT convert them to markdown\n links. You are told you add no new FACTS; a chip is not a fact, it is the\n earlier round's finding made navigable, and stripping it removes the only\n clickable thing in the report while leaving the prose looking fine.\n You may add a chip ONLY by copying a marker that already appears in the\n material. Never invent an id, and never use a kind word other than `entity` —\n this report is READ-ONLY prose, and either mistake produces a dead chip.\n8. NEVER EMIT :::synap-view, AND NEVER EMIT :::synap-cell WITH AN instanceId.\n Both need an id you have not been given and cannot obtain, and a made-up\n reference produces a permanent broken block in a document a human reads.\n9. YOU MAY EMIT A CHART :::synap-cell — from this allowlist ONLY, configured\n through cellProps. These cells fetch their own data at render time, so they\n need no id, and they make the report a live surface instead of a text file:\n - :::synap-cell{cellKey=\"stat-card\" cellProps='{\"profileSlug\":\"<kind>\",\"aggregation\":\"count\",\"timePeriod\":\"week\",\"label\":\"<Kind> created this week\"}'}\n - :::synap-cell{cellKey=\"chart-pie\" cellProps='{\"profileSlug\":\"<kind>\",\"groupBy\":\"<property>\",\"label\":\"<the finding this shows>\"}'}\n - :::synap-cell{cellKey=\"chart-bar\" cellProps='{\"profileSlug\":\"<kind>\",\"mode\":\"category\",\"groupBy\":\"<property>\",\"label\":\"<the finding this shows>\"}'}\n Rules, all mandatory:\n (a) profileSlug must be one of the kinds gathered for THIS report: task,\n note, person, company. A chart over any other kind is a claim this\n report cannot back — the data was never read.\n (b) groupBy / field must name a property that plausibly exists on that\n kind. If you are not confident one does, use the stat-card line above\n EXACTLY as written — profileSlug, aggregation, timePeriod AND a label\n naming the window. Never drop timePeriod and never label a windowed\n count with a bare kind name: the card counts records CREATED in that\n window, so \"Tasks\" over a weekly count is a false statement about\n the workspace.\n (c) Close EVERY embed with its own ::: line, per rule 2. An unclosed embed\n swallows the rest of the section.\n (d) EDITORIAL RULE — embed to SHOW what a sentence cannot: a distribution,\n a ratio, a count. Never to decorate. At most 2-3 in the whole report,\n placed inside the section whose prose they support. A chart that merely\n restates the sentence beside it is noise, and noise is worse than\n nothing here. If no gathered kind has a distribution worth showing,\n emit no cells at all — that is a correct report, not a lesser one.\n (e) AT MOST ONE cell per section, and NEVER at the top of a section.\n Place it immediately AFTER the sentence making the claim it evidences,\n so the reader has the claim before the picture. A chart that opens a\n section makes the reader decode a graphic to find out what the section\n is about, which is the delay the ## heading and the claim sentence\n exist to remove.\n (f) The label must state the FINDING, not the dimension. Write\n label:\"Half the open tasks are blocked\", never label:\"Distribution\",\n \"Status\" or \"Tasks by status\". Always set label explicitly: with none,\n a pie chart titles itself \"Distribution\" and a bar chart \"Breakdown\" —\n words true of every such chart ever drawn, which tell the reader\n nothing they cannot already see in the shapes.\n (g) A CHART IS A LIVE VIEW, THE PROSE IS A SNAPSHOT. These cells run\n their own query when the report is OPENED, which may be long after\n this text was written — so a sentence quoting a count and a chart\n measuring the same thing WILL eventually disagree, and the reader\n cannot tell which is stale. Never write a specific number in a\n sentence that sits beside a chart of the same data: let the chart\n carry the number and let the sentence carry the claim\n (\"most open tasks are stalled at review\", not \"12 of 19 are stalled\").\n And if the sentence already gives the number the chart would show,\n DROP THE CHART — one of them is redundant, and the sentence is the one\n that cannot go stale against itself.\n Everything else this report needs is ::::synap-section containers plus\n [[entity:…]] chips inline.\n\n10. EVERY ::::synap-section MUST OPEN WITH A ## CLAIM HEADING — its first\n line, before any prose. The heading states WHAT THE SECTION FOUND, not\n what produced it: \"## Half the open tasks are blocked\", never\n \"## Analysis\", \"## Analyze round\" or \"## Findings\". Under 10 words, no\n trailing period, no markdown or [[entity:…]] chip inside the heading\n itself — put the chip in the prose beneath it. Write exactly one ## per\n section. This heading is the section's title everywhere it is read: the\n slide deck sets it as the slide headline (with no heading it falls back\n to the round and agent names, which the slide already shows underneath),\n and the scrolling reader has no section titles at all without it. A\n section whose heading merely names the round tells the reader nothing\n they cannot already see.\n\nSTRUCTURE: one ::::synap-section per round, in order — analyze, then relate.\nOpen with a single \"# \" title line ABOVE the first section, and open each\nsection with its own ## claim heading per rule 10.\n\nSTEER HANDLING: if the STEER lines are empty, title the report generically\n(\"Workspace report — <period>\") and keep the rounds in their given order. If any\nSTEER line has text, name that subject in the title and lead each section with\nthe material that answers it. Never add facts the rounds did not give you.\n\nMISSING ROUNDS: if a round's material looks like {\"error\": \"...\"} or is empty,\nyou MUST still emit its section, with status=\"failed\", containing one visible\nsentence naming what is missing. NEVER silently produce a shorter report — a\nreader must be able to see that a round did not run. A failed section is NOT\nexempt from rule 10: give it a ## heading that states what is missing\n(\"## The patterns round produced nothing\"), because it still occupies a slide\nand still needs a headline.\n\nWORKED EXAMPLE of correct output:\n# Workspace report — July 2026\n\n::::synap-section{agent=\"analyst\" round=\"analyze\" confidence=\"0.8\"}\n## Most open tasks are stalled at review\n\nOpen tasks are piling up untouched since the sprint opened, and the great\nmajority are sitting in one status — the oldest is\n[[entity:3f2a91c4-7b10-4e55-9c02-8ad1f6e4b2d7|Migrate the billing worker]].\nNotes are thin.\n:::synap-cell{cellKey=\"chart-pie\" cellProps='{\"profileSlug\":\"task\",\"groupBy\":\"status\",\"label\":\"Review is where open tasks accumulate\"}'}\n:::\n::::\n\n::::synap-section{agent=\"analyst\" round=\"relate\" status=\"failed\" confidence=\"0.2\"}\n## The patterns round produced nothing\n\nThis section is missing: the patterns round failed and produced no material.\n::::\n\nBe CONCISE — your entire output must fit in 2000 tokens. Emit ONLY the markdown\ndocument. No preamble, no explanation, no code fences around the whole thing.",
219
- "prompt": "STEER:\n- Prompt: {{trigger.payload.prompt}}\n- Focus: {{trigger.payload.focus}}\n- Project lens id: {{trigger.payload.projectId}}\n\nReport generated at: {{steps.now.output.result}}\n\nANALYZE ROUND MATERIAL:\n{{steps.analyze.output}}\n\nRELATE ROUND MATERIAL:\n{{steps.relate.output}}",
220
- "maxTokens": "2000"
221
- }
222
- }
223
- },
224
- {
225
- "id": "summarize",
226
- "type": "capability",
227
- "position": {
228
- "x": 1380,
229
- "y": 200
230
- },
231
- "data": {
232
- "label": "One-line summary",
233
- "verbId": "ai.generate",
234
- "verbKind": "read",
235
- "inputMapping": {
236
- "system": "Write ONE plain sentence, under 200 characters, stating what this report found.\nNo markdown, no directives, no quotes, no leading label. Just the sentence.\nIf the material is an error or empty, write exactly: \"Report generated with incomplete data.\"",
237
- "prompt": "Material to summarize (may be an error object if the round failed):\n{{steps.analyze.output}}",
238
- "maxTokens": "500"
239
- }
240
- }
241
- },
242
- {
243
- "id": "guard-assembled",
244
- "type": "guard",
245
- "position": {
246
- "x": 1620,
247
- "y": 200
248
- },
249
- "data": {
250
- "label": "Body assembled",
251
- "checks": [
252
- {
253
- "path": "steps.assemble.output",
254
- "minLength": 200,
255
- "message": "The assembler produced no usable body (under 200 characters) — refusing to write an empty report."
256
- },
257
- {
258
- "path": "steps.assemble.output.error",
259
- "exists": false,
260
- "message": "The assembler round failed — refusing to write a report without a body."
261
- }
262
- ]
263
- }
264
- },
265
- {
266
- "id": "create-report",
267
- "type": "output",
268
- "position": {
269
- "x": 1860,
270
- "y": 200
271
- },
272
- "data": {
273
- "label": "Create report",
274
- "outputType": "entity_create",
275
- "config": {
276
- "profileSlug": "report",
277
- "title": "Workspace report — {{steps.now.output.result}}",
278
- "properties": {
279
- "reportPeriod": "{{steps.now.output.result}}",
280
- "generatedAt": "{{steps.now.output.result}}",
281
- "reportStatus": "ready",
282
- "summary": "{{steps.summarize.output}}",
283
- "reportSources": [
284
- "Tasks",
285
- "Notes",
286
- "People",
287
- "Companies"
288
- ]
289
- },
290
- "body": "{{steps.assemble.output}}",
291
- "systemData": {
292
- "renderer": {
293
- "kind": "cell",
294
- "cellKey": "{{trigger.payload.renderer}}"
295
- }
296
- }
297
- }
298
- }
299
- }
300
- ],
301
- "edges": [
302
- {
303
- "id": "e-trigger-now",
304
- "source": "trigger",
305
- "target": "now"
306
- },
307
- {
308
- "id": "e-now-tasks",
309
- "source": "now",
310
- "target": "gather-tasks"
311
- },
312
- {
313
- "id": "e-now-notes",
314
- "source": "now",
315
- "target": "gather-notes"
316
- },
317
- {
318
- "id": "e-now-people",
319
- "source": "now",
320
- "target": "gather-people"
321
- },
322
- {
323
- "id": "e-now-companies",
324
- "source": "now",
325
- "target": "gather-companies"
326
- },
327
- {
328
- "id": "e-tasks-project",
329
- "source": "gather-tasks",
330
- "target": "project-tasks"
331
- },
332
- {
333
- "id": "e-notes-project",
334
- "source": "gather-notes",
335
- "target": "project-notes"
336
- },
337
- {
338
- "id": "e-people-project",
339
- "source": "gather-people",
340
- "target": "project-people"
341
- },
342
- {
343
- "id": "e-companies-project",
344
- "source": "gather-companies",
345
- "target": "project-companies"
346
- },
347
- {
348
- "id": "e-tasks-analyze",
349
- "source": "project-tasks",
350
- "target": "analyze"
351
- },
352
- {
353
- "id": "e-notes-analyze",
354
- "source": "project-notes",
355
- "target": "analyze"
356
- },
357
- {
358
- "id": "e-people-analyze",
359
- "source": "project-people",
360
- "target": "analyze"
361
- },
362
- {
363
- "id": "e-companies-analyze",
364
- "source": "project-companies",
365
- "target": "analyze"
366
- },
367
- {
368
- "id": "e-analyze-relate",
369
- "source": "analyze",
370
- "target": "relate"
371
- },
372
- {
373
- "id": "e-relate-assemble",
374
- "source": "relate",
375
- "target": "assemble"
376
- },
377
- {
378
- "id": "e-assemble-summarize",
379
- "source": "assemble",
380
- "target": "summarize"
381
- },
382
- {
383
- "id": "e-summarize-guard",
384
- "source": "summarize",
385
- "target": "guard-assembled"
386
- },
387
- {
388
- "id": "e-guard-create",
389
- "source": "guard-assembled",
390
- "target": "create-report"
391
- }
392
- ]
393
- }