@ggui-ai/ui-gen 0.20.0 → 0.22.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 (93) hide show
  1. package/dist/adapters/base.d.ts +7 -0
  2. package/dist/adapters/base.js +1 -0
  3. package/dist/adapters/base.js.map +1 -1
  4. package/dist/adapters/generation-dispatch.d.ts +17 -3
  5. package/dist/adapters/generation-dispatch.js +1078 -3464
  6. package/dist/adapters/generation-dispatch.js.map +1 -1
  7. package/dist/adapters/index.js +60 -3
  8. package/dist/adapters/index.js.map +1 -1
  9. package/dist/advanced/index.d.ts +4 -4
  10. package/dist/advanced/index.js +1346 -3557
  11. package/dist/advanced/index.js.map +1 -1
  12. package/dist/blueprint-validator.js +10 -3
  13. package/dist/blueprint-validator.js.map +1 -1
  14. package/dist/boilerplate.js +2 -2
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +1 -1
  17. package/dist/check/index.js.map +1 -1
  18. package/dist/{create-ui-generator-CxPvudbs.d.ts → create-ui-generator-BEkzEtNz.d.ts} +2 -2
  19. package/dist/{dispatch-N8wdNOX-.d.ts → dispatch-DJNpQ7DI.d.ts} +1 -1
  20. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  21. package/dist/evaluation/axis-checks/index.js.map +1 -1
  22. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  23. package/dist/evaluation/index.d.ts +38 -6
  24. package/dist/evaluation/index.js +670 -3374
  25. package/dist/evaluation/index.js.map +1 -1
  26. package/dist/harness/check/runtime-render/index.d.ts +2 -2
  27. package/dist/harness/check/runtime-render/index.js +10 -3
  28. package/dist/harness/check/runtime-render/index.js.map +1 -1
  29. package/dist/harness/check/runtime-render/render-check-worker.js +5 -3
  30. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  31. package/dist/harness/index.d.ts +2 -2
  32. package/dist/harness/index.js +587 -30
  33. package/dist/harness/index.js.map +1 -1
  34. package/dist/harness/llm-trace-sink.d.ts +7 -1
  35. package/dist/harness/llm-trace-sink.js.map +1 -1
  36. package/dist/harness/prompts.js +1 -1
  37. package/dist/harness/prompts.js.map +1 -1
  38. package/dist/harness/result-types.d.ts +15 -2
  39. package/dist/harness/result-types.js.map +1 -1
  40. package/dist/harness/runtime.d.ts +11 -2
  41. package/dist/harness/runtime.js +3 -3
  42. package/dist/harness/runtime.js.map +1 -1
  43. package/dist/harness/types-public.d.ts +1 -1
  44. package/dist/harness/types-public.js +2 -2
  45. package/dist/harness/types-public.js.map +1 -1
  46. package/dist/harness/validator-trace-sink.d.ts +1 -1
  47. package/dist/index.d.ts +23 -7
  48. package/dist/index.js +1346 -3557
  49. package/dist/index.js.map +1 -1
  50. package/dist/{llm-router-D7fjBhtr.d.ts → llm-router-DwwUK1bz.d.ts} +16 -3
  51. package/dist/patch.d.ts +1 -1
  52. package/dist/providers/index.js +5 -4
  53. package/dist/providers/index.js.map +1 -1
  54. package/dist/{render-check-WfOVO9kU.d.ts → render-check-C0CqVceF.d.ts} +17 -0
  55. package/dist/{types-public-DKR_h70H.d.ts → types-public-mM_-1Yk3.d.ts} +59 -1
  56. package/dist/validation/index.d.ts +1 -1
  57. package/dist/validation/index.js +1 -1
  58. package/dist/validation/index.js.map +1 -1
  59. package/dist/workflows.d.ts +1 -1
  60. package/package.json +16 -12
  61. package/src/adapters/base.ts +8 -0
  62. package/src/adapters/claude/claude-code-login.ts +128 -0
  63. package/src/adapters/claude/sdk.ts +9 -1
  64. package/src/adapters/generation-dispatch.ts +19 -2
  65. package/src/adapters/openai/tokens.ts +18 -0
  66. package/src/adapters/provider-router.ts +28 -0
  67. package/src/boilerplate/generate.ts +19 -4
  68. package/src/boilerplate/hard-sections.ts +1 -1
  69. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +7 -3
  70. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +7 -3
  71. package/src/contract-context.ts +1 -1
  72. package/src/create-ui-generator.ts +49 -10
  73. package/src/evaluation/design-identity.ts +16 -5
  74. package/src/evaluation/index.ts +1 -0
  75. package/src/evaluation/llm-evaluator.ts +40 -13
  76. package/src/evaluation/types-public.ts +58 -0
  77. package/src/evaluation/visual-evaluator.ts +240 -30
  78. package/src/generator-build.ts +158 -0
  79. package/src/harness/check/runtime-render/adapter.ts +9 -0
  80. package/src/harness/check/runtime-render/render-check.ts +26 -5
  81. package/src/harness/claude-code-login-query.ts +529 -0
  82. package/src/harness/coding/assemble-result.ts +18 -2
  83. package/src/harness/coding/generate-task-runner.ts +75 -36
  84. package/src/harness/coding/run-coding-turn.ts +9 -0
  85. package/src/harness/coding/run-eval-round.ts +338 -38
  86. package/src/harness/enforced-coding.ts +20 -0
  87. package/src/harness/llm-router.ts +390 -55
  88. package/src/harness/llm-trace-sink.ts +7 -1
  89. package/src/harness/result-types.ts +14 -0
  90. package/src/harness/runtime.ts +6 -0
  91. package/src/index.ts +2 -1
  92. package/src/providers/openai.ts +31 -19
  93. package/src/validation/primitives.ts +1 -1
@@ -22,17 +22,25 @@ import { listContractGadgets } from "@ggui-ai/protocol";
22
22
  import type { Classification } from "../../classifier/index.js";
23
23
  import type { AgentWorkspace } from "../../coding-agent/workspace.js";
24
24
  import type { CostTracker } from "../../evaluation/cost-tracker.js";
25
- import type { EvalIssue, EvalResult, RuntimeProbeMeta, VisualEvalSummary } from "../../evaluation/types-public.js";
25
+ import type { ContractFeedbackRecord, EvalIssue, EvalResult, RuntimeProbeMeta, VisualEvalSummary, VisualCoverage } from "../../evaluation/types-public.js";
26
26
  import { notApplicableCoverage } from "../../evaluation/types-public.js";
27
- import { mapProviderForEvaluator } from "../enforced-coding.js";
27
+ import { mapProviderForEvaluator, visionJudgeProvider } from "../enforced-coding.js";
28
28
  import { runCheck } from "../index.js";
29
29
  import { isRecoverableRenderCrash } from "../check/runtime-render/index.js";
30
30
  import type { Harness } from "../types-public.js";
31
31
  import type { AgentSpec, SingleComponentParams } from "../runtime.js";
32
+ import type {
33
+ CanvasFitReading,
34
+ VisualEvalContext,
35
+ VisualEvalOutcome,
36
+ VisualFitConfig,
37
+ VisualFitOutcome,
38
+ } from "../../evaluation/visual-evaluator.js";
32
39
 
33
40
  type PreWarmedEvalContext =
34
41
  import("../../evaluation/llm-evaluator.js").PreWarmedEvalContext;
35
42
  type LlmEvalMod = typeof import("../../evaluation/llm-evaluator.js");
43
+ type LLMEvalTokenUsage = import("../../evaluation/llm-evaluator.js").LLMEvalTokenUsage;
36
44
  type VisualEvalMod = typeof import("../../evaluation/visual-evaluator.js");
37
45
 
38
46
  /**
@@ -93,6 +101,20 @@ export interface EvalRoundInput {
93
101
  */
94
102
  export type EvalRoundControl = "break" | "feedback";
95
103
 
104
+ /**
105
+ * One call's (or one round's) token accounting. `input` is the NON-cached
106
+ * input; the cache counts are present only when the provider reported them —
107
+ * absent means unreported, never zero. The runner folds every coding turn and
108
+ * every eval round through the same rule (`absorbTokens`), so the published
109
+ * `tokens.total` holds one convention across both (ggui#1281, #1186).
110
+ */
111
+ export interface TokenUsage {
112
+ readonly input: number;
113
+ readonly output: number;
114
+ readonly cacheRead?: number;
115
+ readonly cacheCreation?: number;
116
+ }
117
+
96
118
  export interface EvalRoundResult {
97
119
  readonly control: EvalRoundControl;
98
120
  /** True only on the low-risk bypass + clean-pass paths. */
@@ -108,7 +130,7 @@ export interface EvalRoundResult {
108
130
  readonly prevFailFingerprints: Set<string>;
109
131
  readonly preWarmedContext: PreWarmedEvalContext | null | undefined;
110
132
  /** Tokens added during this round — caller adds to its own totals. */
111
- readonly evalTokens: { input: number; output: number };
133
+ readonly evalTokens: TokenUsage;
112
134
  /** Wall-clock of the parallel LLM + visual eval block (zero on the
113
135
  * low-risk bypass path, which exits before `Promise.all`). Caller
114
136
  * accumulates into its `cumulativeEvalLlmMs`. */
@@ -117,11 +139,35 @@ export interface EvalRoundResult {
117
139
  readonly lastResultText: string;
118
140
  readonly isEvalFeedback: boolean;
119
141
  readonly lastDiffFailed: boolean;
142
+ /**
143
+ * ggui#1261 — set ONLY on the round that granted a contract-feedback round:
144
+ * what bought it and the source it was fed back on. The caller keeps it past
145
+ * this round (the next round rebuilds `evalResult`) and stamps it on the
146
+ * result the generation ends with.
147
+ */
148
+ readonly contractFeedback?: ContractFeedbackRecord;
120
149
  }
121
150
 
122
151
  /** Cap feedback to N issues per eval-fix turn so the LLM stays surgical. */
123
152
  const MAX_FEEDBACK_ISSUES = 3;
124
153
 
154
+ /**
155
+ * ggui#1195 — the visual leg's deterministic fit verdict: the inline card's
156
+ * content is taller than the box it was judged at (`canvas-overflow`,
157
+ * critical ⇒ fail). Recognised by the marker the evaluator emits, never by
158
+ * the description's wording.
159
+ */
160
+ function isFitFail(issue: EvalIssue): boolean {
161
+ return issue.result === "fail" && issue.category === "visual" && issue.subcategory === "canvas-overflow";
162
+ }
163
+ /** The same marker as {@link isFitFail}, on the fingerprint `fingerprintFail` builds (`category|subcategory|…`). */
164
+ const FIT_FINGERPRINT_PREFIX = "visual|canvas-overflow|";
165
+
166
+ /** `[fit]`-tagged so the LLM can see the violation is the box, not the judge's taste. */
167
+ function formatFitFeedback(issue: EvalIssue): string {
168
+ return `[fit] ${issue.category}/${issue.subcategory ?? ""}: ${issue.description}\n Fix: ${issue.fix ?? ""}`;
169
+ }
170
+
125
171
  /**
126
172
  * `criteriaCoverage` reason stamped on the same-image low-risk bypass
127
173
  * exits: tier-1/2 evaluation is never invoked there BY DESIGN, so every
@@ -131,6 +177,65 @@ const MAX_FEEDBACK_ISSUES = 3;
131
177
  /** Why an eval result carries no criteria: the round itself threw (ggui#1110). */
132
178
  const EVAL_ROUND_THREW_REASON = "eval round threw";
133
179
 
180
+ /**
181
+ * The in-loop visual leg, made total (ggui#1248). The judge runs on a VISION
182
+ * provider: the agent's provider narrowed by `visionJudgeProvider`, never
183
+ * asserted into the vision union — a lane whose generation provider has no
184
+ * vision path (OpenAI, OpenRouter) gets a leg recorded as SKIPPED with the
185
+ * reason, and the judge is not called. And whatever the leg does, it resolves:
186
+ * a rejection becomes the same skipped coverage with the error, so the round
187
+ * never takes its thrown path on account of the visual leg and the runtime
188
+ * probe still runs — a bar that requires the probe decides on the probe, not
189
+ * on a judge that could not be reached.
190
+ *
191
+ * The leg's FIT half does not need the judge (ggui#1195): the fit verdict is a
192
+ * frame at the declared box and a scroll height, so a no-vision lane with
193
+ * canvases runs `measureFit` alone. Its canvas-overflow fail rides the round's
194
+ * issues like the judge path's would — so the `[fit]` round the cap grants
195
+ * can fire on that lane — and the coverage still reads `skipped` for the judge,
196
+ * with what the fit half measured (or why it could not) appended. Without
197
+ * that half, a card taller than its declared chat box on such a lane was
198
+ * never told so in the loop, so the composition fix was never asked for.
199
+ */
200
+ function runVisualLeg(
201
+ visualEvalAgent: AgentSpec,
202
+ run: (provider: "claude" | "google") => Promise<VisualEvalOutcome>,
203
+ measureFit: (() => Promise<VisualFitOutcome>) | undefined,
204
+ ): Promise<VisualEvalOutcome> {
205
+ const provider = visionJudgeProvider(visualEvalAgent.provider);
206
+ if (provider === undefined) {
207
+ const noJudge = `no vision judge for provider '${visualEvalAgent.provider}' (model ${visualEvalAgent.model}) — the in-loop visual judge runs on a vision provider (anthropic, google)`;
208
+ if (measureFit === undefined) {
209
+ return Promise.resolve({ issues: [], coverage: { status: "skipped", reason: noJudge } });
210
+ }
211
+ const notMeasured = (why: string): VisualEvalOutcome => ({
212
+ issues: [],
213
+ coverage: { status: "skipped", reason: `${noJudge}; fit not measured: ${why}` },
214
+ });
215
+ return measureFit().then(
216
+ (fit): VisualEvalOutcome =>
217
+ fit.status === "measured"
218
+ ? {
219
+ issues: fit.issues,
220
+ coverage: { status: "skipped", reason: `${noJudge}; fit measured without the judge: ${describeFitReadings(fit.readings)}` },
221
+ }
222
+ : notMeasured(fit.reason),
223
+ (e: unknown): VisualEvalOutcome => notMeasured(e instanceof Error ? e.message : String(e)),
224
+ );
225
+ }
226
+ return run(provider).catch((e: unknown): VisualEvalOutcome => ({
227
+ issues: [],
228
+ coverage: { status: "skipped", reason: `visual leg failed: ${e instanceof Error ? e.message : String(e)}` },
229
+ }));
230
+ }
231
+
232
+ /** The fit half's receipt on the coverage: `xs-chat-card 384×516 content 535px (overflow)`, one per canvas. */
233
+ function describeFitReadings(readings: readonly CanvasFitReading[]): string {
234
+ return readings
235
+ .map((r) => `${r.canvas} ${r.viewport.width}×${r.viewport.height} content ${r.contentHeight ?? "?"}px${r.overflow ? " (overflow)" : ""}`)
236
+ .join(", ");
237
+ }
238
+
134
239
  const LOW_RISK_BYPASS_REASON =
135
240
  "same-image low-risk bypass: axis checks clean, tier-1/2 evaluation not invoked";
136
241
 
@@ -285,6 +390,42 @@ function formatRuntimeProbeFeedback(issue: EvalIssue): string {
285
390
  );
286
391
  }
287
392
 
393
+ /**
394
+ * Exit-probe checks whose FAIL buys ONE feedback round although it is not a
395
+ * crash: a contract defect the probe diagnoses deterministically, with a fix
396
+ * text the model can act on in a turn. Without the round the loop recorded the
397
+ * finding and shipped it — its own evaluator named the defect and the model
398
+ * never heard. Named, and grown only with evidence: `prop-sensitivity` (a
399
+ * declared prop ignored, a literal baked in its place — "You" for
400
+ * `currentUser`) is the class measured failing across models (ggui#1261).
401
+ */
402
+ const FEEDBACK_PROBE_CHECKS: readonly string[] = ["prop-sensitivity"];
403
+
404
+ /** Rounds past the cap the contract-feedback round may use: one, as the fit round (ggui#1195). */
405
+ const CONTRACT_FEEDBACK_BONUS = 1;
406
+
407
+ /** `runtime:<check>` or `runtime:<check>:<subject>` for a named feedback check, FAIL only. */
408
+ function isFeedbackProbeFail(issue: EvalIssue): boolean {
409
+ const sub = issue.subcategory;
410
+ if (issue.result !== "fail" || typeof sub !== "string") return false;
411
+ return FEEDBACK_PROBE_CHECKS.some((check) => sub === `runtime:${check}` || sub.startsWith(`runtime:${check}:`));
412
+ }
413
+
414
+ /**
415
+ * The exit probe's feedback-worthy FAILs this generation has not been told
416
+ * about yet. A finding whose fingerprint already rode a feedback round and
417
+ * recurred is left out — one attempt per finding, then it ships as recorded.
418
+ */
419
+ /** The round's record: the findings that bought it, and the source they were found on. */
420
+ function contractFeedbackRecord(fails: readonly EvalIssue[], sourceBefore: string): ContractFeedbackRecord {
421
+ return { firedOn: fails.flatMap((i) => (i.subcategory !== undefined ? [i.subcategory] : [])), sourceBefore };
422
+ }
423
+
424
+ function contractFeedbackFails(exitProbe: ProbeAtExitResult, alreadyFedBack: ReadonlySet<string>): EvalIssue[] {
425
+ if (!exitProbe.fired) return [];
426
+ return exitProbe.probeIssues.filter((i) => isFeedbackProbeFail(i) && !alreadyFedBack.has(fingerprintFail(i)));
427
+ }
428
+
288
429
  /**
289
430
  * Run one eval round. Mutates `ctx.costTracker` (records LLM eval tokens)
290
431
  * and `ctx.workspace` is read-only from this function's POV. Returns
@@ -321,7 +462,7 @@ export async function runEvalRound(
321
462
  const currentSource = workspace.read() ?? "";
322
463
 
323
464
  let evalResult: EvalResult | undefined;
324
- let evalTokens = { input: 0, output: 0 };
465
+ let evalTokens: TokenUsage = { input: 0, output: 0 };
325
466
  let evalLlmMs = 0;
326
467
 
327
468
  try {
@@ -445,9 +586,33 @@ export async function runEvalRound(
445
586
  }
446
587
 
447
588
  // ── Tier 1+2: LLM evaluation + Visual eval in parallel ──
448
- let llmResult: (EvalResult & { inputTokens: number; outputTokens: number }) | null = null;
589
+ let llmResult: (EvalResult & LLMEvalTokenUsage) | null = null;
449
590
  let visualIssues: EvalIssue[] | null = null;
450
591
 
592
+ // The visual leg's page and frame — ONE value each, read by the judge and
593
+ // by its fit half alike, so both frame the same component the same way.
594
+ const visualContext: VisualEvalContext = {
595
+ compiledCode,
596
+ originalPrompt: userPrompt,
597
+ profile: harness.profile,
598
+ // The caller's theme, when named — the round paints what the app paints.
599
+ ...(visualEvaluation?.cssTokens !== undefined ? { cssTokens: visualEvaluation.cssTokens } : {}),
600
+ };
601
+ const visualFrame: VisualFitConfig = {
602
+ // The in-loop visual round renders the same sample the runtime probe
603
+ // does (the caller's fixture) unless the config names its own.
604
+ sampleProps: visualEvaluation?.sampleProps ?? fixtureProps,
605
+ // Per-canvas judging (arm-neutral) — absent = single shot.
606
+ ...(visualEvaluation?.canvases !== undefined ? { canvases: visualEvaluation.canvases } : {}),
607
+ // ggui#1195 — the declared box per canvas (same-target with the composer).
608
+ ...(visualEvaluation?.canvasViewports !== undefined ? { canvasViewports: visualEvaluation.canvasViewports } : {}),
609
+ };
610
+ // The fit verdict is per canvas: a single-shot leg has no box to hold content against.
611
+ const measureFit =
612
+ visualMod && visualFrame.canvases !== undefined && visualFrame.canvases.length > 0
613
+ ? () => visualMod.runVisualFit(visualContext, visualFrame)
614
+ : undefined;
615
+
451
616
  const evalLlmStart = Date.now();
452
617
  const [llm, visual] = await Promise.all([
453
618
  llmEvalMod
@@ -483,35 +648,25 @@ export async function runEvalRound(
483
648
  )
484
649
  : null,
485
650
  visualMod
486
- ? visualMod.runVisualEval(
487
- {
488
- compiledCode,
489
- originalPrompt: userPrompt,
490
- profile: harness.profile,
491
- // The caller's theme, when named — the round paints what the app paints.
492
- ...(visualEvaluation?.cssTokens !== undefined ? { cssTokens: visualEvaluation.cssTokens } : {}),
493
- },
494
- {
495
- provider: mapProviderForEvaluator(visualEvalAgent.provider) as "claude" | "google",
496
- model: visualEvalAgent.model,
497
- passThreshold: visualThreshold,
498
- // The in-loop visual round renders the same sample the runtime probe
499
- // does (the caller's fixture) unless the config names its own.
500
- sampleProps: visualEvaluation?.sampleProps ?? fixtureProps,
501
- viewport: visualEvaluation?.viewport,
502
- // Per-canvas judging (arm-neutral) — absent = single shot.
503
- ...(visualEvaluation?.canvases !== undefined
504
- ? { canvases: visualEvaluation.canvases }
505
- : {}),
506
- // #484/#489 — same threading as the LLM-eval leg above:
507
- // the visual eval's multimodal call runs inside the same
508
- // concurrent generation, so it needs the same routing
509
- // override (never fall back to `process.env`) and the
510
- // same provider-429-retry observer (retries reach the
511
- // host application's structured log).
512
- routeOverride: visualEvalAgent.routeOverride,
513
- onRetry: visualEvalAgent.onRetry,
514
- },
651
+ ? runVisualLeg(
652
+ visualEvalAgent,
653
+ (provider) =>
654
+ visualMod.runVisualEval(visualContext, {
655
+ provider,
656
+ model: visualEvalAgent.model,
657
+ passThreshold: visualThreshold,
658
+ viewport: visualEvaluation?.viewport,
659
+ ...visualFrame,
660
+ // #484/#489 — same threading as the LLM-eval leg above:
661
+ // the visual eval's multimodal call runs inside the same
662
+ // concurrent generation, so it needs the same routing
663
+ // override (never fall back to `process.env`) and the
664
+ // same provider-429-retry observer (retries reach the
665
+ // host application's structured log).
666
+ routeOverride: visualEvalAgent.routeOverride,
667
+ onRetry: visualEvalAgent.onRetry,
668
+ }),
669
+ measureFit,
515
670
  )
516
671
  : null,
517
672
  ]);
@@ -519,6 +674,12 @@ export async function runEvalRound(
519
674
  llmResult = llm;
520
675
  visualIssues = visual?.issues ?? null;
521
676
  const visualSummary: VisualEvalSummary | undefined = visual?.summary;
677
+ // ggui#1221 — the judge leg's coverage is ALWAYS stamped: ran / skipped (with the
678
+ // reason) / not-applicable when no visual leg was configured for this round.
679
+ const visualCoverage: VisualCoverage = visual?.coverage ?? { status: 'not-applicable', reason: 'visual leg not configured' };
680
+ if (visualCoverage.status === 'skipped') {
681
+ console.log(`[eval] visual-judge: SKIPPED (${visualCoverage.reason ?? 'no reason recorded'})`);
682
+ }
522
683
 
523
684
  // Track costs from LLM eval calls
524
685
  if (llmResult) {
@@ -526,6 +687,8 @@ export async function runEvalRound(
526
687
  evalTokens = {
527
688
  input: llmResult.inputTokens,
528
689
  output: llmResult.outputTokens,
690
+ ...(llmResult.cacheReadTokens !== undefined ? { cacheRead: llmResult.cacheReadTokens } : {}),
691
+ ...(llmResult.cacheCreationTokens !== undefined ? { cacheCreation: llmResult.cacheCreationTokens } : {}),
529
692
  };
530
693
  }
531
694
 
@@ -549,6 +712,7 @@ export async function runEvalRound(
549
712
  // requested and the leg ran; every later re-stamp spreads this
550
713
  // base so it survives to the assembled result.
551
714
  ...(visualSummary !== undefined ? { visual: visualSummary } : {}),
715
+ visualCoverage,
552
716
  };
553
717
 
554
718
  // ── Log merged results, with mode-check subcategory breakdown ──
@@ -644,6 +808,40 @@ export async function runEvalRound(
644
808
  lastDiffFailed: false,
645
809
  };
646
810
  }
811
+ // ggui#1261 — a contract defect the probe diagnosed (a named check,
812
+ // FAIL) that this generation has not been told about: ONE feedback
813
+ // round with the probe's own diagnosis and fix. The next round re-runs
814
+ // the probe at its exit, so the result this generation ends with
815
+ // carries the post-fix stamp, never this one.
816
+ const contractFails = contractFeedbackFails(exitProbe, prevFailFingerprints);
817
+ if (contractFails.length > 0 && evalRoundsUsed < maxEvalRounds + CONTRACT_FEEDBACK_BONUS) {
818
+ const lines = contractFails.slice(0, MAX_FEEDBACK_ISSUES).map(formatRuntimeProbeFeedback);
819
+ evalResult = {
820
+ ...evalResult,
821
+ issues: [...allIssues, ...exitProbe.probeIssues],
822
+ pass: allPass,
823
+ runtimeProbe: exitProbe.meta,
824
+ };
825
+ console.log(
826
+ `[simple] eval round ${evalRoundsUsed}: tier-1/2 clean BUT contract probe fail — granting +1 turn ` +
827
+ `(ggui#1261; ${contractFails.map((i) => i.subcategory).join(", ")})`,
828
+ );
829
+ return {
830
+ control: "feedback",
831
+ evalDone: false,
832
+ evalResult,
833
+ evalRoundsUsed,
834
+ prevModeSubcats: updatedPrevModeSubcats,
835
+ prevFailFingerprints: new Set([...currFailFingerprints, ...contractFails.map(fingerprintFail)]),
836
+ preWarmedContext,
837
+ evalTokens,
838
+ evalLlmMs,
839
+ lastResultText: lines.join("\n\n"),
840
+ isEvalFeedback: true,
841
+ lastDiffFailed: false,
842
+ contractFeedback: contractFeedbackRecord(contractFails, currentSource),
843
+ };
844
+ }
647
845
  // Probe didn't trip a recoverable fail — fold its issues (if any)
648
846
  // into the merged result and ALWAYS stamp its execution meta, so a
649
847
  // did-not-run probe is visible downstream even on this clean exit.
@@ -683,13 +881,19 @@ export async function runEvalRound(
683
881
  // to localize them; the +2 max-rounds extension is the right escape
684
882
  // hatch, not stuck-loop exit. Keep stuck-loop active for non-runtime
685
883
  // fail-sets (the original tier-1 nitpick case it was built for).
686
- const onlyRuntimeFails =
884
+ // ggui#1195 follow-up: a FIT fail (`visual|canvas-overflow|…`) is exempt
885
+ // the same way — its escape hatch is the one `[fit]` round at the cap, and
886
+ // a card whose measured height did not move between rounds would otherwise
887
+ // exit here as "stuck" before that round is ever reached (the adversarial
888
+ // review ran the commit's own test and found round 2's break WAS the stuck
889
+ // exit). Bounded: the cap block grants the fit round exactly once.
890
+ const onlyExtensionExemptFails =
687
891
  currFailFingerprints.size > 0 &&
688
- [...currFailFingerprints].every((fp) => fp.includes("|runtime:"));
892
+ [...currFailFingerprints].every((fp) => fp.includes("|runtime:") || fp.startsWith(FIT_FINGERPRINT_PREFIX));
689
893
  if (
690
894
  evalRoundsUsed > 1 &&
691
895
  currFailFingerprints.size > 0 &&
692
- !onlyRuntimeFails &&
896
+ !onlyExtensionExemptFails &&
693
897
  [...currFailFingerprints].every((fp) => prevFailFingerprints.has(fp))
694
898
  ) {
695
899
  console.log(
@@ -701,7 +905,8 @@ export async function runEvalRound(
701
905
  // the runtimeProbe stamp lands (Exp 48 found this exit surfacing
702
906
  // as "SKIP — no runtimeProbe stamp" in the bench: honest, but a
703
907
  // probe here is cheap and the shipped code deserves the signal).
704
- // No feedback is granted — stuck means the LLM stopped converging.
908
+ // The RECURRING fails get no more feedback — stuck means the model
909
+ // saw them and stopped converging on them.
705
910
  const stuckExitProbe = await runProbeAtExit({
706
911
  harness,
707
912
  sourceCode: currentSource,
@@ -714,6 +919,36 @@ export async function runEvalRound(
714
919
  issues: [...evalResult.issues, ...stuckExitProbe.probeIssues],
715
920
  runtimeProbe: stuckExitProbe.meta,
716
921
  };
922
+ // ggui#1261 — a contract defect the exit probe diagnosed here was never
923
+ // DELIVERED: the stuck set is non-runtime by construction (runtime-only
924
+ // sets are exempt above), and the stuck premise is "the model saw these
925
+ // and did not resolve them". So it gets the same one round as at the
926
+ // clean-PASS and cap exits — the probe's diagnosis only, never the
927
+ // recurring fail; one attempt per finding; bounded at the cap + 1 (Exp
928
+ // 011, candidate r5: the one FAIL that shipped without a round).
929
+ const stuckContractFails = contractFeedbackFails(stuckExitProbe, prevFailFingerprints);
930
+ if (stuckContractFails.length > 0 && evalRoundsUsed < maxEvalRounds + CONTRACT_FEEDBACK_BONUS) {
931
+ const lines = stuckContractFails.slice(0, MAX_FEEDBACK_ISSUES).map(formatRuntimeProbeFeedback);
932
+ console.log(
933
+ `[simple] eval round ${evalRoundsUsed}: stuck BUT contract probe fail never delivered — granting +1 turn ` +
934
+ `(ggui#1261; ${stuckContractFails.map((i) => i.subcategory).join(", ")})`,
935
+ );
936
+ return {
937
+ control: "feedback",
938
+ evalDone: false,
939
+ evalResult,
940
+ evalRoundsUsed,
941
+ prevModeSubcats: updatedPrevModeSubcats,
942
+ prevFailFingerprints: new Set([...currFailFingerprints, ...stuckContractFails.map(fingerprintFail)]),
943
+ preWarmedContext,
944
+ evalTokens,
945
+ evalLlmMs,
946
+ lastResultText: lines.join("\n\n"),
947
+ isEvalFeedback: true,
948
+ lastDiffFailed: false,
949
+ contractFeedback: contractFeedbackRecord(stuckContractFails, currentSource),
950
+ };
951
+ }
717
952
  return {
718
953
  control: "break",
719
954
  evalDone: false,
@@ -747,6 +982,11 @@ export async function runEvalRound(
747
982
  // stuck-loop detector won't fire (some fails change), but the cap
748
983
  // hits with the runtime fail unresolved.
749
984
  const RUNTIME_EXTENSION_BONUS = 2;
985
+ // ggui#1195 — a fit fail (the hello does not fit its declared first
986
+ // screen — the founder's ruling is FAIL, not warn) active at the cap is
987
+ // worth ONE more round: the fix is a composition change the LLM can make
988
+ // in a turn, and without the round the card ships cut off.
989
+ const FIT_EXTENSION_BONUS = 1;
750
990
  if (evalRoundsUsed >= maxEvalRounds) {
751
991
  // 2026-04-27 (in-loop probe trigger): probe is now off the per-turn
752
992
  // hot path; `blocking` from runCheck won't contain `runtime:*` fails
@@ -764,6 +1004,66 @@ export async function runEvalRound(
764
1004
  const allowRuntimeExtension =
765
1005
  hasRuntimeFail &&
766
1006
  evalRoundsUsed < maxEvalRounds + RUNTIME_EXTENSION_BONUS;
1007
+ const fitFails = evalResult.issues.filter(isFitFail);
1008
+ const allowFitExtension =
1009
+ !allowRuntimeExtension &&
1010
+ fitFails.length > 0 &&
1011
+ evalRoundsUsed < maxEvalRounds + FIT_EXTENSION_BONUS;
1012
+ if (allowFitExtension) {
1013
+ const fitLines = fitFails.slice(0, MAX_FEEDBACK_ISSUES).map(formatFitFeedback);
1014
+ evalResult = {
1015
+ ...evalResult,
1016
+ issues: [...evalResult.issues, ...exitProbe.probeIssues],
1017
+ runtimeProbe: exitProbe.meta,
1018
+ };
1019
+ console.log(
1020
+ `[simple] eval round ${evalRoundsUsed}: fit fail active at cap — granting +1 retry (ggui#1195; ${fitFails.length} canvas-overflow)`,
1021
+ );
1022
+ return {
1023
+ control: "feedback",
1024
+ evalDone: false,
1025
+ evalResult,
1026
+ evalRoundsUsed,
1027
+ prevModeSubcats: updatedPrevModeSubcats,
1028
+ prevFailFingerprints: new Set([...currFailFingerprints, ...fitFails.map(fingerprintFail)]),
1029
+ preWarmedContext,
1030
+ evalTokens,
1031
+ evalLlmMs,
1032
+ lastResultText: fitLines.join("\n\n"),
1033
+ isEvalFeedback: true,
1034
+ lastDiffFailed: false,
1035
+ };
1036
+ }
1037
+ // ggui#1261 — a named contract FAIL from the exit probe active at the
1038
+ // cap: the same one round as at the clean exit, bounded at the cap + 1.
1039
+ const capContractFails = allowRuntimeExtension ? [] : contractFeedbackFails(exitProbe, prevFailFingerprints);
1040
+ if (capContractFails.length > 0 && evalRoundsUsed < maxEvalRounds + CONTRACT_FEEDBACK_BONUS) {
1041
+ const contractLines = capContractFails.slice(0, MAX_FEEDBACK_ISSUES).map(formatRuntimeProbeFeedback);
1042
+ evalResult = {
1043
+ ...evalResult,
1044
+ issues: [...evalResult.issues, ...exitProbe.probeIssues],
1045
+ runtimeProbe: exitProbe.meta,
1046
+ };
1047
+ console.log(
1048
+ `[simple] eval round ${evalRoundsUsed}: contract probe fail active at cap — granting +1 retry ` +
1049
+ `(ggui#1261; ${capContractFails.map((i) => i.subcategory).join(", ")})`,
1050
+ );
1051
+ return {
1052
+ control: "feedback",
1053
+ evalDone: false,
1054
+ evalResult,
1055
+ evalRoundsUsed,
1056
+ prevModeSubcats: updatedPrevModeSubcats,
1057
+ prevFailFingerprints: new Set([...currFailFingerprints, ...capContractFails.map(fingerprintFail)]),
1058
+ preWarmedContext,
1059
+ evalTokens,
1060
+ evalLlmMs,
1061
+ lastResultText: contractLines.join("\n\n"),
1062
+ isEvalFeedback: true,
1063
+ lastDiffFailed: false,
1064
+ contractFeedback: contractFeedbackRecord(capContractFails, currentSource),
1065
+ };
1066
+ }
767
1067
  if (!allowRuntimeExtension) {
768
1068
  // Fold probe issues (if any) into evalResult for telemetry and
769
1069
  // ALWAYS stamp the probe's execution meta, even though we're not
@@ -18,3 +18,23 @@
18
18
  export function mapProviderForEvaluator(provider: 'anthropic' | 'openai' | 'google' | 'openrouter'): 'claude' | 'openai' | 'google' | 'openrouter' {
19
19
  return provider === 'anthropic' ? 'claude' : provider;
20
20
  }
21
+
22
+ /**
23
+ * The visual judge's provider for an agent's provider (ggui#1248) — only the
24
+ * providers whose SDK path takes an image (`createVisionAgent`: Anthropic,
25
+ * Google). `undefined` for a provider with no vision path, so a caller must
26
+ * decide what the visual leg does there instead of asserting it away: the
27
+ * in-loop judge inherits the generation's agent by default, and an OpenAI or
28
+ * OpenRouter lane reaching `createVisionAgent` got `undefined` back and threw.
29
+ */
30
+ export function visionJudgeProvider(provider: 'anthropic' | 'openai' | 'google' | 'openrouter'): 'claude' | 'google' | undefined {
31
+ switch (provider) {
32
+ case 'anthropic':
33
+ return 'claude';
34
+ case 'google':
35
+ return 'google';
36
+ case 'openai':
37
+ case 'openrouter':
38
+ return undefined;
39
+ }
40
+ }