@unoverse-platform/studio 0.1.115 → 0.1.116

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 (37) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/package.json +1 -1
  4. package/screens/AgentChain.tsx +216 -0
  5. package/screens/AgentsView.tsx +45 -6
  6. package/screens/SkillsView.tsx +21 -3
  7. package/screens/index.ts +3 -0
  8. package/vendor/base/src/agent-mcp/discovery.ts +4 -1
  9. package/vendor/base/src/agent-mcp/invoke.ts +155 -25
  10. package/vendor/base/src/agent-mcp/tools.ts +17 -10
  11. package/vendor/base/src/agent-mcp/types.ts +8 -0
  12. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  13. package/vendor/base/src/auth/agentToken.ts +142 -0
  14. package/vendor/base/src/auth/auth.ts +7 -0
  15. package/vendor/base/src/definitions/load/load.ts +6 -1
  16. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  17. package/vendor/base/src/definitions/registry/objects.ts +16 -0
  18. package/vendor/base/src/definitions/registry/pipelines.ts +13 -5
  19. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  20. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  21. package/vendor/base/src/lint/design/index.mjs +10 -2
  22. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  23. package/vendor/base/src/lint/objects/index.mjs +120 -2
  24. package/vendor/base/src/lint/objects/rules.mjs +16 -5
  25. package/vendor/base/src/manifests/executor/toolBridge.ts +10 -3
  26. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  27. package/vendor/base/src/manifests/runtime/http/request.ts +4 -1
  28. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +58 -23
  29. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  30. package/vendor/base/src/platform/agentBudget.ts +65 -0
  31. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  32. package/vendor/base/src/platform/taskChanged.ts +25 -0
  33. package/vendor/base/src/platform/tokenCost.ts +395 -0
  34. package/vendor/base/src/security/permissions.ts +10 -2
  35. package/vendor/sdk/appEngine.ts +42 -1
  36. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  37. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -114,6 +114,19 @@ export interface ObjectMatching {
114
114
  ruleOut?: string[];
115
115
  /** The node that decides a name the rules leave open (TypeSafeChoice). Absent, an open name goes to review. */
116
116
  judge?: string;
117
+ /**
118
+ * THE LINKER, BEFORE THE JUDGE (pipelines README, "Three sweeps"; owner 2026-10-10: "Splink to dedupe
119
+ * our entities"): a node that scores the name against each candidate's names. Sure the same, it links;
120
+ * sure different, the name is none of them; between, the judge decides.
121
+ */
122
+ link?: { node: string; config: Record<string, unknown> };
123
+ /**
124
+ * THE LAST LOOK (owner 2026-10-11: "use the LLM to investigate and solve these"): the node that
125
+ * looks again at every name a run left open, with what the documents say where it is used
126
+ * (OpenAIDecision). Sure, it decides as a person would, with Undo; not sure, it stays open.
127
+ * Absent, the judge looks.
128
+ */
129
+ review?: { node: string; config?: Record<string, unknown> };
117
130
  /** The lines (by name, e.g. a document's `about`) whose name, sure to be no record held, makes the record. A mention on any other line never does. */
118
131
  createFrom?: string[];
119
132
  }
@@ -206,6 +219,8 @@ export interface ObjectDefinition {
206
219
  links?: Record<string, ObjectLink>;
207
220
  /** How a name is matched to its rows (entity-resolution.md); absent, a line to it compares one field with one field. */
208
221
  matching?: ObjectMatching;
222
+ /** How a page reader picks it out of a page (pipelines README, "Or read by a node"): instructions and worked examples. */
223
+ extract?: { instructions?: string; examples?: { text: string; answer: Record<string, unknown>[] }[]; never?: string[] };
209
224
  /** THE TABLE THIS OBJECT OWNS (docs/unoverse/data/objects/dataset.md, PROPOSED 2026-09-18),
210
225
  * from its folder's table.yaml: an object that holds many small facts read together rather
211
226
  * than one row read alone. Absent on every ordinary object, which stays a row. */
@@ -368,6 +383,7 @@ function readFolder(dir: string, name: string, org: string | undefined, seen: st
368
383
  ...(envelope.meta ? { meta: envelope.meta } : parent?.meta ? { meta: parent.meta } : {}),
369
384
  ...(links && Object.keys(links).length ? { links } : {}),
370
385
  ...(envelope.matching ? { matching: envelope.matching as ObjectMatching } : parent?.matching ? { matching: parent.matching } : {}),
386
+ ...(envelope.extract ? { extract: envelope.extract as ObjectDefinition["extract"] } : {}),
371
387
  ...(dataset ? { dataset } : {}),
372
388
  props,
373
389
  ownProps,
@@ -68,11 +68,18 @@ export interface MakeEntry {
68
68
  /** How many of this object one source makes (owner, 2026-09-13): one, or many. The read asks by it; a part's entry says none. */
69
69
  cardinality?: "one" | "many";
70
70
  /**
71
- * WRITTEN FROM EVERY SECTION (owner, 2026-10-05): `per: section` writes this object after the
72
- * document is cut, one read per stored piece of each named section, instead of in the
73
- * document's first read, which sees only its first two pages and its headings.
71
+ * WRITTEN FROM EVERY PAGE (owner, 2026-10-05; page-store.md, 2026-10-07: no sections): `per: page`
72
+ * writes this object once the document's pages are kept, one read per page, instead of in the
73
+ * document's first read, which sees only its first two pages.
74
74
  */
75
- per?: "section";
75
+ per?: "page";
76
+ /**
77
+ * READ BY A NODE, FROM THE PAGES THAT NAME IT (pipelines README, "Or read by a node", owner
78
+ * 2026-10-09): with `per: page`, the node handed the best chunks with a template made from the
79
+ * object's input props, in place of a model reading every page. Its config is resolved over the
80
+ * run's `config` when the rows are collected.
81
+ */
82
+ read?: { node: string; config: Record<string, unknown>; chunks?: number | "all" };
76
83
  sourceUrl?: string;
77
84
  sourceId?: string;
78
85
  /** The item's content, each value a reference over `item`, `index`, `config`, `collect`. */
@@ -154,7 +161,8 @@ function readFolder(dir: string, name: string, org: string | undefined): Pipelin
154
161
  ...(spec.any === true ? { any: list } : { each: list }),
155
162
  ...(spec.cardinality === "one" || spec.cardinality === "many" ? { cardinality: spec.cardinality as "one" | "many" } : {}),
156
163
  ...(whenOf(spec) ? { when: whenOf(spec) } : {}),
157
- ...(spec.per === "section" ? { per: "section" as const } : {}),
164
+ ...(spec.per === "page" ? { per: "page" as const } : {}),
165
+ ...(isRecord(spec.read) && typeof spec.read.node === "string" ? { read: { node: spec.read.node, config: isRecord(spec.read.config) ? spec.read.config : {}, ...(spec.read.chunks === "all" || typeof spec.read.chunks === "number" ? { chunks: spec.read.chunks as number | "all" } : {}) } } : {}),
158
166
  sourceUrl: typeof raw.sourceUrl === "string" ? raw.sourceUrl : "{{ item.address }}",
159
167
  sourceId: typeof raw.sourceId === "string" ? raw.sourceId : "{{ item.universalId }}",
160
168
  content: isRecord(raw.content) ? raw.content : {},
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A TASK START IS A TASK (docs/mcp/MCP_TASKS.md § 2): a titled, described Task Start has an MCP
3
+ * tool on our own door, so any Agent a line is drawn to on its canvas can call it, and is
4
+ * answered with an MCP task. One rule, read by the engine's Task Start (what it offers a wired
5
+ * Agent) and the server (the tool it registers), so the two always name the same tool.
6
+ */
7
+ import { AGENT_CARD, listAgents, type AgentFile } from "./agents.js";
8
+ import { listApps } from "./catalog.js";
9
+
10
+ /** A Task Start as a workflow saves it. */
11
+ export interface TaskStartNode {
12
+ id: string;
13
+ type?: string;
14
+ data?: { config?: Record<string, unknown> };
15
+ }
16
+
17
+ const kebab = (s: string) =>
18
+ s
19
+ .trim()
20
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
21
+ .replace(/[^a-zA-Z0-9]+/g, "-")
22
+ .replace(/^-+|-+$/g, "")
23
+ .toLowerCase();
24
+
25
+ const text = (v: unknown) => (typeof v === "string" ? v.trim() : "");
26
+
27
+ /**
28
+ * A TASK START'S NAME (docs/mcp/MCP_TASKS.md § 2a, step U2): its own `name` field, set once, so its
29
+ * URI (`unoverse://tasks/<org>/<name>`) never moves when someone renames the title. A Task Start
30
+ * saved before the field existed is named from its title, as it always was, and that name is
31
+ * written into the field on its next save (`withFixedTaskName`), so from then on it stays put.
32
+ */
33
+ export function taskStartName(node: TaskStartNode): string {
34
+ const cfg = node.data?.config ?? {};
35
+ return text(cfg.name) || kebab(text(cfg.title));
36
+ }
37
+
38
+ /** The node with its name written in, when it has a title and no name yet. Saving calls this. */
39
+ export function withFixedTaskName<T extends TaskStartNode>(node: T): T {
40
+ if (node.type !== "InputTrigger") return node;
41
+ const cfg = node.data?.config ?? {};
42
+ if (text(cfg.name) || !text(cfg.title)) return node;
43
+ return { ...node, data: { ...node.data, config: { ...cfg, name: kebab(text(cfg.title)) } } };
44
+ }
45
+
46
+ /**
47
+ * The Task a Task Start is, or undefined when it is not one yet (no title, or nothing saying what
48
+ * it does, or its workflow belongs to no org's app). An Agent file that already names this start
49
+ * IS its Task, so the start reuses that file's tool rather than minting a second one.
50
+ */
51
+ export function taskOfStart(workflowId: string, node: TaskStartNode): AgentFile | undefined {
52
+ if (node.type !== undefined && node.type !== "InputTrigger") return undefined;
53
+ const known = listAgents().find((a) => a.workflow === workflowId && a.trigger === node.id);
54
+ if (known) return known;
55
+ const cfg = node.data?.config ?? {};
56
+ const title = text(cfg.title);
57
+ const description = text(cfg.description);
58
+ const whenToUse = text(cfg.whenToUse);
59
+ const id = taskStartName(node);
60
+ if (!id || (!description && !whenToUse)) return undefined;
61
+ const org = listApps().find((a) => a.binding?.workflow === workflowId)?.org;
62
+ if (!org) return undefined;
63
+ return {
64
+ name: `${org}/${id}`,
65
+ org,
66
+ id,
67
+ title,
68
+ ...(description ? { description } : {}),
69
+ ...(whenToUse ? { whenToUse } : {}),
70
+ app: AGENT_CARD,
71
+ workflow: workflowId,
72
+ trigger: node.id,
73
+ ...(text(cfg.resultFrom) ? { output: text(cfg.resultFrom) } : {}),
74
+ path: "",
75
+ };
76
+ }
@@ -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
+ }
@@ -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
 
@@ -1039,7 +1042,12 @@ function placeNames() {
1039
1042
  const walkNode = makeWalkNode(ctx);
1040
1043
  const lintFile = makeLintFile({ ...ctx, walkNode });
1041
1044
 
1042
- for (const home of homes) for (const f of jsonFiles(home.dir)) lintFile(f);
1045
+ for (const home of homes) {
1046
+ const files = home.flat
1047
+ ? readdirSync(home.dir).filter((f) => !f.startsWith(".") && isDefFile(f) && !statSync(join(home.dir, f)).isDirectory()).map((f) => join(home.dir, f))
1048
+ : jsonFiles(home.dir);
1049
+ for (const f of files) lintFile(f);
1050
+ }
1043
1051
  return { problems, homes, designRoot: DESIGN_ROOT, designSystem: DS };
1044
1052
  }
1045
1053
 
@@ -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
 
@@ -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. */
@@ -29,7 +29,7 @@ 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
+ export const PROP_KEYS = new Set(["type", "input", "description", "from", "hydrate", "enum", "oneOf", "minLength", "maxLength", "minItems", "maxItems", "items", "reuse", "answeredBy"]);
33
33
  export const LIMIT_KEYS = ["minLength", "maxLength", "minItems", "maxItems"];
34
34
 
35
35
  /** Words that left the object grammar and must not come back (README 11.7, 12.3, 12.4). */
@@ -117,7 +117,7 @@ export function envelopeFindings(name, envelope, file = `${name}.yaml`) {
117
117
  if (m.createFrom != null && !fields(m.createFrom)) refuse(`object ${name}: matching: createFrom is a list of line names (about)`);
118
118
  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
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`);
120
+ 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
121
  }
122
122
  }
123
123
  return out;
@@ -158,6 +158,15 @@ export function propsFindings(name, ownProps, parentProps = null, base = null, f
158
158
  if (raw.enum != null) say(`object ${name}: prop "${k}" says both enum and oneOf; one or the other`);
159
159
  }
160
160
  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)`);
161
+ 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]`);
162
+ 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`);
163
+ }
164
+ // FILLED FROM DOCUMENTS, EVERY FIELD SAYS WHICH (proved-before-built.md, ruled 2026-10-07): an
165
+ // object one of whose fields names the documents that answer it is filled from documents, so
166
+ // every field it reads names them; a field without would never be asked.
167
+ const own = Object.entries(ownProps).filter(([k, raw]) => isRecord(raw) && !(parentProps && k in parentProps));
168
+ if (own.some(([, raw]) => raw.answeredBy != null)) {
169
+ 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
170
  }
162
171
  return out;
163
172
  }
@@ -304,7 +313,7 @@ export const PIPELINE_READ_KEYS = new Set(["each", "sourceUrl", "sourceId", "con
304
313
  const DROPPED_MAKE_KEYS = ["partsOf", "by", "under", "when", "from", "any", "index"];
305
314
 
306
315
  /** 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"]);
316
+ const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where", "per", "read"]);
308
317
  const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
309
318
 
310
319
  /**
@@ -381,8 +390,10 @@ export function pipelineFindings(name, file, fileName = "pipeline.yaml") {
381
390
  }
382
391
  if (made.includes(o)) refuse(`pipeline ${name}: ${at}.${o} is made above already; one entry makes an object`);
383
392
  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})`);
393
+ 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 and read (${see})`);
394
+ 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})`);
395
+ 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})`);
396
+ 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
397
  // HOW MANY IS SAID by every object the model writes (owner, 2026-09-13).
387
398
  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
399
  if ("any" in spec && spec.any !== true) refuse(`pipeline ${name}: ${at}.${o}: any: true is its only value, the item MAY be one`);
@@ -60,6 +60,8 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
60
60
  userId: pub?.userId,
61
61
  chatId: pub?.chatId,
62
62
  accessToken: executionContext?.auth?.accessToken,
63
+ // The run's stop signal: a task this Agent follows is cancelled when its run stops.
64
+ signal: executionContext?.abortSignal as AbortSignal | undefined,
63
65
  };
64
66
  /**
65
67
  * THE CALLER, for a tool the MODEL calls: each records its own call on the trace under this
@@ -79,7 +81,11 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
79
81
  * would silently drop elicitation — the app would render and the model would never
80
82
  * learn what the human said.
81
83
  */
82
- const appInvokers = new Map<string, (input: unknown) => Promise<unknown>>();
84
+ const appInvokers = new Map<string, (input: unknown, hooks?: { onStatus?: (statusMessage: string) => void }) => Promise<unknown>>();
85
+ // A WIRED TASK (a Task Start a line is drawn from, docs/mcp/MCP_TASKS.md § 2) names its MCP
86
+ // tool: it is called through the front door and followed as an MCP task, like a found one.
87
+ for (const [name, m] of Object.entries<any>(schema.methods))
88
+ if (typeof m?.taskTool === "string" && m.taskTool) appInvokers.set(name, harness.workflowTaskInvoker({ taskTool: m.taskTool }, called));
83
89
 
84
90
  /**
85
91
  * Which apps this run has already unlocked, by component name.
@@ -191,9 +197,10 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
191
197
  return tools;
192
198
  },
193
199
 
194
- async call(name, args) {
200
+ async call(name, args, hooks) {
195
201
  const app = appInvokers.get(name);
196
- if (app) return stringify(await app(args));
202
+ // A followed task's status, as it changes, goes to whoever asked (the tool loop narrates it).
203
+ if (app) return stringify(await app(args, hooks));
197
204
  // THE ARGUMENTS GO OUT AS THE MODEL WROTE THEM. A wire-level rewrite used to strip
198
205
  // queries the conversation had already searched; it was removed 2026-08-16 because it
199
206
  // silently competed with the tool descriptions for the same decision, and the
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A MARKETPLACE MCP IS A TASK (docs/mcp/MCP_TASKS.md § 2a): `unoverse://tasks/marketplace/<node>`,
3
+ * the node's type in kebab case, names a marketplace node that offers MCP tools, and listing it
4
+ * hands over every tool that MCP offers. This is the one place that reads it, for the Agent's
5
+ * runner and for every screen that draws an Agent.
6
+ */
7
+ import { getLoadedManifestTypes, getManifestNode } from "./index.js";
8
+ import { taskParts, MARKETPLACE_ORG } from "../definitions/registry/taskUri.js";
9
+
10
+ /** A node type's task name: `SignalSave` → `signal-save`. */
11
+ export const mcpTaskName = (type: string): string => type.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
12
+
13
+ /** The marketplace MCP a task names: the node type that offers MCP tools, or null. */
14
+ export function marketplaceMcp(uri: string, types: string[] = getLoadedManifestTypes()): string | null {
15
+ const p = taskParts(uri);
16
+ if (!p || p.org !== MARKETPLACE_ORG) return null;
17
+ return (
18
+ types.find((t) => mcpTaskName(t) === p.name && ((getManifestNode(t)?.definition?.serviceConnectors ?? []) as Array<{ serviceType?: string }>).some((c) => c?.serviceType === "mcp")) ?? null
19
+ );
20
+ }
@@ -512,8 +512,11 @@ export async function runCalls(
512
512
  ctx: RunContext,
513
513
  label: string,
514
514
  store?: StateStore,
515
+ /** Replies already in hand from earlier calls the CALLER made itself (a lifecycle hook's node
516
+ * calls, server mcp/lifecycleYaml.ts), so these calls see them as `calls.<name>` too. */
517
+ earlier?: Record<string, any>,
515
518
  ): Promise<{ results: Record<string, any>; last: any }> {
516
- const results: Record<string, any> = {};
519
+ const results: Record<string, any> = { ...(earlier ?? {}) };
517
520
  let last: any = undefined;
518
521
 
519
522
  for (const call of calls) {