@unoverse-platform/studio 0.1.96 → 0.1.98

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 (118) hide show
  1. package/bin/studio.mjs +3 -3
  2. package/local/authoring.ts +1 -1
  3. package/local/catalog.ts +22 -2
  4. package/local/keys.ts +1 -1
  5. package/local/nodes.ts +29 -42
  6. package/local/plugin.ts +75 -13
  7. package/local/sample-identity/brand.yaml +24 -0
  8. package/local/sample-identity/organisation.yaml +14 -0
  9. package/local/sample-identity/purpose.yaml +14 -0
  10. package/local/sample-identity/story.yaml +18 -0
  11. package/local/scaffold.mjs +51 -22
  12. package/local/source.ts +4 -9
  13. package/package.json +1 -1
  14. package/screens/AgentsView.tsx +173 -0
  15. package/screens/ComponentsView.tsx +2 -2
  16. package/screens/EmptyLibrary.tsx +1 -1
  17. package/screens/IdentityHeader.tsx +1 -1
  18. package/screens/IdentityView.tsx +206 -0
  19. package/screens/PromptBlocksView.tsx +1 -1
  20. package/screens/SourceView.tsx +1 -1
  21. package/screens/TemplatesLibraryView.tsx +91 -6
  22. package/screens/TemplatesView.tsx +24 -9
  23. package/screens/index.ts +5 -2
  24. package/src/App.tsx +14 -4
  25. package/src/NodeKeys.tsx +1 -1
  26. package/src/host.ts +3 -16
  27. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  28. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  29. package/vendor/base/src/agent-mcp/index.ts +5 -5
  30. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  31. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  32. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  33. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  34. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  35. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  36. package/vendor/base/src/agent-mcp/types.ts +47 -10
  37. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  38. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  39. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  40. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  41. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  42. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  43. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  44. package/vendor/base/src/definitions/load/load.ts +37 -28
  45. package/vendor/base/src/definitions/model.ts +8 -12
  46. package/vendor/base/src/definitions/registry/agents.ts +111 -0
  47. package/vendor/base/src/definitions/registry/catalog.ts +28 -12
  48. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  49. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  50. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  51. package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
  52. package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
  53. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  54. package/vendor/base/src/definitions/theme.ts +1 -2
  55. package/vendor/base/src/items/collect.ts +33 -25
  56. package/vendor/base/src/items/publish.ts +31 -7
  57. package/vendor/base/src/lint/context.mjs +17 -0
  58. package/vendor/base/src/lint/design/file.mjs +199 -2
  59. package/vendor/base/src/lint/design/index.mjs +66 -73
  60. package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
  61. package/vendor/base/src/lint/design/walk.mjs +14 -4
  62. package/vendor/base/src/lint/index.mjs +15 -9
  63. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  64. package/vendor/base/src/lint/nodes/index.mjs +6 -1
  65. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  66. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  67. package/vendor/base/src/lint/objects/index.mjs +324 -0
  68. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  69. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  70. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  71. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  72. package/vendor/base/src/manifests/compose.ts +81 -10
  73. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  74. package/vendor/base/src/manifests/executor/context.ts +12 -1
  75. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  76. package/vendor/base/src/manifests/executor/session.ts +50 -0
  77. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  78. package/vendor/base/src/manifests/index.ts +1 -1
  79. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  80. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  81. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  82. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  83. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  84. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  85. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  86. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  87. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  88. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  89. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  90. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  91. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  92. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  93. package/vendor/base/src/manifests/source.ts +1 -1
  94. package/vendor/base/src/paths.ts +16 -8
  95. package/vendor/base/src/platform/audioManager.ts +32 -0
  96. package/vendor/base/src/platform/executionContext.ts +14 -0
  97. package/vendor/base/src/platform/liveInput.ts +52 -0
  98. package/vendor/base/src/plugins/README.md +1 -1
  99. package/vendor/base/src/plugins/discovery.ts +1 -1
  100. package/vendor/base/src/template/SafeExpression.ts +6 -0
  101. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  102. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  103. package/vendor/sdk/appEngine.tsx +31 -12
  104. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  105. package/vendor/sdk/lib/app.tsx +70 -95
  106. package/vendor/sdk/lib/connection.tsx +47 -57
  107. package/vendor/sdk/lib/core/actions.ts +6 -0
  108. package/vendor/sdk/lib/core/apps.ts +66 -81
  109. package/vendor/sdk/lib/core/client.ts +5 -14
  110. package/vendor/sdk/lib/core/connection.ts +30 -0
  111. package/vendor/sdk/lib/core/index.ts +2 -2
  112. package/vendor/sdk/lib/core/store.ts +90 -15
  113. package/vendor/sdk/lib/core/types.ts +9 -8
  114. package/vendor/sdk/lib/primitives.tsx +113 -4
  115. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  116. package/vendor/sdk/lib/render.tsx +3 -3
  117. package/vendor/sdk/lib/voice.tsx +27 -13
  118. package/vite.config.ts +1 -1
@@ -0,0 +1,197 @@
1
+ /**
2
+ * PIPELINES, READ (docs/unoverse/data/pipelines/README.md layer 1, ruled 2026-09-09).
3
+ *
4
+ * A pipeline is the source side of ingestion: how content is collected and which objects it
5
+ * may yield. It never knows how an object is built. Its folder is the node's folder:
6
+ *
7
+ * pipelines/<name>/pipeline.yaml type: pipeline, the face (title, description, whenToUse,
8
+ * category, version), and three things of its own:
9
+ * `collect`, the nodes that fetch, in order, each naming a
10
+ * marketplace node and filling its config; `make`, the reads,
11
+ * in order, one block per read and one model call: what is
12
+ * read (each over a list, partsOf, or each <object> by: folder),
13
+ * its address and content, and the objects that read makes
14
+ * pipelines/<name>/config.yaml the Import form: a configSchema as a node's
15
+ * pipelines/<name>/interface.yaml the pipeline as a node on a canvas: signal in, rows out
16
+ * pipelines/<name>/test.yaml the collect steps faked, the rows asserted
17
+ *
18
+ * in two tiers, the marketplace's (`design/marketplace/pipelines/`) and an org's own
19
+ * (`design/<org>/pipelines/`). Nodes are the only way a pipeline touches the world: no HTTP,
20
+ * no credentials, no hosts in the file; the nodes it names carry them.
21
+ *
22
+ * This module only reads. Studio authors, the platform reads.
23
+ */
24
+ import { existsSync, readdirSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { readDefCached, defPath } from "../fsCache.js";
27
+ import { composeIncludes } from "../load/expand/includes.js";
28
+ import { pipelineDirs } from "./homes.js";
29
+ import { pipelineFindings, firstRefusal, PIPELINE_READ_KEYS } from "../../lint/objects/rules.mjs";
30
+
31
+ export interface CollectStep {
32
+ /** The step's name; a later step reads its outputs as `{{ collect.<name>.<output> }}`. */
33
+ name: string;
34
+ /** The marketplace node it runs. */
35
+ node: string;
36
+ /** The node's config, with `{{ config.* }}` and `{{ collect.* }}` references. */
37
+ config: Record<string, unknown>;
38
+ }
39
+
40
+ /**
41
+ * ONE OBJECT A READ MAKES (docs/unoverse/data/pipelines/README.md, "A pipeline, in one screen",
42
+ * 2026-09-14). The file says `make` as a list of reads, each naming the objects it makes; the
43
+ * reader hands the engine one entry per object, carrying its read's words: what is read
44
+ * (`each`: always one, `any`: maybe, the model says), the address and content only the source
45
+ * can say, and the object's count. Objects of one read share its list and address, so the
46
+ * engine reads each unit once for all of them.
47
+ */
48
+ export interface MakeEntry {
49
+ object: string;
50
+ /** A list reference/expression over collect, or the name of an object made above. */
51
+ each?: string;
52
+ /** A list reference/expression over collect: maybe one per unit; the model says. */
53
+ any?: string;
54
+ /**
55
+ * A PART (ruled 2026-09-11, docs/unoverse/data/pipelines/README.md layer 1, parts): the object
56
+ * made above these are part of. One row per item of `each`, a list over `detail` (what that
57
+ * object's make function returned for the read), partOf that row, off the map, no model call.
58
+ */
59
+ partsOf?: string;
60
+ /**
61
+ * WHICH READS MAY MAKE IT (docs/unoverse/data/pipelines/a-document-decides.md, 2026-09-20), an
62
+ * expression over the read (`row`). The file says it as `where`, a list of allowed values per
63
+ * field. It is asked of what the DOCUMENT'S OWN READ said, so a document decides which objects
64
+ * it makes: a monthly risk report says the fund it is about, a DDQ says the firm. Absent: every
65
+ * read of this entry makes it.
66
+ */
67
+ when?: string;
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
+ cardinality?: "one" | "many";
70
+ sourceUrl?: string;
71
+ sourceId?: string;
72
+ /** The item's content, each value a reference over `item`, `index`, `config`, `collect`. */
73
+ content: Record<string, unknown>;
74
+ }
75
+
76
+
77
+ export interface PipelineDefinition {
78
+ name: string;
79
+ /** The org that owns it; undefined for a marketplace pipeline. */
80
+ org?: string;
81
+ /** The face, the words a node's node.yaml uses: title, description, whenToUse, category, version, color, logoUrl. */
82
+ manifest: { title?: string; description?: string; whenToUse?: string; category?: string; version?: string; color?: string; logoUrl?: string };
83
+ collect: CollectStep[];
84
+ /** What this pipeline makes, in order. */
85
+ make: MakeEntry[];
86
+ /** The objects this pipeline makes, by name, in order: derived from `make`, for the screens. */
87
+ yields: string[];
88
+ /** The Import form, as a node's configSchema; empty when the folder has no config.yaml. */
89
+ configSchema: Record<string, unknown>;
90
+ /** The pipeline as a node: inputs, outputs, credentials. */
91
+ interface: Record<string, unknown>;
92
+ /** The folder on disk. */
93
+ dir: string;
94
+ }
95
+
96
+ // NO RESULT MAP, as for objects: the file reader's mtime check is the cache, so an edited
97
+ // or published folder is seen on the next read in every process (2026-09-09: the engine
98
+ // kept the old website pipeline until a restart).
99
+
100
+ function folderOf(name: string, org?: string | null): { dir: string; org?: string } | null {
101
+ for (const { org: o, dir } of pipelineDirs(org ?? null)) {
102
+ const folder = join(dir, name);
103
+ if (existsSync(folder) && statSync(folder).isDirectory() && defPath(folder, "pipeline")) return { dir: folder, org: o };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function isRecord(x: unknown): x is Record<string, unknown> {
109
+ return !!x && typeof x === "object" && !Array.isArray(x);
110
+ }
111
+
112
+ /**
113
+ * `where` IS A LIST OF ALLOWED VALUES PER FIELD, read as the check the engine runs over one read.
114
+ * A document decides which objects it makes: the check is asked of what its own read said, never
115
+ * of the folder it was found in (docs/unoverse/data/pipelines/a-document-decides.md).
116
+ */
117
+ function whenOf(spec: Record<string, any>): string | undefined {
118
+ const where = isRecord(spec.where) ? spec.where : {};
119
+ if (!Object.keys(where).length) return undefined;
120
+ const each = Object.entries(where).map(([field, values]) => `${JSON.stringify(Array.isArray(values) ? values : [values])}.includes(row[${JSON.stringify(field)}])`);
121
+ return `return ${each.join(" && ")}`;
122
+ }
123
+
124
+ function readFolder(dir: string, name: string, org: string | undefined): PipelineDefinition {
125
+ // ITS SIBLINGS, INLINED (`$include`, as an object's folder composes its props): a pipeline
126
+ // that knows many kinds of document keeps one file per kind beside it rather than one long
127
+ // block, and a kind is added by adding a file. Resolved against the pipeline's own folder.
128
+ const file = (composeIncludes(readDefCached<Record<string, any>>(defPath(dir, "pipeline")!) ?? {}, dir) ?? {}) as Record<string, any>;
129
+ // THE RULES ARE THE LINTER'S (lint/objects/rules.mjs pipelineFindings): refused here with
130
+ // the same sentence the linter prints, one spelling of every rule (objects README 9a).
131
+ const refused = firstRefusal(pipelineFindings(name, file));
132
+ if (refused) throw new Error(`${refused} (${dir})`);
133
+ const collect: CollectStep[] = (file.collect as any[]).map((s: any) => ({ name: s.name, node: s.node, config: isRecord(s.config) ? s.config : {} }));
134
+ // THE FILE'S SHAPE, READ INTO THE ENTRIES THE ENGINE RUNS (docs/unoverse/data/pipelines/README.md,
135
+ // `make`, 2026-09-15; a-document-decides.md, 2026-09-20). Every entry reads a list: one item per
136
+ // unit, yielding the objects named under it. An object's `parts` are rows taken from its read;
137
+ // its `where` says which reads make it, asked of what the read said. A row's address and identity
138
+ // are the item's own unless the entry says them; what it carries is the item as listed unless the
139
+ // entry says `content`.
140
+ const make: MakeEntry[] = [];
141
+ for (const raw of file.make as Record<string, any>[]) {
142
+ const objects = Object.keys(raw).filter((k) => !PIPELINE_READ_KEYS.has(k));
143
+ const list = String(raw.each ?? raw.any ?? "").trim();
144
+ for (const object of objects) {
145
+ const spec = isRecord(raw[object]) ? raw[object] : {};
146
+ make.push({
147
+ object,
148
+ ...(spec.any === true ? { any: list } : { each: list }),
149
+ ...(spec.cardinality === "one" || spec.cardinality === "many" ? { cardinality: spec.cardinality as "one" | "many" } : {}),
150
+ ...(whenOf(spec) ? { when: whenOf(spec) } : {}),
151
+ sourceUrl: typeof raw.sourceUrl === "string" ? raw.sourceUrl : "{{ item.address }}",
152
+ sourceId: typeof raw.sourceId === "string" ? raw.sourceId : "{{ item.universalId }}",
153
+ content: isRecord(raw.content) ? raw.content : {},
154
+ });
155
+ for (const [part, from] of Object.entries(isRecord(spec.parts) ? spec.parts : {})) make.push({ object: part, partsOf: object, each: String(from).trim(), content: {} });
156
+ }
157
+ }
158
+ const configPath = defPath(dir, "config");
159
+ const config = configPath ? (readDefCached<Record<string, any>>(configPath) ?? {}) : {};
160
+ const interfacePath = defPath(dir, "interface");
161
+ const iface = interfacePath ? (readDefCached<Record<string, any>>(interfacePath) ?? {}) : {};
162
+ return {
163
+ name,
164
+ ...(org ? { org } : {}),
165
+ manifest: { title: file.title, description: file.description, whenToUse: file.whenToUse, category: file.category, version: file.version, color: file.color, logoUrl: file.logoUrl },
166
+ collect,
167
+ make,
168
+ yields: make.map((m) => m.object),
169
+ configSchema: isRecord(config.configSchema) ? { ...config.configSchema, ...(config["ui:order"] ? { "ui:order": config["ui:order"] } : {}) } : {},
170
+ interface: iface,
171
+ dir,
172
+ };
173
+ }
174
+
175
+ /** One pipeline by name, the org's tier first then the marketplace. Null when no tier holds it. */
176
+ export function readPipeline(name: string, org?: string | null): PipelineDefinition | null {
177
+ const hit = folderOf(name, org);
178
+ return hit ? readFolder(hit.dir, name, hit.org) : null;
179
+ }
180
+
181
+ /** Every pipeline an org can see: the marketplace's and its own. Bare (no org) lists the marketplace's plus every org's. */
182
+ export function listPipelines(org?: string | null): PipelineDefinition[] {
183
+ const out: PipelineDefinition[] = [];
184
+ const seen = new Set<string>();
185
+ for (const { org: o, dir } of pipelineDirs(org ?? null)) {
186
+ if (!existsSync(dir)) continue;
187
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
188
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
189
+ const key = `${o ?? ""}/${e.name}`;
190
+ if (seen.has(key)) continue;
191
+ seen.add(key);
192
+ const def = readPipeline(e.name, o ?? null);
193
+ if (def) out.push(def);
194
+ }
195
+ }
196
+ return out;
197
+ }
@@ -36,7 +36,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
36
36
  // VALUES are each org's own — that's how two clients get different fonts/colors/spacing
37
37
  // from the same components, without re-copying the foundation.
38
38
  import { listOrgs, projectDir } from "./definitions.js";
39
- import { NODES_HOME, PLUGINS_DIR, DESIGN_HOME, INSTALLED_HOME, databaseOnly, designDir } from "../paths.js";
39
+ import { PLUGINS_DIR, DESIGN_HOME, INSTALLED_HOME, databaseOnly, designDir } from "../paths.js";
40
40
 
41
41
  const DESIGN = DESIGN_HOME;
42
42
  export const DEFAULT_ORG = "default";
@@ -58,7 +58,6 @@ function orgStyles(org: string): string | null {
58
58
  * the package (no `rx/marketplace` on disk) once purged from the image (Phase 4).
59
59
  */
60
60
  const DS_STYLES_CANDIDATES = [
61
- join(NODES_HOME, "marketplace", "definitions", "styles"),
62
61
  join(PLUGINS_DIR, "node_modules", "@unoverse-platform", "marketplace", "definitions", "styles"),
63
62
  ];
64
63
  /**
@@ -48,12 +48,8 @@ const isDef = (f: string) => f.endsWith(".yaml") || f.endsWith(".json");
48
48
  function designSystemDir(designRoot: string): string {
49
49
  const onDisk = join(designRoot, "marketplace");
50
50
  if (existsSync(onDisk)) return onDisk;
51
- const nodesHome = join(designRoot, "..", "nodes");
52
- for (const c of [
53
- join(nodesHome, "marketplace", "definitions"),
54
- join(designRoot, "..", "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions"),
55
- ])
56
- if (existsSync(c)) return c;
51
+ const bundled = join(designRoot, "..", "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions");
52
+ if (existsSync(bundled)) return bundled;
57
53
  return onDisk; // nothing found: the version will hash to an empty set, which is honest
58
54
  }
59
55
 
@@ -108,7 +104,12 @@ export function collectProject(designRoot: string, project: string): CollectedIt
108
104
  // A prompt block is NEVER qualified this way — see the blocks/ walk below for why.
109
105
  // Apps keep their bare ids: those are org-qualified by convention
110
106
  // (`<org>-chat`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
111
- const QUALIFIED_KINDS = new Set(["component", "skill", "template"]);
107
+ // `identity` (2026-09-05): the org's four documents, `<org>/brand` like a component.
108
+ // `agent` (2026-09-06): a workflow listed for the map, design/<org>/agents/<name>.yaml, `<org>/<name>`.
109
+ // `object` and `pipeline` (2026-09-09): an org's own kinds beside the marketplace masters
110
+ // (docs/unoverse/data/objects/README.md 4e, docs/unoverse/data/pipelines/README.md layer 1), `<org>/<name>` like a
111
+ // component, since two orgs may each write a `deal`.
112
+ const QUALIFIED_KINDS = new Set(["component", "skill", "template", "identity", "agent", "object", "pipeline"]);
112
113
  const add = (kind: string, name: string, definition: CollectedItem["definition"]) =>
113
114
  items.push({
114
115
  kind,
@@ -134,7 +135,16 @@ export function collectProject(designRoot: string, project: string): CollectedIt
134
135
  // 029): apps publish as kind `app` and the Template Model kind owns the bare
135
136
  // word. Forgetting a design dir here ships deploys that silently drop every
136
137
  // artifact of that kind.
137
- for (const [dir, kind] of [["components", "component"], ["apps", "app"], ["templates", "template"], ["atoms", "atom"]] as const) {
138
+ // IDENTITY (owner ruling 2026-09-05): design/<org>/identity/<doc>/ publishes as kind
139
+ // `identity`, the same walk as a component folder, so it reaches the registry, the
140
+ // spatial catalogue and the map through the one publish path everything else takes.
141
+ // AGENT (owner ruling 2026-09-06): design/<org>/agents/<name>.yaml, a single file like an atom,
142
+ // publishes as kind `agent` so it reaches the registry, the catalogue and the map.
143
+ // OBJECT and PIPELINE (owner ruling 2026-09-09): design/<org>/objects/<name>/ and
144
+ // design/<org>/pipelines/<name>/, folders like a component, publish as their own kinds so
145
+ // an org's custom object and its own source reach the engine that positions its rows and
146
+ // the inbox that collects for it. The marketplace's own go through designSystemItems().
147
+ for (const [dir, kind] of [["components", "component"], ["apps", "app"], ["templates", "template"], ["atoms", "atom"], ["identity", "identity"], ["agents", "agent"], ["objects", "object"], ["pipelines", "pipeline"]] as const) {
138
148
  const home = join(root, dir);
139
149
  if (!existsSync(home)) continue;
140
150
  for (const e of readdirSync(home, { withFileTypes: true })) {
@@ -198,22 +208,20 @@ export function collectProject(designRoot: string, project: string): CollectedIt
198
208
  walk(blocksHome);
199
209
  }
200
210
 
201
- // nodes/ — THE WORKSPACE'S NODE PACKAGES, one row per node, shaped exactly as the
202
- // marketplace stores a node (core/items/catalogue.ts) so the row source composes them
203
- // on the same path as disk: the raw files, plus the package envelope their $refs and
204
- // credential shapes resolve against. The name is the node's `type`, bare: a saved
205
- // workflow stores that identity and nothing else.
211
+ // nodes/ — THE PROJECT'S NODE PACKAGES, design/<org>/nodes/<package>/, one row per
212
+ // node, shaped exactly as the marketplace stores a node (core/items/catalogue.ts) so
213
+ // the row source composes them on the same path as disk: the raw files, plus the
214
+ // package envelope their $refs and credential shapes resolve against. The name is the
215
+ // node's `type`, bare: a saved workflow stores that identity and nothing else.
206
216
  //
207
- // Until 2026-09-05 deploy never looked here, so `deploy studio` shipped a developer's
208
- // components and skills and silently left every node behind.
209
- //
210
- // NOT FROM THE PLATFORM TREE. The monorepo's nodes ship inside the core image and are
211
- // read from disk there; publishing them as rows under whichever org happened to be
212
- // deployed would double every platform node. A design system on disk is the mark of
213
- // the platform tree (the same test designSystemDir makes), and a developer's workspace
214
- // never has one.
215
- const nodesHome = join(designRoot, "..", "nodes");
216
- if (existsSync(nodesHome) && !existsSync(join(designRoot, "marketplace"))) {
217
+ // Until 2026-09-05 deploy never looked at nodes at all, so `deploy studio` shipped a
218
+ // developer's components and skills and silently left every node behind. Until
219
+ // 2026-09-08 it looked at a `nodes/` folder beside design/ while every org already
220
+ // kept its nodes INSIDE its project, so the same nodes were left behind again. Org
221
+ // nodes are org-scoped like skills; the marketplace tier's own nodes sit under
222
+ // design/marketplace/, which is never collected.
223
+ const nodesHome = join(root, "nodes");
224
+ if (existsSync(nodesHome)) {
217
225
  for (const pkg of collectNodePackages(nodesHome)) {
218
226
  for (const raw of pkg.nodes) {
219
227
  const type = nodeType(raw.files["node.yaml"]);
@@ -230,9 +238,9 @@ export function collectProject(designRoot: string, project: string): CollectedIt
230
238
  return items;
231
239
  }
232
240
 
233
- /** The workspace's node packages, read the way the platform reads its own. */
241
+ /** The project's node packages, read the way the platform reads its own. */
234
242
  function collectNodePackages(nodesHome: string) {
235
- return readDiskPackages(nodesHome).filter((pkg) => pkg.name !== "marketplace");
243
+ return readDiskPackages(nodesHome);
236
244
  }
237
245
 
238
246
  /** `type:` off a node.yaml: the one scalar the row is named by. Unparseable means none. */
@@ -10,7 +10,7 @@
10
10
  * new, changed, unchanged, or refused because the name belongs to someone else. Publishing
11
11
  * blind is how a project half-lands and nobody can tell by looking.
12
12
  *
13
- * See docs/architecture/DECLARATIVE_NODES.md §9.
13
+ * See docs/architecture/authoring/DECLARATIVE_NODES.md §9.
14
14
  */
15
15
  import { existsSync } from "node:fs";
16
16
  import { join } from "node:path";
@@ -49,17 +49,41 @@ export async function lintForPublish(designRoot: string, project: string): Promi
49
49
  const { lintDefinitions } = await import("../lint/design/index.mjs");
50
50
  const result = lintDefinitions(designRoot);
51
51
  /**
52
- * NODES TOO. A workspace keeps its node packages beside design/ (the same `nodes/` the
53
- * Studio Nodes screen reads), and until 2026-09-05 this gate never looked at them: a
54
- * node with an events row out of order or a host missing from allowedHosts deployed as
55
- * cleanly as a correct one. The node linter is the same library the platform's own CI
56
- * runs. A workspace with no nodes/ folder has nothing to lint and is not an error.
52
+ * NODES TOO. A project keeps its node packages at design/<project>/nodes/ (the same
53
+ * folder the Studio Nodes screen reads), and until 2026-09-05 this gate never looked
54
+ * at nodes at all: a node with an events row out of order or a host missing from
55
+ * allowedHosts deployed as cleanly as a correct one. The node linter is the same
56
+ * library the platform's own CI runs. A project with no nodes/ folder has nothing to
57
+ * lint and is not an error.
57
58
  */
58
- const nodesHome = join(designRoot, "..", "nodes");
59
+ const nodesHome = join(designRoot, project, "nodes");
59
60
  if (existsSync(nodesHome)) {
60
61
  const { lintNodes } = await import("../lint/nodes/index.mjs");
61
62
  result.problems.push(...lintNodes(nodesHome).problems);
62
63
  }
64
+ /**
65
+ * OBJECTS AND PIPELINES TOO (objects README 9a, 2026-09-10). They live under
66
+ * design/<tier>/objects and /pipelines, folders neither linter above enumerates, and a
67
+ * home not enumerated is a home where every rule is off, silently. The objects linter
68
+ * walks every tier of the design root, like the design linter; the project filter below
69
+ * keeps this project's findings.
70
+ */
71
+ {
72
+ const { lintObjects } = await import("../lint/objects/index.mjs");
73
+ result.problems.push(...lintObjects(designRoot).problems);
74
+ }
75
+ /**
76
+ * SKILLS AND PROMPT BLOCKS TOO (2026-09-18). They live under design/<tier>/skills and
77
+ * /blocks, and nothing read them before publish: a SKILL.md whose frontmatter did not
78
+ * parse (a bare colon in the description) was published, and the run-time loaders then
79
+ * dropped it with one console line, so it was absent from every list and nobody was
80
+ * told. The prompts linter walks every tier, like the objects linter; the project filter
81
+ * below keeps this project's findings.
82
+ */
83
+ {
84
+ const { lintPrompts } = await import("../lint/prompts/index.mjs");
85
+ result.problems.push(...lintPrompts(designRoot).problems);
86
+ }
63
87
  /**
64
88
  * SCOPED TO THE PROJECT BEING DEPLOYED, on paths that may be relative. The old
65
89
  * check looked for "/design/" with a leading slash, which a relative path never
@@ -20,6 +20,21 @@ export const derivedKinds = new Map();
20
20
  /** Credential name -> where it was declared, for cross-package collision rules. */
21
21
  export const allCredentials = new Map();
22
22
 
23
+ /**
24
+ * ATTACHMENTS, and the config fields every other node declares.
25
+ *
26
+ * An attachment clips onto a node whose configSchema has every field it sets, so "can this
27
+ * attachment ever clip onto anything?" cannot be answered while reading the attachment: its
28
+ * host may be in a package that has not been read yet, or in no package at all. Both halves
29
+ * are collected here and checked once at the end of the run (node.mjs lintAttachments).
30
+ *
31
+ * The alternative was making the author declare the host, which is the `accepts:` list the
32
+ * whole design exists to avoid.
33
+ */
34
+ export const attachments = new Map();
35
+ /** Node label -> the set of config field names it declares. */
36
+ export const configFields = new Map();
37
+
23
38
  /** Where this run is reading. Set by lintNodes(), read by rel() and the loaders. */
24
39
  export const state = { nodesHome: "", schemaDir: "", schemas: {} };
25
40
 
@@ -29,6 +44,8 @@ export function reset(nodesHome, schemaDir) {
29
44
  refCounts.clear();
30
45
  derivedKinds.clear();
31
46
  allCredentials.clear();
47
+ attachments.clear();
48
+ configFields.clear();
32
49
  state.nodesHome = nodesHome;
33
50
  state.schemaDir = schemaDir;
34
51
  state.schemas = {};
@@ -85,8 +85,44 @@ function checkDiscoveryMeta(report, file, json, kind, docs) {
85
85
  report("warn", file, `discovery manifest has no "category". Name the domain of the job, never the implementation (${docs})`);
86
86
  }
87
87
 
88
+ /**
89
+ * AN AGENT FILE (docs.unoverse.ai/design/tasks, 2026-09-06): a workflow with no interface,
90
+ * listed for the map. The discovery meta answers to the same rules as every manifest,
91
+ * because the Agent is ranked against what a PERSON said. The door is three fields, always:
92
+ * a canvas, its Input Trigger, and the node whose output is the result; a door missing at
93
+ * run time fails the customer, not the author. The face is optional and defaults to the
94
+ * shared agent-card; when named, it must be a component that resolves.
95
+ */
96
+ function checkAgentFile(report, file, json, componentNames) {
97
+ const docs = "docs.unoverse.ai/design/tasks";
98
+ if (json.type !== "agent") report("error", file, `agent file must declare "type: agent" (${docs})`);
99
+ const key = basename(file).replace(/\.(json|ya?ml)$/, "");
100
+ if (typeof json.name !== "string" || !json.name.trim()) report("error", file, `agent file missing "name" (${docs})`);
101
+ else {
102
+ if (json.name !== key) report("error", file, `agent "name: ${json.name}" must equal the file name "${key}" (${docs})`);
103
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(json.name)) report("error", file, `agent name "${json.name}" must be kebab-case: it is the tool's name on the wire (${docs})`);
104
+ }
105
+ checkDiscoveryMeta(report, file, json, "agent", docs);
106
+ const door = ["workflow", "trigger", "output"].filter((k) => typeof json[k] === "string" && json[k].trim());
107
+ if (door.length < 3)
108
+ report("error", file, `agent door is three fields, always: workflow, trigger and output. Present: ${door.join(", ") || "none"} (${docs})`);
109
+ const app = typeof json.app === "string" ? json.app.trim() : "";
110
+ if (app) {
111
+ if (!/^unoverse:\/\/components\//.test(app))
112
+ report("error", file, `agent "app: ${app}" must be a component URI: the face while it runs (${docs})`);
113
+ else if (componentNames && componentNames.size && !componentNames.has(app.split("/").pop().toLowerCase()))
114
+ report("error", file, `agent face "${app}" does not resolve: no such component in this project or the design system (${docs})`);
115
+ }
116
+ if (json.instructions !== undefined && typeof json.instructions !== "string")
117
+ report("error", file, `agent "instructions" must be a string: what to do (${docs})`);
118
+ if (json.skills !== undefined && !(Array.isArray(json.skills) && json.skills.every((x) => typeof x === "string" && x.trim())))
119
+ report("error", file, `agent "skills" must be a list of skill names (${docs})`);
120
+ if (json.message !== undefined && typeof json.message !== "string")
121
+ report("error", file, `agent "message" must be a string: what the face says while it runs (${docs})`);
122
+ }
123
+
88
124
  export function makeLintFile(ctx) {
89
- const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
125
+ const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, isAgentPath, defRoot, componentNamesForFile, placeNames, DS, orgDirs, RX, readText } = ctx;
90
126
 
91
127
  function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
92
128
  if (!Array.isArray(order)) return;
@@ -115,6 +151,39 @@ function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
115
151
  // one folder grammar — while a manifest-held tree remains the legacy read, so
116
152
  // both call sites validate identically).
117
153
  function checkAppTree(json, root, file) {
154
+ // PREVIEW NAMES RESOLVE (docs.unoverse.ai/design/apps, ruled 2026-09-10). Studio seeds a
155
+ // picked state from its `preview` list by looking each name up among the components and
156
+ // templates the org can see, by bare name, and a name it cannot find seeds NOTHING and says
157
+ // nothing: the state's tab looks dead and nobody is told why (observed live: an app said
158
+ // `alpha/deal` where the component is `deal`). So the name is checked here, where
159
+ // the file is written, against the same set: the marketplace's and this org's own.
160
+ {
161
+ const comps = componentNamesForFile ? componentNamesForFile(file) : null;
162
+ const walkStates = (states, path) => {
163
+ if (!states || typeof states !== "object" || Array.isArray(states)) return;
164
+ for (const [name, entry] of Object.entries(states)) {
165
+ if (!entry || typeof entry !== "object") continue;
166
+ const where = path ? `${path}.${name}` : name;
167
+ if (Array.isArray(entry.preview)) {
168
+ for (const item of entry.preview) {
169
+ const raw = typeof item === "string" ? item : item && typeof item === "object" && !Array.isArray(item) ? Object.keys(item)[0] : undefined;
170
+ if (typeof raw !== "string" || !raw) continue;
171
+ // THE HASH NAMES THE STATE IT OPENS IN (STATE_MODEL rule 3, ruled 2026-09-09; owner
172
+ // 2026-09-11: a preview is written `deal#grid`). The name is what is checked; the
173
+ // state after the hash is the component's own to declare.
174
+ const ref = raw.split("#")[0];
175
+ if (ref.includes("/"))
176
+ report("error", file, `state "${where}" preview names "${ref}" with an org prefix. Studio seeds a preview by the component's bare name ("${ref.split("/").pop()}"); a prefixed name resolves to nothing and the state's tab shows nothing (docs.unoverse.ai/design/apps)`);
177
+ else if (comps && !comps.has(ref.toLowerCase()))
178
+ report("error", file, `state "${where}" preview names "${ref}", which is no component or template this org can see (design/marketplace/, this org's components/ and templates/). Studio would seed nothing and the state's tab would show nothing (docs.unoverse.ai/design/apps)`);
179
+ }
180
+ } else if (entry.preview !== undefined && (typeof entry.preview !== "object" || entry.preview === null))
181
+ report("error", file, `state "${where}" preview must be a list of component names, or an object of app-state mock data (docs.unoverse.ai/design/apps)`);
182
+ walkStates(entry.states, where);
183
+ }
184
+ };
185
+ walkStates(json?.states, "");
186
+ }
118
187
  if (json.states !== undefined) {
119
188
  if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
120
189
  report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
@@ -190,6 +259,94 @@ function checkAppTree(json, root, file) {
190
259
  } else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
191
260
  }
192
261
 
262
+ // ── an app's places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.2) ──
263
+ // An app that declares places is drawn from the screen document, which replaces a task with the
264
+ // next one and steps aside what a turn did not touch. An app that declares none runs the old
265
+ // render pipeline, where nothing replaces anything: a voice call stacked three tasks, and a
266
+ // comparison it built never reached the screen (2026-09-12). So the lint says which an app is,
267
+ // and holds a places app to the places its org's interfaces name.
268
+ function checkAppPlaces(json, root, file) {
269
+ const files = [];
270
+ const collect = (dir) => {
271
+ if (!existsSync(dir)) return;
272
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
273
+ const f = join(dir, e.name);
274
+ if (e.isDirectory()) collect(f);
275
+ else if (isDefFile(e.name)) files.push(f);
276
+ }
277
+ };
278
+ collect(join(root, "layouts"));
279
+ collect(join(root, "components"));
280
+ const places = new Set();
281
+ const claims = [];
282
+ const walk = (n, f) => {
283
+ if (Array.isArray(n)) return n.forEach((c) => walk(c, f));
284
+ if (!n || typeof n !== "object") return;
285
+ if (n.type === "Place" && typeof n.name === "string") places.add(n.name);
286
+ if (n.type === "ComponentSlot" && n.select?.where?.field === "state") claims.push(relative(root, f));
287
+ for (const v of Object.values(n)) walk(v, f);
288
+ };
289
+ for (const f of files) {
290
+ try {
291
+ walk(parseDef(readFileSync(f, "utf8"), f), f);
292
+ } catch {
293
+ /* an unreadable file is reported by its own lint */
294
+ }
295
+ }
296
+ // THE MANIFEST'S `layout:` NAMES THE BASE STATE. Pointing it at a state the app no longer has
297
+ // leaves the app with no drawing at all: it loads, and nothing is placed (2026-09-12).
298
+ const manifest = defPath(root, "manifest");
299
+ if (manifest) {
300
+ try {
301
+ const m = parseDef(readFileSync(manifest, "utf8"), manifest);
302
+ const names = json.states && typeof json.states === "object" && !Array.isArray(json.states) ? Object.keys(json.states) : [];
303
+ if (m && typeof m.layout === "string" && names.length && !names.includes(m.layout))
304
+ report("error", file, `manifest.yaml says layout: ${m.layout}, which is no state of this app (${names.join(", ")}). The app would load with no drawing. Delete the line: the first state is the base (docs.unoverse.ai/design/apps)`);
305
+ } catch {
306
+ /* reported by its own lint */
307
+ }
308
+ }
309
+ if (!places.size) {
310
+ report("warn", file, `app declares no Place, so it runs the old render pipeline: nothing replaces an open task and nothing steps aside when the conversation moves on. Declare its places in the layout, as sab-chat does (UNOVERSE_SCREEN.md §2.2)`);
311
+ return;
312
+ }
313
+ const states = json.states && typeof json.states === "object" && !Array.isArray(json.states) ? Object.keys(json.states) : [];
314
+ if (states.length > 1)
315
+ report("error", file, `app declares places and ${states.length} states (${states.join(", ")}). An app with places has one arrangement: its places open and close as the screen fills them, so a state per surface is the old ladder and draws nothing (UNOVERSE_SCREEN.md §2.2)`);
316
+ for (const f of new Set(claims))
317
+ report("error", file, `${f} claims interfaces by state (a ComponentSlot with select.where on state), the old reaction surface. In an app with places the screen decides what is shown; draw it in a Place (UNOVERSE_SCREEN.md §2.2)`);
318
+ // THE PLACES THIS ORG'S INTERFACES NAME. An interface whose state names a place this app lacks
319
+ // lands in the conversation, which a voice app does not draw at all.
320
+ const org = basename(dirname(root)) === "apps" ? dirname(dirname(root)) : null;
321
+ if (!org) return;
322
+ const named = new Map();
323
+ const statesOf = (st, where) => {
324
+ if (!st || typeof st !== "object" || Array.isArray(st)) return;
325
+ for (const [n, e] of Object.entries(st)) {
326
+ if (!e || typeof e !== "object") continue;
327
+ if (typeof e.place === "string" && e.place !== "chat" && !named.has(e.place)) named.set(e.place, `${where} (state "${n}")`);
328
+ statesOf(e.states, where);
329
+ }
330
+ };
331
+ for (const kind of ["components", "templates"]) {
332
+ const dir = join(org, kind);
333
+ if (!existsSync(dir)) continue;
334
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
335
+ if (!e.isDirectory()) continue;
336
+ const f = defPath(join(dir, e.name), e.name);
337
+ if (!f) continue;
338
+ try {
339
+ statesOf(parseDef(readFileSync(f, "utf8"), f)?.states, `${kind}/${e.name}`);
340
+ } catch {
341
+ /* reported by its own lint */
342
+ }
343
+ }
344
+ }
345
+ for (const [place, by] of named)
346
+ if (!places.has(place))
347
+ report("error", file, `${by} is shown in place "${place}", which this app does not declare, so here it would land in the conversation. Declare a Place named "${place}" (this app has: ${[...places].sort().join(", ")}) (UNOVERSE_SCREEN.md §2.3)`);
348
+ }
349
+
193
350
  // ── lint one file ──
194
351
  function lintFile(file) {
195
352
  // `readText` is disk unless the caller supplied an overlay for this path (index.mjs).
@@ -200,7 +357,8 @@ function lintFile(file) {
200
357
  // Exempt: `appWidth` — the HOST-facing outer width (state-owned sizing, docs.unoverse.ai/design/apps).
201
358
  // It is raw CSS the embed host applies to the app panel ("min(50vw, 760px)", "360px"),
202
359
  // never a style the SDK resolves — token law governs the inside, not the envelope.
203
- if (!isFixture(file) && !isHook(file) && !isManifest(file))
360
+ // An Agent file carries URIs and ids, never a style, so the token law has nothing to judge.
361
+ if (!isFixture(file) && !isHook(file) && !isManifest(file) && !(isAgentPath && isAgentPath(file)))
204
362
  src.split("\n").forEach((line, i) => {
205
363
  /**
206
364
  * THE RULE JUDGES CODE, NEVER COMMENTARY. A comment explaining a contrast
@@ -236,6 +394,44 @@ function lintFile(file) {
236
394
  if (isFixture(file)) return; // legacy fixture, unused: don't choke
237
395
  if (isHook(file)) return; // a lifecycle hook: calls + a projection, not a UI tree
238
396
  if (isPropsExtract(file)) return; // the envelope's props map, composed there — not a UI tree
397
+ if (isAgentPath && isAgentPath(file)) { // an Agent: a workflow listed for the map, not a UI tree
398
+ checkAgentFile(report, file, json, componentNamesForFile ? componentNamesForFile(file) : null);
399
+ return;
400
+ }
401
+
402
+ // ONE WORD (UNOVERSE_STATE_MODEL §5, "One word: `state`"): a step is a state, so a state tree
403
+ // carries no second name (`on:`), and a step's name is never a face's name.
404
+ if (!isAppPath(file) && json && typeof json === "object" && !Array.isArray(json) && json.states && typeof json.states === "object" && !Array.isArray(json.states)) {
405
+ const faces = new Set(Object.keys(json.states));
406
+ for (const [face, entry] of Object.entries(json.states)) {
407
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
408
+ if (entry.on !== undefined)
409
+ report("error", file, `state "${face}" names its steps with "on: ${entry.on}". A step is a state: drop "on:" and write state everywhere, Switch on: state, field: state, key: state (UNOVERSE_STATE_MODEL §5). Arrangement picked by data is a Switch in the layout, not a state`);
410
+ if (entry.states && typeof entry.states === "object" && !Array.isArray(entry.states))
411
+ for (const step of Object.keys(entry.states))
412
+ if (faces.has(step)) report("error", file, `state "${face}" has a step named "${step}", which is also a face. With one word, state, a name means one thing: rename the step (UNOVERSE_STATE_MODEL §5)`);
413
+ }
414
+ }
415
+
416
+ // A STATE'S PLACE (UNOVERSE_SCREEN.md §2.3) is `chat` or a Place some app declares. A name no
417
+ // app has sends the interface to the conversation without a word, so it is refused here.
418
+ if (placeNames && !isAppPath(file) && json && typeof json === "object" && !Array.isArray(json) && json.states && typeof json.states === "object") {
419
+ const known = placeNames();
420
+ const walkPlaces = (states, path) => {
421
+ for (const [name, entry] of Object.entries(states)) {
422
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
423
+ const where = path ? `${path}.${name}` : name;
424
+ if (entry.place !== undefined && known.size > 0) {
425
+ if (typeof entry.place !== "string")
426
+ report("error", file, `state "${where}" place must be a place name: chat, or a Place an app declares (UNOVERSE_SCREEN.md §2.3)`);
427
+ else if (entry.place !== "chat" && !known.has(entry.place))
428
+ report("error", file, `state "${where}" names place "${entry.place}", which no app declares. An interface in this state would land in the conversation instead. Use chat or one of: ${[...known].sort().join(", ")} (UNOVERSE_SCREEN.md §2.3)`);
429
+ }
430
+ if (entry.states && typeof entry.states === "object" && !Array.isArray(entry.states)) walkPlaces(entry.states, where);
431
+ }
432
+ };
433
+ walkPlaces(json.states, "");
434
+ }
239
435
 
240
436
  if (isManifest(file)) {
241
437
  const root = dirname(file);
@@ -424,6 +620,7 @@ function lintFile(file) {
424
620
  // APP envelope: the states tree lives HERE (one folder grammar) — validate it
425
621
  // exactly as the legacy manifest-held tree was.
426
622
  if (declared === "app") checkAppTree(json, root, file);
623
+ if (declared === "app") checkAppPlaces(json, root, file);
427
624
  if (json.root) walkNode(json.root, file, root);
428
625
 
429
626
  // ── the contained-microapp discipline (mirrors microapp-structure.test.ts) ──