@unoverse-platform/studio 0.1.91 → 0.1.93

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.
@@ -22,7 +22,7 @@ export function lintNode(dir, pkg) {
22
22
  const label = rel(dir);
23
23
 
24
24
  if (!existsSync(nodePath))
25
- return report("error", label, `has no node.yaml. It is the one REQUIRED file in a node folder (DECLARATIVE_NODES.md §5)`);
25
+ return report("error", label, `has no node.yaml. It is the one REQUIRED file in a node folder (https://docs.unoverse.ai/nodes/manifest-nodes)`);
26
26
 
27
27
  const node = readYaml(nodePath);
28
28
  if (!node) return;
@@ -39,13 +39,13 @@ export function lintNode(dir, pkg) {
39
39
 
40
40
  const ways = [hasFolder ? `${s}/` : null, onDisk ? `${s}.yaml` : null, inline ? "node.yaml" : null].filter(Boolean);
41
41
  if (ways.length > 1) {
42
- report("error", rel(hasFolder ? folder : file), `"${s}" is defined in ${ways.join(" and ")}. Pick one; this is never a merge (DECLARATIVE_NODES.md §5)`);
42
+ report("error", rel(hasFolder ? folder : file), `"${s}" is defined in ${ways.join(" and ")}. Pick one; this is never a merge (https://docs.unoverse.ai/nodes/manifest-nodes)`);
43
43
  continue;
44
44
  }
45
45
 
46
46
  // `api` is always the folder form, so every node reads the same way.
47
47
  if (s === "api" && (onDisk || inline)) {
48
- report("error", onDisk ? rel(file) : rel(nodePath), `api must be a FOLDER: api/run.yaml, api/events.yaml, and one file per further part (api/service.yaml, api/toolExchange.yaml, api/narrate.yaml). Every node has the same shape (DECLARATIVE_NODES.md §5)`);
48
+ report("error", onDisk ? rel(file) : rel(nodePath), `api must be a FOLDER: api/run.yaml, api/events.yaml, and one file per further part (api/service.yaml, api/toolExchange.yaml, api/narrate.yaml). Every node has the same shape (https://docs.unoverse.ai/nodes/manifest-nodes)`);
49
49
  continue;
50
50
  }
51
51
 
@@ -54,7 +54,7 @@ export function lintNode(dir, pkg) {
54
54
  // every rule below reads the same shape whichever layout the author chose.
55
55
  const files = readdirSync(folder).filter((f) => /\.ya?ml$/.test(f)).sort();
56
56
  if (!files.length) {
57
- report("error", rel(folder), `is an empty ${s}/ folder. Delete it, or add one file per top-level key (DECLARATIVE_NODES.md §5)`);
57
+ report("error", rel(folder), `is an empty ${s}/ folder. Delete it, or add one file per top-level key (https://docs.unoverse.ai/nodes/manifest-nodes)`);
58
58
  continue;
59
59
  }
60
60
  const doc = {};
@@ -64,7 +64,7 @@ export function lintNode(dir, pkg) {
64
64
  if (part === undefined || part === null) continue;
65
65
  // The filename IS the key, so a file that repeats it nests the block twice.
66
66
  if (typeof part === "object" && !Array.isArray(part) && Object.keys(part).length === 1 && key in part)
67
- report("error", rel(join(folder, f)), `starts with "${key}:" again. The FILENAME is the key, so this file holds the CONTENTS of ${key} (DECLARATIVE_NODES.md §5)`);
67
+ report("error", rel(join(folder, f)), `starts with "${key}:" again. The FILENAME is the key, so this file holds the CONTENTS of ${key} (https://docs.unoverse.ai/nodes/manifest-nodes)`);
68
68
  doc[key] = part;
69
69
  }
70
70
  parts[s] = { doc, file: rel(folder) };
@@ -142,7 +142,7 @@ export function lintNode(dir, pkg) {
142
142
 
143
143
  // The service channel must match what the node advertises. A method with no
144
144
  // connector is undiscoverable; a connector method with no implementation is a lie
145
- // that only surfaces when a consumer calls it (08-mcp-services.md).
145
+ // that only surfaces when a consumer calls it (https://docs.unoverse.ai/nodes/mcp-services).
146
146
  if (api?.service || (iface.serviceConnectors ?? []).some((s) => s.isService === true)) {
147
147
  const advertised = new Set(
148
148
  (iface.serviceConnectors ?? []).filter((s) => s.isService === true).flatMap((s) => s.methods ?? []),
@@ -179,12 +179,12 @@ export function lintNode(dir, pkg) {
179
179
  report(
180
180
  "error",
181
181
  F.node,
182
- `offers service methods and declares no outputs, so it is a pure service node and needs "template: service". Without it the canvas draws connector dots that can never carry anything (DECLARATIVE_NODES.md §5)`,
182
+ `offers service methods and declares no outputs, so it is a pure service node and needs "template: service". Without it the canvas draws connector dots that can never carry anything (https://docs.unoverse.ai/nodes/manifest-nodes)`,
183
183
  );
184
184
 
185
185
  // The mirror of it: advertising the discovery method as a tool.
186
186
  if (advertised.has(DISCOVERY_METHOD))
187
- report("error", F.iface ?? F.node, `a serviceConnector lists "${DISCOVERY_METHOD}" in methods. It is tool DISCOVERY, called by convention, and listing it advertises it to the model as a tool (08-mcp-services.md)`);
187
+ report("error", F.iface ?? F.node, `a serviceConnector lists "${DISCOVERY_METHOD}" in methods. It is tool DISCOVERY, called by convention, and listing it advertises it to the model as a tool (https://docs.unoverse.ai/nodes/mcp-services)`);
188
188
  for (const method of advertised)
189
189
  if (!api?.service?.[method])
190
190
  report("error", F.iface ?? F.node, `advertises method "${method}" but api/service.yaml has no implementation. A consumer calling it gets an error`);
@@ -224,14 +224,14 @@ export function lintNode(dir, pkg) {
224
224
  : api.toolExchange
225
225
  ? `it declares a toolExchange, which is a multi-turn loop`
226
226
  : `an input declares a SPAWN signal`;
227
- report("error", F.node, `kind is "${node.kind}" but this node is a ${derived}: ${why} (DECLARATIVE_NODES.md §5)`);
227
+ report("error", F.node, `kind is "${node.kind}" but this node is a ${derived}: ${why} (https://docs.unoverse.ai/nodes/manifest-nodes)`);
228
228
  }
229
229
  derivedKinds.set(label, derived);
230
230
 
231
231
  // The events table is compulsory for a node the graph can trigger: it is the only
232
232
  // way anything leaves the node, so without it the node is a call into the void.
233
233
  if (!(api.events ?? []).length)
234
- report("error", F.api, `has run calls but no api/events.yaml. The events table is compulsory, it is the only way anything leaves a node (DECLARATIVE_NODES.md §5)`);
234
+ report("error", F.api, `has run calls but no api/events.yaml. The events table is compulsory, it is the only way anything leaves a node (https://docs.unoverse.ai/nodes/manifest-nodes)`);
235
235
  } else if (existsSync(join(dir, "src")) === false && !parts.api && !isAnnotation) {
236
236
  // An ANNOTATION node is exempt, because "does nothing" is the whole specification. `Note`
237
237
  // has no inputs, no outputs and no api: it is a markdown sticky on the canvas. Warning about
@@ -253,7 +253,7 @@ export function lintNode(dir, pkg) {
253
253
  for (const [i, r] of rows.entries()) {
254
254
  const from = r.from ?? "response";
255
255
  if (!SOURCES.includes(from))
256
- report("error", F.api, `events[${i}] has from: "${from}". Must be one of ${SOURCES.join(", ")} (DECLARATIVE_NODES.md §5)`);
256
+ report("error", F.api, `events[${i}] has from: "${from}". Must be one of ${SOURCES.join(", ")} (https://docs.unoverse.ai/nodes/manifest-nodes)`);
257
257
  /**
258
258
  * A `send` ROW ADDRESSES A NODE, NOT A CONNECTOR, so the coverage and order rules below do
259
259
  * not apply to it: there is no dot for it to cover and none to be out of order with. What it
@@ -310,7 +310,7 @@ export function lintNode(dir, pkg) {
310
310
  report(
311
311
  "error",
312
312
  F.api,
313
- `events rows are ordered ${rowOrder.join(", ")} but the output connectors are ${want.join(", ")}. Keep the table in connector order so the node reads top to bottom (DECLARATIVE_NODES.md §5)`,
313
+ `events rows are ordered ${rowOrder.join(", ")} but the output connectors are ${want.join(", ")}. Keep the table in connector order so the node reads top to bottom (https://docs.unoverse.ai/nodes/manifest-nodes)`,
314
314
  );
315
315
  }
316
316
 
@@ -323,12 +323,12 @@ export function lintNode(dir, pkg) {
323
323
  // Retired keys. Left in place they would silently do nothing, which is worse than a
324
324
  // hard error, so each one names its replacement.
325
325
  if (api?.response !== undefined)
326
- report("error", F.api, `api.response is retired: transport/terminator/error moved INTO the call (request), and what leaves the node is the events table (DECLARATIVE_NODES.md §5)`);
326
+ report("error", F.api, `api.response is retired: transport/terminator/error moved INTO the call (request), and what leaves the node is the events table (https://docs.unoverse.ai/nodes/manifest-nodes)`);
327
327
  if (api?.narrate?.output !== undefined)
328
- report("error", F.api, `narrate.output is retired. The narrator's line lands via an \`events\` row with from: narrator (DECLARATIVE_NODES.md §5)`);
328
+ report("error", F.api, `narrate.output is retired. The narrator's line lands via an \`events\` row with from: narrator (https://docs.unoverse.ai/nodes/manifest-nodes)`);
329
329
  for (const [name, m] of Object.entries(api?.service ?? {}))
330
330
  if (m.response !== undefined)
331
- report("error", F.api, `service.${name}.response is retired: transport/error moved into the call, and the value it hands back is service.${name}.returns (DECLARATIVE_NODES.md §5)`);
331
+ report("error", F.api, `service.${name}.response is retired: transport/error moved into the call, and the value it hands back is service.${name}.returns (https://docs.unoverse.ai/nodes/manifest-nodes)`);
332
332
 
333
333
  // ── chained calls ──
334
334
  //
@@ -340,7 +340,7 @@ export function lintNode(dir, pkg) {
340
340
  for (const [i, s] of list.entries()) {
341
341
  const isLast = i === list.length - 1;
342
342
  if (!s?.name) {
343
- report("error", F.api, `${at}[${i}] has no name. A call is reached as calls.<name>, so an unnamed one cannot be read by a later call, an events row, or returns (DECLARATIVE_NODES.md §5)`);
343
+ report("error", F.api, `${at}[${i}] has no name. A call is reached as calls.<name>, so an unnamed one cannot be read by a later call, an events row, or returns (https://docs.unoverse.ai/nodes/manifest-nodes)`);
344
344
  } else {
345
345
  if (names.includes(s.name))
346
346
  report("error", F.api, `${at} declares two calls named "${s.name}". The second silently overwrites the first in calls.<name>`);
@@ -354,7 +354,7 @@ export function lintNode(dir, pkg) {
354
354
  // left on the old spelling would send NO credential at all and fail as a vendor 401,
355
355
  // which reads as a bad key rather than a manifest that was never migrated.
356
356
  if (s?.auth !== undefined)
357
- report("error", F.api, `${at}[${i}] (${s.name ?? "unnamed"}) uses "auth", which is retired on a call. Rename it to "credential" — that is how this node proves itself to the VENDOR. node.yaml's "auth" is the other direction: who may run this node (DECLARATIVE_NODES.md §9.13)`);
357
+ report("error", F.api, `${at}[${i}] (${s.name ?? "unnamed"}) uses "auth", which is retired on a call. Rename it to "credential" — that is how this node proves itself to the VENDOR. node.yaml's "auth" is the other direction: who may run this node (https://docs.unoverse.ai/nodes/who-can-run-it)`);
358
358
 
359
359
  // Only the LAST step may stream, and only on the workflow channel: its reply is the
360
360
  // node's answer. An earlier step exists so a later one can READ it, and there is
@@ -418,8 +418,8 @@ export function lintNode(dir, pkg) {
418
418
  "error",
419
419
  F.api,
420
420
  isLast
421
- ? `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}". A provided method hands back ONE value, so it has no connector to stream onto (DECLARATIVE_NODES.md §5)`
422
- : `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}" but is not the last step. Only the last may stream (DECLARATIVE_NODES.md §5)`,
421
+ ? `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}". A provided method hands back ONE value, so it has no connector to stream onto (https://docs.unoverse.ai/nodes/manifest-nodes)`
422
+ : `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}" but is not the last step. Only the last may stream (https://docs.unoverse.ai/nodes/manifest-nodes)`,
423
423
  );
424
424
  // Only when it tests an earlier REPLY. A first call may perfectly well be gated on
425
425
  // config or on the caller's params, and Salesforce's is. What is meaningless is
@@ -451,7 +451,7 @@ export function lintNode(dir, pkg) {
451
451
  if (at === "narrate.request")
452
452
  for (const k of ["name", "when"])
453
453
  if (call[k] !== undefined)
454
- report("error", F.api, `${at} sets "${k}", which belongs to a call in a list and is ignored here (DECLARATIVE_NODES.md §5)`);
454
+ report("error", F.api, `${at} sets "${k}", which belongs to a call in a list and is ignored here (https://docs.unoverse.ai/nodes/manifest-nodes)`);
455
455
 
456
456
  // RETIRED SHAPES. Each would silently do nothing rather than fail: the executor reads
457
457
  // `run` and `service`, finds no calls at all, and the node dies on its first run with a
@@ -459,18 +459,18 @@ export function lintNode(dir, pkg) {
459
459
  // because these are what every older example and every half-remembered habit produces.
460
460
  for (const [old, now] of [["request", "api/run.yaml, a LIST"], ["steps", "api/run.yaml"], ["provides", "api/service.yaml"]])
461
461
  if (api?.[old] !== undefined)
462
- report("error", F.api, `api/${old}.yaml is retired. This is ${now}. A node's calls are ALWAYS a list, each entry starting "- name: <what it fetches>" (DECLARATIVE_NODES.md §5)`);
462
+ report("error", F.api, `api/${old}.yaml is retired. This is ${now}. A node's calls are ALWAYS a list, each entry starting "- name: <what it fetches>" (https://docs.unoverse.ai/nodes/manifest-nodes)`);
463
463
 
464
464
  for (const [name, m] of Object.entries(api?.service ?? {}))
465
465
  for (const old of ["request", "steps"])
466
466
  if (m[old] !== undefined)
467
- report("error", F.api, `service.${name} declares "${old}". A method's calls are ALWAYS a list named "calls", even when there is one (DECLARATIVE_NODES.md §5)`);
467
+ report("error", F.api, `service.${name} declares "${old}". A method's calls are ALWAYS a list named "calls", even when there is one (https://docs.unoverse.ai/nodes/manifest-nodes)`);
468
468
 
469
469
  // Credentials must resolve to a declared type, or the run fails at execute time.
470
470
  for (const c of iface.credentials ?? []) {
471
471
  const name = c.name;
472
472
  if (!pkg.credentials.has(name))
473
- report("error", F.iface ?? F.node, `needs credential "${name}" but no credentials/${name}.yaml exists in this package. A package DECLARES the credentials it needs (04-credentials.md)`);
473
+ report("error", F.iface ?? F.node, `needs credential "${name}" but no credentials/${name}.yaml exists in this package. A package DECLARES the credentials it needs (https://docs.unoverse.ai/nodes/credentials)`);
474
474
  }
475
475
 
476
476
  // configSchema internal consistency.
@@ -495,7 +495,7 @@ export function lintNode(dir, pkg) {
495
495
  report(
496
496
  "error",
497
497
  F.config,
498
- `config.yaml declares "${field}", which is platform chrome: compose injects the run-authorization controls into every node's settings form, and a manifest must not carry its own copy (15-who-can-run-it.md). Delete the field and its ui:order entry`,
498
+ `config.yaml declares "${field}", which is platform chrome: compose injects the run-authorization controls into every node's settings form, and a manifest must not carry its own copy (https://docs.unoverse.ai/nodes/who-can-run-it). Delete the field and its ui:order entry`,
499
499
  );
500
500
  }
501
501
  for (const r of config.configSchema.required ?? [])
@@ -514,7 +514,7 @@ export function lintNode(dir, pkg) {
514
514
  if (!names.includes(dep)) report("error", F.config, `${n}: ui:dependencies names "${dep}", which is not a sibling property`);
515
515
  // A template field on an object/array takes a `return` expression, not handlebars.
516
516
  if (f["ui:field"] === "template" && (f.type === "object" || f.type === "array") && typeof f.default === "string" && f.default.trim() && !f.default.startsWith("return "))
517
- report("warn", F.config, `${n}: is an object template, so its value is a "return ..." expression, not handlebars (06-config-schema.md)`);
517
+ report("warn", F.config, `${n}: is an object template, so its value is a "return ..." expression, not handlebars (https://docs.unoverse.ai/nodes/config-schema)`);
518
518
  // A NUMBER OR A BOOLEAN IS NEVER A TEMPLATE FIELD, and this is an error rather than a
519
519
  // warning because it takes the browser down. `ui:field: template` draws the CodeMirror
520
520
  // editor, whose document is TEXT; handed the number 10 or the boolean true it builds a
@@ -529,7 +529,7 @@ export function lintNode(dir, pkg) {
529
529
  report(
530
530
  "error",
531
531
  F.config,
532
- `${n}: is a ${f.type}, so it cannot carry "ui:field: template" — that draws a text editor, which a non-string value crashes. Drop the marker; a route can still wire the field, since the engine templates any string config value (06-config-schema.md)`,
532
+ `${n}: is a ${f.type}, so it cannot carry "ui:field: template" — that draws a text editor, which a non-string value crashes. Drop the marker; a route can still wire the field, since the engine templates any string config value (https://docs.unoverse.ai/nodes/config-schema)`,
533
533
  );
534
534
  }
535
535
 
@@ -578,7 +578,7 @@ export function lintNode(dir, pkg) {
578
578
  if (!test?.testData && !api) {
579
579
  // nothing to run, so nothing to fixture
580
580
  } else if (!test?.testData) {
581
- report("error", F.node, `has no testData. Every node needs sample data so it can be loaded and run (DECLARATIVE_NODES.md §6)`);
581
+ report("error", F.node, `has no testData. Every node needs sample data so it can be loaded and run (https://docs.unoverse.ai/nodes/testing-nodes)`);
582
582
  } else {
583
583
  if (config?.configSchema) {
584
584
  /**
@@ -619,7 +619,7 @@ export function lintNode(dir, pkg) {
619
619
  report("error", F.test, `this node has only service methods, so testData needs a "call" block naming one. Otherwise nothing can run it`);
620
620
  }
621
621
 
622
- // WHO MAY RUN THIS NODE (DECLARATIVE_NODES.md §9.13).
622
+ // WHO MAY RUN THIS NODE (https://docs.unoverse.ai/nodes/who-can-run-it).
623
623
  //
624
624
  // COMPULSORY, and that is the entire point of the block. Before 2026-07-28 authorization was
625
625
  // an OPTIONAL `requires: { role }`, so a node that said nothing was indistinguishable from a
@@ -627,17 +627,17 @@ export function lintNode(dir, pkg) {
627
627
  // answer to "who may run this", so each node now states its stance out loud and a reviewer
628
628
  // reads a decision rather than an absence.
629
629
  if (node.requires !== undefined)
630
- report("error", F.node, `"requires" is retired on a node. Authorization is now "auth: { required, role }" — and unlike requires it is compulsory, so a node cannot stay silent about who may run it (DECLARATIVE_NODES.md §9.13)`);
630
+ report("error", F.node, `"requires" is retired on a node. Authorization is now "auth: { required, role }" — and unlike requires it is compulsory, so a node cannot stay silent about who may run it (https://docs.unoverse.ai/nodes/who-can-run-it)`);
631
631
 
632
632
  const auth = node.auth;
633
633
  if (auth === undefined) {
634
- report("error", F.node, `node.yaml has no "auth" block. Every node states who may run it: "auth: { required: false }" adds no requirement of its own (the trigger still owns the door), "required: true" demands a signed-in caller, and "role: noun:verb" demands a specific claim (DECLARATIVE_NODES.md §9.13)`);
634
+ report("error", F.node, `node.yaml has no "auth" block. Every node states who may run it: "auth: { required: false }" adds no requirement of its own (the trigger still owns the door), "required: true" demands a signed-in caller, and "role: noun:verb" demands a specific claim (https://docs.unoverse.ai/nodes/who-can-run-it)`);
635
635
  } else if (typeof auth.required !== "boolean") {
636
- report("error", F.node, `auth.required must be true or false, written out. Leaving it off would put the node back to being silent about authorization, which is what this block exists to stop (DECLARATIVE_NODES.md §9.13)`);
636
+ report("error", F.node, `auth.required must be true or false, written out. Leaving it off would put the node back to being silent about authorization, which is what this block exists to stop (https://docs.unoverse.ai/nodes/who-can-run-it)`);
637
637
  } else if (auth.role !== undefined && auth.required === false) {
638
638
  // A role lives on a token, so demanding one while waiving the token is unsatisfiable. It
639
639
  // would read as "protected" on the acceptance screen and admit everyone at run time.
640
- report("error", F.node, `auth declares role "${auth.role}" with required: false. A role lives on a token, so this can never be satisfied — a node asking for a role is asking for a signed-in caller (DECLARATIVE_NODES.md §9.13)`);
640
+ report("error", F.node, `auth declares role "${auth.role}" with required: false. A role lives on a token, so this can never be satisfied — a node asking for a role is asking for a signed-in caller (https://docs.unoverse.ai/nodes/who-can-run-it)`);
641
641
  }
642
642
 
643
643
  // whenToUse decides whether the node SURFACES to the building agent at all.
@@ -646,15 +646,15 @@ export function lintNode(dir, pkg) {
646
646
  // Absorbed from scripts/check-node-meta.mjs (2026-07-29, previously an orphan
647
647
  // nothing invoked): a node without whenToUse is invisible to catalog ranking
648
648
  // and marketplace search — a silent discovery failure, so it fails the lint.
649
- report("error", F.node, `whenToUse is missing. The ranker embeds it for catalog/semantic search; without it this node cannot be discovered by capability (14-node-discoverability.md)`);
649
+ report("error", F.node, `whenToUse is missing. The ranker embeds it for catalog/semantic search; without it this node cannot be discovered by capability (https://docs.unoverse.ai/nodes/node-discoverability)`);
650
650
  }
651
651
  if (w) {
652
652
  if (/^use this (node|when)/i.test(w))
653
- report("warn", F.node, `whenToUse opens by restating itself. Lead with the OUTCOME in task vocabulary (14-node-discoverability.md)`);
653
+ report("warn", F.node, `whenToUse opens by restating itself. Lead with the OUTCOME in task vocabulary (https://docs.unoverse.ai/nodes/node-discoverability)`);
654
654
  if (/^(hybrid|attach|this is an?|a powerful)/i.test(w))
655
- report("warn", F.node, `whenToUse opens with plumbing or marketing. Outcome first, mechanism last; it sinks the ranking (14-node-discoverability.md)`);
655
+ report("warn", F.node, `whenToUse opens with plumbing or marketing. Outcome first, mechanism last; it sinks the ranking (https://docs.unoverse.ai/nodes/node-discoverability)`);
656
656
  if (node.description && w.toLowerCase() === node.description.toLowerCase())
657
- report("error", F.node, `whenToUse merely repeats description. It carries zero selection signal (14-node-discoverability.md)`);
657
+ report("error", F.node, `whenToUse merely repeats description. It carries zero selection signal (https://docs.unoverse.ai/nodes/node-discoverability)`);
658
658
  }
659
659
 
660
660
  // ALLOWED_HOSTS. A manifest cannot execute, but it can say "send this credential to
@@ -733,7 +733,7 @@ export function lintNode(dir, pkg) {
733
733
  // Renamed with the call key it mirrors, so the package's promise and the call's spelling
734
734
  // stay the same word. Left as `auth` it would list schemes for a key no call has.
735
735
  if (pkg.requires?.auth !== undefined)
736
- report("error", rel(pkg.file), `package.yaml uses requires.auth, which is retired. Rename it to requires.credential to match the call key (DECLARATIVE_NODES.md §9.13)`);
736
+ report("error", rel(pkg.file), `package.yaml uses requires.auth, which is retired. Rename it to requires.credential to match the call key (https://docs.unoverse.ai/nodes/who-can-run-it)`);
737
737
 
738
738
  if (pkg.requires && api)
739
739
  for (const { at, call } of calls) {
@@ -46,7 +46,7 @@ export function lintPackage(dir) {
46
46
  report(
47
47
  "error",
48
48
  rel(join(credDir, f)),
49
- `credential "${doc.name}" is also declared in ${prior.file} with different fields (${prior.fields} vs ${fields}). They share one entry in context.credentials and must agree (04-credentials.md)`,
49
+ `credential "${doc.name}" is also declared in ${prior.file} with different fields (${prior.fields} vs ${fields}). They share one entry in context.credentials and must agree (https://docs.unoverse.ai/nodes/credentials)`,
50
50
  );
51
51
  else if (!prior) allCredentials.set(doc.name, { file: rel(join(credDir, f)), fields });
52
52
  }
@@ -67,7 +67,7 @@ export function lintPackage(dir) {
67
67
  report(
68
68
  "error",
69
69
  rel(entry),
70
- `still calls api.registerCredential() while this package also declares credentials/ manifests. Registration is first-wins and silent, so one of them is dropped at random. Move the declaration, do not copy it (04-credentials.md)`,
70
+ `still calls api.registerCredential() while this package also declares credentials/ manifests. Registration is first-wins and silent, so one of them is dropped at random. Move the declaration, do not copy it (https://docs.unoverse.ai/nodes/credentials)`,
71
71
  );
72
72
  }
73
73
 
@@ -40,6 +40,9 @@ export function contextFor(node: ComposedNode, inputs: any, config: any, executi
40
40
  credentials: executionContext?.credentials ?? {},
41
41
  signal: inputs ?? {},
42
42
  services: executionContext?.services ?? {},
43
+ // The caller's disconnect. A tool loop stops between turns when it fires, so a run
44
+ // the engine reaped cannot keep spending model calls (observed live 2026-09-05).
45
+ abort: executionContext?.abortSignal,
43
46
  // Picked field by field, never spread: a future field on auth.user must be an explicit
44
47
  // decision to expose, not something a manifest silently inherits.
45
48
  user: { email: user.email, id: user.id ?? user.sub, name: user.name },
@@ -11,7 +11,7 @@ import type { RunContext } from "../context.js";
11
11
  import { evaluate } from "../templating.js";
12
12
  import { runCalls } from "../http/request.js";
13
13
  import type { StateStore } from "../state.js";
14
- import { rowComponentsFromResults, renderRowComponents } from "../../../agent-mcp/rowComponents.js";
14
+ import { rowComponentsFromResults, renderRowComponents, screenLine } from "../../../agent-mcp/rowComponents.js";
15
15
  import { withHelpers } from "../helpers.js";
16
16
 
17
17
  /**
@@ -103,13 +103,26 @@ async function serviceMethod(
103
103
  if (!session?.userId) {
104
104
  console.log(`[manifests] ${node.type}: ${found.length} row component(s) not rendered — no live session on this run`);
105
105
  }
106
- renderRowComponents(found, sessionCtx, log);
106
+ const outcomes = renderRowComponents(found, sessionCtx, log);
107
+ /**
108
+ * THE SEARCH MIRRORS TOO (MCP_APP_PROTOCOL §0.1, ruled 2026-09-05). When the delivery
109
+ * holds a component that takes input (a shell), the model must answer the SCREEN,
110
+ * not the rows: it wait for the delivery's verdict — instant here, the ladder at the
111
+ * door leaves one candidate and the rules brain answers at once — and the result
112
+ * carries a `screen` line saying what is on screen and what was not shown.
113
+ * Card-only deliveries stay fire-and-forget, as fast as ever.
114
+ */
115
+ if (found.some((i) => i.shell)) {
116
+ const screen = screenLine(await outcomes);
117
+ const result = await evaluate(spec.returns, { response: last, calls: results, params, config: ctx.config, user: ctx.user });
118
+ return screen && result && typeof result === "object" && !Array.isArray(result) ? { ...(result as Record<string, unknown>), screen } : result;
119
+ }
107
120
  } else {
108
121
  const n = Array.isArray(rows) ? rows.length : 0;
109
122
  console.log(`[manifests] ${node.type}: no row components in ${n} row(s)`);
110
123
  // The empty delivery is announced (§12e mechanical spent rule): this search
111
124
  // ran and surfaced nothing, and the standing set's fate hangs on that fact.
112
- renderRowComponents(found, sessionCtx, log);
125
+ void renderRowComponents(found, sessionCtx, log);
113
126
  }
114
127
  }
115
128
 
@@ -29,6 +29,12 @@ export interface RunContext {
29
29
  * phrase for two different things.
30
30
  */
31
31
  calls: Record<string, any>;
32
+ /**
33
+ * The CALLER'S DISCONNECT, when the node runs over a stream. Set from the request's
34
+ * own signal; a turn loop checks it between turns and stops. Absent for a node that
35
+ * runs in-process, which cannot be hung up on.
36
+ */
37
+ abort?: AbortSignal;
32
38
  /**
33
39
  * The SIGNED-IN person, and nothing that authenticates as them.
34
40
  *
@@ -223,6 +223,14 @@ export async function runToolLoop(
223
223
  const usage = makeUsageCollector(node, ctx);
224
224
 
225
225
  for (turn = 1; turn <= maxTurns; turn++) {
226
+ // THE CALLER HUNG UP. The engine reaps a run by aborting the node's request; a loop
227
+ // that did not look was observed spending a dozen model turns against a run that no
228
+ // longer existed (2026-09-05). Checked between turns: a turn in flight completes,
229
+ // the next never starts.
230
+ if (ctx.abort?.aborted) {
231
+ console.warn(`[manifests] ${node.type}: run aborted by the caller after ${turn - 1} turn(s) — stopping`);
232
+ break;
233
+ }
226
234
  const scoped: RunContext = {
227
235
  ...ctx,
228
236
  services: { ...ctx.services, [connector]: { tools: live } },
@@ -134,6 +134,17 @@ export function diskSource(home: string): ManifestSource {
134
134
  return {
135
135
  name: "disk",
136
136
  async listPackages(): Promise<RawPackage[]> {
137
+ return readDiskPackages(home);
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * The same read, synchronous. Disk settles at once; the source above is async only
144
+ * because the row source beside it must be. The publish collector (items/collect.ts) is
145
+ * synchronous end to end, so it reads here rather than growing a promise it cannot await.
146
+ */
147
+ export function readDiskPackages(home: string): RawPackage[] {
137
148
  if (!fs.existsSync(home)) return [];
138
149
  const packages: RawPackage[] = [];
139
150
 
@@ -182,6 +193,4 @@ export function diskSource(home: string): ManifestSource {
182
193
  if (pkg.nodes.length || Object.keys(pkg.credentials).length) packages.push(pkg);
183
194
  }
184
195
  return packages;
185
- },
186
- };
187
196
  }
@@ -44,6 +44,9 @@ export function buildExecutionContext(nodeType: string, inputs: any, config: any
44
44
  },
45
45
  publishingContext,
46
46
  activeLoop: context.activeLoop,
47
+ // The caller's disconnect, when the node runs over a stream (routeExecuteStream). A
48
+ // turn loop checks it between turns so a reaped run stops spending model calls.
49
+ abortSignal: context.abortSignal,
47
50
  logger: console,
48
51
  auth: context.auth,
49
52
  };
@@ -14,6 +14,9 @@
14
14
  * - data.config.enableAgentMemory → getGoalContext, writeNote,
15
15
  * updateGoalState, searchHistory, resumeGoal, archiveGoal (goal-scoped working memory)
16
16
  *
17
+ * recordAttempt is PLATFORM-ONLY (PLATFORM_MEMORY_METHODS): the judge writes the ledger
18
+ * through executePlatformMemoryMethod; it is never merged into an agent's schema.
19
+ *
17
20
  * Per-agent (not canvas-wide) so memory never leaks into worker agents UNO
18
21
  * builds inside the workflows it creates — those default off. Still a TOGGLE,
19
22
  * not a connector: a bare agent with the toggle on still gets its schema with
@@ -57,7 +60,7 @@ export const USER_MEMORY_METHODS: Record<string, any> = {
57
60
  export const AGENT_MEMORY_METHODS: Record<string, any> = {
58
61
  getGoalContext: {
59
62
  description:
60
- "Your wake-up briefing: the active goal (description, acceptance criteria), your last saved goal-state (plan, current step, blockers), recent journal notes, durable environment facts, and recent past-goal summaries. Call FIRST when starting or resuming work — it replaces re-reading history. Read-only, cheap.",
63
+ "Your wake-up briefing: the active goal (description, acceptance criteria), the LEDGER (attempts so far, whether the goal is met, the open criteria the judge failed last time with its reason, any standing directive), your last saved goal-state (plan, current step), recent journal notes, durable environment facts, and recent past-goal summaries. Call FIRST when starting or resuming work — it replaces re-reading history. Read-only, cheap.",
61
64
  input: { type: "object", additionalProperties: false, properties: {} },
62
65
  },
63
66
  writeNote: {
@@ -79,7 +82,7 @@ export const AGENT_MEMORY_METHODS: Record<string, any> = {
79
82
  },
80
83
  updateGoalState: {
81
84
  description:
82
- "Overwrite your compact goal-state object — the FIRST thing future-you reads after a reset. Keep it current after each meaningful step: the goal description, the plan (short steps with status), the current step, and blockers. Supplied fields merge over the existing state; it is also snapshotted to durable storage. (The acceptance bar is NOT set here — lockCriteria authors and locks it independently, so you cannot grade your own homework.)",
85
+ "Overwrite your compact goal-state object — the FIRST thing future-you reads after a reset. Keep it current after each meaningful step: the goal description, the plan (short steps with status), and the current step. Supplied fields merge over the existing state; it is also snapshotted to durable storage. (The acceptance bar is NOT set here, and neither are blockers: the bar is locked independently and what is still open is derived from the judge's last verdict, so you cannot grade your own homework.)",
83
86
  input: {
84
87
  type: "object",
85
88
  additionalProperties: false,
@@ -87,7 +90,6 @@ export const AGENT_MEMORY_METHODS: Record<string, any> = {
87
90
  description: { type: "string", description: "The goal in one sentence (sets/updates the goal description)" },
88
91
  plan: { type: "array", items: { type: "string" }, description: "Short step list, each prefixed with its status, e.g. '[done] fetch schema'" },
89
92
  currentStep: { type: "string", description: "What is being worked on right now" },
90
- blockers: { type: "array", items: { type: "string" }, description: "Open blockers; empty array clears them" },
91
93
  },
92
94
  },
93
95
  },
@@ -134,14 +136,18 @@ export const USER_MEMORY_INSTRUCTIONS =
134
136
  "User memory is available. A background summary of who this user is (identity + current state) was injected into your context at start — do not re-fetch it. queryMemory(topic) searches deeper when a specific topic needs prior context.";
135
137
 
136
138
  export const AGENT_MEMORY_INSTRUCTIONS =
137
- "You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext before doing work — it shows the active goal AND your recent goals with their progress (done/total). If an earlier goal is unfinished or was closed too soon, resumeGoal reopens it so you continue from where you left off instead of starting over. While working: record decisions, failures, environment facts, and progress with writeNote, and keep updateGoalState accurate after each meaningful step (mark steps '[done] …' as you finish them — don't leave the plan frozen on step 1). On completion: REVIEW first — verify the output meets the locked acceptance criteria, mark the remaining plan steps done and set a closing currentStep via updateGoalState, then archiveGoal with a specific summary. The journal is for future-you — write what re-hydration needs, nothing else.";
139
+ "You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext before doing work — it shows the active goal, its ledger (attempts, whether it is met, the criteria still open from the judge's last verdict), AND your recent goals with their progress (done/total). If an earlier goal is unfinished or was closed too soon, resumeGoal reopens it so you continue from where you left off instead of starting over. While working: record decisions, failures, environment facts, and progress with writeNote, and keep updateGoalState accurate after each meaningful step (mark steps '[done] …' as you finish them — don't leave the plan frozen on step 1). On completion: REVIEW first — verify the output meets the locked acceptance criteria, mark the remaining plan steps done and set a closing currentStep via updateGoalState, then archiveGoal with a specific summary. The journal is for future-you — write what re-hydration needs, nothing else.";
138
140
 
139
141
  // A harness BUILDER runs under an external gate (Define→build→Verdict→Deliver). It must NOT
140
142
  // close or complete the goal itself — the harness's Deliver node owns archiving, and only when
141
143
  // an independent Verdict passed. So the builder gets the read/journal memory tools WITHOUT the
142
144
  // goal-lifecycle tools (archiveGoal/resumeGoal), and an instruction that forbids self-completion.
143
145
  export const AGENT_MEMORY_INSTRUCTIONS_BUILDER =
144
- "You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext BEFORE doing work — it shows the active goal AND any revision feedback (blockers) from a prior attempt. If blockers are present, your last build was REJECTED by the independent harness: address those exact gaps before rebuilding, and do not re-submit the same build. While working: record decisions, failures, environment facts, and progress with writeNote, and keep updateGoalState accurate (plan, currentStep, blockers). You do NOT complete, archive, or close the goal — an external harness independently judges the result and owns completion. Build the best workflow, produce its real output, then stop and report; never declare the goal done yourself.";
146
+ "You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext BEFORE doing work — it shows the active goal AND its ledger: the criteria the independent judge failed on the last attempt (each with its reason and how many attempts in a row it has failed), plus any standing directive. If open criteria are present, your last build was REJECTED: address those exact gaps before rebuilding, and do not re-submit the same build. While working: record decisions, failures, environment facts, and progress with writeNote, and keep updateGoalState accurate (plan, currentStep). You do NOT complete, archive, or close the goal — an external harness independently judges the result and owns completion. Build the best workflow, produce its real output, then stop and report; never declare the goal done yourself.";
147
+
148
+ // PLATFORM-ONLY methods: executed by the harness through executePlatformMemoryMethod, never
149
+ // merged into an agent's schema. The judge records verdicts; an agent cannot.
150
+ export const PLATFORM_MEMORY_METHODS = new Set(["recordAttempt"]);
145
151
 
146
152
  // UnoverseMCP authoring methods. An agent whose toolset contains any of these IS a harness
147
153
  // builder — used to strip its goal-completion authority (see mergePlatformMemorySchema).
@@ -276,19 +282,16 @@ function resolveIdentity(params: any, context: any): MemoryCallIdentity | { erro
276
282
  if (!userId) return { error: "No user identity in execution context — cannot access memory" };
277
283
  if (!workflowId) return { error: "No workflow id in execution context — cannot access memory" };
278
284
 
279
- // Shared per-workflow agent memory: all agents on a canvas share ONE goal drawer.
280
- // The pointer is agent_goal:{user}:{workflowId}:shared — the workflowId is unique
281
- // per canvas, so "shared" gives exactly one goal per workflow. The Agent Memory
282
- // toggle is the access switch: any agent with memory enabled reads/writes the SAME
283
- // goal, so a harness Define node and the worker agent share the goal automatically.
284
- // A caller may still pass an explicit `agentId` to target a specific drawer instead
285
- // (e.g. isolated builder scratch for a future tournament).
286
- const agentId = params?.agentId || "shared";
287
- const agentNode =
288
- agentId === "shared"
289
- ? null
290
- : (context?.workflow?.nodes || context?.cachedWorkflow?.nodes || []).find((n: any) => n?.id === agentId);
291
- const agentName = agentNode?.data?.label || (agentId === "shared" ? "Canvas" : undefined);
285
+ // TWO DRAWERS (AGENT_MEMORY.md ruling 2026-09-05, part 2). The GOAL is a property of the
286
+ // canvas (HARNESS.md §12 ruling 1) — one active goal per workflowId, shared brief for
287
+ // everyone. SCRATCH is per agent: the calling node on a canvas, or "caller" for an external
288
+ // builder over the builder MCP (no node). The platform resolves the id from the execution
289
+ // context; agents never pass identity. A caller may still pass an explicit `agentId` to
290
+ // address a specific scratch (isolated builder scratch for a future tournament).
291
+ const nodes: any[] = context?.workflow?.nodes || context?.cachedWorkflow?.workflow?.nodes || context?.cachedWorkflow?.nodes || [];
292
+ const agentId: string = params?.agentId || context?.callingNodeId || context?.nodeId || "caller";
293
+ const agentNode = nodes.find((n: any) => n?.id === agentId);
294
+ const agentName: string | undefined = agentNode?.data?.label || (agentId === "caller" ? "External builder" : undefined);
292
295
 
293
296
  return {
294
297
  userId,
@@ -395,14 +398,22 @@ export async function executePlatformMemoryMethod(method: string, params: any, c
395
398
  );
396
399
  }
397
400
  case "updateGoalState": {
398
- // acceptanceCriteria is a first-class column on the goal (the checkAcceptance
399
- // judge reads it from goal.acceptanceCriteria), NOT part of the free-form state
400
- // blob — pull it out so it routes to its own field, or it never reaches the checker.
401
- const { description, acceptanceCriteria, ...stateFields } = params || {};
401
+ // description, acceptanceCriteria and directive are first-class columns on the goal
402
+ // (the judge reads criteria from goal.acceptanceCriteria; the directive is the one
403
+ // platform instruction slot), NOT part of the free-form state blob — pull them out so
404
+ // they route to their own fields. `directive` is only sent when the caller passed it,
405
+ // because null means CLEAR.
406
+ const { description, acceptanceCriteria, directive, ...stateFields } = params || {};
402
407
  const state = Object.keys(stateFields).length > 0 ? stateFields : undefined;
408
+ const body: Record<string, any> = { userId, workflowId, agentId, agentName, description, acceptanceCriteria, state };
409
+ if (params && Object.prototype.hasOwnProperty.call(params, "directive")) body.directive = directive ?? null;
410
+ return await memoryServerPost("/agent/state", body, accessToken);
411
+ }
412
+ case "recordAttempt": {
413
+ // The judge's verdict → one ledger row. Platform-only (PLATFORM_MEMORY_METHODS).
403
414
  return await memoryServerPost(
404
- "/agent/state",
405
- { userId, workflowId, agentId, agentName, description, acceptanceCriteria, state },
415
+ "/agent/attempt",
416
+ { userId, workflowId, agentId, results: params?.results, deliverables: params?.deliverables || [] },
406
417
  accessToken,
407
418
  );
408
419
  }