@vendoai/apps 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +8 -0
  3. package/dist/adapter-conformance.d.ts +48 -0
  4. package/dist/adapter-conformance.d.ts.map +1 -0
  5. package/dist/adapter-conformance.js +196 -0
  6. package/dist/adapter-conformance.js.map +1 -0
  7. package/dist/agent-tools.d.ts +10 -0
  8. package/dist/agent-tools.d.ts.map +1 -0
  9. package/dist/agent-tools.js +252 -0
  10. package/dist/agent-tools.js.map +1 -0
  11. package/dist/app-data.d.ts +23 -0
  12. package/dist/app-data.d.ts.map +1 -0
  13. package/dist/app-data.js +129 -0
  14. package/dist/app-data.js.map +1 -0
  15. package/dist/app-token.d.ts +24 -0
  16. package/dist/app-token.d.ts.map +1 -0
  17. package/dist/app-token.js +61 -0
  18. package/dist/app-token.js.map +1 -0
  19. package/dist/audit.d.ts +11 -0
  20. package/dist/audit.d.ts.map +1 -0
  21. package/dist/audit.js +19 -0
  22. package/dist/audit.js.map +1 -0
  23. package/dist/automation-plan.d.ts +43 -0
  24. package/dist/automation-plan.d.ts.map +1 -0
  25. package/dist/automation-plan.js +212 -0
  26. package/dist/automation-plan.js.map +1 -0
  27. package/dist/bench/client.d.ts +43 -0
  28. package/dist/bench/client.d.ts.map +1 -0
  29. package/dist/bench/client.js +129 -0
  30. package/dist/bench/client.js.map +1 -0
  31. package/dist/bench/fixtures.d.ts +42 -0
  32. package/dist/bench/fixtures.d.ts.map +1 -0
  33. package/dist/bench/fixtures.js +146 -0
  34. package/dist/bench/fixtures.js.map +1 -0
  35. package/dist/bench/judge.d.ts +9 -0
  36. package/dist/bench/judge.d.ts.map +1 -0
  37. package/dist/bench/judge.js +71 -0
  38. package/dist/bench/judge.js.map +1 -0
  39. package/dist/bench/metrics.d.ts +32 -0
  40. package/dist/bench/metrics.d.ts.map +1 -0
  41. package/dist/bench/metrics.js +172 -0
  42. package/dist/bench/metrics.js.map +1 -0
  43. package/dist/bench/prompts.d.ts +17 -0
  44. package/dist/bench/prompts.d.ts.map +1 -0
  45. package/dist/bench/prompts.js +100 -0
  46. package/dist/bench/prompts.js.map +1 -0
  47. package/dist/bench/report.d.ts +43 -0
  48. package/dist/bench/report.d.ts.map +1 -0
  49. package/dist/bench/report.js +74 -0
  50. package/dist/bench/report.js.map +1 -0
  51. package/dist/bench/runner.d.ts +15 -0
  52. package/dist/bench/runner.d.ts.map +1 -0
  53. package/dist/bench/runner.js +26 -0
  54. package/dist/bench/runner.js.map +1 -0
  55. package/dist/bench/toolfork.d.ts +18 -0
  56. package/dist/bench/toolfork.d.ts.map +1 -0
  57. package/dist/bench/toolfork.js +125 -0
  58. package/dist/bench/toolfork.js.map +1 -0
  59. package/dist/box-agent.d.ts +78 -0
  60. package/dist/box-agent.d.ts.map +1 -0
  61. package/dist/box-agent.js +137 -0
  62. package/dist/box-agent.js.map +1 -0
  63. package/dist/box-env.d.ts +26 -0
  64. package/dist/box-env.d.ts.map +1 -0
  65. package/dist/box-env.js +52 -0
  66. package/dist/box-env.js.map +1 -0
  67. package/dist/call.d.ts +23 -0
  68. package/dist/call.d.ts.map +1 -0
  69. package/dist/call.js +45 -0
  70. package/dist/call.js.map +1 -0
  71. package/dist/cloud.d.ts +4917 -0
  72. package/dist/cloud.d.ts.map +1 -0
  73. package/dist/cloud.js +16 -0
  74. package/dist/cloud.js.map +1 -0
  75. package/dist/e2b/index.d.ts +30 -0
  76. package/dist/e2b/index.d.ts.map +1 -0
  77. package/dist/e2b/index.js +273 -0
  78. package/dist/e2b/index.js.map +1 -0
  79. package/dist/egress-approval.d.ts +73 -0
  80. package/dist/egress-approval.d.ts.map +1 -0
  81. package/dist/egress-approval.js +74 -0
  82. package/dist/egress-approval.js.map +1 -0
  83. package/dist/engine.d.ts +188 -0
  84. package/dist/engine.d.ts.map +1 -0
  85. package/dist/engine.js +1507 -0
  86. package/dist/engine.js.map +1 -0
  87. package/dist/fn.d.ts +22 -0
  88. package/dist/fn.d.ts.map +1 -0
  89. package/dist/fn.js +108 -0
  90. package/dist/fn.js.map +1 -0
  91. package/dist/history.d.ts +21 -0
  92. package/dist/history.d.ts.map +1 -0
  93. package/dist/history.js +158 -0
  94. package/dist/history.js.map +1 -0
  95. package/dist/inclient.d.ts +48 -0
  96. package/dist/inclient.d.ts.map +1 -0
  97. package/dist/inclient.js +68 -0
  98. package/dist/inclient.js.map +1 -0
  99. package/dist/index.d.ts +24 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +25 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/interchange.d.ts +17 -0
  104. package/dist/interchange.d.ts.map +1 -0
  105. package/dist/interchange.js +133 -0
  106. package/dist/interchange.js.map +1 -0
  107. package/dist/machine-lifecycle.d.ts +102 -0
  108. package/dist/machine-lifecycle.d.ts.map +1 -0
  109. package/dist/machine-lifecycle.js +360 -0
  110. package/dist/machine-lifecycle.js.map +1 -0
  111. package/dist/manifest.d.ts +42 -0
  112. package/dist/manifest.d.ts.map +1 -0
  113. package/dist/manifest.js +53 -0
  114. package/dist/manifest.js.map +1 -0
  115. package/dist/open.d.ts +45 -0
  116. package/dist/open.d.ts.map +1 -0
  117. package/dist/open.js +245 -0
  118. package/dist/open.js.map +1 -0
  119. package/dist/parked-action.d.ts +51 -0
  120. package/dist/parked-action.d.ts.map +1 -0
  121. package/dist/parked-action.js +29 -0
  122. package/dist/parked-action.js.map +1 -0
  123. package/dist/persistence.d.ts +41 -0
  124. package/dist/persistence.d.ts.map +1 -0
  125. package/dist/persistence.js +86 -0
  126. package/dist/persistence.js.map +1 -0
  127. package/dist/pins.d.ts +231 -0
  128. package/dist/pins.d.ts.map +1 -0
  129. package/dist/pins.js +213 -0
  130. package/dist/pins.js.map +1 -0
  131. package/dist/pipeline.d.ts +142 -0
  132. package/dist/pipeline.d.ts.map +1 -0
  133. package/dist/pipeline.js +1014 -0
  134. package/dist/pipeline.js.map +1 -0
  135. package/dist/prewired-schema.d.ts +16 -0
  136. package/dist/prewired-schema.d.ts.map +1 -0
  137. package/dist/prewired-schema.js +48 -0
  138. package/dist/prewired-schema.js.map +1 -0
  139. package/dist/redaction.d.ts +13 -0
  140. package/dist/redaction.d.ts.map +1 -0
  141. package/dist/redaction.js +67 -0
  142. package/dist/redaction.js.map +1 -0
  143. package/dist/runtime.d.ts +463 -0
  144. package/dist/runtime.d.ts.map +1 -0
  145. package/dist/runtime.js +1715 -0
  146. package/dist/runtime.js.map +1 -0
  147. package/dist/sandbox.d.ts +90 -0
  148. package/dist/sandbox.d.ts.map +1 -0
  149. package/dist/sandbox.js +2 -0
  150. package/dist/sandbox.js.map +1 -0
  151. package/dist/schedules.d.ts +89 -0
  152. package/dist/schedules.d.ts.map +1 -0
  153. package/dist/schedules.js +334 -0
  154. package/dist/schedules.js.map +1 -0
  155. package/dist/secret-exposure.d.ts +55 -0
  156. package/dist/secret-exposure.d.ts.map +1 -0
  157. package/dist/secret-exposure.js +53 -0
  158. package/dist/secret-exposure.js.map +1 -0
  159. package/dist/ship-diff.d.ts +47 -0
  160. package/dist/ship-diff.d.ts.map +1 -0
  161. package/dist/ship-diff.js +39 -0
  162. package/dist/ship-diff.js.map +1 -0
  163. package/dist/testing/fake-box.d.ts +67 -0
  164. package/dist/testing/fake-box.d.ts.map +1 -0
  165. package/dist/testing/fake-box.js +166 -0
  166. package/dist/testing/fake-box.js.map +1 -0
  167. package/dist/testing/fake-sandbox-v2.d.ts +59 -0
  168. package/dist/testing/fake-sandbox-v2.d.ts.map +1 -0
  169. package/dist/testing/fake-sandbox-v2.js +121 -0
  170. package/dist/testing/fake-sandbox-v2.js.map +1 -0
  171. package/dist/testing/fake-sandbox.d.ts +94 -0
  172. package/dist/testing/fake-sandbox.d.ts.map +1 -0
  173. package/dist/testing/fake-sandbox.js +239 -0
  174. package/dist/testing/fake-sandbox.js.map +1 -0
  175. package/dist/testing/guard-fixture.d.ts +19 -0
  176. package/dist/testing/guard-fixture.d.ts.map +1 -0
  177. package/dist/testing/guard-fixture.js +134 -0
  178. package/dist/testing/guard-fixture.js.map +1 -0
  179. package/dist/testing/index.d.ts +8 -0
  180. package/dist/testing/index.d.ts.map +1 -0
  181. package/dist/testing/index.js +8 -0
  182. package/dist/testing/index.js.map +1 -0
  183. package/dist/testing/memory-store.d.ts +5 -0
  184. package/dist/testing/memory-store.d.ts.map +1 -0
  185. package/dist/testing/memory-store.js +4 -0
  186. package/dist/testing/memory-store.js.map +1 -0
  187. package/dist/testing/scripted-model.d.ts +32 -0
  188. package/dist/testing/scripted-model.d.ts.map +1 -0
  189. package/dist/testing/scripted-model.js +112 -0
  190. package/dist/testing/scripted-model.js.map +1 -0
  191. package/dist/testing/seed-app-row.d.ts +4 -0
  192. package/dist/testing/seed-app-row.d.ts.map +1 -0
  193. package/dist/testing/seed-app-row.js +7 -0
  194. package/dist/testing/seed-app-row.js.map +1 -0
  195. package/dist/unified-diff.d.ts +13 -0
  196. package/dist/unified-diff.d.ts.map +1 -0
  197. package/dist/unified-diff.js +127 -0
  198. package/dist/unified-diff.js.map +1 -0
  199. package/dist/version-hash.d.ts +9 -0
  200. package/dist/version-hash.d.ts.map +1 -0
  201. package/dist/version-hash.js +12 -0
  202. package/dist/version-hash.js.map +1 -0
  203. package/package.json +84 -0
package/dist/engine.js ADDED
@@ -0,0 +1,1507 @@
1
+ import { KIT_WIRE_COMPONENT_NAMES, WIRE_COMPONENT_NAMES, RESERVED_COMPONENT_NAMES, TREE_MAX_COMPONENT_SOURCE_BYTES, TREE_MAX_GENERATED_COMPONENTS, TREE_MAX_NODES, TREE_MAX_QUERIES, TREE_MAX_TOTAL_COMPONENT_BYTES, VENDO_APP_FORMAT, VENDO_TREE_FORMAT_V2, VendoError, compileWirePatchV2, compileWireV2, describeShapeWithSemantics, findDeprecatedReshapeUsage, kitPrompt, kitSpec, ISLAND_AMBIENT_KIT_NAMES, ISLAND_STRIPPED_SPECIFIERS, islandNetworkViolations, resolveIslandToolName, scanIslandTools, shapeAtPointer, stripIslandImports, printWireV2, isPathBinding, isStateBinding, validateAppDocument, validateTreeV2, } from "@vendoai/core";
2
+ import { actionFaults, endPass, extractEdit, literalDataFaults, regionParallelCreate, structuredRepair, } from "./pipeline.js";
3
+ import { hasDefaultExport, pinComponentName, pinForkSource } from "./pins.js";
4
+ import { prewiredPropNames, prewiredSchemaPrompt } from "./prewired-schema.js";
5
+ // execution-v2 Wave 3 — the graduation judgment (instructionRequiresServer):
6
+ // UNAMBIGUOUS signals of the four machine reasons (scheduled/background work,
7
+ // third-party egress with secrets, heavy logic, app-owned state) — words that
8
+ // essentially never label a visible element.
9
+ const SERVER_INSTRUCTION = /\b(server|server-side|backend|database|persist|mutation|mutate|egress|schedule|scheduled|scheduling|cron|recurring)\b/i;
10
+ // Words that signal server work only OUTSIDE a visible-element label (ENG-349):
11
+ // "watch my invoices"/"email a daily digest" escalate, but "the digest card",
12
+ // "the watch list", "the API status card" stay on the cheap tree path.
13
+ const AMBIGUOUS_SERVER_TERM = /\b(api|http|web app|function|external|secret|digest|watch|monitor|daily|nightly|hourly|periodic)\b/gi;
14
+ const VISIBLE_ELEMENT_LABEL = /^(?:\w+\s+)?(card|button|badge|chip|header|heading|title|label|caption|text|list|table|column|row|cell|section|panel|chart|graph|icon|field|tab|menu|toolbar|sidebar|footer|banner|tile|widget)s?\b/i;
15
+ /** Wave 4 (layer 3) — asks whose UI needs exceed the tree: a real served web
16
+ * app, or interaction vocabulary (drag-and-drop, rich text) the tree's
17
+ * component walk cannot express. These are unambiguous — they never label a
18
+ * visible element. */
19
+ const SERVED_APP_INSTRUCTION = /\b(full web app|served web app|custom (?:ui|client|frontend)|drag[- ]?(?:and|&|'n')[- ]?drop|wysiwyg|rich[- ]text editor|ui:? ?http)\b/i;
20
+ /** Wave 4 — served-app words that can also LABEL a visible element ("make the
21
+ * kanban board heading blue" is a tree ask); same ENG-349 rule as the
22
+ * ambiguous server terms. */
23
+ const AMBIGUOUS_SERVED_TERM = /\b(kanban|whiteboard|draggable)\b/gi;
24
+ /** Wave 9 (escalation ladder, rung c) — UNAMBIGUOUS custom-code signals: real
25
+ * computation or bespoke logic no tool composition can express, so only a box
26
+ * (in-box agent writes server code) can serve the ask. */
27
+ const BOX_INSTRUCTION = /\b(custom (?:code|logic|parser|parsing|algorithm|scoring|dedup\w*)|write (?:a |an )?(?:parser|algorithm|script)|state machine|levenshtein|fuzzy[- ]?match\w*)\b/i;
28
+ /** Wave 9 — custom-code words that can also LABEL a visible element ("make the
29
+ * parse errors card blue", "show the ledger table"); same ENG-349 rule. */
30
+ const AMBIGUOUS_BOX_TERM = /\b(parse|parsing|parser|csv|xlsx|regex|algorithm|dedup\w*|de-dup\w*|reconcil\w*|ledger)\b/gi;
31
+ /** Wave 9 (rung b) — UNAMBIGUOUS per-run-judgment signals: each firing needs a
32
+ * model's call (who, which, what tone), but every effect is tool-reachable —
33
+ * the agentic automation run model, never a box. */
34
+ const AGENTIC_INSTRUCTION = /\b(decide|decides|deciding|judgment|judgement|discretion|deserv\w+|as appropriate|appropriately)\b/i;
35
+ /** Wave 9 — judgment words that can also LABEL a visible element ("rename the
36
+ * triage board"); same ENG-349 rule. */
37
+ const AMBIGUOUS_AGENTIC_TERM = /\b(triage|classify|prioriti[sz]e|assess|judge|escalate)\b/gi;
38
+ const reserved = new Set(WIRE_COMPONENT_NAMES);
39
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
40
+ const catalogPrompt = (catalog) => JSON.stringify(catalog.map(({ name, description, propsJsonSchema, examples }) => ({
41
+ name,
42
+ whenToUse: description,
43
+ propsJsonSchema: propsJsonSchema ?? null,
44
+ examples: examples ?? [],
45
+ })), null, 2);
46
+ const pinBaselinesPrompt = (baselines = []) => JSON.stringify(baselines.map((baseline) => ({
47
+ slot: baseline.slot,
48
+ componentName: pinComponentName(baseline.slot),
49
+ })), null, 2);
50
+ const composePromptSections = (sections) => sections
51
+ .map(({ content }) => content.trim())
52
+ .filter((content) => content.length > 0)
53
+ .join("\n\n");
54
+ const generationPromptSections = (deps) => [{
55
+ id: "role",
56
+ content: "You are the Vendo app generation engine. Return JSON only, with no markdown.",
57
+ }, {
58
+ id: "tree-contract",
59
+ content: `TREE CONTRACT (vendo-genui/v2):
60
+ - At rest the app is {name, description?, tree, components?}; never emit id, server, secrets, egress, storage, or authority.
61
+ - tree.formatVersion is "vendo-genui/v2" and tree contains root, nodes, optional data and queries. Generated component sources live at the DOCUMENT level in components — the tree itself never carries them.
62
+ - Maximums: ${TREE_MAX_NODES} nodes, ${TREE_MAX_QUERIES} queries, ${TREE_MAX_GENERATED_COMPONENTS} generated components, ${TREE_MAX_COMPONENT_SOURCE_BYTES} bytes per generated component source, ${TREE_MAX_TOTAL_COMPONENT_BYTES} bytes of generated-component source in total.
63
+ - Reserved prewired primitive names: ${RESERVED_COMPONENT_NAMES.join(", ")}.
64
+ - Every node is exactly {id, component, source?, props?, children?}. "component" is a REQUIRED non-empty string on EVERY node, including layout containers — use a prewired primitive (e.g. Stack, Row, Grid) as the component for containers; children is an array of node ids. Never emit a node without a component.
65
+ - "nodes" is a FLAT array of every node; nesting is expressed only through "children" id references, never by inlining child objects. "root" is the id of the top node.
66
+ - A node source is "prewired", "host", or "generated". Generated names are PascalCase, non-reserved, and require a document components[name] ESM React source.
67
+ - Prefer a host component whenever it covers the need. Matching the host brand is a hard goal.
68
+ - Prop bindings are exactly {"$path":"/json/pointer"} and {"$state":"clientStateKey"}. A query's result lives at "/" + its name.
69
+ - Queries are {name, tool, input?}; name is a bare identifier. Actions embedded in props are {action,payload?}.
70
+ - Query tools and action names are host tool names, or fn:<name> where name matches [A-Za-z_][A-Za-z0-9_-]*. A rung-1 tree cannot use fn: because it has no server.
71
+ `,
72
+ }, {
73
+ // v4 (M9) — without a clock the model guesses the year and hardcodes it
74
+ // into filters/headers ("Top 10 in 2025" over 2026 data = a false empty
75
+ // state). Computed per call, never cached.
76
+ id: "clock",
77
+ content: `CURRENT DATE: ${new Date().toISOString().slice(0, 10)} — this is "now" for the host's data. Resolve every relative period the user asks for ("this year", "this month", "next 90 days") from this date; never assume or hardcode a different year or period.`,
78
+ }, {
79
+ id: "component-styling",
80
+ content: `GENERATED COMPONENT STYLING:
81
+ - The component renders in a sandbox that sits directly on the host page's background (THEME TOKENS colors.background when provided; otherwise assume a light background). Never design for an imaginary dark backdrop; give the component's own containers explicit backgrounds.
82
+ - The host's brand tokens are available as CSS custom properties: --vendo-color-background, --vendo-color-surface, --vendo-color-text, --vendo-color-muted, --vendo-color-accent, --vendo-color-accent-text, --vendo-color-danger, --vendo-color-border, --vendo-font-family, --vendo-heading-family, --vendo-font-size, --vendo-radius-small/medium/large. Prefer them (e.g. color: "var(--vendo-color-text)") so the view matches the host brand.
83
+ `,
84
+ }, {
85
+ id: "catalog",
86
+ content: `HOST CATALOG (names, when-to-use guidance, props JSON schemas, and usage examples):\n${catalogPrompt(deps.catalog)}\nWhen a host catalog entry fits any part of the request, you MUST use a source:"host" node with its exact name and props schema; do not generate an equivalent component. Compose host, prewired, and generated nodes when needed.`,
87
+ }, {
88
+ id: "theme",
89
+ content: `THEME TOKENS:\n${JSON.stringify(deps.theme ?? null, null, 2)}`,
90
+ }, {
91
+ id: "design-rules",
92
+ content: `HOST DESIGN RULES:\n${(typeof deps.designRules === "function" ? deps.designRules() : deps.designRules)?.trim() || "(none provided)"}`,
93
+ }, {
94
+ // Gesture-owned forking (2026-07-21): the fork is executed DETERMINISTICALLY
95
+ // by the engine when the user acts on a remixable slot — the model never
96
+ // decides to fork, so the edit dialect no longer teaches <ForkPin> (the op
97
+ // keeps compiling for stored apps). This section teaches only what edits on
98
+ // EXISTING forks need.
99
+ id: "remixable-slots",
100
+ content: (deps.pinBaselines ?? []).length === 0 ? "" : `REMIXABLE HOST SLOTS (slot -> the generated component a user fork ships under):
101
+ ${pinBaselinesPrompt(deps.pinBaselines)}
102
+ - Forking a slot is a USER GESTURE the engine executes deterministically — never fork a slot yourself, and never copy or imitate captured host source in a new island.
103
+ - A slot the user has forked appears as the generated component named above (its pin is listed in APP_META.pins) with its full source in CURRENT_APP. Edit it like any island: re-declare <Island name="componentName">...complete updated source...</Island> with the SMALLEST change the instruction needs — keep the original structure, styling, behavior, and every comment intact (the fork is reviewed as a diff against the host's source; wholesale rewrites and stripped comments are review noise).
104
+ - Never remove or rename a pinned component, and never invent or alter baseline hashes.`,
105
+ }];
106
+ /** W3 — the COMPONENTS section is GENERATED from the component schemas
107
+ * (kitPrompt over the Kit specs + the legacy primitive signatures); no
108
+ * hand-written component list survives here. Deps-independent, so it is
109
+ * rendered once per process (perf budget: gen-scripted:create). */
110
+ let componentsPromptCache;
111
+ const componentsPromptSection = () => componentsPromptCache ??= `COMPONENTS (generated from the component schemas — use these EXACT component and prop names; an unknown prop is silently dropped and fails validation):
112
+
113
+ ${kitPrompt({ only: [...KIT_WIRE_COMPONENT_NAMES] })}
114
+
115
+ # Legacy primitives (also available)
116
+ ${prewiredSchemaPrompt()}`;
117
+ /** W4b §3 — the island contract, shared by the create and edit dialects. The
118
+ * "LAST RESORT" fear rules are retired (spec §format Islands): use the Kit
119
+ * when it covers the need (faster, branded); write an island for custom
120
+ * visuals/logic/interaction. Byte caps, the TSX + default-export gate, and
121
+ * the no-network CSP all stand. */
122
+ const islandContract = () => `- <Island name="PascalName">TSX with an \`export default\` component</Island> defines a generated component, referenced as <PascalName/> — plain source, never wrapped in braces, template literals, or fences. The island's name must be DISTINCT from every host catalog, Kit, and prewired component name (name resolution prefers those, so a colliding island never renders). Use a host catalog or Kit/prewired component when it covers the need (faster, brand-native); write an island for custom visuals, novel interactions, or client-side logic they cannot express (search-as-you-type, derived calculations, bespoke visualizations). Never put the whole app or its layout inside one island: compose regions so the app streams in progressively.
123
+ - Islands have NO import statements — everything is already in scope: React and its hooks (useState, useEffect, useMemo, useCallback, useRef), the entire Kit (${ISLAND_AMBIENT_KIT_NAMES.join(", ")}), and \`fmt\` value helpers (fmt.money(cents), fmt.dateTime(iso), fmt.percent(ratio), fmt.num(n)). Never write an import: known react/kit imports are stripped, and anything else (recharts, d3, lodash…) cannot load in the network-denied sandbox — the ambient Kit charts cover charting. Host catalog and prewired components are NOT in island scope (they live in the host page): compose them in the tree, and inside an island use only the ambient Kit and your own local components. This holds even when a host catalog component matches the visualization — inside an island, its ambient Kit equivalent (LineChart, BarChart, DonutChart, Sparkline, Progress) is the correct choice.
124
+ - Islands call host tools directly with the ambient tools API: \`const result = await tools.<tool_name>(args)\`, where <tool_name> is a HOST TOOLS name written as a LITERAL member access — never tools[expr], never aliasing or passing \`tools\` around. args must match that tool's (input: …) sketch exactly — field names AND nesting. The sandbox has NO network — fetch/XHR/WebSocket are blocked by CSP; the ambient tools API is the only way an island reads or acts. A read tool resolves with the tool's output. A MUTATING tool pauses at the user approval gate: the call resolves {status:"pending-approval"} and its effect lands after the user approves — render a pending/awaiting-approval state, never treat it as a failure. An island can only reach the tools its own source literally names.
125
+ - Data honesty holds inside islands: every number or row an island renders comes from its props (bound to a tool reference) or from an ambient tools read — never hand-typed.`;
126
+ /** v2 spec §2 — the JSX-wire create contract. The model emits markup, never
127
+ * JSON; the deterministic compiler owns ids, bindings, and validation. */
128
+ const wireContractSections = (deps) => [{
129
+ id: "role",
130
+ content: "You are the Vendo app generation engine. Return ONLY vendo-genui/v2 wire markup: a single <App> element. No prose, no markdown fences, no JSON.",
131
+ }, {
132
+ id: "tree-contract",
133
+ content: `WIRE DIALECT (vendo-genui/v2):
134
+ - Emit exactly one <App name="..."> element containing the whole app. No HTML/JSX comments anywhere — emit only elements. Positional nesting expresses the tree; NEVER emit id attributes — the compiler mints stable ids.
135
+ - DATA comes from INLINE TOOL REFERENCES written directly in a prop: rows={host_listTransactions({limit:20}).data} or value={host_getBudgets({}).totalCents} — the tool call (exact HOST TOOLS name + an args object, {} when none) followed by the field path. The compiler turns each distinct call into one fetch; IDENTICAL call+args used twice is ONE fetch, so reuse the same expression for the same data. Explicit <Query id="name" tool="tool_name" input={{...}}/> declarations (bound as {name.field.path}) are also accepted.
136
+ - Call args (and query inputs) are LITERAL JSON only — never put a reference/binding inside an args object: one call's input can NOT come from another call's result (the runtime executes inputs literally). When a tool needs an id you don't have literally, prefer the no-arg/list variant of the ask, or build the dependent lookup inside an <Island> using ambient tools.
137
+ - Attribute values: "string", {42}, {true}, bare attribute for true, {{...}} objects, {[...]} arrays, and data bindings. A binding is ONE inline tool call plus a plain field path (or a declared query name plus a field path) — NO other computation: no arithmetic, no .filter/.map/.length, no bracket indexing (address array elements with dot-numeric segments, e.g. {host_listAccounts({}).data.0.sparkline}), no string concatenation, no chained or nested calls. If a value would need computing, bind the closest raw field instead and let the component render it. There is NO string interpolation: never write a binding inside a \"string\" attribute — bind the whole prop to one {reference} or use separate Text nodes.
138
+ - Components resolve host catalog -> built-in components (the Kit and legacy primitives in the COMPONENTS section below) -> your <Island> components; the host brand wins a name collision.
139
+ - COMPOSE the app from host catalog and built-in components bound to query data. Prefer a host catalog component whenever it covers the need, with its exact name and props schema; use the Kit (DataTable/Stat/Money/DateTime/charts) and layout primitives for everything else. Matching the host brand is a hard goal.
140
+ - Never hardcode business data (invoices, balances, metrics, rows). Every number, label, and row the user sees must come from a tool binding (inline reference or <Query>); if no tool provides it, leave the region out rather than inventing data. This applies to CHARTS and METRICS too: when NO host tool supplies the numbers, render an honest empty-state (a short Text/Badge that the data isn't available), never fabricated, placeholder, or example figures.
141
+ - Actions are on* attributes naming a host tool or fn:<name> (name matches [A-Za-z_][A-Za-z0-9_-]*), e.g. onClick="host_tool" or onRun="fn:submit". A rung-1 app has no server, so never use fn: on create.
142
+ - An action that CHANGES host state (a write/destructive tool) MUST carry a payload binding the context it acts on — the per-row id for a row action, the form field values for a submit — e.g. onClick={{action:"host_send_reminder", payload:{invoiceId: invoices.rows.0.id}}}. Never wire a submit/primary Button to a read-only tool, and never leave a submit/primary Button with no action: a button that does nothing is a fake affordance. When NO host tool can perform the requested action, do NOT render a dead Submit — render an honest disclaimer (Text/Badge) saying the action isn't available on this host.
143
+ ${islandContract()}
144
+ - Maximums: ${TREE_MAX_NODES} nodes, ${TREE_MAX_QUERIES} queries, ${TREE_MAX_GENERATED_COMPONENTS} islands, ${TREE_MAX_COMPONENT_SOURCE_BYTES} bytes per island, ${TREE_MAX_TOTAL_COMPONENT_BYTES} bytes of island source total.`,
145
+ }, {
146
+ id: "prewired-props",
147
+ content: componentsPromptSection(),
148
+ }, ...hostToolSections(deps),
149
+ ...generationPromptSections(deps).filter(({ id }) => id === "clock" || id === "component-styling" || id === "catalog" || id === "theme" || id === "design-rules")];
150
+ /** W4b — a one-line sketch of a tool's INPUT (top-level fields, one nesting
151
+ * level deep). Without it the model guesses arg shapes: the live P3 island
152
+ * called a body-nested tool with flat args, the host route read an empty
153
+ * JSON body, and the approved mutation ran on defaults. */
154
+ const toolInputSketch = (inputSchema) => {
155
+ const properties = inputSchema?.properties;
156
+ if (typeof properties !== "object" || properties === null)
157
+ return "";
158
+ const fields = Object.entries(properties).map(([field, schema]) => {
159
+ const child = schema?.properties;
160
+ if (typeof child === "object" && child !== null) {
161
+ return `${field}: {${Object.keys(child).join(", ")}}`;
162
+ }
163
+ return field;
164
+ });
165
+ return fields.length === 0 ? "" : ` (input: {${fields.join(", ")}})`;
166
+ };
167
+ /** verify-v2 fixes — the tools a query may name, and (v2 spec §3) the shape
168
+ * cards the model must bind against. Without the tool list the model invents
169
+ * tool names; without shapes it binds blind (the broken-chart class). */
170
+ const hostToolSections = (deps) => [
171
+ ...(deps.tools === undefined || deps.tools.length === 0 ? [] : [{
172
+ id: "catalog",
173
+ content: `HOST TOOLS (the ONLY tools a binding — inline reference or <Query> — or an action may name; anything else is a validation error). Every call's args MUST match the tool's (input: …) sketch exactly — same field names, same nesting (a field shown as {body: {…}} means the args object carries a "body" object):\n${deps.tools.map(({ name, description, risk, inputSchema }) => `- ${name} [${risk}]${toolInputSketch(inputSchema)}: ${description}`).join("\n")}`,
174
+ }]),
175
+ // W3 — the domain manifest is FACT derived at sync, not guidance: it tells
176
+ // the model what data exists at all, so an out-of-domain ask becomes an
177
+ // honest disclaimer instead of a repurposed tool or invented figures.
178
+ ...(deps.domains === undefined || (deps.domains.has.length === 0 && deps.domains.hasNot.length === 0) ? [] : [{
179
+ id: "catalog",
180
+ content: `DATA DOMAINS (fact, derived from this host's tools — not guidance):${deps.domains.has.length === 0 ? "" : `\n- This host HAS data for: ${deps.domains.has.join(", ")}.`}${deps.domains.hasNot.length === 0 ? "" : `\n- This host has NO data for: ${deps.domains.hasNot.join(", ")}.`}
181
+ - An ask about a domain not covered above cannot be answered with real data: render an honest empty-state/disclaimer for that part, never repurpose an unrelated tool and never invent figures.`,
182
+ }]),
183
+ ...(deps.toolShapes === undefined || Object.keys(deps.toolShapes).length === 0 ? [] : [{
184
+ id: "catalog",
185
+ content: `TOOL RESPONSE SHAPES (bind only to fields that exist; a binding outside these shapes fails validation). Field annotations mark semantics: :money.cents = integer CENTS (bind the RAW number into Money cents / a format:"money" column — never pre-format it), :money.dollars = whole dollars, :date.iso and :date.epoch = machine dates (DateTime / format:"date"), :enum(a|b) = closed vocabulary (EnumBadge), :id = OPAQUE host identifier (for action payloads — NEVER invent, guess, or abbreviate an id value; when a call would need an id you don't literally have, use the un-filtered list variant instead), :percent.ratio = 0..1, :percent.0-100 = whole percent.\n${Object.entries(deps.toolShapes).map(([tool, shape]) => `- ${tool}: ${describeShapeWithSemantics(shape, deps.semantics?.[tool] ?? {})}`).join("\n")}`,
186
+ }, {
187
+ id: "catalog",
188
+ content: `RESHAPE PIPES — a binding may end with a bounded \`| op(...)\` pipe (this is the ONLY computation allowed in a binding). PREFER native field-name props over pipes: components read RAW tool rows directly — Select takes the raw object array plus labelField/valueField, DataTable/CardList columns resolve dot-path keys ({key:"client.name"}), Kit charts read raw rows via data + xKey + series. Never pre-project rows a component can read raw.
189
+ - Only a HOST prop whose schema declares [{label, value}] items takes asPoints: points={revenue.rows | asPoints(month, revenue)}. Kit charts read raw rows and never need it.
190
+ FORMAT for DISPLAY — money from host tools is integer CENTS, and dates are raw ISO/epoch; a bare number or ISO string shown to the user is a defect, on EVERY host. The Kit formats for you — USE it: <Money cents={...}/> and <DateTime value={...}/> for single values, DataTable/CardList column/field format tokens ("money", "date") for rows, <EnumBadge value={...}/> for enum fields. Cents money ALWAYS rides the Kit (<Money cents={...}/>, a format:"money" column) — never route a cents field through a legacy slot. When another such field must show through a LEGACY slot (Text value, legacy Table column, legacy Stat value, Badge label), a format(...) pipe is mandatory. format(...) turns a value into a STRING, so it is ONLY for text the user reads, NEVER for data a component computes on:
191
+ - format(...) on a legacy text/label slot: dates value={invoice.dueDate | format(date)}, or a legacy Table column in place: rows={invoices | format(dueDate, date)}. Percents (0..1): format(percent). Plain numbers: format(number). Whole-dollar (non-cents) amounts: format(currency).
192
+ - One way or the other is NOT optional: EVERY date/timestamp field and EVERY cents money field the user sees must ride a Kit semantic component / format token (dates in a legacy slot may carry a format step instead). A raw ISO string like 2026-07-21T17:00:00-07:00 or raw cents like 285000 on screen is a defect.
193
+ - NEVER format a value bound into a CHART or visualization component — anything that draws from numbers (a *Chart/*Donut/*Graph/*Plot host component, or its slices/series/points/segments/data/values prop), an <Island>, or a reshape aggregate (sum/avg/asPoints). Those need the RAW numeric field; a chart or total fed formatted STRINGS computes NaN and draws nothing. Example: for a spending donut + a table off the same query, bind slices={spending.data} (raw) and give the DataTable the same raw rows with a format:"money" column — never bind pre-formatted strings into the donut.
194
+ NEVER bind a raw object or array into a Text body, a Stat value, a Badge label, or a Table cell — it renders as raw JSON like {"received":3,"total":6} and fails validation. Reach the nested SCALAR instead: a DataTable/CardList dot-path column key ({key:"assignedTo.name"}, {key:"progress.received"}), or bind the specific scalar field ({dashboard.data.nearestDeadline.clientName}). Otherwise exclude the object column via columns=[...scalar keys].`,
195
+ }]),
196
+ ];
197
+ const wireContract = (deps) => composePromptSections(wireContractSections(deps));
198
+ // ---------------------------------------------------------------------------
199
+ // v4 create contract (spec 2026-07-20-vendo-v4-generation-wave) — single-voice
200
+ // sections, each principle stated once, worked exemplars spanning archetypes.
201
+ // Rules a validator catches are deliberately absent: the repair loop re-teaches
202
+ // them with the violation message when broken. Opt-in via pipeline.promptRewrite
203
+ // while the A/B against wireContract runs on dev prompts.
204
+ // ---------------------------------------------------------------------------
205
+ /** The fictional host used by the exemplars. Exported so the exemplar-validity
206
+ * test compiles every exemplar against these exact tools — a broken example
207
+ * teaches broken apps, so the examples are pinned by test. */
208
+ export const V4_EXEMPLAR_TOOLS = [
209
+ {
210
+ name: "acme_getReceivables", description: "Receivables summary.", risk: "read",
211
+ inputSchema: { type: "object", properties: {} },
212
+ },
213
+ {
214
+ name: "acme_listInvoices", description: "List invoices.", risk: "read",
215
+ inputSchema: { type: "object", properties: { status: { type: "string" } } },
216
+ },
217
+ {
218
+ name: "acme_sendReminder", description: "Email a payment reminder for one invoice.", risk: "write",
219
+ inputSchema: { type: "object", properties: { invoiceId: { type: "string" } }, required: ["invoiceId"] },
220
+ },
221
+ {
222
+ name: "acme_payInvoice", description: "Pay one invoice from the default account. Moves money.", risk: "destructive",
223
+ inputSchema: { type: "object", properties: { invoiceId: { type: "string" } }, required: ["invoiceId"] },
224
+ },
225
+ ];
226
+ export const V4_EXEMPLARS = [{
227
+ title: "A worklist",
228
+ request: "which invoices are overdue and let me chase them",
229
+ wire: `<App name="Overdue invoices">
230
+ <Stack gap={5}>
231
+ <Row gap={4}>
232
+ <Stat label="Overdue total" value={acme_getReceivables({}).overdueTotalCents} format="money" tone="accent"/>
233
+ <Stat label="Invoices overdue" value={acme_getReceivables({}).overdueCount}/>
234
+ <Stat label="Oldest due" value={acme_getReceivables({}).oldestDueDate} format="date"/>
235
+ </Row>
236
+ <DataTable rows={acme_listInvoices({status:"overdue"}).data} sortBy="dueDate asc" searchable columns={[{key:"clientName",label:"Client"},{key:"amountCents",label:"Amount",format:"money",align:"end"},{key:"dueDate",label:"Due",format:"date"},{key:"status",label:"Status"}]} emptyState="No overdue invoices — nothing to chase."/>
237
+ <Button label="Send reminder for the most overdue" onClick={{action:"acme_sendReminder", payload:{invoiceId: acme_listInvoices({status:"overdue"}).data.0.id}}}/>
238
+ <BarChart data={acme_getReceivables({}).byMonth} xKey="month" series={["overdueCents"]} format="money" emptyState="No history to chart yet."/>
239
+ </Stack>
240
+ </App>`,
241
+ why: "The hero is the number the user came for; the table is the working surface; the action is wired to a real tool with its context bound into payload (it will pause for user approval); the chart supports; every value rides a formatting component; every label is true of its binding.",
242
+ }, {
243
+ title: "An action flow where half the ask is impossible",
244
+ request: "pay an invoice and set up autopay",
245
+ wire: `<App name="Pay an invoice">
246
+ <Stack gap={5}>
247
+ <Island name="PayInvoicePanel">
248
+ export default function PayInvoicePanel() {
249
+ const [invoices, setInvoices] = useState([]);
250
+ const [invoiceId, setInvoiceId] = useState("");
251
+ const [phase, setPhase] = useState("idle");
252
+ useEffect(() => { tools.acme_listInvoices({ status: "open" }).then((r) => setInvoices(r.data)); }, []);
253
+ const pay = async () => {
254
+ setPhase("sending");
255
+ const result = await tools.acme_payInvoice({ invoiceId });
256
+ setPhase(result && result.status === "pending-approval" ? "awaiting" : "paid");
257
+ };
258
+ return (
259
+ <div style={{ display: "grid", gap: 12, padding: 16, background: "var(--vendo-color-surface)", border: "1px solid var(--vendo-color-border)", borderRadius: 8 }}>
260
+ <select value={invoiceId} onChange={(e) => setInvoiceId(e.target.value)}>
261
+ <option value="">Choose an invoice…</option>
262
+ {invoices.map((inv) => (
263
+ <option key={inv.id} value={inv.id}>{inv.clientName} — {fmt.money(inv.amountCents)}</option>
264
+ ))}
265
+ </select>
266
+ <button onClick={pay} disabled={invoiceId === "" || phase !== "idle"}>
267
+ {phase === "awaiting" ? "Awaiting your approval…" : phase === "paid" ? "Paid" : "Pay invoice"}
268
+ </button>
269
+ </div>
270
+ );
271
+ }
272
+ </Island>
273
+ <PayInvoicePanel/>
274
+ <DataTable rows={acme_listInvoices({status:"open"}).data} columns={[{key:"clientName",label:"Client"},{key:"amountCents",label:"Amount",format:"money",align:"end"},{key:"dueDate",label:"Due",format:"date"}]} emptyState="No open invoices."/>
275
+ <Disclaimer title="Autopay isn't available" reason="No tool on this host manages autopay, so it can't be set up here. Any open invoice can be paid above."/>
276
+ </Stack>
277
+ </App>`,
278
+ why: "The feasible half is BUILT (the hero is the form), the impossible half gets a plain disclaimer — never a dead control. The island does its own dependent lookup with ambient tools, renders the awaiting-approval state for the mutating call, and formats with fmt.",
279
+ }, {
280
+ title: "A detail page",
281
+ request: "show me my latest invoice in full",
282
+ wire: `<App name="Latest invoice">
283
+ <Stack gap={5}>
284
+ <Row gap={3}>
285
+ <Text variant="heading" text={acme_listInvoices({}).data.0.clientName}/>
286
+ <EnumBadge value={acme_listInvoices({}).data.0.status}/>
287
+ </Row>
288
+ <Row gap={4}>
289
+ <Stat label="Amount" value={acme_listInvoices({}).data.0.amountCents} format="money"/>
290
+ <Stat label="Due" value={acme_listInvoices({}).data.0.dueDate} format="date"/>
291
+ <Stat label="Invoice #" value={acme_listInvoices({}).data.0.number}/>
292
+ </Row>
293
+ <DataTable rows={acme_listInvoices({}).data.0.lineItems} columns={[{key:"description",label:"Item"},{key:"amountCents",label:"Amount",format:"money",align:"end"}]} emptyState="No line items on this invoice."/>
294
+ <Button label="Send reminder" onClick={{action:"acme_sendReminder", payload:{invoiceId: acme_listInvoices({}).data.0.id}}}/>
295
+ </Stack>
296
+ </App>`,
297
+ why: "A detail page, not a dashboard: identity first (name + live status), the facts row, then the record's contents. Dot-numeric segments address the newest item; the identical call is written identically everywhere, so it is one fetch.",
298
+ }];
299
+ const v4Role = `<role>
300
+ You are the Vendo app generation engine, embedded in the host product. From one user request you compose a small, trustworthy, beautiful app out of this host's own data and actions. Emit exactly one <App name="..."> element in vendo-genui/v2 wire markup — no prose, no fences, no JSON, no comments.
301
+ </role>`;
302
+ const v4GreatApps = `<building_great_apps>
303
+ Work in this order:
304
+ 1. Find the hero. Every ask has one thing the user came for — a number to check, a list to work through, a form to submit, a question to answer. Put it first and make it unmistakably the most important thing on screen.
305
+ 2. Pick the shape that serves the ask: a dashboard answers "how are things?"; a worklist serves "what do I act on?"; a detail page serves "tell me about X"; a form or flow serves "do this for me"; a board or timeline serves "how is this progressing?"; a report serves "brief me". Not every ask is a dashboard — a message-composer's hero is the compose box.
306
+ 3. Compose a hierarchy, not a pile. After the hero, add 2–4 supporting sections in descending order of usefulness. Give the hero and charts full width; group related small stats into one row; a section either fills its row or shares it evenly — never leave a lone card floating beside empty space.
307
+ 4. Match density to the job. A glanceable digest gets a few big numbers; a working table gets compact rows, search, and filters. Pick one density per app and hold it.
308
+ 5. Speak human. Titles say what the data actually is ("Checking balance", not "Total balance" over one account). Enum values render through EnumBadge. Every date, amount, and percent rides its Kit component so it formats itself.
309
+ 6. Design the gaps. An empty query gets a written emptyState with a next step, never a blank region. A part of the ask no tool can serve gets a Disclaimer that says so plainly — placed where that part would have appeared, one per missing part, never one blanket note for the whole app — and the parts that ARE feasible still get built.
310
+ 7. Choose charts by what the data says. Bars compare categories; lines show change over time; a donut shows shares of a whole (six slices or fewer — more reads better as a horizontal bar list); a sparkline is an inline hint, not a section. One chart per fact — never two visualizations of the same numbers.
311
+ 8. Restraint is the brand. Accent belongs to the hero and the primary action — if everything is highlighted, nothing is. Tones carry meaning (danger means something is actually wrong), never decoration. Whitespace separates sections; it is structure, not waste.
312
+ 9. Keep the details quiet. Right-align money and numbers in tables. Sentence case for labels and titles. Human-form timestamps unless the ask is an audit trail.
313
+ 10. Wear the host's brand. Reach for the host catalog components first, the Kit second, and follow the host design rules below. The bar: the app looks like the host shipped it.
314
+ </building_great_apps>`;
315
+ const v4Principles = `<principles>
316
+ 1. Real data only. Every number, row, and chart point the user sees comes from a tool binding — including derived values: a computation may only combine tool data (an invented rate or constant is fabrication). When no tool backs an ask, the Disclaimer is the correct output.
317
+ 2. Claims tell the truth. Every title, header, badge, and sentence of copy is literally true of the data beneath it. When the data can't support the claim, change the words, not the data.
318
+ 3. Actions are real and gated. A button either names a host tool with its context bound into payload, or it doesn't exist. Mutations pause for user approval — render that state. When no tool can perform the ask, say so plainly instead.
319
+ 4. Brand-native. Host catalog components first, Kit second, host tokens always — on every host, the app should read as if the host shipped it.
320
+ </principles>`;
321
+ const v4Grammar = () => `<wire_grammar>
322
+ - One <App name="..."> contains the whole app. Positional nesting expresses the tree; the compiler mints ids — never write id attributes.
323
+ - Data binds inline: rows={host_listInvoices({limit:20}).data} — an exact HOST TOOLS name, a literal args object ({} when none), then a field path. The identical call+args expression is ONE fetch — reuse it for the same data. <Query id="name" tool="..." input={{...}}/> declarations (bound as {name.field.path}) also work.
324
+ - The field path goes through the tool's response envelope exactly as TOOL RESPONSE SHAPES declares it — when a shape shows {data: {...}}, the path is host_getClient({id:"..."}).data.name, never a guessed top-level field.
325
+ - A binding is one call plus a plain field path — components handle computation, sorting, and formatting. Address list items with dot-numeric segments: {host_listAccounts({}).data.0.name}. A binding may end with one bounded | op(...) pipe; the common need is display format on a legacy text slot: value={x.dueDate | format(date)} — Kit components format themselves and never need it, and cents money always rides the Kit (<Money cents={...}/>, a format:"money" column), never a legacy slot.
326
+ - Args are literal JSON: a call's input never comes from another call's result. For a dependent lookup, use an <Island> with ambient tools.
327
+ - Actions are on* attributes naming a host tool, with the context they act on bound into payload: onClick={{action:"host_sendReminder", payload:{invoiceId: host_listInvoices({}).data.0.id}}}.
328
+ - <Island name="PascalName"> holds TSX with an export default component, rendered in a sandboxed frame and referenced as <PascalName/>. Already in scope (write no imports): React and its hooks, the entire Kit (${ISLAND_AMBIENT_KIT_NAMES.join(", ")}), and fmt helpers (fmt.money(cents), fmt.dateTime(iso), fmt.percent(ratio), fmt.num(n)).
329
+ - Host catalog components render on the host page — use them as tree nodes. An island's sandbox has only the ambient Kit: inside island source, the Kit equivalent is the correct choice (a host component name there can never render).
330
+ - Islands read and act ONLY through the ambient tools API — await tools.host_listInvoices({}), the tool name as a literal member access, args matching the tool's (input: …) sketch. There is no network. A mutating call resolves {status:"pending-approval"} and its effect lands after the user approves — render an awaiting-approval state.
331
+ - Island styling (until the utility sheet ships): inline styles over the host tokens — var(--vendo-color-background|surface|text|muted|accent|accent-text|danger|border), var(--vendo-font-family), var(--vendo-radius-small|medium|large). The frame sits on the host page's light background.
332
+ - Compose regions in the tree so the app streams in — an island is one region, never the whole app.
333
+ - Limits: ${TREE_MAX_NODES} nodes, ${TREE_MAX_QUERIES} queries, ${TREE_MAX_GENERATED_COMPONENTS} islands, ${TREE_MAX_COMPONENT_SOURCE_BYTES} bytes per island.
334
+ </wire_grammar>`;
335
+ const v4Examples = () => `<examples>
336
+ Three complete apps for a FICTIONAL billing host. Its acme_* tools are NOT available to you — bind only the HOST TOOLS listed above. Study the shape, not the tools.
337
+
338
+ ${V4_EXEMPLARS.map(({ title, request, wire, why }) => `<example>
339
+ ${title}. Request: "${request}"
340
+
341
+ ${wire}
342
+
343
+ Why this is right: ${why}
344
+ </example>`).join("\n\n")}
345
+ </examples>`;
346
+ /** v4 — charts preamble carried with the components section (the historical
347
+ * $NaN class: a chart fed formatted strings draws nothing). */
348
+ const V4_COMPONENTS_PREAMBLE = "Charts and visualizations read RAW numeric fields — their format prop handles display. Money is integer cents end-to-end; the Kit formats it.";
349
+ export const wireContractV4 = (deps) => composePromptSections([
350
+ { id: "role", content: v4Role },
351
+ { id: "tree-contract", content: v4GreatApps },
352
+ { id: "tree-contract", content: v4Grammar() },
353
+ { id: "tree-contract", content: v4Principles },
354
+ { id: "prewired-props", content: `<components>\n${V4_COMPONENTS_PREAMBLE}\n\n${componentsPromptSection()}\n</components>` },
355
+ {
356
+ id: "catalog",
357
+ content: `<host>\n${composePromptSections([
358
+ ...hostToolSections(deps),
359
+ ...generationPromptSections(deps).filter(({ id }) => id === "catalog" || id === "theme" || id === "design-rules" || id === "clock"
360
+ || (deps.pinBaselines !== undefined && deps.pinBaselines.length > 0 && id === "remixable-slots")),
361
+ ])}\n</host>`,
362
+ },
363
+ { id: "prewired-props", content: v4Examples() },
364
+ ]);
365
+ /** Contract selection for the create lanes (full, paint, section): the v4
366
+ * rewrite rides pipeline.promptRewrite while the A/B is measured. */
367
+ const createContract = (deps) => deps.pipeline?.promptRewrite === true ? wireContractV4(deps) : wireContract(deps);
368
+ /** v2 spec §4 — the tier-0 lane emits a complete, fully-WIRED generic app
369
+ * immediately; the full lane then upgrades it in place by stable id. */
370
+ const tier0Contract = (deps) => `${createContract(deps)}
371
+
372
+ PAINT PASS (tier-0): emit a complete, minimal, fully-wired GENERIC app for the request RIGHT NOW.
373
+ - Catalog components with conservative default props; real inline tool references (or <Query> declarations) for the most relevant read tools so live data flows immediately.
374
+ - NO <Island> code islands — catalog and prewired components only.
375
+ - Keep it small (well under 40 nodes) and generic; a full-quality pass will replace it subtree-by-subtree, so favor a stable, conventional layout over cleverness.`;
376
+ /** The compact tier-0 structure the full lane is conditioned on: minted id +
377
+ * component per node, in document order, so the full lane can keep the
378
+ * layout ordering (and therefore the minted ids) stable for in-place
379
+ * hot-swap. */
380
+ const layoutHeader = (compiled) => compiled.tree.nodes.map((node) => `${node.id}:${node.component}`).join(" ");
381
+ /** Models wrap output in prose or a markdown fence despite instructions
382
+ * (the deleted v1 JSON path had the same tolerance). The wire is
383
+ * everything from the first `<App` through the last `</App>` (or stream
384
+ * end while it is still open) — deterministic, so prefix compiles stay
385
+ * valid-while-partial. */
386
+ const extractWire = (text) => {
387
+ const start = text.indexOf("<App");
388
+ if (start === -1)
389
+ return text;
390
+ const closeTag = "</App>";
391
+ const close = text.lastIndexOf(closeTag);
392
+ return close === -1 ? text.slice(start) : text.slice(start, close + closeTag.length);
393
+ };
394
+ /** W3 Part 3 (W1 Exp1 verdict: ADOPT) — the production compile options:
395
+ * inline tool refs ON everywhere the engine compiles model wire (the
396
+ * registry names enable single-segment production tool heads); `<Query>`
397
+ * declarations stay accepted unchanged. */
398
+ const wireCompileOptionsFor = (deps, hostComponents) => ({
399
+ hostComponents,
400
+ inlineRefs: true,
401
+ ...(deps.tools === undefined ? {} : { inlineTools: deps.tools.map(({ name }) => name) }),
402
+ ...(deps.toolShapes === undefined ? {} : { toolShapes: deps.toolShapes }),
403
+ });
404
+ /** Stream the wire, compiling each accumulated prefix (throttled) into a
405
+ * valid-while-partial tree for the onPartial seam. */
406
+ const streamWire = async (deps, system, prompt, hostComponents, timing) => {
407
+ let text = "";
408
+ let timer;
409
+ let lastFlushAt = 0;
410
+ let firstPartialAt = 0;
411
+ const pending = [];
412
+ const reportTiming = (phase, usage) => {
413
+ if (deps.onTiming === undefined || timing === undefined)
414
+ return;
415
+ deps.onTiming({ lane: timing.lane, phase, atMs: Date.now() - timing.startedAt, thinking: timing.thinking, ...(usage === undefined ? {} : { usage }) });
416
+ };
417
+ const flush = () => {
418
+ if (deps.onPartial === undefined)
419
+ return;
420
+ if (firstPartialAt === 0) {
421
+ firstPartialAt = Date.now();
422
+ reportTiming("first-partial");
423
+ }
424
+ lastFlushAt = Date.now();
425
+ const compiled = compileWireV2(extractWire(text), wireCompileOptionsFor(deps, hostComponents));
426
+ const partial = {
427
+ tree: compiled.tree,
428
+ ...(compiled.name === undefined ? {} : { name: compiled.name }),
429
+ ...(Object.keys(compiled.components).length === 0 ? {} : { components: compiled.components }),
430
+ };
431
+ pending.push(Promise.resolve(deps.onPartial(partial)).catch(() => undefined));
432
+ };
433
+ const schedule = () => {
434
+ if (deps.onPartial === undefined)
435
+ return;
436
+ const remaining = Math.max(0, 100 - (Date.now() - lastFlushAt));
437
+ if (lastFlushAt === 0 || remaining === 0) {
438
+ if (timer !== undefined)
439
+ clearTimeout(timer);
440
+ timer = undefined;
441
+ flush();
442
+ }
443
+ else if (timer === undefined) {
444
+ timer = setTimeout(() => {
445
+ timer = undefined;
446
+ flush();
447
+ }, remaining);
448
+ }
449
+ };
450
+ const finishPartials = async () => {
451
+ // A throttled flush may still be pending at stream end — deliver it so
452
+ // the last pre-final prefix reaches the seam before the final document.
453
+ if (timer !== undefined) {
454
+ clearTimeout(timer);
455
+ timer = undefined;
456
+ flush();
457
+ }
458
+ await Promise.all(pending);
459
+ };
460
+ try {
461
+ const { streamText } = await import("ai");
462
+ const result = streamText({
463
+ model: deps.model,
464
+ system,
465
+ prompt,
466
+ temperature: 0,
467
+ maxRetries: 0,
468
+ });
469
+ for await (const delta of result.textStream) {
470
+ text += delta;
471
+ schedule();
472
+ }
473
+ await finishPartials();
474
+ if (deps.onTiming !== undefined && timing !== undefined) {
475
+ const usage = await Promise.resolve(result.usage).catch(() => undefined);
476
+ reportTiming("complete", usage === undefined ? undefined : { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens });
477
+ }
478
+ return { compiled: compileWireV2(extractWire(text), wireCompileOptionsFor(deps, hostComponents)), raw: extractWire(text), issues: [] };
479
+ }
480
+ catch (error) {
481
+ await finishPartials();
482
+ return { issues: [`model generation failed: ${error instanceof Error ? error.message : "unknown error"}`] };
483
+ }
484
+ };
485
+ /** verify-v2 fixes — models wrap island TSX in a JSX template-literal
486
+ * expression (`{`…`}`) despite instructions; strip it deterministically,
487
+ * the way {@link extractWire} strips fences. */
488
+ const ISLAND_WRAPPER = /^\{\s*`([\s\S]*)`\s*\}$/;
489
+ const normalizeIslandSource = (source) => {
490
+ const trimmed = source.trim();
491
+ const match = ISLAND_WRAPPER.exec(trimmed);
492
+ return match === null ? trimmed : match[1].trim();
493
+ };
494
+ /** TSX syntax gate for island sources. esbuild loads lazily (same pattern as
495
+ * the "ai" import); when unavailable the syntax check is skipped and the
496
+ * default-export check still applies.
497
+ *
498
+ * The magic comments below are bundler directives, not runtime code — Node
499
+ * ignores them and this stays a plain dynamic import (proven: still works
500
+ * under Vitest's vm-sandboxed test runner, unlike a `new Function`-built
501
+ * indirection, which throws ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING there).
502
+ * `webpackIgnore`/`turbopackIgnore` tell the bundler to skip resolving this
503
+ * specific specifier at build time instead of walking into esbuild's
504
+ * package — which is where the real damage happens: esbuild's own
505
+ * lib/main.js resolves its native binary with a dynamic require, and once a
506
+ * bundler is inside esbuild's module graph at all, it tries to parse the
507
+ * platform binary and its README.md as JS and hard-fails the build.
508
+ * Confirmed empirically: without these comments (or `serverExternalPackages:
509
+ * ["esbuild"]` in the host's next.config), `next build` on a host importing
510
+ * "@vendoai/vendo/server" fails with "Unknown module type" /
511
+ * "invalid utf-8 sequence" on esbuild's platform binary; with them, the
512
+ * same host builds clean with esbuild left OUT of `serverExternalPackages`
513
+ * entirely (corpus-triage Task 10). */
514
+ const esbuildTransform = (async () => {
515
+ try {
516
+ const esbuild = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ "esbuild");
517
+ return (source) => void esbuild.transformSync(source, { loader: "tsx" });
518
+ }
519
+ catch {
520
+ return undefined;
521
+ }
522
+ })();
523
+ /** Every module specifier an island source imports — static (`import … from`,
524
+ * side-effect `import "x"`, `export … from`), dynamic `import("x")`, and
525
+ * `require("x")`. The jail's sucrase loader rewrites all of these to its
526
+ * require table, so any specifier here that is not an island-resolvable
527
+ * module (`ISLAND_STRIPPED_SPECIFIERS`) cannot resolve at runtime. */
528
+ const IMPORT_SPECIFIER = /(?:\bimport\b|\bexport\b)[^'"]*?\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g;
529
+ const islandImportSpecifiers = (source) => {
530
+ const specifiers = [];
531
+ for (const match of source.matchAll(IMPORT_SPECIFIER)) {
532
+ const specifier = match[1] ?? match[2] ?? match[3] ?? match[4];
533
+ if (specifier !== undefined)
534
+ specifiers.push(specifier);
535
+ }
536
+ return specifiers;
537
+ };
538
+ const ISLAND_RESOLVABLE_MODULE_SET = new Set(ISLAND_STRIPPED_SPECIFIERS);
539
+ /** verify-v2 fixes + W4b — a broken island must never persist: it renders as
540
+ * a contained error instead of an app. Checked at create AND edit, routed to
541
+ * repair. An island reaching for a module the ambient scope cannot provide
542
+ * error-boxes the whole app (verify-v2 #5: `recharts`), so a disallowed
543
+ * import is rejected before the syntax gate; computed/aliased `tools` access
544
+ * and unknown tool names are rejected before they can reach the runtime. */
545
+ const prepareIslands = async (rawComponents, tools, hostComponents = []) => {
546
+ const issues = [];
547
+ const components = {};
548
+ const componentTools = {};
549
+ const knownTools = tools === undefined ? undefined : new Set(tools.map((tool) => tool.name));
550
+ // Host catalog + prewired components render in the HOST page — they can
551
+ // never cross into the opaque-origin jail, so an island JSX tag naming one
552
+ // is a guaranteed ReferenceError (live P4: <MapleSpendingDonut/>). Names
553
+ // the ambient Kit also provides are fine — the Kit version renders.
554
+ const ambientNames = new Set(ISLAND_AMBIENT_KIT_NAMES);
555
+ const hostOnlyNames = [...new Set([...hostComponents, ...RESERVED_COMPONENT_NAMES])]
556
+ .filter((componentName) => !ambientNames.has(componentName));
557
+ // W3 (#432) — name resolution is host catalog → built-ins → islands, so an
558
+ // island NAMED after any of those never renders: the built-in wins and the
559
+ // island is dead weight. Reject the name itself → repair to a distinct one.
560
+ const unreachableIslandNames = new Set([
561
+ ...hostComponents,
562
+ ...RESERVED_COMPONENT_NAMES,
563
+ ...KIT_WIRE_COMPONENT_NAMES,
564
+ ]);
565
+ const transform = await esbuildTransform;
566
+ for (const [name, rawSource] of Object.entries(rawComponents)) {
567
+ if (unreachableIslandNames.has(name)) {
568
+ issues.push(`island "${name}" would never render — component names resolve host catalog → built-ins (Kit/prewired) → islands, so "${name}" always resolves to the built-in/host component instead. Rename the island to a distinct PascalCase name.`);
569
+ }
570
+ const stripped = stripIslandImports(normalizeIslandSource(rawSource));
571
+ issues.push(...stripped.issues.map((issue) => `island "${name}" ${issue}`));
572
+ const source = stripped.source.trim();
573
+ components[name] = source;
574
+ if (!hasDefaultExport(source)) {
575
+ issues.push(`island "${name}" must be plain TSX with an \`export default\` component — no braces, template literals, or fences around the source`);
576
+ continue;
577
+ }
578
+ const disallowed = [...new Set(islandImportSpecifiers(source))].filter((specifier) => !ISLAND_RESOLVABLE_MODULE_SET.has(specifier));
579
+ if (disallowed.length > 0) {
580
+ issues.push(`island "${name}" imports ${disallowed.map((specifier) => `"${specifier}"`).join(", ")} — islands have NO imports; React, the Kit components (including the ambient Kit charts), fmt, and tools are already in scope, and nothing else can load in the network-denied sandbox. Remove the import and use the ambient names.`);
581
+ continue;
582
+ }
583
+ const hostTags = hostOnlyNames.filter((componentName) => new RegExp(`<\\s*${componentName}\\b`).test(source)
584
+ // A locally-declared component of the same name is the island's own
585
+ // (review): the local binding wins inside the jail, so don't reject it.
586
+ && !new RegExp(`\\b(?:function|const|let|var|class)\\s+${componentName}\\b`).test(source));
587
+ if (hostTags.length > 0) {
588
+ issues.push(`island "${name}" renders ${hostTags.map((tag) => `<${tag}>`).join(", ")} — host catalog and prewired components exist only in the host page and can never load inside an island. Compose them in the TREE, or use the ambient Kit inside the island (${ISLAND_AMBIENT_KIT_NAMES.join(", ")}).`);
589
+ }
590
+ // The jail has no network: a habit-written fetch/XHR dies silently at the
591
+ // CSP, so catch it here and repair to the ambient tools API instead.
592
+ for (const api of islandNetworkViolations(source)) {
593
+ issues.push(`island "${name}" calls ${api}(…) — an island has no network (the sandbox blocks fetch/XHR/WebSocket); the ambient tools API is the ONLY way to read or act: \`await tools.<tool_name>(args)\` with a HOST TOOLS name.`);
594
+ }
595
+ // The ambient tools contract: literal member access only, every chain
596
+ // resolved against the live registry, the result stamped as the island's
597
+ // entire runtime tool surface.
598
+ const scan = scanIslandTools(source);
599
+ issues.push(...scan.violations.map((violation) => `island "${name}" ${violation}`));
600
+ const manifest = new Set();
601
+ for (const path of scan.paths) {
602
+ if (knownTools === undefined) {
603
+ manifest.add(path.join("_"));
604
+ continue;
605
+ }
606
+ const resolved = resolveIslandToolName(path, knownTools);
607
+ if (resolved === null) {
608
+ issues.push(`island "${name}" calls unknown tool "tools.${path.join(".")}" — the host tools are: ${[...knownTools].join(", ")}`);
609
+ }
610
+ else {
611
+ manifest.add(resolved);
612
+ }
613
+ }
614
+ componentTools[name] = [...manifest].sort();
615
+ if (transform === undefined)
616
+ continue;
617
+ try {
618
+ transform(source);
619
+ }
620
+ catch (error) {
621
+ issues.push(`island "${name}" is not valid TSX: ${error instanceof Error ? error.message.split("\n")[0] : "syntax error"}`);
622
+ }
623
+ }
624
+ return { components, componentTools, issues };
625
+ };
626
+ /** Conservative kind check between a bound field's shape and the host prop's
627
+ * declared JSON-schema type: only CLEAR mismatches flag (an array of objects
628
+ * where number[] is expected renders an empty chart — the verify-v2 class);
629
+ * unknown shapes/schemas stay silent. */
630
+ const shapeSchemaMismatch = (shape, schema) => {
631
+ const type = typeof schema.type === "string" ? schema.type : undefined;
632
+ if (type === undefined || shape.kind === "json")
633
+ return null;
634
+ if (type === "array") {
635
+ if (shape.kind !== "array")
636
+ return `expected an array, the bound field is ${shape.kind}`;
637
+ const items = schema.items;
638
+ return isRecord(items) ? shapeSchemaMismatch(shape.items, items) : null;
639
+ }
640
+ if (type === "number" || type === "integer") {
641
+ return shape.kind === "number" ? null : `expected a number, the bound field is ${shape.kind}`;
642
+ }
643
+ if (type === "string")
644
+ return shape.kind === "string" ? null : `expected a string, the bound field is ${shape.kind}`;
645
+ if (type === "boolean")
646
+ return shape.kind === "boolean" ? null : `expected a boolean, the bound field is ${shape.kind}`;
647
+ if (type === "object")
648
+ return shape.kind === "object" ? null : `expected an object, the bound field is ${shape.kind}`;
649
+ return null;
650
+ };
651
+ /** verify-v2 fixes — with tool shapes AND the catalog's prop schemas both in
652
+ * hand, a top-level `$path` prop on a host node can be kind-checked end to
653
+ * end. Existence is shape-check.ts's job; this catches the type mismatches
654
+ * that render silently broken (empty chart, blank stat). */
655
+ const bindingKindIssues = (compiled, deps) => {
656
+ if (deps.toolShapes === undefined)
657
+ return [];
658
+ const issues = [];
659
+ const queryTool = new Map((compiled.tree.queries ?? []).map((query) => [query.name, query.tool]));
660
+ const hostSchemas = new Map(deps.catalog.map((component) => [component.name, component.propsJsonSchema]));
661
+ for (const node of compiled.tree.nodes) {
662
+ if (node.source !== "host" || node.props === undefined)
663
+ continue;
664
+ const schema = hostSchemas.get(node.component);
665
+ const properties = isRecord(schema) && isRecord(schema.properties) ? schema.properties : undefined;
666
+ if (properties === undefined)
667
+ continue;
668
+ for (const [prop, value] of Object.entries(node.props)) {
669
+ if (!isPathBinding(value))
670
+ continue;
671
+ const [, queryName = "", ...rest] = value.$path.split("/");
672
+ const tool = queryTool.get(queryName);
673
+ const toolShape = tool === undefined ? undefined : deps.toolShapes[tool];
674
+ if (toolShape === undefined)
675
+ continue;
676
+ const bound = shapeAtPointer(toolShape, rest.length === 0 ? "" : `/${rest.join("/")}`);
677
+ if (bound === undefined)
678
+ continue;
679
+ const propSchema = properties[prop];
680
+ if (!isRecord(propSchema))
681
+ continue;
682
+ const mismatch = shapeSchemaMismatch(bound, propSchema);
683
+ if (mismatch !== null) {
684
+ issues.push(`node "${node.id}" prop "${prop}" binds ${value.$path}: ${mismatch} — bind a field whose shape matches the component's prop type`);
685
+ }
686
+ }
687
+ }
688
+ return issues;
689
+ };
690
+ /** W3 (live-verify finding) — asPoints/asOptions produce generic
691
+ * {label,value}/{value,label} items; a HOST prop whose schema declares its
692
+ * OWN item field names cannot read them (the Maple donut drew $NaN). The
693
+ * raw rows are the legal binding — reject the reshape at compile. */
694
+ const GENERIC_ITEM_RESHAPES = new Set(["asPoints", "asOptions"]);
695
+ const hostReshapeIssues = (compiled, deps) => {
696
+ const issues = [];
697
+ const hostSchemas = new Map(deps.catalog.map((component) => [component.name, component.propsJsonSchema]));
698
+ for (const node of compiled.tree.nodes) {
699
+ if (node.source !== "host" || node.props === undefined)
700
+ continue;
701
+ const schema = hostSchemas.get(node.component);
702
+ const properties = isRecord(schema) && isRecord(schema.properties) ? schema.properties : undefined;
703
+ if (properties === undefined)
704
+ continue;
705
+ for (const [prop, value] of Object.entries(node.props)) {
706
+ if (!isPathBinding(value))
707
+ continue;
708
+ const reshape = value.$reshape;
709
+ if (!Array.isArray(reshape) || !reshape.some((step) => GENERIC_ITEM_RESHAPES.has(step?.op ?? "")))
710
+ continue;
711
+ const propSchema = properties[prop];
712
+ const items = isRecord(propSchema) && isRecord(propSchema.items) ? propSchema.items : undefined;
713
+ const itemProperties = items !== undefined && isRecord(items.properties) ? Object.keys(items.properties) : [];
714
+ if (itemProperties.length === 0)
715
+ continue;
716
+ if (itemProperties.includes("label") && itemProperties.includes("value"))
717
+ continue;
718
+ issues.push(`node "${node.id}" prop "${prop}" reshapes with asPoints/asOptions, but host component "${node.component}" declares its own item fields (${itemProperties.join(", ")}) — it cannot read generic {label, value} items. Bind the RAW rows (drop the reshape) so the component receives the fields its schema names.`);
719
+ }
720
+ }
721
+ return issues;
722
+ };
723
+ /** W3 law 2 (live-verify finding) — a query input executes as LITERAL JSON:
724
+ * the runtime never resolves bindings inside it, so a dependent call
725
+ * (`accountId: accounts.data.0.id`) reaches the tool as an unresolved
726
+ * binding object and the app ships broken. Reject at compile → repair. */
727
+ const queryInputIssues = (tree) => {
728
+ const issues = [];
729
+ const findBinding = (value) => {
730
+ if (isPathBinding(value) || isStateBinding(value))
731
+ return true;
732
+ if (Array.isArray(value))
733
+ return value.some(findBinding);
734
+ if (isRecord(value))
735
+ return Object.values(value).some(findBinding);
736
+ return false;
737
+ };
738
+ for (const query of tree.queries ?? []) {
739
+ if (query.input !== undefined && findBinding(query.input)) {
740
+ issues.push(`query "${query.name}" (tool "${query.tool}") embeds a binding in its input — query inputs must be LITERAL JSON the tool can execute directly; another query's result can never feed a query input. Use a literal value (or drop the optional input), or build the dependent lookup inside an <Island> with ambient tools.`);
741
+ }
742
+ }
743
+ return issues;
744
+ };
745
+ /** W3 law 1 raw typing — probe values per shape kind, parsed against the Kit
746
+ * prop's zod schema. Kind-level only: a string-shaped field bound into
747
+ * Money.cents fails (pre-formatted money strings never reach a numeric
748
+ * slot); unknown shapes stay silent. */
749
+ const KIND_PROBES = {
750
+ string: "probe",
751
+ number: 1,
752
+ boolean: true,
753
+ array: [],
754
+ object: {},
755
+ };
756
+ const KIT_WIRE_SET = new Set(KIT_WIRE_COMPONENT_NAMES);
757
+ const kitSlotIssues = (compiled, deps) => {
758
+ if (deps.toolShapes === undefined)
759
+ return [];
760
+ const issues = [];
761
+ const queryTool = new Map((compiled.tree.queries ?? []).map((query) => [query.name, query.tool]));
762
+ for (const node of compiled.tree.nodes) {
763
+ if (node.source === "host" || node.source === "generated" || node.props === undefined)
764
+ continue;
765
+ if (!KIT_WIRE_SET.has(node.component))
766
+ continue;
767
+ const spec = kitSpec(node.component);
768
+ if (spec === undefined)
769
+ continue;
770
+ for (const [prop, value] of Object.entries(node.props)) {
771
+ if (!isPathBinding(value) || "$reshape" in value)
772
+ continue;
773
+ const propSpec = spec.props[prop];
774
+ if (propSpec === undefined)
775
+ continue;
776
+ const [, queryName = "", ...rest] = value.$path.split("/");
777
+ const tool = queryTool.get(queryName);
778
+ const shape = tool === undefined ? undefined : deps.toolShapes[tool];
779
+ if (shape === undefined)
780
+ continue;
781
+ const bound = shapeAtPointer(shape, rest.length === 0 ? "" : `/${rest.join("/")}`);
782
+ if (bound === undefined || bound.kind === "json" || bound.kind === "null")
783
+ continue;
784
+ const probe = KIND_PROBES[bound.kind];
785
+ if (probe === undefined)
786
+ continue;
787
+ if (!propSpec.schema.safeParse(probe).success) {
788
+ issues.push(`node "${node.id}" prop "${prop}" on <${node.component}> binds ${value.$path}, a ${bound.kind} field, but this slot takes a different RAW type (${propSpec.doc}) — bind the raw field with that type (e.g. the integer-cents field, not a pre-formatted display string).`);
789
+ }
790
+ }
791
+ }
792
+ return issues;
793
+ };
794
+ /** verify-v2 fixes — models write "Total: {metric.total}" inside STRING
795
+ * attributes; the wire has no string interpolation, so the braces render
796
+ * literally. Any string prop embedding a declared query reference is a
797
+ * repair-routed error. */
798
+ const interpolationIssues = (compiled) => {
799
+ const queryNames = (compiled.tree.queries ?? []).map((query) => query.name);
800
+ if (queryNames.length === 0)
801
+ return [];
802
+ const pattern = new RegExp(`\\{(?:${queryNames.join("|")})(?:\\.[A-Za-z0-9_]+)*\\}`);
803
+ const issues = [];
804
+ const walk = (nodeId, prop, value) => {
805
+ if (typeof value === "string") {
806
+ if (pattern.test(value)) {
807
+ issues.push(`node "${nodeId}" prop "${prop}" embeds a binding inside a string — string interpolation is unsupported; bind the prop to a single {reference} or split the text into separate Text nodes`);
808
+ }
809
+ return;
810
+ }
811
+ if (Array.isArray(value)) {
812
+ for (const item of value)
813
+ walk(nodeId, prop, item);
814
+ return;
815
+ }
816
+ if (isRecord(value)) {
817
+ for (const child of Object.values(value))
818
+ walk(nodeId, prop, child);
819
+ }
820
+ };
821
+ for (const node of compiled.tree.nodes) {
822
+ for (const [prop, value] of Object.entries(node.props ?? {}))
823
+ walk(node.id, prop, value);
824
+ }
825
+ return issues;
826
+ };
827
+ /** v2 create validation: the compile must be complete and clean, the tree
828
+ * catalog-consistent and renderable, islands syntactically sound, queries
829
+ * aimed at real host tools, bindings shape-checked, and the assembled
830
+ * document valid. */
831
+ const validateCompiledCreate = async (compiled, deps) => {
832
+ const issues = [];
833
+ if (!compiled.complete)
834
+ issues.push("wire did not parse to a complete <App> document");
835
+ issues.push(...compiled.issues.map(({ code, message }) => `wire ${code}: ${message}`));
836
+ const name = compiled.name?.trim() ?? "";
837
+ if (name === "")
838
+ issues.push('App must carry a non-empty name="..." attribute');
839
+ const prepared = await prepareIslands(compiled.components, deps.tools, deps.catalog.map(({ name: componentName }) => componentName));
840
+ const components = Object.keys(prepared.components).length === 0 ? undefined : prepared.components;
841
+ issues.push(...prepared.issues);
842
+ if (deps.tools !== undefined) {
843
+ const known = new Set(deps.tools.map((tool) => tool.name));
844
+ for (const query of compiled.tree.queries ?? []) {
845
+ if (!query.tool.startsWith("fn:") && !known.has(query.tool)) {
846
+ issues.push(`query "${query.name}" names unknown tool "${query.tool}"; the host tools are: ${[...known].join(", ")}`);
847
+ }
848
+ }
849
+ }
850
+ issues.push(...compiled.bindingErrors.map((error) => `binding ${error.path} on node "${error.nodeId}" prop "${error.prop}": ${error.message}${error.available === undefined ? "" : ` (available: ${error.available.join(", ")})`}`));
851
+ issues.push(...bindingKindIssues(compiled, deps));
852
+ issues.push(...kitSlotIssues(compiled, deps));
853
+ issues.push(...hostReshapeIssues(compiled, deps));
854
+ issues.push(...queryInputIssues(compiled.tree));
855
+ issues.push(...interpolationIssues(compiled));
856
+ issues.push(...await catalogIssues(compiled.tree, components, deps.catalog));
857
+ // Law 1 is checkable only when a tool surface exists to trace data to —
858
+ // a tool-less composition (fresh init, bare tests) has nothing to bind.
859
+ if (deps.tools !== undefined && deps.tools.length > 0) {
860
+ issues.push(...literalDataIssues(compiled.tree, deps.catalog));
861
+ }
862
+ issues.push(...actionIssues(compiled.tree, deps.tools));
863
+ issues.push(...rootedRenderIssues(compiled.tree));
864
+ if (issues.length > 0)
865
+ return { issues };
866
+ const document = {
867
+ format: VENDO_APP_FORMAT,
868
+ name,
869
+ ui: "tree",
870
+ tree: structuredClone(compiled.tree),
871
+ ...(components === undefined ? {} : {
872
+ components: structuredClone(components),
873
+ // W4b §2 — the compiler-stamped per-island tool manifest (least
874
+ // privilege: an island with no tools carries an explicit empty list).
875
+ componentTools: structuredClone(prepared.componentTools),
876
+ }),
877
+ };
878
+ const appValidation = validateAppDocument({ ...document, id: "app_generation_validation" });
879
+ if (!appValidation.ok)
880
+ return { issues: [appValidation.error.message] };
881
+ return { document, issues: [] };
882
+ };
883
+ const withoutId = (app) => {
884
+ const { id: _id, ...document } = structuredClone(app);
885
+ return document;
886
+ };
887
+ const isActionBinding = (value) => isRecord(value) && typeof value.action === "string";
888
+ const isRuntimeBound = (value) => isPathBinding(value) || isStateBinding(value) || isActionBinding(value);
889
+ const standardIssuePath = (issue) => {
890
+ if (!isRecord(issue) || !Array.isArray(issue.path))
891
+ return [];
892
+ return issue.path.flatMap((segment) => {
893
+ const key = isRecord(segment) && "key" in segment ? segment.key : segment;
894
+ return typeof key === "string" || typeof key === "number" ? [key] : [];
895
+ });
896
+ };
897
+ const pathTargetsRuntimeBinding = (value, path) => {
898
+ let current = value;
899
+ if (isRuntimeBound(current))
900
+ return true;
901
+ for (const segment of path) {
902
+ if (Array.isArray(current) && typeof segment === "number") {
903
+ current = current[segment];
904
+ }
905
+ else if (isRecord(current)) {
906
+ current = current[String(segment)];
907
+ }
908
+ else {
909
+ return false;
910
+ }
911
+ if (isRuntimeBound(current))
912
+ return true;
913
+ }
914
+ return false;
915
+ };
916
+ const issueMessage = (issue) => {
917
+ if (isRecord(issue) && typeof issue.message === "string")
918
+ return issue.message;
919
+ return "props did not match the registered schema";
920
+ };
921
+ const hostPropsIssues = async (node, component) => {
922
+ // 01 §14: schema-less entries validate permissively by design — the model
923
+ // infers props and the entry carries no validator.
924
+ if (component.propsSchema === undefined)
925
+ return [];
926
+ const props = node.props ?? {};
927
+ try {
928
+ const result = await component.propsSchema["~standard"].validate(props);
929
+ if (!isRecord(result) || !Array.isArray(result.issues))
930
+ return [];
931
+ return result.issues.flatMap((issue) => {
932
+ const path = standardIssuePath(issue);
933
+ if (pathTargetsRuntimeBinding(props, path))
934
+ return [];
935
+ const location = path.length === 0 ? "" : ` at props.${path.join(".")}`;
936
+ return [`node "${node.id}" props invalid for host component "${component.name}"${location}: ${issueMessage(issue)}`];
937
+ });
938
+ }
939
+ catch (error) {
940
+ return [`node "${node.id}" props validation failed for host component "${component.name}": ${error instanceof Error ? error.message : "unknown schema error"}`];
941
+ }
942
+ };
943
+ /** Prewired primitives are handed to the model by name plus an exact prop
944
+ * signature (prewired-schema.ts). The compiler keeps any attribute the model
945
+ * writes, so a wrong name (`data` for Table's `rows`, `onPress` for Button's
946
+ * `onClick`) survives into props and the renderer silently ignores it — the
947
+ * "valid table, empty rows" class. Reject unknown prop names so the model
948
+ * repairs to the real one instead of shipping a dead component. */
949
+ const prewiredPropsIssues = (node) => {
950
+ const allowed = prewiredPropNames.get(node.component);
951
+ const props = node.props;
952
+ if (allowed === undefined || props === undefined)
953
+ return [];
954
+ return Object.keys(props)
955
+ .filter((name) => !allowed.has(name))
956
+ .map((name) => `node "${node.id}" sets unknown prop "${name}" on prewired component "${node.component}"; the renderer drops it. Allowed props: ${[...allowed].join(", ") || "(none)"}`);
957
+ };
958
+ const catalogIssues = async (tree, components, catalog) => {
959
+ const hostCatalog = new Map(catalog.map((component) => [component.name, component]));
960
+ const hostNames = new Set(hostCatalog.keys());
961
+ const generatedNames = new Set(Object.keys(components ?? {}));
962
+ const issues = [];
963
+ for (const node of tree.nodes) {
964
+ if (node.source === "host") {
965
+ const component = hostCatalog.get(node.component);
966
+ if (component === undefined) {
967
+ issues.push(`node "${node.id}" references host component "${node.component}" absent from the catalog`);
968
+ }
969
+ else {
970
+ issues.push(...await hostPropsIssues(node, component));
971
+ }
972
+ }
973
+ else if (node.source === "prewired") {
974
+ if (!reserved.has(node.component)) {
975
+ issues.push(`node "${node.id}" references unknown prewired component "${node.component}"`);
976
+ }
977
+ else {
978
+ issues.push(...prewiredPropsIssues(node));
979
+ }
980
+ }
981
+ else if (node.source === "generated" && !generatedNames.has(node.component)) {
982
+ issues.push(`node "${node.id}" references generated component "${node.component}" without source`);
983
+ }
984
+ else if (node.source === undefined) {
985
+ // Legacy/direct trees can omit source; the renderer resolves the name to
986
+ // a prewired primitive first, so a reserved name here gets the same
987
+ // prop-name gate as an explicit source:"prewired" node — otherwise a
988
+ // stored tree could still ship an ignored prop (e.g. Table.data).
989
+ if (reserved.has(node.component)) {
990
+ issues.push(...prewiredPropsIssues(node));
991
+ }
992
+ else if (!hostNames.has(node.component) && !generatedNames.has(node.component)) {
993
+ issues.push(`node "${node.id}" references unknown component "${node.component}"`);
994
+ }
995
+ }
996
+ }
997
+ return issues;
998
+ };
999
+ /** Action-wiring honesty (verify-v2 #4 reminder, #6 intake). A mutating action
1000
+ * with no payload has nothing to change; a submit button wired to a read tool
1001
+ * or to nothing at all is a fake affordance. Each routes to repair, where the
1002
+ * model binds the row/form context — or, when the host has no tool for the
1003
+ * ask, replaces the dead button with an honest disclaimer. Detection lives in
1004
+ * pipeline.ts (shared with the structured-repair fix space); this renders the
1005
+ * repair-facing messages. */
1006
+ const actionIssues = (tree, tools) => actionFaults(tree, tools).map((fault) => {
1007
+ if (fault.kind === "dead-submit") {
1008
+ return `node "${fault.nodeId}" is a submit affordance ("${fault.label}") with no action — a submit that does nothing is a fake affordance. Wire its action to a host tool that performs it, binding the form/row context into payload; or if NO host tool can perform it, replace it with an honest disclaimer that the action isn't available.`;
1009
+ }
1010
+ if (fault.kind === "missing-payload") {
1011
+ return `node "${fault.nodeId}" prop "${fault.prop}" invokes mutating tool "${fault.action}" with no payload — bind the context it acts on (a per-row id, or the form field values) into payload:{...} so the action has something to change.`;
1012
+ }
1013
+ if (fault.kind === "unknown-tool") {
1014
+ return `node "${fault.nodeId}" prop "${fault.prop}" invokes unknown tool "${fault.action}" — law 2: an action must name a REAL host tool from the HOST TOOLS list (or fn:<name>). Pick the real tool, or render an honest disclaimer if the host has none.`;
1015
+ }
1016
+ if (fault.kind === "ungrounded-payload") {
1017
+ const unknown = fault.unknownFields ?? [];
1018
+ const missing = fault.missingFields ?? [];
1019
+ const parts = [
1020
+ ...(unknown.length === 0 ? [] : [`sends payload field(s) ${unknown.map((field) => `"${field}"`).join(", ")} the tool does not declare`]),
1021
+ ...(missing.length === 0 ? [] : [`omits the tool's REQUIRED input parameter(s) ${missing.map((field) => `"${field}"`).join(", ")}`]),
1022
+ ];
1023
+ return `node "${fault.nodeId}" prop "${fault.prop}" invokes tool "${fault.action}" but ${parts.join(" and ")} — law 2: the payload must carry exactly the tool's real input parameters (${(fault.allowedFields ?? []).join(", ")}).`;
1024
+ }
1025
+ return `node "${fault.nodeId}" submit affordance ("${fault.label}") prop "${fault.prop}" is wired to read-only tool "${fault.action}" — a submit that only reads is a fake affordance. Wire it to a mutating host tool with a payload, or render an honest disclaimer if the host has none.`;
1026
+ });
1027
+ /** W3 law 1 — hand-typed business data on a data-classed prop (Kit prop
1028
+ * classes, legacy data props, host catalog schemas). Detection lives in
1029
+ * pipeline.ts (shared with the structured-repair fix space). */
1030
+ const literalDataIssues = (tree, catalog) => literalDataFaults(tree, catalog).map((fault) => `node "${fault.nodeId}" prop "${fault.prop}" on <${fault.component}> carries hand-typed LITERAL business data — law 1: every data-classed prop must be a binding to a tool result, e.g. ${fault.prop}={queryName.field.path}. If NO host tool provides this data, render an honest <Disclaimer reason="..."/> instead — never invent figures.`);
1031
+ export const distinctIssues = (current, next) => [
1032
+ ...new Set([...current, ...next]),
1033
+ ];
1034
+ const insertChild = (parent, nodeId, index) => {
1035
+ const children = parent.children ?? [];
1036
+ const position = typeof index === "number" && Number.isInteger(index)
1037
+ ? Math.max(0, Math.min(index, children.length))
1038
+ : children.length;
1039
+ children.splice(position, 0, nodeId);
1040
+ parent.children = children;
1041
+ };
1042
+ const rootedRenderIssues = (tree) => {
1043
+ const nodes = new Map(tree.nodes.map((node) => [node.id, node]));
1044
+ const pending = [tree.root];
1045
+ const visited = new Set();
1046
+ const issues = [];
1047
+ let hasRenderableContent = false;
1048
+ while (pending.length > 0) {
1049
+ const id = pending.pop();
1050
+ if (id === undefined || visited.has(id))
1051
+ continue;
1052
+ visited.add(id);
1053
+ const node = nodes.get(id);
1054
+ if (node === undefined) {
1055
+ issues.push(`rooted node "${id}" is missing; persisted edits cannot rely on streaming placeholders`);
1056
+ continue;
1057
+ }
1058
+ if (node.source === "generated" || node.source === "host") {
1059
+ hasRenderableContent = true;
1060
+ }
1061
+ else if (node.component === "Text") {
1062
+ const text = node.props?.text;
1063
+ if (text !== undefined && text !== null && String(text).trim() !== "")
1064
+ hasRenderableContent = true;
1065
+ }
1066
+ else if (!new Set(["Stack", "Row", "Grid"]).has(node.component)) {
1067
+ hasRenderableContent = true;
1068
+ }
1069
+ pending.push(...(node.children ?? []));
1070
+ }
1071
+ if (!hasRenderableContent) {
1072
+ issues.push(`tree root "${tree.root}" renders an empty layout; keep at least one attached, visible node`);
1073
+ }
1074
+ return issues;
1075
+ };
1076
+ const validateEditedApp = async (app, deps, source) => {
1077
+ const validation = validateAppDocument(app);
1078
+ if (!validation.ok)
1079
+ return [validation.error.message];
1080
+ if (app.tree?.formatVersion !== VENDO_TREE_FORMAT_V2)
1081
+ return ["tree edit produced an unsupported format"];
1082
+ const treeValidation = validateTreeV2(app.tree);
1083
+ if (!treeValidation.ok)
1084
+ return [treeValidation.error.message];
1085
+ const sourceTreeValidation = validateTreeV2(source.tree);
1086
+ // Filter EVERY per-node check against the pre-existing app the same way, so
1087
+ // an edit that doesn't touch a stale node (a legacy Table.data prop, an
1088
+ // already-dead button) is never blocked by that node's issue — only issues
1089
+ // the edit newly introduces surface. Ids are stable across an edit, so a
1090
+ // carried-over issue is a byte-identical string.
1091
+ const sourceRenderIssues = sourceTreeValidation.ok
1092
+ ? new Set(rootedRenderIssues(sourceTreeValidation.tree))
1093
+ : new Set();
1094
+ const sourceCatalogIssues = sourceTreeValidation.ok
1095
+ ? new Set([
1096
+ ...await catalogIssues(sourceTreeValidation.tree, source.components, deps.catalog),
1097
+ ...literalDataIssues(sourceTreeValidation.tree, deps.catalog),
1098
+ ...actionIssues(sourceTreeValidation.tree, deps.tools),
1099
+ ])
1100
+ : new Set();
1101
+ return [
1102
+ ...rootedRenderIssues(treeValidation.tree).filter((issue) => !sourceRenderIssues.has(issue)),
1103
+ ...(await catalogIssues(treeValidation.tree, app.components, deps.catalog)).filter((issue) => !sourceCatalogIssues.has(issue)),
1104
+ ...literalDataIssues(treeValidation.tree, deps.catalog).filter((issue) => !sourceCatalogIssues.has(issue)),
1105
+ ...actionIssues(treeValidation.tree, deps.tools).filter((issue) => !sourceCatalogIssues.has(issue)),
1106
+ ];
1107
+ };
1108
+ const repairPrompt = (issues) => issues.length === 0 ? "" : `\nREPAIR_THESE_ISSUES: ${JSON.stringify(issues)}`;
1109
+ /** v2 spec §5 — the one-dialect edit contract: the model sees the app as
1110
+ * id-anchored wire markup and emits ONE <Edit> patch in the same grammar.
1111
+ * The JSON ops dialect is gone. */
1112
+ const editContract = (deps) => composePromptSections([{
1113
+ id: "role",
1114
+ content: "You are the Vendo app edit engine. Return ONLY one vendo-genui/v2 <Edit>...</Edit> patch document. No prose, no markdown fences, no JSON.",
1115
+ }, {
1116
+ id: "tree-contract",
1117
+ content: `EDIT DIALECT (vendo-genui/v2): patch the CURRENT_APP wire below against its id="..." anchors; never regenerate the whole app and never invent ids.
1118
+ Ops (attribute-only elements unless noted):
1119
+ - <Set id="node-id" attr=.../> merges attributes into the node's props. Same value forms as create: "string", {expr}, bare attribute for true, on*="host_tool"|"fn:name" actions, bindings {queryName.path | reshape(...)}.
1120
+ - <Unset id="node-id" propName otherProp/> removes the named props.
1121
+ - <Insert into="parent-id" at={index}>...new elements in the create grammar...</Insert> — omit at to append; the compiler mints ids for inserted nodes.
1122
+ - <Remove id="node-id"/> removes the node and its subtree. The root cannot be removed.
1123
+ - <Move id="node-id" into="parent-id" at={index}/> reparents/reorders.
1124
+ - <Query id="name" tool="tool_name" input={{...}}/> adds or replaces a query; <RemoveQuery id="name"/> deletes it.
1125
+ - <Island name="PascalName">raw TSX with a default export</Island> adds or replaces a generated component; <RemoveIsland name="PascalName"/> deletes it. Island rules:
1126
+ ${islandContract()}
1127
+ - <SetName name="..."/> renames the app; <SetDescription text="..."/> sets its description.
1128
+ Emit at least one op. Keep patches minimal and local to the instruction.`,
1129
+ }, ...generationPromptSections(deps).filter(({ id }) => id === "clock" || id === "component-styling" || id === "catalog" || id === "theme" || id === "design-rules" || id === "remixable-slots")]);
1130
+ /** Raw-text model call for the edit dialect (no streaming seam: edits are
1131
+ * small and apply atomically). */
1132
+ const generateWireText = async (deps, system, prompt) => {
1133
+ try {
1134
+ const { streamText } = await import("ai");
1135
+ const result = streamText({
1136
+ model: deps.model,
1137
+ system,
1138
+ prompt,
1139
+ temperature: 0,
1140
+ maxRetries: 0,
1141
+ });
1142
+ let text = "";
1143
+ for await (const delta of result.textStream) {
1144
+ text += delta;
1145
+ }
1146
+ return { text, issues: [] };
1147
+ }
1148
+ catch (error) {
1149
+ return { issues: [`model generation failed: ${error instanceof Error ? error.message : "unknown error"}`] };
1150
+ }
1151
+ };
1152
+ /** The deterministic fork core (06-apps §8): copies the TRUSTED captured
1153
+ * baseline into the named generated component, mints and attaches the node,
1154
+ * and records the pin — no model involvement, source is never retyped.
1155
+ * Gesture-owned forking (2026-07-21): the runtime's pins.fork surface is the
1156
+ * primary caller (the user's Remix gesture); the <ForkPin> extension op keeps
1157
+ * compiling for stored apps but is no longer taught to the model. */
1158
+ export const applyPinFork = (app, props, pinBaselines) => {
1159
+ const fail = (message) => [`<ForkPin> failed: ${message}`];
1160
+ const slot = props.slot;
1161
+ if (typeof slot !== "string" || slot.length === 0)
1162
+ return fail("requires a non-empty slot attribute");
1163
+ const baseline = pinBaselines?.find((candidate) => candidate.slot === slot);
1164
+ if (baseline === undefined)
1165
+ return fail(`pin baseline "${slot}" is unavailable`);
1166
+ if (app.pins?.some((pin) => pin.slot === baseline.slot))
1167
+ return fail(`pin slot "${baseline.slot}" is already forked`);
1168
+ // ENG-348 — a named-export capture forks with a synthesized default export.
1169
+ const forkSource = pinForkSource(baseline.source);
1170
+ if (!hasDefaultExport(forkSource)) {
1171
+ return fail(`pin baseline "${slot}" has no default export and no detectable named component export; export the component from its module and re-run vendo sync`);
1172
+ }
1173
+ const componentName = pinComponentName(baseline.slot);
1174
+ if (app.components?.[componentName] !== undefined)
1175
+ return fail(`generated component "${componentName}" already exists`);
1176
+ const tree = app.tree;
1177
+ const parentId = props.into === undefined ? tree.root : props.into;
1178
+ if (typeof parentId !== "string")
1179
+ return fail("into must be a string node id when present");
1180
+ const parent = tree.nodes.find(({ id }) => id === parentId);
1181
+ if (parent === undefined)
1182
+ return fail(`parent "${parentId}" does not exist`);
1183
+ if (props.at !== undefined && (typeof props.at !== "number" || !Number.isInteger(props.at) || props.at < 0)) {
1184
+ return fail("at must be a non-negative integer when present");
1185
+ }
1186
+ // Compiler-owned id discipline: mint past the existing ordinals, exactly
1187
+ // like an <Insert> would.
1188
+ const key = componentName.toLowerCase();
1189
+ let ordinal = 0;
1190
+ for (const { id } of tree.nodes) {
1191
+ const match = /^([a-z][a-z0-9]*)-([1-9]\d*)$/.exec(id);
1192
+ if (match !== null && match[1] === key)
1193
+ ordinal = Math.max(ordinal, Number(match[2]));
1194
+ }
1195
+ const node = {
1196
+ id: `${key}-${ordinal + 1}`,
1197
+ component: componentName,
1198
+ source: "generated",
1199
+ ...(isRecord(props.props) ? { props: structuredClone(props.props) } : {}),
1200
+ };
1201
+ tree.nodes.push(node);
1202
+ insertChild(parent, node.id, props.at);
1203
+ app.components = { ...(app.components ?? {}), [componentName]: forkSource };
1204
+ app.pins = [...(app.pins ?? []), { slot: baseline.slot, base: baseline.hash }];
1205
+ return [];
1206
+ };
1207
+ const editTree = async (input, deps) => {
1208
+ if (input.app.tree?.formatVersion !== VENDO_TREE_FORMAT_V2) {
1209
+ return { kind: "failure", issues: ["tree edits require a vendo-genui/v2 app"] };
1210
+ }
1211
+ const hostComponents = deps.catalog.map(({ name }) => name);
1212
+ const base = {
1213
+ tree: input.app.tree,
1214
+ components: input.app.components ?? {},
1215
+ name: input.app.name,
1216
+ };
1217
+ const context = printWireV2(base, { includeIds: true });
1218
+ let issues = [...(input.repairIssues ?? [])];
1219
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1220
+ const output = await generateWireText(deps, editContract(deps), `TASK: EDIT_TREE\nINSTRUCTION: ${input.instruction}\nCURRENT_APP (wire markup; id attributes are your anchors):\n${context}\nAPP_META: ${JSON.stringify({ name: input.app.name, description: input.app.description ?? null, pins: input.app.pins ?? [] })}${repairPrompt(issues)}`);
1221
+ issues = distinctIssues(issues, output.issues);
1222
+ if (output.text !== undefined) {
1223
+ const patched = compileWirePatchV2(extractEdit(output.text), base, {
1224
+ hostComponents,
1225
+ ...(deps.toolShapes === undefined ? {} : { toolShapes: deps.toolShapes }),
1226
+ extensionOps: ["ForkPin", "SetDescription"],
1227
+ });
1228
+ const patchIssues = [
1229
+ ...(patched.complete ? [] : ["wire did not parse to a complete <Edit> document"]),
1230
+ ...patched.issues.map(({ code, message }) => `wire ${code}: ${message}`),
1231
+ ];
1232
+ if (patchIssues.length === 0) {
1233
+ const app = {
1234
+ ...structuredClone(input.app),
1235
+ ...(patched.name === undefined ? {} : { name: patched.name }),
1236
+ tree: structuredClone(patched.tree),
1237
+ };
1238
+ // W4b — model islands go through the same ambient contract as create
1239
+ // (strip + tools scan + manifest restamp). PINNED components are
1240
+ // captured host source on the furnishing trust path: their real
1241
+ // imports resolve through the captured tables, so they are neither
1242
+ // stripped nor scanned — and get NO ambient-tools manifest.
1243
+ const pinnedNames = new Set((input.app.pins ?? []).map((pin) => pinComponentName(pin.slot)));
1244
+ const isPinned = ([componentName]) => pinnedNames.has(componentName);
1245
+ const splitIslands = (all) => ({
1246
+ pinned: Object.fromEntries(Object.entries(all).filter(isPinned)),
1247
+ model: Object.fromEntries(Object.entries(all).filter((entry) => !isPinned(entry))),
1248
+ });
1249
+ const patchedIslands = splitIslands(patched.components);
1250
+ const prepared = await prepareIslands(patchedIslands.model, deps.tools, hostComponents);
1251
+ // Pre-existing island issues never block an unrelated edit (same
1252
+ // filtering rule as catalog/action issues below).
1253
+ const sourcePrepared = await prepareIslands(splitIslands(input.app.components ?? {}).model, deps.tools, hostComponents);
1254
+ const sourceIslandIssues = new Set(sourcePrepared.issues);
1255
+ const islandIssues = prepared.issues.filter((issue) => !sourceIslandIssues.has(issue));
1256
+ const nextComponents = { ...patchedIslands.pinned, ...prepared.components };
1257
+ if (Object.keys(nextComponents).length === 0) {
1258
+ delete app.components;
1259
+ delete app.componentTools;
1260
+ }
1261
+ else {
1262
+ app.components = structuredClone(nextComponents);
1263
+ app.componentTools = structuredClone(prepared.componentTools);
1264
+ }
1265
+ const extensionIssues = [];
1266
+ const changed = patched.appliedOps > 0 || patched.extensionOps.length > 0;
1267
+ for (const extension of patched.extensionOps) {
1268
+ if (extension.op === "SetDescription") {
1269
+ if (typeof extension.props.text !== "string") {
1270
+ extensionIssues.push("<SetDescription> needs a string text attribute");
1271
+ }
1272
+ else {
1273
+ app.description = extension.props.text;
1274
+ }
1275
+ continue;
1276
+ }
1277
+ extensionIssues.push(...applyPinFork(app, extension.props, deps.pinBaselines));
1278
+ }
1279
+ if (!changed)
1280
+ extensionIssues.push("the patch contained no effective ops; emit at least one op for the instruction");
1281
+ // A <ForkPin> in this patch may have added a pinned component after
1282
+ // the manifest restamp above. Keep componentTools DEFINED whenever
1283
+ // components exist, so the renderer's stamped-era rule (missing key
1284
+ // = zero tools) applies instead of the source-scan fallback (review).
1285
+ if (app.components !== undefined && app.componentTools === undefined) {
1286
+ app.componentTools = {};
1287
+ }
1288
+ if (extensionIssues.length === 0) {
1289
+ const validationIssues = [...islandIssues, ...await validateEditedApp(app, deps, input.app)];
1290
+ if (validationIssues.length === 0) {
1291
+ return { kind: "document", document: withoutId(app) };
1292
+ }
1293
+ issues = distinctIssues(issues, validationIssues);
1294
+ }
1295
+ else {
1296
+ issues = distinctIssues(issues, extensionIssues);
1297
+ }
1298
+ }
1299
+ else {
1300
+ issues = distinctIssues(issues, patchIssues);
1301
+ }
1302
+ }
1303
+ }
1304
+ return { kind: "failure", issues: issues.length === 0 ? ["tree edit failed validation"] : issues };
1305
+ };
1306
+ /**
1307
+ * Speed lane — page-open prewarm. Pays the provider import + TLS/keep-alive
1308
+ * connection cost up front with a throwaway 1-token generation so the first
1309
+ * real create reuses a live socket instead of opening one cold. Best-effort:
1310
+ * any failure (no key, offline) is swallowed. Measured effect on first-paint
1311
+ * is small (model time-to-first-token dominates), so this is a cheap
1312
+ * worst-case guard, not a headline win — see docs/verification/vendo-v2-speed.
1313
+ */
1314
+ export const prewarmModels = async (models) => {
1315
+ const { generateText } = await import("ai");
1316
+ await Promise.all(models.map((model) => generateText({ model, prompt: "ok", maxOutputTokens: 1, maxRetries: 0 }).then(() => undefined).catch(() => undefined)));
1317
+ };
1318
+ /** W5a (v3 spec §Dialect retirement) — compile INFO, never an error: a NEW
1319
+ * create that emits a deprecated reshape op still compiles (stored apps
1320
+ * depend on the ops), but each distinct usage logs one observable line so
1321
+ * live traffic tells us when deletion is safe. */
1322
+ const logDeprecatedDialect = (document) => {
1323
+ for (const notice of findDeprecatedReshapeUsage(document.tree)) {
1324
+ console.info(`[vendo] INFO generated app "${document.name}" uses a ${notice} (kept compiling for stored apps; W5a staged retirement)`);
1325
+ }
1326
+ return document;
1327
+ };
1328
+ /** A provider-form designRules is resolved ONCE per create/edit, so the
1329
+ * paint/retry/repair prompts within one generation never mix rule sets; the
1330
+ * next generation re-resolves. */
1331
+ const snapshotDesignRules = (deps) => typeof deps.designRules === "function" ? { ...deps, designRules: deps.designRules() } : deps;
1332
+ /** 06-apps §§2,5; v2 spec §§2,4 — wire-backed rung-1 generation and
1333
+ * two-dialect edit planning. */
1334
+ export const modelEngine = {
1335
+ async create(input, rawDeps) {
1336
+ const deps = snapshotDesignRules(rawDeps);
1337
+ const startedAt = Date.now();
1338
+ const hostComponents = deps.catalog.map(({ name }) => name);
1339
+ const basePrompt = `TASK: CREATE_APP\nUSER_REQUEST: ${input.prompt}`;
1340
+ // W4 pipeline context: structured repair / region-parallel / end pass all
1341
+ // validate through the ONE create validator.
1342
+ const pipelineContext = {
1343
+ deps,
1344
+ hostComponents,
1345
+ startedAt,
1346
+ validate: (compiled) => validateCompiledCreate(compiled, deps),
1347
+ };
1348
+ const finish = (document) => endPass(document, input.prompt, pipelineContext).then(logDeprecatedDialect);
1349
+ // Tier-0 paint lane (v2 spec §4): only with a streaming consumer — the
1350
+ // instant paint exists to reach a screen. One attempt, no repair loop:
1351
+ // an invalid paint is simply not resident (the full lane still runs).
1352
+ let resident;
1353
+ let residentLayout;
1354
+ if (deps.onPartial !== undefined && deps.paint?.disabled !== true) {
1355
+ const paintDeps = deps.paint?.model === undefined ? deps : { ...deps, model: deps.paint.model };
1356
+ const paint = await streamWire(paintDeps, tier0Contract(deps), basePrompt, hostComponents, { lane: "paint", thinking: false, startedAt });
1357
+ if (paint.compiled !== undefined) {
1358
+ const validated = await validateCompiledCreate(paint.compiled, deps);
1359
+ if (validated.document !== undefined) {
1360
+ resident = validated.document;
1361
+ residentLayout = layoutHeader(paint.compiled);
1362
+ }
1363
+ }
1364
+ }
1365
+ // The upgrade must never regress the painted surface: while the resident
1366
+ // tier-0 app is on screen, full-lane prefixes smaller than the resident
1367
+ // stay suppressed — the paint holds until the upgrade can replace it
1368
+ // whole (ids stay stable via the TIER0_LAYOUT conditioning).
1369
+ const residentNodes = resident === undefined
1370
+ ? 0
1371
+ : resident.tree.nodes.length;
1372
+ const forward = deps.onPartial;
1373
+ const fullLaneDeps = forward === undefined || residentNodes === 0
1374
+ ? deps
1375
+ : {
1376
+ ...deps,
1377
+ onPartial: (partial) => partial.tree.nodes.length < residentNodes ? undefined : forward(partial),
1378
+ };
1379
+ // W4 pipeline steps 1+3 — outline + region-parallel tier-2 (flagged).
1380
+ // Any planning/section/assembly failure falls through to the
1381
+ // single-stream loop below: parallel is an optimization, never a gate.
1382
+ if (deps.pipeline?.regionParallel === true) {
1383
+ const parallel = await regionParallelCreate(pipelineContext, {
1384
+ userRequest: input.prompt,
1385
+ generateSection: async (prompt) => {
1386
+ const output = await streamWire(
1387
+ // Section streams bypass onPartial (the assembled prefix is
1388
+ // emitted on section completion below) but keep the model/deps.
1389
+ { ...deps, onPartial: undefined }, createContract(deps), prompt, hostComponents, { lane: "section", thinking: false, startedAt });
1390
+ return output.raw;
1391
+ },
1392
+ ...(fullLaneDeps.onPartial === undefined ? {} : {
1393
+ emitPartial: (assembledWire) => {
1394
+ const compiled = compileWireV2(assembledWire, wireCompileOptionsFor(deps, hostComponents));
1395
+ if (compiled.tree.nodes.length < residentNodes)
1396
+ return;
1397
+ void Promise.resolve(fullLaneDeps.onPartial?.({
1398
+ tree: compiled.tree,
1399
+ ...(compiled.name === undefined ? {} : { name: compiled.name }),
1400
+ ...(Object.keys(compiled.components).length === 0 ? {} : { components: compiled.components }),
1401
+ })).catch(() => undefined);
1402
+ },
1403
+ }),
1404
+ });
1405
+ if (parallel.document !== undefined)
1406
+ return finish(parallel.document);
1407
+ }
1408
+ let issues = [];
1409
+ // W4 pipeline step 5 — structured repair budget: at most 2 strict rounds
1410
+ // per create, then today's free-form regeneration loop takes over.
1411
+ let repairRounds = deps.pipeline?.structuredRepair === false ? 0 : 2;
1412
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1413
+ const output = await streamWire(fullLaneDeps, createContract(deps), `${basePrompt}${residentLayout === undefined
1414
+ ? ""
1415
+ : `\nTIER0_LAYOUT: ${residentLayout}\nAn instant paint pass already rendered that layout. Emit the full-quality app; keep the top-level component ordering compatible where reasonable so minted node ids stay stable and the upgrade swaps in place.`}${repairPrompt(issues)}`, hostComponents, { lane: "full", thinking: false, startedAt });
1416
+ issues = distinctIssues(issues, output.issues);
1417
+ if (output.compiled !== undefined) {
1418
+ const validated = await validateCompiledCreate(output.compiled, deps);
1419
+ if (validated.document !== undefined)
1420
+ return finish(validated.document);
1421
+ issues = distinctIssues(issues, validated.issues);
1422
+ if (repairRounds > 0) {
1423
+ const repaired = await structuredRepair(output.compiled, input.prompt, pipelineContext, repairRounds);
1424
+ repairRounds -= repaired.rounds;
1425
+ if (repaired.document !== undefined)
1426
+ return finish(repaired.document);
1427
+ issues = distinctIssues(issues, repaired.issues);
1428
+ }
1429
+ }
1430
+ }
1431
+ // Never a white box: a failed full lane falls back to the resident
1432
+ // tier-0 app (v2 spec §4).
1433
+ if (resident !== undefined)
1434
+ return logDeprecatedDialect(resident);
1435
+ throw new VendoError("validation", "model could not produce a valid app", issues);
1436
+ },
1437
+ async edit(input, deps) {
1438
+ // execution-v2 Wave 3 — the engine only patches the tree. Server work is
1439
+ // the in-box agent's job (graduation, runtime.machine.editApp); the tree
1440
+ // gains its fn: bindings through this same tree-edit path afterward.
1441
+ return editTree(input, snapshotDesignRules(deps));
1442
+ },
1443
+ };
1444
+ /**
1445
+ * execution-v2 Wave 3 — the graduation judgment: whether an instruction needs
1446
+ * server capability (scheduled/background work, third-party egress, heavy
1447
+ * logic, app-owned state) and so must ride the in-box agent (runtime graduates
1448
+ * 1→2, delegates the server work to the box, then lands fn: bindings via the
1449
+ * tree-edit path). A false answer keeps the edit on the pure tree path.
1450
+ * Ambiguous words like "api" or "function" count only when they are not
1451
+ * labeling a visible element — "make the API status card blue" must stay on
1452
+ * the cheap tree path (ENG-349).
1453
+ */
1454
+ export const instructionRequiresServer = (app, instruction) => SERVER_INSTRUCTION.test(instruction)
1455
+ || SERVED_APP_INSTRUCTION.test(instruction)
1456
+ || app.ui === "http"
1457
+ || matchesOutsideElementLabel(instruction, AMBIGUOUS_SERVER_TERM);
1458
+ /** The ENG-349 rule as a helper: an ambiguous term counts only when it is not
1459
+ * labeling a visible element ("watch my invoices" escalates; "the watch list"
1460
+ * stays on the cheap tree path). */
1461
+ const matchesOutsideElementLabel = (instruction, term) => [...instruction.matchAll(term)].some((match) => !VISIBLE_ELEMENT_LABEL.test(instruction.slice(match.index + match[0].length).trimStart()));
1462
+ /**
1463
+ * execution-v2 Wave 9 — the server-work ESCALATION LADDER (Yousef's economics
1464
+ * ruling: box graduation costs minutes of model round trips; the existing
1465
+ * automations engine covers most server-shaped needs in seconds). For a
1466
+ * server-shaped instruction the runtime prefers, in order:
1467
+ *
1468
+ * (a) "steps" — expressible as deterministic tool calls (host + connected
1469
+ * tools + existing fn: refs) with jsonata reshaping, forEach,
1470
+ * and park/resume approval gates. A steps automation on the
1471
+ * EXISTING automations engine; no machine.
1472
+ * (b) "agentic" — needs per-run judgment, but every effect is tool-reachable.
1473
+ * An agentic automation (the agent-loop run model); no machine.
1474
+ * (c) "box" — only when actual custom code is required (real computation,
1475
+ * libraries, complex persistent state, non-tool-shaped egress,
1476
+ * latency-sensitive logic). Box graduation — EXPERIMENTAL,
1477
+ * gated by `experimentalMachines`.
1478
+ *
1479
+ * `null` means the instruction is not server-shaped at all (pure tree path).
1480
+ * Same judge shape as {@link instructionRequiresServer}: deterministic word
1481
+ * classes with the ENG-349 visible-element rule for ambiguous terms. A served
1482
+ * (ui: "http") app is always box-shaped — its whole surface lives in its
1483
+ * machine.
1484
+ */
1485
+ export const serverWorkRung = (app, instruction) => {
1486
+ if (app.ui === "http")
1487
+ return "box";
1488
+ if (BOX_INSTRUCTION.test(instruction) || matchesOutsideElementLabel(instruction, AMBIGUOUS_BOX_TERM)) {
1489
+ return "box";
1490
+ }
1491
+ if (AGENTIC_INSTRUCTION.test(instruction) || matchesOutsideElementLabel(instruction, AMBIGUOUS_AGENTIC_TERM)) {
1492
+ return "agentic";
1493
+ }
1494
+ return instructionRequiresServer(app, instruction) ? "steps" : null;
1495
+ };
1496
+ /**
1497
+ * execution-v2 Wave 4 — the 2→3 escalation judgment (same judge shape as
1498
+ * {@link instructionRequiresServer}): whether an instruction's UI needs exceed
1499
+ * the tree, so the box agent must build a REAL web app the machine serves
1500
+ * (layer 3, experimental — the runtime refuses this path unless the host
1501
+ * enabled `experimentalServedApps`). An already-served app is always a
1502
+ * layer-3 subject: every edit of it is served-app work.
1503
+ */
1504
+ export const instructionRequiresServedApp = (app, instruction) => app.ui === "http"
1505
+ || SERVED_APP_INSTRUCTION.test(instruction)
1506
+ || [...instruction.matchAll(AMBIGUOUS_SERVED_TERM)].some((match) => !VISIBLE_ELEMENT_LABEL.test(instruction.slice(match.index + match[0].length).trimStart()));
1507
+ //# sourceMappingURL=engine.js.map