@unoverse-platform/studio 0.1.115 → 0.1.117

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.
Files changed (72) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/local/scaffold.d.mts +1 -0
  4. package/local/scaffold.mjs +12 -0
  5. package/package.json +1 -1
  6. package/screens/AgentChain.tsx +232 -0
  7. package/screens/AgentsView.tsx +85 -6
  8. package/screens/SkillsView.tsx +21 -3
  9. package/screens/TemplatesLibraryView.tsx +32 -3
  10. package/screens/TemplatesView.tsx +2 -2
  11. package/screens/index.ts +4 -1
  12. package/src/host.ts +5 -0
  13. package/src/index.css +11 -0
  14. package/vendor/base/src/agent-mcp/discovery.ts +5 -2
  15. package/vendor/base/src/agent-mcp/index.ts +3 -1
  16. package/vendor/base/src/agent-mcp/invoke.ts +199 -25
  17. package/vendor/base/src/agent-mcp/progressive.ts +124 -0
  18. package/vendor/base/src/agent-mcp/tools.ts +18 -11
  19. package/vendor/base/src/agent-mcp/types.ts +8 -0
  20. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  21. package/vendor/base/src/auth/agentToken.ts +142 -0
  22. package/vendor/base/src/auth/auth.ts +7 -0
  23. package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
  24. package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
  25. package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
  26. package/vendor/base/src/definitions/load/load.ts +6 -1
  27. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  28. package/vendor/base/src/definitions/registry/catalog.ts +1 -1
  29. package/vendor/base/src/definitions/registry/objects.ts +109 -5
  30. package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
  31. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  32. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  33. package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
  34. package/vendor/base/src/items/collect.ts +10 -0
  35. package/vendor/base/src/items/publish.ts +10 -0
  36. package/vendor/base/src/lint/design/index.mjs +14 -3
  37. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  38. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  39. package/vendor/base/src/lint/objects/index.mjs +121 -3
  40. package/vendor/base/src/lint/objects/rules.d.mts +3 -0
  41. package/vendor/base/src/lint/objects/rules.mjs +106 -7
  42. package/vendor/base/src/manifests/compose.ts +3 -0
  43. package/vendor/base/src/manifests/executor/callback.ts +3 -2
  44. package/vendor/base/src/manifests/executor/memory.ts +27 -0
  45. package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
  46. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  47. package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
  48. package/vendor/base/src/manifests/runtime/events.ts +1 -1
  49. package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
  50. package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
  51. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
  52. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  53. package/vendor/base/src/platform/agentBudget.ts +65 -0
  54. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  55. package/vendor/base/src/platform/inbound.ts +67 -0
  56. package/vendor/base/src/platform/taskChanged.ts +25 -0
  57. package/vendor/base/src/platform/tokenCost.ts +414 -0
  58. package/vendor/base/src/security/permissions.ts +10 -2
  59. package/vendor/sdk/appEngine.ts +42 -1
  60. package/vendor/sdk/lib/core/actions.ts +10 -0
  61. package/vendor/sdk/lib/core/apps.ts +2 -2
  62. package/vendor/sdk/lib/core/client.ts +11 -0
  63. package/vendor/sdk/lib/core/store.ts +1 -1
  64. package/vendor/sdk/lib/core/types.ts +2 -0
  65. package/vendor/sdk/lib/dom/app.ts +3 -2
  66. package/vendor/sdk/lib/dom/component.ts +38 -1
  67. package/vendor/sdk/lib/dom/element.ts +23 -0
  68. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  69. package/vendor/sdk/lib/dom/primitives.ts +97 -6
  70. package/vendor/sdk/lib/voiceService.ts +3 -1
  71. package/vendor/sdk/main.ts +1 -1
  72. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ONE URI FOR EVERY TASK (docs/mcp/MCP_TASKS.md § 2a, ruled 2026-10-10).
3
+ *
4
+ * Every task, of every kind (a form, a component, a template, an app, a workflow's Task Start, a
5
+ * pipeline), is `unoverse://tasks/<org>/<name>`. The URI is an address, never a file: it points at
6
+ * the asset that is the task. This module is the only place that reads or writes it; nothing else
7
+ * knows another spelling.
8
+ *
9
+ * It accepts exactly two forms: the URI, and the same identity written short, `<org>/<name>` (what
10
+ * live pages carry in `data-unoverse-interface`). Everything else is refused, so a caller holding an
11
+ * old spelling finds out here rather than saving or calling under a second name. A marketplace
12
+ * task with no org of its own is in the org `marketplace`.
13
+ *
14
+ * Pure: no files, no registry.
15
+ */
16
+
17
+ export const TASK_SCHEME = "unoverse://tasks/";
18
+
19
+ /** The org of a marketplace task that belongs to no org. */
20
+ export const MARKETPLACE_ORG = "marketplace";
21
+
22
+ const ORG = /^[a-z0-9][a-z0-9-]*$/;
23
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
24
+
25
+ export interface TaskParts {
26
+ org: string;
27
+ name: string;
28
+ }
29
+
30
+ /** The task's parts, from its URI or its short form; null for anything else. */
31
+ export function taskParts(input: unknown): TaskParts | null {
32
+ if (typeof input !== "string") return null;
33
+ const raw = input.trim();
34
+ const body = raw.startsWith(TASK_SCHEME) ? raw.slice(TASK_SCHEME.length) : raw.includes("://") ? null : raw;
35
+ if (body === null) return null;
36
+ const parts = body.split("/");
37
+ if (parts.length !== 2) return null;
38
+ const [org, name] = parts;
39
+ if (!ORG.test(org) || !NAME.test(name)) return null;
40
+ return { org, name };
41
+ }
42
+
43
+ /** The task's URI, `unoverse://tasks/<org>/<name>`, from either form; null for anything else. */
44
+ export function taskUri(input: unknown): string | null {
45
+ const p = taskParts(input);
46
+ return p ? `${TASK_SCHEME}${p.org}/${p.name}` : null;
47
+ }
48
+
49
+ /** The URI of a task from its org (none: the marketplace) and its name. */
50
+ export function taskUriOf(org: string | null | undefined, name: string): string | null {
51
+ return taskUri(`${org || MARKETPLACE_ORG}/${name}`);
52
+ }
53
+
54
+ /** The short form, `<org>/<name>`: what definitions are looked up by (`loadDefinition`). */
55
+ export function taskShort(input: unknown): string | null {
56
+ const p = taskParts(input);
57
+ return p ? `${p.org}/${p.name}` : null;
58
+ }
59
+
60
+ /**
61
+ * The task's MCP tool name, `<org>.<name>`. MCP tool names cannot hold `://` or `/`, so the name on
62
+ * the wire is made from the URI by this one rule, never by hand.
63
+ */
64
+ export function taskToolName(input: unknown): string | null {
65
+ const p = taskParts(input);
66
+ return p ? `${p.org}.${p.name}` : null;
67
+ }
@@ -31,6 +31,8 @@ const STATUS_TEXT: Record<number, string> = {
31
31
  404: "Not Found",
32
32
  405: "Method Not Allowed",
33
33
  406: "Not Acceptable",
34
+ 413: "Payload Too Large",
35
+ 429: "Too Many Requests",
34
36
  500: "Internal Server Error",
35
37
  };
36
38
  const statusLine = (code: number): string => `${code} ${STATUS_TEXT[code] ?? "OK"}`;
@@ -154,9 +156,23 @@ function writeCors(res: HttpResponse): void {
154
156
  res.writeHeader("Access-Control-Max-Age", "86400");
155
157
  }
156
158
 
157
- /** Buffer the full request body; resolves on the last chunk (copies as it goes). */
158
- function readBody(res: HttpResponse): Promise<Buffer> {
159
+ /**
160
+ * LIMITS FOR A PUBLIC DOOR (docs/auth-security/INBOUND.md § 3). A route anyone can call says how
161
+ * much it will read and whether to read at all, so a flood or an oversized body is refused before
162
+ * it costs anything. Optional: every route without them reads as it always did.
163
+ */
164
+ export interface FetchLimits {
165
+ /** The most bytes this route reads. Over it: 413, and the rest is never buffered. */
166
+ maxBody?: number;
167
+ /** Asked before a single byte is read; false is 429. The route's rate limit. */
168
+ admit?: (path: string) => boolean;
169
+ }
170
+
171
+ /** Buffer the full request body; resolves on the last chunk (copies as it goes), or null once it passes `maxBody`. */
172
+ export function readBody(res: Pick<HttpResponse, "onData">, maxBody = Infinity): Promise<Buffer | null> {
159
173
  return new Promise((resolve) => {
174
+ let size = 0;
175
+ let over = false;
160
176
  // Collect copies and concat ONCE at the end. The old per-chunk
161
177
  // `Buffer.concat([buf, chunk])` reallocated+copied the whole accumulator on every
162
178
  // chunk — O(n²) in body size, which hurts large /execute payloads. Each chunk is
@@ -167,6 +183,13 @@ function readBody(res: HttpResponse): Promise<Buffer> {
167
183
  // ArrayBuffer after this callback returns. `Buffer.from(arrayBuffer)` is only a
168
184
  // VIEW (no copy), so wrap in a Uint8Array, which Buffer.from then copies; deferring
169
185
  // a bare view to the final concat would read detached memory.
186
+ if (over) return;
187
+ size += chunk.byteLength;
188
+ if (size > maxBody) {
189
+ over = true;
190
+ chunks.length = 0;
191
+ return resolve(null);
192
+ }
170
193
  chunks.push(Buffer.from(new Uint8Array(chunk)));
171
194
  if (isLast) resolve(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks));
172
195
  });
@@ -181,6 +204,7 @@ export async function handleFetch(
181
204
  res: HttpResponse,
182
205
  req: HttpRequest,
183
206
  handler: (request: Request) => Promise<Response>,
207
+ limits: FetchLimits = {},
184
208
  ): Promise<void> {
185
209
  const state: PumpState = { aborted: false };
186
210
  // THE HANDLER LEARNS OF THE DISCONNECT. `Request.signal` is how a Fetch handler is told
@@ -215,8 +239,20 @@ export async function handleFetch(
215
239
  return;
216
240
  }
217
241
 
218
- const body = method !== "GET" && method !== "HEAD" ? await readBody(res) : undefined;
242
+ const refuse = (code: 413 | 429): void => {
243
+ res.cork(() => {
244
+ res.writeStatus(statusLine(code));
245
+ res.writeHeader("Content-Type", "application/json");
246
+ writeCors(res);
247
+ res.end(JSON.stringify({ error: code === 413 ? "too large" : "too many requests" }));
248
+ });
249
+ };
250
+ if (limits.admit && !limits.admit(new URL(url).pathname)) return refuse(429);
251
+ if (limits.maxBody !== undefined && Number(headers["content-length"] ?? 0) > limits.maxBody) return refuse(413);
252
+
253
+ const body = method !== "GET" && method !== "HEAD" ? await readBody(res, limits.maxBody) : undefined;
219
254
  if (state.aborted) return;
255
+ if (body === null) return refuse(413);
220
256
 
221
257
  let response: Response;
222
258
  try {
@@ -91,6 +91,16 @@ function orgDesignSystemDir(dir: string): string {
91
91
  }
92
92
 
93
93
  /** Collect an org repo into items: the same walk as a monorepo project, rooted at the repo. */
94
+ /**
95
+ * EVERY KIND THE WALK READS from an org's folder. A sync that reads a folder with this walk
96
+ * knows each of these completely: none of a kind in the folder means none in the org, so the
97
+ * sync may remove what is gone. A kind not listed (a recipe) is never read here, so a sync
98
+ * never removes it.
99
+ */
100
+ export const COLLECTED_KINDS: ReadonlySet<string> = new Set([
101
+ "component", "app", "template", "atom", "identity", "agent", "object", "pipeline", "style", "skill", "prompt-block", "node",
102
+ ]);
103
+
94
104
  export function collectOrg(home: OrgHome): CollectedItem[] {
95
105
  return collectFrom(home.dir, home.org, orgDesignSystemDir(home.dir));
96
106
  }
@@ -118,6 +118,16 @@ export async function lintOrg(home: OrgHome): Promise<{ problems: Finding[]; err
118
118
  return { problems: result.problems, errors: result.problems.filter((p: Finding) => p.level === "error") };
119
119
  }
120
120
 
121
+ /**
122
+ * Lint an org repo's objects and pipelines with some files as they WOULD be (`overlay`: absolute path ->
123
+ * text): a change is checked before it is written (an approved table wording, pipelines/tables.md).
124
+ */
125
+ export async function lintOrgObjects(home: OrgHome, overlay: Record<string, string> = {}): Promise<{ problems: Finding[]; errors: Finding[] }> {
126
+ const { lintObjects } = await import("../lint/objects/index.mjs");
127
+ const problems = lintObjects(home.dir, { org: { name: home.org, dir: home.dir }, overlay }).problems;
128
+ return { problems, errors: problems.filter((p: Finding) => p.level === "error") };
129
+ }
130
+
121
131
  /**
122
132
  * What publishing WOULD do, without doing it.
123
133
  *
@@ -693,7 +693,10 @@ for (const orgDir of orgDirs) {
693
693
  // rule is off, silently.
694
694
  for (const sub of ["apps", "components", "templates", "agents"]) {
695
695
  const d = join(orgDir, sub);
696
- if (existsSync(d) && statSync(d).isDirectory()) homes.push({ dir: d });
696
+ // AN AGENT FOLDER (agents/<name>/agent.yaml, docs/architecture/signal/AGENTS.md § 3) is not a
697
+ // definition: the objects linter checks it, as it does a pipeline folder. Only a flat file
698
+ // directly in agents/ is the older Agent file, still checked here until it is converted.
699
+ if (existsSync(d) && statSync(d).isDirectory()) homes.push(sub === "agents" ? { dir: d, flat: true } : { dir: d });
697
700
  }
698
701
  }
699
702
 
@@ -865,7 +868,10 @@ for (const orgDir of [DS, ...orgDirs]) {
865
868
  // This was an org-blocking error until it stopped a yasisland deploy over a form
866
869
  // nobody needed that day.
867
870
  if (credentialDefs.size)
868
- for (const cname of Array.isArray(readDef(mf).credentials) ? readDef(mf).credentials : [])
871
+ for (const entry of Array.isArray(readDef(mf).credentials) ? readDef(mf).credentials : [])
872
+ // `- googleApiCredential` or `- googleApiCredential: MapKey` (the saved name chooses
873
+ // between two of one type); the definition looked for is the TYPE either way.
874
+ for (const cname of typeof entry === "string" ? [entry] : entry && typeof entry === "object" ? Object.keys(entry) : [String(entry)])
869
875
  if (!credentialDefs.has(String(cname)))
870
876
  report(
871
877
  "warn",
@@ -1039,7 +1045,12 @@ function placeNames() {
1039
1045
  const walkNode = makeWalkNode(ctx);
1040
1046
  const lintFile = makeLintFile({ ...ctx, walkNode });
1041
1047
 
1042
- for (const home of homes) for (const f of jsonFiles(home.dir)) lintFile(f);
1048
+ for (const home of homes) {
1049
+ const files = home.flat
1050
+ ? readdirSync(home.dir).filter((f) => !f.startsWith(".") && isDefFile(f) && !statSync(join(home.dir, f)).isDirectory()).map((f) => join(home.dir, f))
1051
+ : jsonFiles(home.dir);
1052
+ for (const f of files) lintFile(f);
1053
+ }
1043
1054
  return { problems, homes, designRoot: DESIGN_ROOT, designSystem: DS };
1044
1055
  }
1045
1056
 
@@ -72,7 +72,7 @@ export const INTERFACE_FIELDS = new Set([
72
72
  export const AUTHORED_CONTROLS = ["maxLength", "minItems", "maxItems", "enum"];
73
73
 
74
74
  export const PRIMITIVE_PROPS = {
75
- Box: ["children", "autoScroll", "revealOnStick", "selection", "appWidth"],
75
+ Box: ["children", "autoScroll", "revealOnStick", "selection", "appWidth", "pan"],
76
76
  Stack: ["children", "autoScroll", "revealOnStick", "appWidth"],
77
77
  Row: ["children", "autoScroll", "revealOnStick", "appWidth"],
78
78
  Column: ["children", "autoScroll", "revealOnStick", "appWidth"],
@@ -17,6 +17,7 @@ export interface ObjectsResult {
17
17
  problems: Finding[];
18
18
  objectCount: number;
19
19
  pipelineCount: number;
20
+ agentCount: number;
20
21
  designRoot: string;
21
22
  }
22
23
 
@@ -22,7 +22,7 @@ import { problems, reset, report, state } from "../context.mjs";
22
22
  import { validateAgainst, Validator } from "../nodes/schema.mjs";
23
23
  import {
24
24
  envelopeFindings, propsFindings, fromFindings, serviceFindings, makeFindings, rowKeyFindings, objectTestFindings,
25
- pipelineFindings, pipelineFolderFindings,
25
+ pipelineFindings, pipelineFolderFindings, layoutFindings,
26
26
  } from "./rules.mjs";
27
27
 
28
28
  const require_ = createRequire(import.meta.url);
@@ -46,6 +46,7 @@ export const SCHEMA_ID = {
46
46
  config: "https://docs.unoverse.ai/schemas/pipelines/config.schema.json",
47
47
  interface: "https://docs.unoverse.ai/schemas/pipelines/interface.schema.json",
48
48
  pipelineTest: "https://docs.unoverse.ai/schemas/pipelines/test.schema.json",
49
+ agent: "https://docs.unoverse.ai/schemas/agents/agent.schema.json",
49
50
  };
50
51
 
51
52
  /**
@@ -57,7 +58,7 @@ export const SCHEMA_ID = {
57
58
  */
58
59
  function schemaDirs() {
59
60
  const out = [];
60
- for (const kind of ["objects", "pipelines", "nodes"]) {
61
+ for (const kind of ["objects", "pipelines", "agents", "nodes"]) {
61
62
  for (const c of [join(HERE, "_schema", kind), resolve(HERE, `../../../../docs/schemas/${kind}`), join(HERE, "../nodes/_schema")]) {
62
63
  if (existsSync(c)) {
63
64
  out.push(c);
@@ -161,6 +162,28 @@ export function lintObjects(designRoot, options = {}) {
161
162
  }
162
163
  return out;
163
164
  };
165
+ /**
166
+ * THE MARKETPLACE MCPS, by task name (MCP_TASKS.md § 2a): every marketplace node that offers MCP
167
+ * tools (`serviceType: mcp` in its interface), its type in kebab case. Null when the marketplace
168
+ * is not seen.
169
+ */
170
+ const marketplaceMcps = () => {
171
+ if (!marketplaceSeen) return null;
172
+ const out = new Set();
173
+ const home = join(marketplace, "nodes");
174
+ if (!isDir(home)) return out;
175
+ for (const pkg of readdirSync(home)) {
176
+ const nodes = join(home, pkg, "nodes");
177
+ if (!isDir(nodes)) continue;
178
+ for (const n of readdirSync(nodes)) {
179
+ const iface = join(nodes, n, "interface.yaml");
180
+ if (!existsSync(iface) || !/serviceType:\s*mcp\b/.test(readFileSync(iface, "utf8"))) continue;
181
+ const m = /^type:\s*([A-Za-z0-9_]+)/m.exec(existsSync(join(nodes, n, "node.yaml")) ? readFileSync(join(nodes, n, "node.yaml"), "utf8") : "");
182
+ out.add((m ? m[1] : n).replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase());
183
+ }
184
+ }
185
+ return out;
186
+ };
164
187
  const foldersIn = (tierDir, kind) => {
165
188
  const home = join(tierDir, kind);
166
189
  if (!isDir(home)) return [];
@@ -340,9 +363,104 @@ export function lintObjects(designRoot, options = {}) {
340
363
  if (tier.org && marketplacePipelines.has(name)) report("error", at("pipeline.yaml"), `pipeline ${name}: an org's pipeline may not take a marketplace pipeline's name; the connector's name would be ambiguous`);
341
364
  }
342
365
 
366
+ /**
367
+ * AGENTS (docs/architecture/signal/AGENTS.md § 3, § 3a): one folder each, `agents/<name>/`, its
368
+ * `agent.yaml` and its soul. The schema says the shape; these say what a schema cannot: the
369
+ * folder and the name agree, the skills and the soul exist, each task's URI names a task of the
370
+ * org, each trigger names a node there is, and the soul gives no orders. A flat file directly in
371
+ * agents/ is the older Agent file, checked by the design linter until it is converted.
372
+ */
373
+ let agentCount = 0;
374
+ for (const tier of tiers) {
375
+ if (!tier.org) continue;
376
+ for (const name of foldersIn(tier.dir, "agents")) {
377
+ agentCount++;
378
+ const dir = join(tier.dir, "agents", name);
379
+ const file = join(dir, "agent.yaml");
380
+ const at = rel(file);
381
+ const doc = readYaml(file);
382
+ if (doc == null) {
383
+ if (!existsSync(file)) report("error", rel(dir), `agent ${name}: the folder holds no agent.yaml`);
384
+ continue;
385
+ }
386
+ validateAgainst(SCHEMA_ID.agent, doc, at, `agent ${name}`);
387
+ if (doc.name !== name) report("error", at, `agent ${name}: name is "${doc.name}"; it must equal the folder`);
388
+ // NO BUDGET, NO AGENT, enforced where it runs: Signal refuses to switch it on (AGENTS.md § 9).
389
+ // Here it is a warning, so a draft Agent still syncs and shows in Studio as "not set", and
390
+ // never holds back the rest of its org's update (owner, 2026-10-10).
391
+ if (!isRecord(doc.model) || !doc.model.use) report("warn", at, `agent ${name}: no model set; it cannot be switched on until model.use is set`);
392
+ if (!isRecord(doc.budget) || !doc.budget.perDay) report("warn", at, `agent ${name}: no daily budget set; it cannot be switched on until budget.perDay is set`);
393
+ for (const skill of Array.isArray(doc.skills) ? doc.skills : []) {
394
+ if (!existsSync(join(tier.dir, "skills", skill, "SKILL.md")) && !existsSync(join(marketplace, "skills", skill, "SKILL.md"))) {
395
+ report("error", at, `agent ${name}: skill "${skill}" is not in skills/`);
396
+ }
397
+ }
398
+ if (typeof doc.soul === "string") {
399
+ const soulFile = join(dir, doc.soul);
400
+ if (!existsSync(soulFile)) report("error", at, `agent ${name}: its soul, ${doc.soul}, is not beside agent.yaml`);
401
+ else {
402
+ // CHARACTER ONLY (§ 3a): a soul that names a permission or a task changes nothing, and a
403
+ // reader may think it did. Said, not refused: the words may be innocent.
404
+ const soul = readText(soulFile);
405
+ if (/\b[a-z]+:(read|write|read-all|manage|author|publish)\b|unoverse:\/\/tasks\//.test(soul)) {
406
+ report("warn", rel(soulFile), `agent ${name}: the soul names a permission or a task. A soul is character only: put access and tasks in agent.yaml`);
407
+ }
408
+ }
409
+ }
410
+ // Each task's URI names a task of the org: an app, template, component or pipeline folder.
411
+ // A workflow's Task Start is named on its canvas, not here, so one not found is a hint.
412
+ const own = new Set(["apps", "templates", "components", "pipelines"].flatMap((k) => foldersIn(tier.dir, k)));
413
+ const mcpNodes = marketplaceMcps();
414
+ for (const uri of Array.isArray(doc.tasks) ? doc.tasks : []) {
415
+ const m = /^unoverse:\/\/tasks\/([a-z0-9][a-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(String(uri));
416
+ if (!m) continue;
417
+ if (m[1] !== tier.org && m[1] !== "marketplace") report("error", at, `agent ${name}: task ${uri} belongs to another org; an Agent calls its own org's tasks and the marketplace's`);
418
+ // A MARKETPLACE MCP IS A TASK (MCP_TASKS.md § 2a): its name is a node type in kebab case.
419
+ else if (m[1] === "marketplace" && mcpNodes && !mcpNodes.has(m[2])) report("error", at, `agent ${name}: task ${uri} is not a marketplace MCP; one is a node that offers MCP tools, by its type in kebab case (signal-save, spatial-search)`);
420
+ else if (m[1] === tier.org && !own.has(m[2])) report("hint", at, `agent ${name}: task ${uri} is not an app, template, component or pipeline here; if it is a workflow's Task Start, check its Name on the canvas`);
421
+ }
422
+ const nodes = visibleNodes(tier);
423
+ for (const ch of Array.isArray(doc.triggers) ? doc.triggers : []) {
424
+ if (!isRecord(ch)) continue;
425
+ if (ch.name === "spatial") {
426
+ if (ch.node) report("error", at, `agent ${name}: the spatial trigger names no node; the Agent is found in its org's Spatial`);
427
+ continue;
428
+ }
429
+ if (!ch.node) report("error", at, `agent ${name}: trigger ${ch.name} names no node`);
430
+ else if (nodes && !nodes.has(ch.node)) report("error", at, `agent ${name}: trigger ${ch.name} names node ${ch.node}, which is not a node here`);
431
+ }
432
+ if (!Array.isArray(doc.tasks) || doc.tasks.length === 0) report("error", at, `agent ${name}: it has no tasks, so it can do nothing`);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * ONE NAME PER TASK IN AN ORG (docs/mcp/MCP_TASKS.md § 2a, U9). Every task is
438
+ * `unoverse://tasks/<org>/<name>`, whatever it is built from, so two assets of an org may not
439
+ * share a name. The one exception is the usual one: an app and the template or component it
440
+ * wraps share its name, and the app is the task. A workflow's Task Start is named on its canvas,
441
+ * not in these folders, so it is not seen here.
442
+ */
443
+ for (const tier of tiers) {
444
+ if (!tier.org) continue;
445
+ const kinds = new Map();
446
+ for (const kind of ["apps", "templates", "components", "pipelines"]) {
447
+ for (const n of foldersIn(tier.dir, kind)) kinds.set(n, [...(kinds.get(n) ?? []), kind]);
448
+ }
449
+ for (const [n, list] of kinds) {
450
+ if (list.length < 2) continue;
451
+ const wrapped = list.includes("apps") && list.length === 2 && (list.includes("templates") || list.includes("components"));
452
+ if (wrapped) continue;
453
+ report(
454
+ "error",
455
+ rel(join(tier.dir, list[0], n)),
456
+ `task ${tier.org}/${n}: ${list.join(" and ")} share one name, and a task's name is unique in its org (unoverse://tasks/${tier.org}/${n}). Rename one.`,
457
+ );
458
+ }
459
+ }
460
+
343
461
  const rank = { error: 0, warn: 1, hint: 2 };
344
462
  const sorted = [...problems].sort((a, b) => rank[a.level] - rank[b.level] || a.file.localeCompare(b.file));
345
- return { problems: sorted, objectCount, pipelineCount, designRoot: ROOT };
463
+ return { problems: sorted, objectCount, pipelineCount, agentCount, designRoot: ROOT };
346
464
  }
347
465
 
348
466
  /** True when anything would fail a build. Warnings and hints inform, errors stop. */
@@ -31,6 +31,9 @@ export function objectTestFindings(name: string, test: unknown, service: unknown
31
31
  /** The words a `make` block says about its read; every other key is an object that read makes. */
32
32
  export const PIPELINE_READ_KEYS: Set<string>;
33
33
  export function pipelineFindings(name: string, file: unknown, fileName?: string): RuleFinding[];
34
+ /** A layout names no document (document-map.md, 2026-10-21): refuses a fund's, a firm's or a ticker's name, and lists of names. */
35
+ export function layoutFindings(name: string, file: unknown, fileName?: string): RuleFinding[];
36
+ export const LAYOUT_VOCABULARY: Set<string>;
34
37
  export function collectRefs(value: unknown): Set<string>;
35
38
  export function configRefs(value: unknown): Set<string>;
36
39
  export function pipelineFolderFindings(
@@ -29,7 +29,9 @@ export const ELEMENTS = new Set(["purpose", "story", "content", "capability", "p
29
29
  export const FACE_KEYS = ["title", "description", "whenToUse", "category", "version"];
30
30
 
31
31
  /** The prop grammar of UNOVERSE_AUTHORING 3b plus the object's one word, `from` (README 4c). */
32
- export const PROP_KEYS = new Set(["type", "input", "description", "from", "hydrate", "enum", "oneOf", "minLength", "maxLength", "minItems", "maxItems", "items", "reuse"]);
32
+ // `none` IS NO DEFAULT (2026-10-15): it is what a cell that STATES there is none ("None", "N/A") stands for, so a
33
+ // stated "no performance fee" is 0 and a fee nobody states stays empty.
34
+ export const PROP_KEYS = new Set(["type", "input", "description", "from", "hydrate", "enum", "oneOf", "minLength", "maxLength", "minItems", "maxItems", "items", "reuse", "answeredBy", "standard", "wordings", "wordingsIn", "onlyWith", "format", "pattern", "none"]);
33
35
  export const LIMIT_KEYS = ["minLength", "maxLength", "minItems", "maxItems"];
34
36
 
35
37
  /** Words that left the object grammar and must not come back (README 11.7, 12.3, 12.4). */
@@ -96,7 +98,9 @@ export function envelopeFindings(name, envelope, file = `${name}.yaml`) {
96
98
  if (pairs.length !== 1 || pairs.some(([a, b]) => typeof a !== "string" || typeof b !== "string" || !a || !b)) refuse(`object ${name}: link ${ln}: match is one pair, this row's field naming that row's ({ manager: legalName })`);
97
99
  if (l.between != null || l.order != null) refuse(`object ${name}: link ${ln}: a line to another object says to and match, never between or order`);
98
100
  if (l.alike != null && (!Array.isArray(l.alike) || l.alike.some((f) => typeof f !== "string" || !pairs.some(([a]) => a === f)))) refuse(`object ${name}: link ${ln}: alike lists this row's match field whose value may name one thing several ways`);
99
- for (const k of Object.keys(l)) if (!["type", "to", "match", "when", "alike"].includes(k)) say(`object ${name}: link ${ln}: "${k}" is not a word here`);
101
+ // WHAT A LINE CARRIES (relationships-and-labels.md, 2026-10-14) is fields of this row, by name.
102
+ if (l.carries != null && (!Array.isArray(l.carries) || l.carries.some((f) => typeof f !== "string" || !f))) refuse(`object ${name}: link ${ln}: carries lists fields of this row each line keeps ([role])`);
103
+ for (const k of Object.keys(l)) if (!["type", "to", "match", "when", "alike", "carries"].includes(k)) say(`object ${name}: link ${ln}: "${k}" is not a word here`);
100
104
  continue;
101
105
  }
102
106
  if (!Array.isArray(l.between) || !l.between.length || l.between.some((f) => typeof f !== "string")) refuse(`object ${name}: link ${ln}: between is a list of the fields whose equal values make one line of rows`);
@@ -116,8 +120,10 @@ export function envelopeFindings(name, envelope, file = `${name}.yaml`) {
116
120
  for (const k of ["ids", "evidence", "ruleOut"]) if (m[k] != null && !fields(m[k])) refuse(`object ${name}: matching: ${k} is a list of field names`);
117
121
  if (m.createFrom != null && !fields(m.createFrom)) refuse(`object ${name}: matching: createFrom is a list of line names (about)`);
118
122
  if (m.remember != null && (typeof m.remember !== "string" || !(m.names ?? []).includes(m.remember))) refuse(`object ${name}: matching: remember names one of names, the list a confirmed name is added to`);
119
- if (m.judge != null && (typeof m.judge !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(m.judge))) refuse(`object ${name}: matching: judge names a node by its type (TypeSafeChoice)`);
120
- for (const k of Object.keys(m)) if (!["ids", "names", "remember", "evidence", "ruleOut", "judge", "createFrom"].includes(k)) say(`object ${name}: matching: "${k}" is not a word here`);
123
+ // A JUDGE IS A NODE BY ITS TYPE, OR A DECISION NODE WITH ITS CONFIG as `review` is ({ node: OpenAIDecision, config: { model } }).
124
+ const judgeNode = isRecord(m.judge) ? m.judge.node : m.judge;
125
+ if (m.judge != null && (typeof judgeNode !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(judgeNode) || (isRecord(m.judge) && m.judge.config != null && !isRecord(m.judge.config)))) refuse(`object ${name}: matching: judge names a node by its type (TypeSafeChoice), or a decision node with its config ({ node: OpenAIDecision, config: { model } })`);
126
+ for (const k of Object.keys(m)) if (!["ids", "names", "remember", "evidence", "ruleOut", "judge", "link", "review", "createFrom"].includes(k)) say(`object ${name}: matching: "${k}" is not a word here`);
121
127
  }
122
128
  }
123
129
  return out;
@@ -158,6 +164,29 @@ export function propsFindings(name, ownProps, parentProps = null, base = null, f
158
164
  if (raw.enum != null) say(`object ${name}: prop "${k}" says both enum and oneOf; one or the other`);
159
165
  }
160
166
  if (raw.type === "array" && !isRecord(raw.items)) say(`object ${name}: prop "${k}" is an array and carries no items: an array says the shape of one element, in the same grammar one level down (README 4c)`);
167
+ if (raw.answeredBy != null && (!Array.isArray(raw.answeredBy) || !raw.answeredBy.length || raw.answeredBy.some((d) => typeof d !== "string" || !d.trim()))) say(`object ${name}: prop "${k}": answeredBy is a list of the document kinds that may answer it, e.g. [offering-memorandum, side-letter]`);
168
+ if (raw.answeredBy != null && raw.input !== true) say(`object ${name}: prop "${k}" says answeredBy but not input: true; only a field read from documents is answered by one`);
169
+ // A FIELD A TABLE CAN FILL (pipelines/tables.md, 2026-10-12): `standard` is its id in the org's
170
+ // field list, `wordings` the words documents print for it, matched exactly after lower case.
171
+ if (raw.standard != null && (typeof raw.standard !== "string" || !raw.standard.trim())) say(`object ${name}: prop "${k}": standard is the field's id in the org's field list, one word (OFST452010)`);
172
+ if (raw.wordings != null && (!Array.isArray(raw.wordings) || !raw.wordings.length || raw.wordings.some((w) => typeof w !== "string" || !w.trim()))) say(`object ${name}: prop "${k}": wordings is a list of the words documents print for this field, e.g. [management fee, mgmt fee]`);
173
+ if (raw.pattern != null) { try { if (typeof raw.pattern !== "string" || !raw.pattern.trim()) throw 0; new RegExp(raw.pattern); } catch { say(`object ${name}: prop "${k}": pattern is the regular expression a value must match, e.g. ^[A-Z]{2}[A-Z0-9]{9}[0-9]$; a value that does not match is never saved`); } }
174
+ if (raw.format != null && raw.format !== "date" && raw.format !== "country") say(`object ${name}: prop "${k}": format is date, country, or nothing (a date is written yyyy-mm-dd; a country is held by its one name from ISO 3166, "UK" saved as United Kingdom)`);
175
+ // A WORDING SCOPED TO ONE KIND OF DOCUMENT (2026-10-26): `wordingsIn` lists, under a document type, the words that name
176
+ // this field in that type only, where one type prints for this field the words another prints for another field.
177
+ if (raw.wordingsIn != null && (!isRecord(raw.wordingsIn) || !Object.keys(raw.wordingsIn).length || Object.values(raw.wordingsIn).some((ws) => !Array.isArray(ws) || !ws.length || ws.some((w) => typeof w !== "string" || !w.trim())))) say(`object ${name}: prop "${k}": wordingsIn lists, under each document type, the words that name this field in that type only, as { <document type>: [<words>] }`);
178
+ if (raw.wordingsIn != null && raw.input !== true) say(`object ${name}: prop "${k}" says wordingsIn but not input: true; only a field read from documents is filled from a table`);
179
+ // A TERM OF ANOTHER FIELD (2026-10-26): `onlyWith` names the field this one is a term of; where that field is none,
180
+ // this one is not kept.
181
+ if (raw.onlyWith != null && (typeof raw.onlyWith !== "string" || raw.onlyWith === k || !(raw.onlyWith in ownProps || (parentProps && raw.onlyWith in parentProps)))) say(`object ${name}: prop "${k}": onlyWith names another field of this object that this one is a term of, and is kept only where that field is not none`);
182
+ if ((raw.standard != null || raw.wordings != null) && raw.input !== true) say(`object ${name}: prop "${k}" says standard or wordings but not input: true; only a field read from documents is filled from a table`);
183
+ }
184
+ // FILLED FROM DOCUMENTS, EVERY FIELD SAYS WHICH (proved-before-built.md, ruled 2026-10-07): an
185
+ // object one of whose fields names the documents that answer it is filled from documents, so
186
+ // every field it reads names them; a field without would never be asked.
187
+ const own = Object.entries(ownProps).filter(([k, raw]) => isRecord(raw) && !(parentProps && k in parentProps));
188
+ if (own.some(([, raw]) => raw.answeredBy != null)) {
189
+ for (const [k, raw] of own) if (raw.input === true && raw.answeredBy == null) say(`object ${name}: prop "${k}" is input: true and says no answeredBy; on an object filled from documents every field read names the documents that answer it, or nothing ever asks for it`);
161
190
  }
162
191
  return out;
163
192
  }
@@ -304,7 +333,7 @@ export const PIPELINE_READ_KEYS = new Set(["each", "sourceUrl", "sourceId", "con
304
333
  const DROPPED_MAKE_KEYS = ["partsOf", "by", "under", "when", "from", "any", "index"];
305
334
 
306
335
  /** What an object may say: under `each`, how many and what it holds; made once per pack, how many and from which rows. */
307
- const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where", "per"]);
336
+ const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where", "per", "read", "tables"]);
308
337
  const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
309
338
 
310
339
  /**
@@ -381,8 +410,14 @@ export function pipelineFindings(name, file, fileName = "pipeline.yaml") {
381
410
  }
382
411
  if (made.includes(o)) refuse(`pipeline ${name}: ${at}.${o} is made above already; one entry makes an object`);
383
412
  const allowed = ITEM_OBJECT_KEYS;
384
- for (const k of Object.keys(spec)) if (!allowed.has(k)) refuse(`pipeline ${name}: ${at}.${o}: "${k}" is not a word here; an object says cardinality, any, parts, where and per (${see})`);
385
- if ("per" in spec && spec.per !== "section") refuse(`pipeline ${name}: ${at}.${o}: per says section, the only unit an object is written per (${see})`);
413
+ for (const k of Object.keys(spec)) if (!allowed.has(k)) refuse(`pipeline ${name}: ${at}.${o}: "${k}" is not a word here; an object says cardinality, any, parts, where, per, read and tables (${see})`);
414
+ if ("tables" in spec && (!isRecord(spec.tables) || ("whose" in spec.tables && (!isRecord(spec.tables.whose) || typeof spec.tables.whose.node !== "string" || !spec.tables.whose.node)))) refuse(`pipeline ${name}: ${at}.${o}: tables is a record; its whose names the node that says which row a table is about: tables: { whose: { node: OpenAIDecision, config: { ... } } } (pipelines/tables.md)`);
415
+ if (isRecord(spec.tables) && "labels" in spec.tables && (!isRecord(spec.tables.labels) || typeof spec.tables.labels.node !== "string" || !spec.tables.labels.node)) refuse(`pipeline ${name}: ${at}.${o}: tables.labels names the node that proposes a field for a label no rule matched: tables: { labels: { node: OpenAIDecision, config: { ... } } } (pipelines/tables.md)`);
416
+ if (isRecord(spec.tables) && "allocations" in spec.tables && (typeof spec.tables.allocations !== "string" || !spec.tables.allocations)) refuse(`pipeline ${name}: ${at}.${o}: tables.allocations names the line an allocation table draws to each fund it holds: tables: { allocations: allocatesTo } (pipelines/tables.md 3c)`);
417
+ if (isRecord(spec.tables) && "boards" in spec.tables && (!Array.isArray(spec.tables.boards) || !spec.tables.boards.length || !spec.tables.boards.every((b) => typeof b === "string" && b))) refuse(`pipeline ${name}: ${at}.${o}: tables.boards names the lines a board list may draw, each to whose board it is: tables: { boards: [directorOf, worksAt] } (pipelines/tables.md 3d)`);
418
+ if ("read" in spec && (!isRecord(spec.read) || typeof spec.read.node !== "string" || !spec.read.node)) refuse(`pipeline ${name}: ${at}.${o}: read names the node that reads the pages: read: { node: BeamEndpoint, config: { ... } } (${see})`);
419
+ if ("read" in spec && spec.per !== "page") refuse(`pipeline ${name}: ${at}.${o}: read is how an object written per page is read; say per: page beside it (${see})`);
420
+ if ("per" in spec && spec.per !== "page") refuse(`pipeline ${name}: ${at}.${o}: per says page, the only unit an object is written per; there are no sections (page-store.md) (${see})`);
386
421
  // HOW MANY IS SAID by every object the model writes (owner, 2026-09-13).
387
422
  if (spec.cardinality !== "one" && spec.cardinality !== "many") say(`pipeline ${name}: ${at}.${o} says cardinality: one or many, how many of it one row makes (${see})`);
388
423
  if ("any" in spec && spec.any !== true) refuse(`pipeline ${name}: ${at}.${o}: any: true is its only value, the item MAY be one`);
@@ -456,6 +491,70 @@ export function pipelineFolderFindings(name, { file, config, test }, { nodeTypes
456
491
  }
457
492
 
458
493
  /** The first error's message. */
494
+ // ── Layouts ─────────────────────────────────────────────────────────────────────────────────────────
495
+ /**
496
+ * A LAYOUT NAMES NO DOCUMENT (docs/unoverse/data/pipelines/document-map.md, owner 2026-10-21, seeing a
497
+ * draft carry "EAM B.V." and "TL-X"). A layout is the map of a document TYPE: it speaks in the type's
498
+ * structure and wording, never in a fund's, a firm's, an index's or a ticker's name. Names belong on
499
+ * records. Refused here, so a layout that fits one house's documents is never loaded.
500
+ */
501
+ /** A legal form as one word of a name, in any jurisdiction's spelling: the mark of a firm's or a fund's name. */
502
+ const LEGAL_FORM_WORD = /^(?:Ltd|Limited|LLC|LLP|L\.?P\.?|LP|plc|PLC|Inc\.?|Corp\.?|Co\.?|GmbH|AG|S\.?A\.?|S\.?C\.?A\.?|S\.?p\.?A\.?|SpA|B\.?V\.?|BV|N\.?V\.?|NV|S\.?à\.?r\.?l\.?|SARL|Sàrl|Pte\.?|Pty\.?|KGaA|KG|ApS|AB|AS|Oy|SE|SAS|SL|SRL|Srl|V)\.?$/;
503
+ const LAYOUT_TEXT_KEYS = new Set(["pattern", "after", "heading", "text", "row", "table", "say", "describe", "trim", "split"]);
504
+ const LAYOUT_NAME_KEYS = new Set(["names", "sameAs", "neverMerge", "aliases"]);
505
+ /** Every capitalised run of two or more words in a string, regex syntax and escapes aside. */
506
+ function capitalisedRuns(str) {
507
+ // Regex syntax is not words: character classes, counts, escapes and group openers go before the words are read.
508
+ const plain = String(str).replace(/\(\?<?[!=][^)]*\)/g, " ").replace(/\[[^\]]*\]/g, " ").replace(/\{[^}]*\}/g, " ").replace(/\\[A-Za-z]/g, " ").replace(/\(\?[:!=<]?/g, " ").replace(/[\[\]()|^$?*+{}.\\]/g, " ").replace(/\s+/g, " ");
509
+ return [...plain.matchAll(/(?:\b[A-Z][\w'&.-]*(?: (?:of|de|di|du|des|and|&) )?\s?){2,}/g)].map((m) => m[0].trim()).filter((r) => r.split(/\s+/).length >= 2);
510
+ }
511
+ /** A ticker or an identifier spelled out: letters and digits run together ("LU0000000000", "ABCDEF LX", "X1AA"), or an ISIN shape. */
512
+ const LITERAL_CODE = /\b(?:[A-Z]{2}[A-Z0-9]{9}\d|[A-Z]{4,}\s[A-Z]{2}|[A-Z]+\d+[A-Z]+|TL-[A-Z]\d*)\b/;
513
+ /**
514
+ * The same inside a finder that reads any case (`case: any`, 2026-10-24): there capitals are the type's wording as printed
515
+ * ("A SUB-FUND OF THE"), so a word of four capitals then one of two is a ticker only when the two stand alone ("ABCDEF LX");
516
+ * a code with digits or an identifier's shape is refused as everywhere.
517
+ */
518
+ const LITERAL_CODE_ANY_CASE = /\b(?:[A-Z]{2}[A-Z0-9]{9}\d|[A-Z]+\d+[A-Z]+|TL-[A-Z]\d*)\b|(?<![A-Za-z][\s-]?)\b[A-Z]{4,}\s[A-Z]{2}\b(?![\s-]?[A-Za-z])/;
519
+ export function layoutFindings(name, file, fileName = `${name}.yaml`) {
520
+ const out = [];
521
+ const refuse = (msg) => out.push({ file: fileName, msg, refuses: true });
522
+ if (!isRecord(file)) return [{ file: fileName, msg: `layout ${name}: ${fileName} must be a record`, refuses: true }];
523
+ const walk = (v, path, anyCase = false) => {
524
+ if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${path}[${i}]`, anyCase)); return; }
525
+ if (isRecord(v)) {
526
+ // A FINDER THAT READS ANY CASE (`case: any`): its own strings are wording, capitals and all; a record beneath it is its own.
527
+ const own = v.case === "any";
528
+ for (const [k, x] of Object.entries(v)) {
529
+ if (LAYOUT_NAME_KEYS.has(k)) refuse(`layout ${name}: "${k}" at ${path} lists names; a layout names no document, aliases live on records (document-map.md, 2026-10-21)`);
530
+ else if (k === "map" && isRecord(x)) { for (const [from, to] of Object.entries(x)) for (const t of [from, String(to)]) check(t, `${path}.map`); }
531
+ else walk(x, path ? `${path}.${k}` : k, own && typeof x === "string");
532
+ }
533
+ return;
534
+ }
535
+ if (typeof v === "string") { const key = path.split(/[.[]/).pop()?.replace("]", ""); if (LAYOUT_TEXT_KEYS.has(key)) check(v, path, anyCase); }
536
+ };
537
+ const check = (str, path, anyCase = false) => {
538
+ // BY STRUCTURE, NEVER BY A WORD LIST (owner 2026-10-24: a list of a document type's words grew from one org's documents).
539
+ // A name shows itself two ways: a capitalised run that ends in a legal form and holds a word that is not one
540
+ // ("Example Asset Management B.V."; an alternation of legal forms alone is pattern syntax), and a `never` pattern that
541
+ // is plain words with no regular-expression structure, which can only be one name. Wording a pattern matches or maps
542
+ // ("No Distribution", "Swiss Paying Agent") is the type's own and carries no name.
543
+ const plain = String(str).replace(/\\[A-Za-z]/g, " ");
544
+ for (const run of capitalisedRuns(plain)) {
545
+ const words = run.split(/\s+/).filter(Boolean);
546
+ const last = words[words.length - 1] ?? "";
547
+ // A lone capital letter is a legal form's initial once its dots are gone ("S A", "B V"), never a word of a name.
548
+ const ownWords = words.filter((w) => !LEGAL_FORM_WORD.test(w) && !/^[A-Z]$/.test(w) && !/^(?:of|de|di|du|des|and|&)$/i.test(w));
549
+ if (LEGAL_FORM_WORD.test(last) && ownWords.length >= 1) refuse(`layout ${name}: "${run}" at ${path} reads like a name (a legal form ends it); a layout names no document (document-map.md, 2026-10-21)`);
550
+ }
551
+ if (/(^|\.)never\[\d+\]\.pattern$/.test(path) && !/[\\^$.*+?()[\]{}|]/.test(str) && /[A-Z]/.test(str)) refuse(`layout ${name}: "${str.slice(0, 60)}" at ${path} reads like a name (plain words with no structure); a never rule is structural (document-map.md, 2026-10-21)`);
552
+ if ((anyCase ? LITERAL_CODE_ANY_CASE : LITERAL_CODE).test(str.replace(/\[[^\]]*\]|\{[^}]*\}|\\[A-Za-z]/g, " "))) refuse(`layout ${name}: "${str.slice(0, 60)}" at ${path} spells out a code or ticker; a layout names no document (document-map.md, 2026-10-21)`);
553
+ };
554
+ walk(file, "");
555
+ return out;
556
+ }
557
+
459
558
  export function firstError(findings) {
460
559
  const f = findings.find((x) => !x.level || x.level === "error");
461
560
  return f ? f.msg : null;
@@ -586,6 +586,9 @@ export function composeNode(raw: RawNode, pkg: RawPackage): ComposedNode {
586
586
  attaches: iface.attaches ?? null,
587
587
  visibility: node.visibility ?? "public",
588
588
  capabilities: node.capabilities ?? null,
589
+ // HOW THE SERVICE PROVES A MESSAGE IS ITS OWN (docs/auth-security/INBOUND.md § 2 step 3): a
590
+ // scheme the platform verifies, and the credential field holding the secret. Null: it sends none.
591
+ inbound: node.inbound ?? null,
589
592
  inputs: iface.inputs ?? [],
590
593
  outputs: iface.outputs ?? [],
591
594
  credentials: iface.credentials ?? [],
@@ -6,7 +6,7 @@ import { contextFor } from "./context.js";
6
6
  import { sessionFor, stateStoreFor, audioLaneFor } from "./session.js";
7
7
  import { refireAfterDocMutation } from "./refire.js";
8
8
  import { toolBridgeFor } from "./toolBridge.js";
9
- import { hydrateMemory, ingestMemoryTurn, sessionTurnRecorder } from "./memory.js";
9
+ import { hydrateMemory, hydrateOpenedTask, ingestMemoryTurn, sessionTurnRecorder } from "./memory.js";
10
10
 
11
11
  export class ManifestCallbackExecutor {
12
12
  logger: any = console;
@@ -69,7 +69,8 @@ export class ManifestCallbackExecutor {
69
69
  // A SESSION is a run that holds a socket for the length of a call: the block rides the
70
70
  // replayed history instead of a prompt, and turns are saved as they complete (below).
71
71
  const session = Array.isArray(node.api?.run) && node.api.run.some((r: any) => r?.transport === "ws");
72
- const hydratedConfig = await hydrateMemory(event.config, executionContext ?? this.executionContext, { session });
72
+ const memoryConfig = await hydrateMemory(event.config, executionContext ?? this.executionContext, { session });
73
+ const hydratedConfig = session ? await hydrateOpenedTask(memoryConfig, executionContext ?? this.executionContext) : memoryConfig;
73
74
  const ctx = contextFor(node, event.inputs, hydratedConfig, executionContext ?? this.executionContext);
74
75
  const turns = session ? sessionTurnRecorder(node.type, event.config, executionContext ?? this.executionContext) : null;
75
76