@ggui-ai/ui-gen 0.23.0 → 0.25.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 (123) hide show
  1. package/dist/adapter-Cg_rkpGq.d.ts +201 -0
  2. package/dist/adapters/generation-dispatch.d.ts +58 -15
  3. package/dist/adapters/generation-dispatch.js +1418 -180
  4. package/dist/adapters/generation-dispatch.js.map +1 -1
  5. package/dist/adapters/index.js +3 -1
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/advanced/index.d.ts +12 -8
  8. package/dist/advanced/index.js +1467 -239
  9. package/dist/advanced/index.js.map +1 -1
  10. package/dist/{axes-CzLEMDeB.d.ts → axes-CKVCzR_x.d.ts} +1 -1
  11. package/dist/bank-CT4eEkta.d.ts +118 -0
  12. package/dist/blueprint-validator.js +153 -59
  13. package/dist/blueprint-validator.js.map +1 -1
  14. package/dist/boilerplate.js +24 -12
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +12 -4
  17. package/dist/check/index.js +94 -2
  18. package/dist/check/index.js.map +1 -1
  19. package/dist/classifier/index.d.ts +2 -2
  20. package/dist/compose.d.ts +2 -2
  21. package/dist/{create-ui-generator-D0kbWwln.d.ts → create-ui-generator-DFnnArrv.d.ts} +43 -11
  22. package/dist/{dispatch-wxaIMwZt.d.ts → dispatch-CStl9eAw.d.ts} +5 -3
  23. package/dist/evaluation/axis-checks/index.d.ts +6 -5
  24. package/dist/evaluation/axis-checks/index.js +90 -2
  25. package/dist/evaluation/axis-checks/index.js.map +1 -1
  26. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  27. package/dist/evaluation/axis-checks/registry.js +88 -1
  28. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  29. package/dist/evaluation/index.d.ts +301 -23
  30. package/dist/evaluation/index.js +746 -30
  31. package/dist/evaluation/index.js.map +1 -1
  32. package/dist/evaluation/types.d.ts +9 -1
  33. package/dist/evaluation/types.js.map +1 -1
  34. package/dist/fragments/index.d.ts +3 -3
  35. package/dist/harness/check/runtime-render/index.d.ts +30 -11
  36. package/dist/harness/check/runtime-render/index.js +203 -60
  37. package/dist/harness/check/runtime-render/index.js.map +1 -1
  38. package/dist/harness/check/runtime-render/render-check-worker.d.ts +8 -1
  39. package/dist/harness/check/runtime-render/render-check-worker.js +72 -32
  40. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  41. package/dist/harness/index.d.ts +5 -4
  42. package/dist/harness/index.js +33 -15
  43. package/dist/harness/index.js.map +1 -1
  44. package/dist/harness/prompts.js.map +1 -1
  45. package/dist/harness/result-types.d.ts +4 -3
  46. package/dist/harness/runtime.d.ts +11 -4
  47. package/dist/harness/runtime.js +26 -14
  48. package/dist/harness/runtime.js.map +1 -1
  49. package/dist/harness/types-public.d.ts +13 -4
  50. package/dist/harness/types-public.js +33 -15
  51. package/dist/harness/types-public.js.map +1 -1
  52. package/dist/harness/validator-trace-sink.d.ts +4 -3
  53. package/dist/hash.d.ts +1 -1
  54. package/dist/index.d.ts +59 -8
  55. package/dist/index.js +1476 -241
  56. package/dist/index.js.map +1 -1
  57. package/dist/{llm-router-DwwUK1bz.d.ts → llm-router-nfO7wPec.d.ts} +3 -1
  58. package/dist/patch.d.ts +4 -3
  59. package/dist/policy.d.ts +8 -5
  60. package/dist/policy.js.map +1 -1
  61. package/dist/tools/get-primitives-ts.d.ts +1 -1
  62. package/dist/tools/get-primitives-ts.js +1 -1
  63. package/dist/tools/get-primitives-ts.js.map +1 -1
  64. package/dist/tools/render-check-worker.d.ts +7 -1
  65. package/dist/tools/render-check-worker.js +55 -23
  66. package/dist/tools/render-check-worker.js.map +1 -1
  67. package/dist/{types-BOvHNG7K.d.ts → types-Dh1s4K9j.d.ts} +1 -1
  68. package/dist/{types-public-Ow-tNtFE.d.ts → types-public-BJTLpzWn.d.ts} +208 -15
  69. package/dist/validation/index.d.ts +1 -1
  70. package/dist/validation/index.js +1 -1
  71. package/dist/validation/index.js.map +1 -1
  72. package/dist/workflows.d.ts +4 -3
  73. package/package.json +12 -12
  74. package/src/adapters/generation-dispatch.ts +173 -98
  75. package/src/adapters/tools.ts +2 -1
  76. package/src/boilerplate/free-design-prompt.ts +1 -1
  77. package/src/boilerplate/generate.ts +26 -1
  78. package/src/boilerplate/hard-sections.ts +13 -13
  79. package/src/boilerplate/system-prompt.ts +1 -1
  80. package/src/check/index.ts +1 -0
  81. package/src/check/run-tier0.ts +80 -1
  82. package/src/check/sample-props.ts +63 -0
  83. package/src/coding-agent/tools.ts +4 -1
  84. package/src/create-harness.ts +3 -3
  85. package/src/create-ui-generator.ts +204 -95
  86. package/src/evaluation/__fixtures__/png.ts +100 -0
  87. package/src/evaluation/axis-checks/checks/state-merge.ts +1 -1
  88. package/src/evaluation/axis-checks/checks/universal.ts +111 -0
  89. package/src/evaluation/axis-checks/dispatch.ts +4 -1
  90. package/src/evaluation/criteria/bank.ts +85 -0
  91. package/src/evaluation/criteria/context.ts +52 -0
  92. package/src/evaluation/criteria/resolve.ts +159 -0
  93. package/src/evaluation/criteria/select.ts +79 -0
  94. package/src/evaluation/index.ts +23 -0
  95. package/src/evaluation/ink-extent.ts +200 -0
  96. package/src/evaluation/theme-css.ts +12 -5
  97. package/src/evaluation/types-public.ts +200 -11
  98. package/src/evaluation/types.ts +9 -0
  99. package/src/evaluation/visual-evaluator.ts +636 -30
  100. package/src/harness/check/runtime-render/adapter.ts +221 -75
  101. package/src/harness/check/runtime-render/index.ts +75 -2
  102. package/src/harness/check/runtime-render/render-check-host.ts +82 -19
  103. package/src/harness/check/runtime-render/render-check-worker.ts +83 -29
  104. package/src/harness/check/runtime-render/render-check.ts +154 -117
  105. package/src/harness/coding/generate-task-runner.ts +60 -4
  106. package/src/harness/coding/init-session.ts +13 -0
  107. package/src/harness/coding/run-coding-turn.ts +7 -3
  108. package/src/harness/coding/run-eval-round.ts +257 -15
  109. package/src/harness/policy.ts +11 -0
  110. package/src/harness/primitive-slice.ts +104 -0
  111. package/src/harness/runtime.ts +5 -0
  112. package/src/harness/types-public.ts +9 -1
  113. package/src/harness-tool-names.ts +38 -0
  114. package/src/index.ts +16 -0
  115. package/src/policy.ts +7 -4
  116. package/src/run-check.ts +2 -0
  117. package/src/tools/get-primitives-ts.ts +1 -1
  118. package/src/tools/get-wire.ts +1 -1
  119. package/src/tools/render-check-worker.ts +74 -23
  120. package/src/tools/render-check.ts +0 -60
  121. package/src/tools.ts +11 -1
  122. package/src/validation/primitives.ts +1 -1
  123. package/dist/render-check-Blwj8gB6.d.ts +0 -105
@@ -1,10 +1,11 @@
1
- import { C as Classification } from './axes-CzLEMDeB.js';
1
+ import { C as Classification } from './axes-CKVCzR_x.js';
2
2
  import { Workflow } from './harness/types-public.js';
3
3
  import '@ggui-ai/protocol';
4
4
  import './styling-profile-CxfvV48g.js';
5
- import './types-public-Ow-tNtFE.js';
5
+ import './types-public-BJTLpzWn.js';
6
+ import '@ggui-ai/mcp-server-core';
6
7
  import './design-mode-DLufhrNp.js';
7
- import './types-BOvHNG7K.js';
8
+ import './types-Dh1s4K9j.js';
8
9
  import './llm.js';
9
10
  import './policy.js';
10
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -189,9 +189,9 @@
189
189
  "puppeteer-core": "^24.40.0 || ^25.0.0",
190
190
  "react": "^18.0.0 || ^19.0.0",
191
191
  "react-dom": "^18.0.0 || ^19.0.0",
192
- "@ggui-ai/mcp-server-core": "0.23.0",
193
- "@ggui-ai/protocol": "0.23.0",
194
- "@ggui-ai/project-config": "0.23.0"
192
+ "@ggui-ai/project-config": "0.25.0",
193
+ "@ggui-ai/mcp-server-core": "0.25.0",
194
+ "@ggui-ai/protocol": "0.25.0"
195
195
  },
196
196
  "peerDependenciesMeta": {
197
197
  "@google/adk": {
@@ -233,11 +233,11 @@
233
233
  "openai": "^7.9.0",
234
234
  "typescript": "^5.0.0",
235
235
  "zod": "^4.3.6",
236
- "@ggui-ai/design": "0.23.0",
237
- "@ggui-ai/gadgets": "0.23.0",
238
- "@ggui-ai/sandbox": "0.23.0",
239
- "@ggui-ai/wire": "0.23.0",
240
- "@ggui-ai/ui-visual-tester": "0.23.0"
236
+ "@ggui-ai/design": "0.25.0",
237
+ "@ggui-ai/ui-visual-tester": "0.25.0",
238
+ "@ggui-ai/gadgets": "0.25.0",
239
+ "@ggui-ai/sandbox": "0.25.0",
240
+ "@ggui-ai/wire": "0.25.0"
241
241
  },
242
242
  "devDependencies": {
243
243
  "@google/adk": "^2.0.0",
@@ -252,9 +252,9 @@
252
252
  "react-dom": "^19.0.0",
253
253
  "tsup": "^8.5.0",
254
254
  "vitest": "^3.2.6",
255
- "@ggui-ai/mcp-server-core": "0.23.0",
256
- "@ggui-ai/project-config": "0.23.0",
257
- "@ggui-ai/protocol": "0.23.0"
255
+ "@ggui-ai/mcp-server-core": "0.25.0",
256
+ "@ggui-ai/project-config": "0.25.0",
257
+ "@ggui-ai/protocol": "0.25.0"
258
258
  },
259
259
  "engines": {
260
260
  "node": ">=22.0.0"
@@ -33,11 +33,15 @@ import { buildSystemPrompt as buildSystemPromptWithFunnel } from "../harness/run
33
33
  // predicate against the full registry and passes the filtered result.
34
34
  import { REGISTRY as AXIS_CHECK_REGISTRY } from "../evaluation/axis-checks/registry.js";
35
35
  import { matches as axisCheckMatches } from "../evaluation/types-public.js";
36
- // Runtime-render default. The `createHarness` constructor accepts an
37
- // optional runtimeRender; this callsite injects
38
- // `DEFAULT_RUNTIME_RENDER_CHECK` so production keeps the happy-dom-
39
- // backed contract-wiring verification step.
36
+ // Runtime-render check. The `createHarness` constructor accepts an
37
+ // optional runtimeRender; this callsite wires the caller's instance
38
+ // (`params.runtimeRender`) or, absent one, `DEFAULT_RUNTIME_RENDER_CHECK`,
39
+ // so production keeps the happy-dom-backed contract-wiring verification
40
+ // step. The dispatch never BUILDS a check: the instance carries the
41
+ // concurrency cap, so its lifetime must be the caller's (one per
42
+ // generator), never one per generation.
40
43
  import { DEFAULT_RUNTIME_RENDER_CHECK } from "../harness/check/runtime-render/adapter.js";
44
+ import type { RuntimeRenderCheck } from "../harness/types-public.js";
41
45
  import { warmupRuntimeRenderProbe } from "../harness/check/runtime-render/index.js";
42
46
  import { assembleGenerationResult } from "../harness/coding/assemble-result.js";
43
47
  import {
@@ -54,13 +58,24 @@ import type {
54
58
  import type { ProviderName, ToolDefinition } from "./types.js";
55
59
  import type { QualityConfig } from "../evaluation/types-public.js";
56
60
  import type { AgentConfig } from "../harness/llm-router.js";
57
- import {
58
- type GadgetDescriptor,
59
- type JsonObject,
60
- } from "@ggui-ai/protocol";
61
+ import { type GadgetDescriptor, type JsonObject } from "@ggui-ai/protocol";
61
62
  import { canvasForRendering, type CanvasClass, type DesignMode } from "../design-mode.js";
63
+ import type { VisionProvider } from "../harness/llm-router.js";
64
+ import type { CriteriaBank } from "../evaluation/criteria/bank.js";
62
65
  import type { GenerationProfileInput } from "../boilerplate/styling-profile.js";
63
66
 
67
+ /**
68
+ * ggui#1250 — the in-loop VISUAL judge as its own agent: a vision provider (typed so an OpenAI judge cannot
69
+ * be written), a model, and — when the caller resolves provider keys itself — that provider's key, which
70
+ * becomes the agent's own `routeOverride`. Never the coding agent's route: where this exists (an OpenAI
71
+ * coding lane), the coding key is another provider's.
72
+ */
73
+ export interface VisualEvalAgentSpec {
74
+ readonly provider: VisionProvider;
75
+ readonly model: string;
76
+ readonly apiKey?: string;
77
+ }
78
+
64
79
  export interface GenerationDispatchParams {
65
80
  provider: ProviderName;
66
81
  model: string;
@@ -108,6 +123,9 @@ export interface GenerationDispatchParams {
108
123
  * the same box. See `VisualEvalConfig.canvasViewports`.
109
124
  */
110
125
  canvasViewports?: Partial<Readonly<Record<CanvasClass, { width: number; height: number }>>>;
126
+ /** ggui#1436 — see `SingleComponentParams.visualEvaluation.criteriaBank`. */
127
+ criteriaBank?: CriteriaBank;
128
+ criteriaKind?: string;
111
129
  };
112
130
  /** Quality config controlling eval tiers and improvement behavior */
113
131
  qualityConfig?: QualityConfig;
@@ -118,20 +136,38 @@ export interface GenerationDispatchParams {
118
136
  */
119
137
  fixtureProps?: JsonObject;
120
138
  /**
121
- * Whether to wire `DEFAULT_RUNTIME_RENDER_CHECK` into the harness's
122
- * check leg. When `true` (default), the probe runs every coding turn
123
- * and feeds wiring-check failures back to the coding agent — useful
124
- * for quality gating but ~3-5s per call adds wall-clock per turn.
139
+ * Whether to wire the runtime-render check (`runtimeRender` when given,
140
+ * else `DEFAULT_RUNTIME_RENDER_CHECK`) into the harness's check leg.
141
+ * When `true` (default), the runtime-render probe runs ONCE
142
+ * at the exit decision, after the coding turns, in an isolated
143
+ * subprocess — never on the per-turn hot path. Without an `evaluation`
144
+ * config that round is observe + one repair turn (ggui#1380): a
145
+ * recoverable render crash buys exactly one repair turn and one
146
+ * re-probe, then the generation serves what it has; a probe that timed
147
+ * out, could not run, or had nothing to probe never blocks and never
148
+ * repairs. With an `evaluation` config the probe is the evaluation
149
+ * round's exit gate. Its status and timing land on
150
+ * `evalResult.runtimeProbe` (and on the generation's metadata through
151
+ * `createUiGenerator`).
125
152
  *
126
- * When `false`, the harness omits runtime-render from the in-loop
127
- * check pipeline. Cloud production runs with this off (per the
128
- * migration plan — probe was never in the production hot path).
129
- * Bench callers can run the probe externally as a final post-gen
130
- * check to get pass/fail visibility without slowing the loop.
153
+ * When `false`, the harness omits runtime-render entirely: no probe
154
+ * round runs, and a caller can run the probe externally as a final
155
+ * post-generation check instead.
131
156
  *
132
157
  * Default: `true`.
133
158
  */
134
159
  enableRuntimeRender?: boolean;
160
+ /**
161
+ * The runtime-render check to wire (ggui#1380) — built by the CALLER with
162
+ * `createRuntimeRenderCheck(config)` and kept for the caller's lifetime,
163
+ * because the check instance owns the probe's concurrency cap: one
164
+ * instance per generator bounds the workers a serving deployment has
165
+ * live at once; one per dispatch would give every generation its own
166
+ * free slots and bound nothing. Absent ⇒ `DEFAULT_RUNTIME_RENDER_CHECK`
167
+ * (the evaluation lane's bounds, no concurrency cap). Only read when
168
+ * `enableRuntimeRender` is on.
169
+ */
170
+ runtimeRender?: RuntimeRenderCheck;
135
171
  /**
136
172
  * Operator-registered gadget catalog forwarded to the
137
173
  * code-gen system prompt's `clientCapabilities — registered
@@ -171,6 +207,13 @@ export interface GenerationDispatchParams {
171
207
  * existing behavior for callers that don't set it.
172
208
  */
173
209
  routeOverride?: AgentConfig["routeOverride"];
210
+ /**
211
+ * ggui#1250 — the in-loop visual judge named as its own agent (see {@link VisualEvalAgentSpec}).
212
+ * Absent (default, every existing caller): today's chain, `visualEval ← evaluation ← coding`, which on
213
+ * a lane without a vision path records the score half as skipped (#1248). Off by default; a measurement
214
+ * knob first, a product knob only by ruling.
215
+ */
216
+ visualEvalAgent?: VisualEvalAgentSpec;
174
217
  /**
175
218
  * Retry observer (#489) — see `AgentConfig.onRetry`. Threaded onto
176
219
  * the coding + evaluation agent specs so a rate-limited retry inside
@@ -200,7 +243,9 @@ export interface GenerationDispatchParams {
200
243
  * Map ProviderName ('claude' | 'openai' | 'google') to AgentConfig provider ('anthropic' | 'openai' | 'google').
201
244
  * The LLM router uses 'anthropic' while the rest of the codebase uses 'claude'.
202
245
  */
203
- function mapProviderForLLMRouter(provider: ProviderName): "anthropic" | "openai" | "google" | "openrouter" {
246
+ function mapProviderForLLMRouter(
247
+ provider: ProviderName
248
+ ): "anthropic" | "openai" | "google" | "openrouter" {
204
249
  return provider === "claude" ? "anthropic" : provider;
205
250
  }
206
251
 
@@ -209,7 +254,7 @@ function mapProviderForLLMRouter(provider: ProviderName): "anthropic" | "openai"
209
254
  * provider-agnostic.
210
255
  */
211
256
  export async function dispatchGeneration(
212
- params: GenerationDispatchParams,
257
+ params: GenerationDispatchParams
213
258
  ): Promise<GenerationResult> {
214
259
  // NOTE: userPrompt arrives already enriched with rendering context + contract
215
260
  // (including examples) from the caller (runner.ts / generator.ts).
@@ -247,8 +292,17 @@ export async function dispatchGeneration(
247
292
  onRetry: params.onRetry,
248
293
  }
249
294
  : undefined;
250
- // ModelRoles doesn't currently expose a visualEval slot — falls through
251
- // to the documented evaluation → coding chain inside resolveSessionAgents.
295
+ // ggui#1250 — the in-loop visual judge as its own agent when named: its own provider and its own key
296
+ // route, never the coding agent's. Absent: today's chain, visualEval ← evaluation ← coding, inside
297
+ // resolveSessionAgents (ModelRoles has no visualEval slot — a role is a model, and this needs a provider).
298
+ const visualEvalAgent = params.visualEvalAgent
299
+ ? {
300
+ provider: params.visualEvalAgent.provider,
301
+ model: params.visualEvalAgent.model,
302
+ ...(params.visualEvalAgent.apiKey !== undefined ? { routeOverride: { apiKey: params.visualEvalAgent.apiKey } } : {}),
303
+ onRetry: params.onRetry,
304
+ }
305
+ : undefined;
252
306
 
253
307
  // ── Contract enrichment ──────────────────────────────────────────────
254
308
  // The contract is used as-is: `clientCapabilities.gadgets`
@@ -294,17 +348,13 @@ export async function dispatchGeneration(
294
348
  // from '<package>'` lines for thin contract refs. The closure
295
349
  // capture below (systemPromptBuilder) wires the SAME catalog
296
350
  // into the HOW leg.
297
- ...(params.appGadgets !== undefined
298
- ? { appGadgets: params.appGadgets }
299
- : {}),
351
+ ...(params.appGadgets !== undefined ? { appGadgets: params.appGadgets } : {}),
300
352
  // Forward the third-party wrapper `.d.ts` map so
301
353
  // `WhatLeg.gadgetTypes` reaches the coding-agent's typecheck
302
354
  // overlay (autoCommit → runTier0Checks → typecheck). The
303
355
  // systemPromptBuilder closure below also captures `gadgetTypes`
304
356
  // from `params` directly for the prompt `Type:` lines.
305
- ...(params.gadgetTypes !== undefined
306
- ? { gadgetTypes: params.gadgetTypes }
307
- : {}),
357
+ ...(params.gadgetTypes !== undefined ? { gadgetTypes: params.gadgetTypes } : {}),
308
358
  // Design mode + canvas — the harness carries them to every leg.
309
359
  ...(params.designMode !== undefined ? { designMode: params.designMode } : {}),
310
360
  // ggui#1117 — the canvas is ALWAYS carried: a stated one, else the class
@@ -334,7 +384,15 @@ export async function dispatchGeneration(
334
384
  // prompt's gadget catalog renders a `Type:` line per third-party
335
385
  // gadget (the LLM sees a wrapper's real call shape it otherwise
336
386
  // can't know).
337
- systemPromptBuilder: ({ userRequest, shellType, screen, axisDelta, designMode, canvas, profile }) =>
387
+ systemPromptBuilder: ({
388
+ userRequest,
389
+ shellType,
390
+ screen,
391
+ axisDelta,
392
+ designMode,
393
+ canvas,
394
+ profile,
395
+ }) =>
338
396
  buildSystemPromptWithFunnel(
339
397
  userRequest,
340
398
  shellType,
@@ -344,29 +402,30 @@ export async function dispatchGeneration(
344
402
  params.gadgetTypes,
345
403
  designMode,
346
404
  canvas,
347
- profile,
405
+ profile
348
406
  ),
349
407
  // Pre-filtered axis-check registry. The `matches()` predicate
350
408
  // selects the checks relevant to this generation's axis vector.
351
409
  axisChecks: AXIS_CHECK_REGISTRY.filter((check) =>
352
- axisCheckMatches(classification.vector, check),
410
+ axisCheckMatches(classification.vector, check)
353
411
  ),
354
- // The default runtime-render check. Heavy deps (happy-dom + wire +
355
- // design) stay on the dispatch side of the boundary; the skeleton
356
- // createHarness defaults this to undefined.
412
+ // The runtime-render check, built under the caller's probe config.
413
+ // Heavy deps (happy-dom + wire + design) stay on the dispatch side of
414
+ // the boundary; the skeleton createHarness defaults this to undefined.
357
415
  //
358
416
  // Gated behind `enableRuntimeRender` (default true) so
359
417
  // benchmark/dev callers can opt out of in-loop probe execution and
360
418
  // run the probe externally as a final post-generation check.
361
- runtimeRender: (params.enableRuntimeRender ?? true)
362
- ? DEFAULT_RUNTIME_RENDER_CHECK
363
- : undefined,
419
+ runtimeRender:
420
+ (params.enableRuntimeRender ?? true)
421
+ ? (params.runtimeRender ?? DEFAULT_RUNTIME_RENDER_CHECK)
422
+ : undefined,
364
423
  });
365
424
  console.log(
366
- `[harness] id=${harness.id} name=${harness.name} workflow=${harness.process.workflow.name} overrides=${harness.meta.overrides.join(",") || "none"}`,
425
+ `[harness] id=${harness.id} name=${harness.name} workflow=${harness.process.workflow.name} overrides=${harness.meta.overrides.join(",") || "none"}`
367
426
  );
368
427
  console.log(
369
- `[simple] axis-vector: ${JSON.stringify(classification.vector)} risk=${classification.riskTier}`,
428
+ `[simple] axis-vector: ${JSON.stringify(classification.vector)} risk=${classification.riskTier}`
370
429
  );
371
430
 
372
431
  // ── Guard: staged workflow is not wired on the dispatch path. ──
@@ -392,7 +451,7 @@ export async function dispatchGeneration(
392
451
  `This is a misconfiguration (probably pickProcessMode returning WORKFLOWS.staged). ` +
393
452
  `For staged-like behaviour, set process.mode="staged" + workflow.name="single_pass" instead — the A1 ` +
394
453
  `scaffold→fill phase machine lives inside the generate runner and fires without the workflow topology. ` +
395
- `See ralph #3 report (2026-04-14) for the full rationale.`,
454
+ `See ralph #3 report (2026-04-14) for the full rationale.`
396
455
  );
397
456
  }
398
457
 
@@ -400,6 +459,7 @@ export async function dispatchGeneration(
400
459
  const agents = resolveSessionAgents({
401
460
  codingAgent,
402
461
  evaluationAgent,
462
+ ...(visualEvalAgent !== undefined ? { visualEvalAgent } : {}),
403
463
  });
404
464
 
405
465
  // ── Runtime policy resolution ──
@@ -409,29 +469,58 @@ export async function dispatchGeneration(
409
469
  // resolveRunPolicyForProfile falls through to the identity
410
470
  // resolveRunPolicy — byte-identical to the default behavior.
411
471
  const { resolveRunPolicyForProfile } = await import("../harness/policy.js");
412
- const resolvedPolicy = resolveRunPolicyForProfile(
413
- process.env.GGUI_POLICY_PROFILE,
414
- harness,
415
- { provider: routerProvider, modelId: resolvedModel },
416
- );
472
+ const resolvedPolicy = resolveRunPolicyForProfile(process.env.GGUI_POLICY_PROFILE, harness, {
473
+ provider: routerProvider,
474
+ modelId: resolvedModel,
475
+ });
417
476
 
418
- // ── Axis-keyed primitives doc slice ──
419
- // When the resolved policy flips to axis-keyed, rebuild the first-turn
420
- // system prompt with a sliced PRIMITIVES_DOCUMENTATION derived from the
421
- // classification. The slice typically cuts the system prompt by ~80 KB
422
- // (~20K tokens). Surface awareness is preserved — the name-only primitive
423
- // catalog already lives in the system prompt above the sliced block.
424
- // Always logs `[simple] primitive-doc-bytes` telemetry so both modes
425
- // are auditable post-hoc.
477
+ // ── The primitives reference the first-turn prompt carries ──
478
+ // The system prompt embeds PRIMITIVES_DOCUMENTATION (markdown) verbatim.
479
+ // Three policy knobs decide what is SENT in its place, composed in this
480
+ // order (ggui#1324, speed/002 — the slice applies to the format in force;
481
+ // slicing the markdown before the TS swap sent a sliced markdown doc about
482
+ // the size of the full TS doc, which speed/001 measured as a trim):
483
+ // 1. primitiveDocFormat: "markdown" (the embedded doc) or "ts" (its
484
+ // TypeScript-interface form, ~55% smaller, same information);
485
+ // 2. primitiveDocSlice: "full", or an axis-keyed allowlist from the
486
+ // classification ("axis-keyed", or "axis-keyed+contract" which adds the
487
+ // inputs the contract implies) applied to THAT format's doc;
488
+ // 3. primitiveIndex: a name-only index that replaces whatever is left
489
+ // (tool-driven docs; wins when on).
490
+ // Every cell logs `[simple] primitive-doc-bytes … mode=…` (the slice) and
491
+ // `[simple] system-prompt-bytes … doc-format=… doc-bytes=…` (what is sent),
492
+ // so a prompt-size claim is auditable from the log, not inferred.
426
493
  let systemPromptOverride: string | undefined;
427
494
  {
428
495
  const { PRIMITIVES_DOCUMENTATION } = await import("../validation/index.js");
429
496
  const fullBytes = Buffer.byteLength(PRIMITIVES_DOCUMENTATION, "utf8");
497
+ const docFormat = resolvedPolicy.context.primitiveDocFormat ?? "markdown";
498
+ const indexMode = resolvedPolicy.context.primitiveIndex ?? "off";
430
499
  const mode = resolvedPolicy.context.primitiveDocSlice ?? "full";
431
- if (mode === "axis-keyed") {
432
- const { computePrimitiveAllowlist, slicePrimitiveDocumentation } =
433
- await import("../harness/primitive-slice.js");
500
+
501
+ let doc = PRIMITIVES_DOCUMENTATION;
502
+ if (docFormat === "ts" && indexMode === "off") {
503
+ const { PRIMITIVES_DOCUMENTATION_TS } = await import("../tools/get-primitives-ts.js");
504
+ doc = PRIMITIVES_DOCUMENTATION_TS;
505
+ const tsBytes = Buffer.byteLength(doc, "utf8");
506
+ console.log(
507
+ `[simple] primitive-doc-ts-bytes=${tsBytes} baseline=${fullBytes} drop=${((1 - tsBytes / fullBytes) * 100).toFixed(1)}%`
508
+ );
509
+ }
510
+ const formatBytes = Buffer.byteLength(doc, "utf8");
511
+
512
+ if (mode === "axis-keyed" || mode === "axis-keyed+contract") {
513
+ const {
514
+ computePrimitiveAllowlist,
515
+ computeContractPrimitives,
516
+ slicePrimitiveDocumentation,
517
+ slicePrimitiveDocumentationTs,
518
+ } = await import("../harness/primitive-slice.js");
434
519
  let allowlist = computePrimitiveAllowlist(classification);
520
+ if (mode === "axis-keyed+contract") {
521
+ const fromContract = computeContractPrimitives(effectiveContract);
522
+ allowlist = [...new Set([...allowlist, ...fromContract])].sort();
523
+ }
435
524
  // Apply policy-driven excludes — some policy profiles drop
436
525
  // near-synonym layout primitives (e.g. Row/Box/Spacer).
437
526
  const excludes = resolvedPolicy.context.primitiveDocExcludes ?? [];
@@ -439,61 +528,41 @@ export async function dispatchGeneration(
439
528
  const excludeSet = new Set(excludes);
440
529
  allowlist = allowlist.filter((p) => !excludeSet.has(p));
441
530
  }
442
- const sliced = slicePrimitiveDocumentation(
443
- PRIMITIVES_DOCUMENTATION,
444
- allowlist,
445
- );
446
- const slicedBytes = Buffer.byteLength(sliced, "utf8");
531
+ doc =
532
+ docFormat === "ts" && indexMode === "off"
533
+ ? slicePrimitiveDocumentationTs(doc, allowlist)
534
+ : slicePrimitiveDocumentation(doc, allowlist);
535
+ const slicedBytes = Buffer.byteLength(doc, "utf8");
447
536
  const excludeTag = excludes.length ? ` excludes=${excludes.join(",")}` : "";
448
537
  console.log(
449
- `[simple] primitive-doc-bytes=${slicedBytes} mode=axis-keyed baseline=${fullBytes} drop=${((1 - slicedBytes / fullBytes) * 100).toFixed(1)}% allowlist=${allowlist.length}/${allowlist.join(",")}${excludeTag}`,
450
- );
451
- systemPromptOverride = harness.how.systemPrompt.replace(
452
- PRIMITIVES_DOCUMENTATION,
453
- sliced,
538
+ `[simple] primitive-doc-bytes=${slicedBytes} mode=${mode} baseline=${formatBytes} drop=${((1 - slicedBytes / formatBytes) * 100).toFixed(1)}% allowlist=${allowlist.length}/${allowlist.join(",")}${excludeTag}`
454
539
  );
455
540
  } else {
456
541
  console.log(
457
- `[simple] primitive-doc-bytes=${fullBytes} mode=full baseline=${fullBytes} drop=0.0%`,
542
+ `[simple] primitive-doc-bytes=${formatBytes} mode=full baseline=${formatBytes} drop=0.0%`
458
543
  );
459
544
  }
460
545
 
461
546
  // ── Tool-driven primitive docs ──
462
- // Layers on top of axis-keyed slicing — if slicing already trimmed
463
- // the doc, the index replaces whatever's left. When both are
464
- // active, the index wins (it is a more aggressive cut). The two are
465
- // mutually exclusive in practice via profile selection; this
466
- // ordering just defines the tiebreak.
467
- const indexMode = resolvedPolicy.context.primitiveIndex ?? "off";
547
+ // A name-only index replaces whatever is left (it is the more
548
+ // aggressive cut). The two are mutually exclusive in practice via
549
+ // profile selection; this ordering just defines the tiebreak.
468
550
  if (indexMode !== "off") {
469
551
  const { buildPrimitiveIndex } = await import("../harness/primitive-index.js");
470
- const index = buildPrimitiveIndex(PRIMITIVES_DOCUMENTATION, indexMode);
471
- const indexBytes = Buffer.byteLength(index, "utf8");
552
+ doc = buildPrimitiveIndex(PRIMITIVES_DOCUMENTATION, indexMode);
553
+ const indexBytes = Buffer.byteLength(doc, "utf8");
472
554
  console.log(
473
- `[simple] primitive-index-bytes=${indexBytes} mode=${indexMode} baseline=${fullBytes} drop=${((1 - indexBytes / fullBytes) * 100).toFixed(1)}%`,
474
- );
475
- systemPromptOverride = (systemPromptOverride ?? harness.how.systemPrompt).replace(
476
- PRIMITIVES_DOCUMENTATION,
477
- index,
555
+ `[simple] primitive-index-bytes=${indexBytes} mode=${indexMode} baseline=${fullBytes} drop=${((1 - indexBytes / fullBytes) * 100).toFixed(1)}%`
478
556
  );
479
557
  }
480
558
 
481
- // ── TypeScript-interface processed doc ──
482
- // Replace the markdown-table doc with the TS-interface format
483
- // version (~55% smaller, same info). Mutually exclusive with index
484
- // modes.
485
- const docFormat = resolvedPolicy.context.primitiveDocFormat ?? "markdown";
486
- if (docFormat === "ts" && indexMode === "off") {
487
- const { PRIMITIVES_DOCUMENTATION_TS } = await import("../tools/get-primitives-ts.js");
488
- const tsBytes = Buffer.byteLength(PRIMITIVES_DOCUMENTATION_TS, "utf8");
489
- console.log(
490
- `[simple] primitive-doc-ts-bytes=${tsBytes} baseline=${fullBytes} drop=${((1 - tsBytes / fullBytes) * 100).toFixed(1)}%`,
491
- );
492
- systemPromptOverride = (systemPromptOverride ?? harness.how.systemPrompt).replace(
493
- PRIMITIVES_DOCUMENTATION,
494
- PRIMITIVES_DOCUMENTATION_TS,
495
- );
559
+ if (doc !== PRIMITIVES_DOCUMENTATION) {
560
+ systemPromptOverride = harness.how.systemPrompt.replace(PRIMITIVES_DOCUMENTATION, doc);
496
561
  }
562
+ const sentPrompt = systemPromptOverride ?? harness.how.systemPrompt;
563
+ console.log(
564
+ `[simple] system-prompt-bytes=${Buffer.byteLength(sentPrompt, "utf8")} doc-format=${docFormat === "ts" && indexMode === "off" ? "ts" : docFormat} doc-bytes=${Buffer.byteLength(doc, "utf8")} mode=${indexMode !== "off" ? `index:${indexMode}` : mode}`
565
+ );
497
566
 
498
567
  // ── plan→impl pipeline augmentation ──
499
568
  // Append a 2-phase flow description when planFirstTurn is active so
@@ -548,7 +617,7 @@ export async function dispatchGeneration(
548
617
  console.error(
549
618
  `[dispatch] runHarness terminated: ${runResult.reason} ` +
550
619
  `(source=${runResult.finalSource ? runResult.finalSource.length + "B" : "null"}, ` +
551
- `compiled=${runResult.finalCompiled ? runResult.finalCompiled.length + "B" : "null"})`,
620
+ `compiled=${runResult.finalCompiled ? runResult.finalCompiled.length + "B" : "null"})`
552
621
  );
553
622
  }
554
623
 
@@ -560,7 +629,11 @@ export async function dispatchGeneration(
560
629
  // `assembleGenerationResult` is async — the spread below must see the
561
630
  // RESOLVED result, never the promise (a spread promise is `{}`: every
562
631
  // consumer downstream lost `compiledCode` / `tokens` on 2026-09-09).
563
- const result: GenerationResult = await assembleGenerationResult({ session, telemetry, source: finalSource });
632
+ const result: GenerationResult = await assembleGenerationResult({
633
+ session,
634
+ telemetry,
635
+ source: finalSource,
636
+ });
564
637
  // Record the arm this generation ran under (bench reports read it per
565
638
  // cell). The canvas is the one the free prompt stated: explicit when
566
639
  // passed, else the same shell × screen derivation the prompt used.
@@ -579,11 +652,13 @@ export async function dispatchGeneration(
579
652
  */
580
653
  function withArmRecord(
581
654
  result: GenerationResult,
582
- params: Pick<GenerationDispatchParams, "designMode" | "canvas" | "shellType" | "screen">,
655
+ params: Pick<GenerationDispatchParams, "designMode" | "canvas" | "shellType" | "screen">
583
656
  ): GenerationResult {
584
657
  const recordedCanvas: CanvasClass | undefined =
585
658
  params.canvas ??
586
- (params.designMode === "free" ? canvasForRendering(params.shellType, params.screen) : undefined);
659
+ (params.designMode === "free"
660
+ ? canvasForRendering(params.shellType, params.screen)
661
+ : undefined);
587
662
  return {
588
663
  ...result,
589
664
  ...(params.designMode !== undefined ? { designMode: params.designMode } : {}),
@@ -20,7 +20,8 @@ import { validateAllContracts } from '../check/index.js';
20
20
  import { typecheck } from '../check/index.js';
21
21
  import { lintReactHooks } from '../check/index.js';
22
22
  import ts from 'typescript';
23
- import { tryRender, generateSampleProps } from '../tools/render-check.js';
23
+ import { tryRender } from '../tools/render-check.js';
24
+ import { generateSampleProps } from '../check/sample-props.js';
24
25
 
25
26
  /**
26
27
  * Context for creating generator tools.
@@ -212,7 +212,7 @@ With raw elements YOU own the semantics — nothing is accessible by constructio
212
212
  3. **Images have \`alt\`** (\`alt=""\` for decorative). Icon-only buttons need \`aria-label\`; decorative icons and emoji are \`aria-hidden="true"\`.
213
213
  4. **Live & streaming data** (a \`useStream\` \`.latest\` value, counters, clocks, flashing prices) sits in an \`aria-live="polite"\` (or \`role="status"\`) region so screen readers announce the change.
214
214
  5. **Headings nest**: one \`<h1>\` per screen, \`<h2>\` for sections, \`<h3>\` below — never skipped or inverted.
215
- 6. **Busy state is announced**: \`<button disabled={isLoading} aria-busy={isLoading}>{isLoading ? 'Submitting…' : 'Submit'}</button>\`.
215
+ 6. **Busy state is announced**: every control that dispatches an action (a button, a select whose change dispatches, an input that sends on Enter) stays pending from \`useActionPending('<action>')\` until the agent's answer repaints the card — \`<button disabled={sendPending} aria-busy={sendPending}>{sendPending ? 'Sending…' : 'Send'}</button>\`. Only a control that dispatches the action carries its busy look; while it is pending, other controls of the same record may be disabled — nothing more (controls of other records, creation controls and the action's own cancel stay enabled); busy state the card owns (\`isLoading\`) the same way.
216
216
  7. **Stateful controls announce state**: \`role="checkbox"\` + \`aria-checked\` on a toggleable row, \`aria-pressed\` on a toggle button, \`aria-selected\` on the chosen item, \`aria-expanded\` on an open/close affordance. Styling (strikethrough, color, a check mark) is never the only carrier of state.
217
217
  8. **Focus is visible** — style \`:focus-visible\` with a token color; never \`outline: none\` without a replacement.`;
218
218
 
@@ -230,6 +230,7 @@ export function generateBoilerplate(
230
230
  const actionTypeAliases: string[] = [];
231
231
  const actionHookCalls: string[] = [];
232
232
  const actionReturnFields: string[] = [];
233
+ let hasOneShotAction = false;
233
234
  const actionsMap: ActionSpec = contract?.actionSpec ?? {};
234
235
  for (const [key, entry] of Object.entries(actionsMap)) {
235
236
  const label = entry.label ?? key;
@@ -274,7 +275,29 @@ export function generateBoilerplate(
274
275
  // instruction for every action. So it stays, ALONE on the hook line — what
275
276
  // sits next to it is part of what it says.
276
277
  const onceHint = " — if this is meant ONCE, disable its control after it fires";
277
- actionHookCalls.push(` const ${key} = useAction<${typeName}>('${key}'); // ${callSig}${toolHint}${onceHint}`);
278
+ // ggui#1223 (rnd reliability/013, arm C): an action the contract DECLARES
279
+ // `oneShot` gets its spent state as data, pre-bound beside the dispatcher —
280
+ // WHAT to code, beside HOW — and the generic once-hint leaves ITS line only:
281
+ // left there, the hint steered the model to a local `useState(false)` guard
282
+ // against the prompt's block (arm B: 0 of 6 declared cells used the hook).
283
+ // The generic hint stays verbatim on every undeclared action.
284
+ if (entry.oneShot === true) {
285
+ hasOneShotAction = true;
286
+ actionHookCalls.push(
287
+ ` const ${key} = useAction<${typeName}>('${key}'); // ${callSig}${toolHint} — declared oneShot: the runtime drops a second dispatch`,
288
+ );
289
+ actionHookCalls.push(
290
+ ` const ${key}Spent = useActionSpent('${key}'); // true once fired, also after a reload — render the control from THIS: disabled={${key}Spent} and a spent label`,
291
+ );
292
+ } else {
293
+ actionHookCalls.push(` const ${key} = useAction<${typeName}>('${key}'); // ${callSig}${toolHint}${onceHint}`);
294
+ }
295
+ // ggui#1398 — the dispatch's in-flight state, bound at the site the model
296
+ // writes the control (reliability/013: a rule alone reached 1/6, the
297
+ // binding 6/6).
298
+ actionHookCalls.push(
299
+ ` const ${key}Pending = useActionPending('${key}'); // true from the tap until the agent's answer repaints the card — render EVERY control that dispatches '${key}' (a Button, a Select's onChange, an Input's Enter) from THIS: disabled={${key}Pending} aria-busy={${key}Pending}, and a working label on a button; no other control carries its aria-busy`,
300
+ );
278
301
  actionReturnFields.push(key);
279
302
  }
280
303
 
@@ -507,6 +530,8 @@ export function generateBoilerplate(
507
530
 
508
531
  const wireHooks: string[] = [];
509
532
  if (hasActions) wireHooks.push("useAction");
533
+ if (hasOneShotAction) wireHooks.push("useActionSpent");
534
+ if (hasActions) wireHooks.push("useActionPending");
510
535
  if (hasStream) wireHooks.push("useStream");
511
536
  if (hasContext) wireHooks.push("useGguiContext");
512
537
  const wireImport = hasAnyWireFromWire
@@ -214,29 +214,29 @@ Rules:
214
214
  2. Layout and styling are universal. Colors, spacing, structure — these are the reusable part.
215
215
  3. Default values come from the current request — so the component renders correctly standalone.
216
216
  4. Copy comes from the props. NEVER invent display text the contract does not carry — no eyebrow / kicker / overline (a \`<Text caps>\` label), no helper line, no status text, no tagline. **Do NOT add a caps label above the heading** — not \`<Text caps>LET'S BEGIN</Text>\`, not \`WELCOME\`, not a section kicker like \`GETTING STARTED\` or \`OVERVIEW\` — UNLESS a prop supplies that exact text (\`<Text caps>{props.section}</Text>\`). When the request asks for a heading and a line, render the heading and the line and NOTHING above the heading. A heading's fallback is the request's own words, never a slogan of your own. Text the agent cannot change is chatter on every render, and a caps eyebrow you wrote is the most common form of it. An action's control shows the contract's \`label\` VERBATIM — every character, including \`&\`, \`/\`, \`–\` and quotes — never paraphrased, shortened or re-cased (\`"Confirm & Schedule"\` is not \`"Confirm Schedule"\`).
217
- 5. Props interface must be typed and exported.`;
217
+ 5. Props interface must be typed and exported.
218
+ 6. Keys on a mapped list come from the item's own identity: \`key={item.id}\` when the contract gives an id; when the items carry none (KPI tiles \`{label, value}\`, forecast days \`{day, high}\`), key by the field that names each one — \`key={item.label}\`, \`key={day.day}\` — never by the map index: a reorder or a stream merge re-keys by index and re-mounts the wrong rows, and the self-check refuses an index key.`;
218
219
 
219
220
  /**
220
221
  * A control that fires an action the user would not mean to repeat must not
221
- * stay armed (ggui#1108 MITIGATION — the fix is one-shot-ness DECLARED on the
222
- * action and enforced by the runtime; this reduces exposure meanwhile). Both
223
- * arms, one HARD section: the model knows from the request and the contract
224
- * which actions are terminal, which no static rule of ours can infer.
222
+ * stay armed (ggui#1108). One-shot-ness is DECLARED on the action
223
+ * (`actionSpec.<name>.oneShot`), enforced by the runtime, and readable by the
224
+ * component through `useActionSpent` (ggui#1223) — so a declared action's
225
+ * control renders from data that survives a reload, and the local-state guard
226
+ * remains only as the fallback for an action the contract failed to declare.
227
+ * Both arms, one HARD section: the contract says which actions are once; where
228
+ * it does not, the request may, and no static rule of ours can infer it from a
229
+ * verb.
225
230
  */
226
231
  export const TERMINAL_ACTIONS = `## Actions the user means once
227
232
 
228
233
  A second press is a mistake only when it would make a SECOND THING HAPPEN IN THE WORLD — a second booking, a second charge, a second order, a second application sent. If pressing again would only write the SAME STATE AGAIN — saving, updating, renaming, setting a preference, toggling — nothing is duplicated and the control stays armed.
229
234
 
230
- Guard a control only where you have evidence, not a feeling about the verb: the request itself names a one-time commitment — "submit the application", "place the order", "confirm the booking".
235
+ **The contract says which actions are once.** An action the contract declares \`oneShot: true\` (in the Contract block above) is guarded by the runtime — a second dispatch is dropped — and its spent state is DATA you read, never state you hold: render its control from \`const spent = useActionSpent('<name>')\`, \`disabled={spent}\`, and say so in the label or beside it ("Submitted", "Request sent") — a control that is disabled without explanation reads as broken. The hook's answer survives a reload and a second tab; \`useState(false)\` does not, so a reloaded card built on local state re-arms a control that already fired.
231
236
 
232
- **When in doubt, leave it armed.** A control the user cannot press a second time has lost them something that worked — save, edit, save again is ordinary. A control left armed is only what every interface does by default.
237
+ **An action the contract does not declare \`oneShot\` stays armed.** Never guard one from a feeling about the verb — "confirm", "submit", "place" alone are not evidence. Guard it only where the request itself names a one-time commitment the contract failed to declare ("submit the application", "place the order"), and then with local state as the fallback: \`const [submitted, setSubmitted] = useState(false)\` set in the handler, \`disabled={submitted}\` with a label — knowing that this state lives with the component, not with the request, and starts fresh on a re-render with new props.
233
238
 
234
- When you do guard one:
235
- - Hold a small piece of state for it and set it in the handler: \`const [submitted, setSubmitted] = useState(false)\`.
236
- - \`disabled={submitted}\` on the control that fires it, and say so in the label or beside it ("Submitted", "Request sent") — a control that is disabled without explanation reads as broken.
237
- - A card re-rendered with new props starts fresh; the state above lives with the component, not with the request.
238
-
239
- Nothing in the wire DECLARES one-shot-ness yet, which is why the test is what a second press would DO — not how final the word sounds.`;
239
+ **When in doubt, leave it armed.** A control the user cannot press a second time has lost them something that worked — save, edit, save again is ordinary. A control left armed is only what every interface does by default.`;
240
240
 
241
241
  /**
242
242
  * The frame owns the height — the component never sizes itself to the