@adia-ai/a2ui-compose 0.7.26 → 0.7.28

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.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,93 @@
1
1
  # Changelog — @adia-ai/a2ui-compose
2
+ ## [0.7.28] — 2026-07-14
3
+
4
+ A2UI pipeline overhaul (comparing gen-ui-kit's system against a sibling
5
+ project's from-scratch A2UI implementation surfaced a concrete gap: only
6
+ one of six generation strategies ran a genuine closed-loop validate→retry
7
+ before returning; the rest either skipped it, did a single repair shot,
8
+ or validated a narrower plan/grounding contract).
9
+
10
+ ### Added
11
+
12
+ - **`packages/a2ui/compose/shared/validate-and-repair.js`** — a shared
13
+ closed-loop validate→repair helper, extracted from `generate-thinking`'s
14
+ original inline loop (schema + Ajv catalog + anti-pattern conformance,
15
+ bounded LLM-fed repair attempts). Every LLM-calling engine can now share
16
+ one implementation instead of hand-rolling its own; escalation policy
17
+ (retry vs. give up vs. escalate to a bigger tier) stays entirely with
18
+ the caller — the module only reports `{valid, exhausted, attempts,
19
+ failures}`.
20
+
21
+ ### Fixed
22
+
23
+ - **`generate-pro` — replaces its single hardcoded repair shot with the
24
+ shared closed loop** (`maxAttempts: 2`, reflecting pro's cheaper cost
25
+ tier vs. thinking's 3) and adds Ajv catalog-schema coverage it never had
26
+ before.
27
+ - **`free-form-composer` — now runs a real schema/catalog/anti-pattern
28
+ check (validate-only) on its transpiled output**, which it previously
29
+ never did at all (a hardcoded `score: 85` regardless of actual
30
+ validity). This surfaced a real, previously-invisible defect: the
31
+ synthesized root component used `id: 'free-form-root'` instead of the
32
+ `'root'` convention every other engine's root uses, which
33
+ `validator.js`'s `checkHasRootComponent` hard-fails on — fixed at the
34
+ source (`transpile.js`). `score` stays 85 on the success path
35
+ (unchanged), so the tuned `eval:diff` floors don't shift as a side
36
+ effect of adding a measurement; new `schemaValid`/`schemaValidation`
37
+ fields are additive.
38
+ - **`monolithic-instant` no longer silently returns a hard-fail's broken
39
+ output.** The dispatcher (`strategies/registry.js`'s
40
+ `generateInstantAdapter`) now escalates a hard-fail to `generate-pro`
41
+ exactly once, only when an `llmAdapter` is available — capped at one
42
+ hop by construction (a pro-mode failure returns straight to the
43
+ original caller, never chains to thinking). A counter +
44
+ `console.warn` ship from day one, since this silently converts a
45
+ 0-cost request into a billed LLM call.
46
+ - **`chunk-zettel` now emits a real component graph and qualifies for
47
+ the shared closed loop (TKT-0009).** `composeFromIntent`
48
+ (`chunk-synthesizer.js`) previously returned only a raw HTML string,
49
+ which both callers wrapped into a single node with an unregistered
50
+ `component: 'article'` type — full schema validation could only ever
51
+ report `invalid`. Ratified direction: reuse `transpileHTML()` (the
52
+ same converter `harvest-chunks.mjs` already runs at corpus-admission
53
+ time) to produce a real `template` component array on every path (a
54
+ chunk's own precomputed template when available, else transpiled on
55
+ demand). Both callers (`registry.js`'s `generateChunkZettelAdapter`,
56
+ `generator-adapter.js`'s `bridgeToChunkSynthesis`) now prefer
57
+ `result.template` and route the final messages through
58
+ `validate-and-repair.js` (validate-only). Verified live: Tier-1
59
+ retrieval and Tier-2 synthesis intents both produce `valid:true`
60
+ component graphs (score 90/92); zero regression confirmed via a
61
+ stash-based before/after comparison of `eval:compose-from-chunks`
62
+ (the eval calls `composeFromIntent` directly and never touches the
63
+ caller code this change modified).
64
+ - **`transpiler/transpiler-maps.js` — dynamic array props (`Chart.data`,
65
+ `Table.data`) are now actually parsed, not copied through as raw
66
+ strings (TKT-0010).** Compiled dynamic props are a bare `$ref`
67
+ (`DynamicString`/`DynamicObjectList`/etc.) with no inline `type`, so
68
+ `CATALOG_PROPS`'s `pdef.type || 'string'` silently defaulted every
69
+ dynamic array prop to string extraction — a declarative
70
+ `data='[{...}]'` attribute's JSON text was copied through unparsed,
71
+ never `JSON.parse`d, so it could never satisfy the catalog's
72
+ array-shaped schema. Fixed by resolving `$ref` names to their
73
+ primitive kind (new `REF_TYPE_MAP`) and adding a `JSON.parse`-ing
74
+ array extraction branch (skipping silently on parse failure or a
75
+ non-array result, matching `chart.yaml`'s own documented "non-array
76
+ renders empty" contract). Root-caused via the corpus admission
77
+ validator surfacing the same `Chart`/`Table`.`data` `oneOf` failure
78
+ across 8+ unrelated chunks — a schema-generation/transpiler bug, not
79
+ independent content authoring mistakes. See `@adia-ai/a2ui-corpus`'s
80
+ CHANGELOG for the companion schema-side fix (`DynamicObjectList`).
81
+
82
+ ## [0.7.27] — 2026-07-12
83
+
84
+ ### Fixed
85
+ - **Transpiler — `[span]` is now universally preserved (TKT-0002).** `extractProps()` had a universal preserve for `[slot]` but not `[span]` — the same class of cross-cutting, layout-bearing attribute, consumed purely via `grid.css`'s `& > [span="N"]` child selectors and never declared in any component's own yaml `props:`. Every transpiled asymmetric grid collapsed to equal columns (the admin-dashboard's 3:2 Overview/Recent-Sales split was the operator-reported case). Regression tests cover `span="3"`, `span="full"`, and the absent case.
86
+ - **Transpiler — `Tag` is a leaf type; labels ride the `text` prop (TKT-0004 A).** `Tag` was missing from `LEAF_TYPES`, so `<tag-ui size="sm">label</tag-ui>` transpiled to `Tag > Text variant=body` — a full body-size paragraph inside the pill, inflating every converted chip (581 instances across 233 docs pages measured). Labels now ride tag.yaml's documented `attr(text)` contract, in both the pure-text and element-children extraction paths.
87
+ - **`strategies/zettel/issue-reporter.js` — issue storage repointed to `qa/findings/issues/`.** The previous path would have silently recreated the retired `.brain/` tree at runtime on the first `report_issue` call.
88
+
89
+ ### Maintenance
90
+ - Transpiler test suite: `extract-props.test.js` 19 → 24 cases (span preservation ×3, Tag text contract ×2).
2
91
 
3
92
  ## [0.7.26] — 2026-07-04
4
93
 
@@ -916,7 +1005,7 @@ See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the v0.4.5 ove
916
1005
  - **`compose/strategies/monolithic/generate-pro.js` + companion `_shared/fragment-expander.js` — fragment machinery removed (§37).** Fragments retired; 85 compositions inlined with previously-referenced fragment content. Expander deleted; its caller's try/catch in `generator-adapter.js` handles the absence gracefully.
917
1006
  - **MCP server `mcp/server.js` (§37).** Removed `get_fragment` tool, simplified `zettel_stats`, updated imports + boot logs + engine description to reflect the post-fragment world.
918
1007
 
919
- See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the cross-cutting arc narrative + [.claude/docs/journal/2026/05/2026-05-12.md](../../../.claude/docs/journal/2026/05/2026-05-12.md) §§ 37 / 38 / 41 / 47 for per-§ details.
1008
+ See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the cross-cutting arc narrative + `.claude/docs/journal/2026/05/2026-05-12.md` §§ 37 / 38 / 41 / 47 for per-§ details.
920
1009
  ## [0.4.3] - 2026-05-11
921
1010
 
922
1011
  ### Fixed
@@ -1200,7 +1289,7 @@ packages now share one version, governed by
1200
1289
  [`.claude/docs/specs/package-architecture.md` § 15 (Versioning Policy)](../../../.claude/docs/specs/package-architecture.md#15-versioning-policy).
1201
1290
  This release also lands the `engine/` ↔ `engines/` collision fix from
1202
1291
  T3 of the
1203
- [`.claude/docs/plans/packages-architecture-fixes-2026-05-02.md`](../../../.brain/archive/2026-Q2/PLAN-packages-architecture-fixes-2026-05-02.md)
1292
+ `.claude/docs/plans/packages-architecture-fixes-2026-05-02.md`
1204
1293
  plan.
1205
1294
 
1206
1295
  ### Changed
@@ -1255,8 +1344,8 @@ multi-turn surface, plus a first-class telemetry surface that auto-fires
1255
1344
  issue tickets on engine failure paths.
1256
1345
 
1257
1346
  Spec: [`.claude/docs/specs/genui-multiturn-architecture.md`](../../../.claude/docs/specs/genui-multiturn-architecture.md) (Active v0.1.0).
1258
- Plan: [`.claude/docs/plans/genui-multiturn-rollout-2026-04-28.md`](../../../.claude/docs/plans/genui-multiturn-rollout-2026-04-28.md) (Phase A scoped).
1259
- ADR: [`0008-multiturn-genui-architecture.md`](../../../.brain/adrs/0008-multiturn-genui-architecture.md).
1347
+ Plan: `.claude/docs/plans/genui-multiturn-rollout-2026-04-28.md` (Phase A scoped).
1348
+ ADR: `0008-multiturn-genui-architecture.md`.
1260
1349
 
1261
1350
  ### Added (engine modules)
1262
1351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.7.26",
3
+ "version": "0.7.28",
4
4
  "description": "AdiaUI A2UI compose engine \u2014 framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -293,7 +293,7 @@ describe('transpilePlan — root container (§103 auto-grouping)', () => {
293
293
  }, lookup);
294
294
  const root = result.messages[0].components[0];
295
295
  expect(root.component).toBe('Column');
296
- expect(root.id).toBe('free-form-root');
296
+ expect(root.id).toBe('root');
297
297
  expect(root.wrap).toBeUndefined();
298
298
  expect(root.columns).toBeUndefined();
299
299
  });
@@ -490,3 +490,25 @@ describe('transpilePlan — multi-attribute substitutions (§105 v0.5.0)', () =>
490
490
  expect(node.text).toBe('42');
491
491
  });
492
492
  });
493
+
494
+ // A2UI overhaul (Phase 3): full schema/anti-pattern/catalog check now runs
495
+ // on the transpiled result — previously this engine reported a hardcoded
496
+ // score=85 with NO real validation behind it at all.
497
+ describe('free-form-composer — schema validation (Phase 3 addition)', () => {
498
+ it('a validly-transpiled composition reports schemaValid: true, score stays 85 (unchanged eval-floor contract)', async () => {
499
+ const fakeLLM = {
500
+ complete: async () => ({
501
+ content: JSON.stringify({ ingredients: [{ name: 'demo-login' }], rationale: 'ok' }),
502
+ }),
503
+ };
504
+ const result = await generateFreeForm({
505
+ intent: 'sign me in',
506
+ llmAdapter: fakeLLM,
507
+ compositions: FIXTURE_VOCAB,
508
+ });
509
+ expect(result.strategy).toBe('free-form-composed');
510
+ expect(result.validation.score).toBe(85);
511
+ expect(result.schemaValid).toBe(true);
512
+ expect(result.schemaValidation).toBeTruthy();
513
+ });
514
+ });
@@ -33,6 +33,7 @@ import {
33
33
  buildFreeFormParaphraseRetry,
34
34
  } from './system-prompt.js';
35
35
  import { transpilePlan, parsePlan } from './transpile.js';
36
+ import { validateAndRepair } from '../../shared/validate-and-repair.js';
36
37
 
37
38
  const MAX_ATTEMPTS = 2;
38
39
 
@@ -209,9 +210,27 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
209
210
  };
210
211
  }
211
212
 
212
- // Score reflects "we successfully composed N ingredients without
213
- // hallucination". Validator-level scoring is upstream's responsibility.
214
- // 85 chosen to match zettel's `composition-match` avgScore baseline.
213
+ // Full schema/catalog/anti-pattern conformance the same final pass
214
+ // every other LLM-calling engine runs (see validate-and-repair.js) —
215
+ // ADDED here, not previously run at all; this engine used to report a
216
+ // hardcoded score=85 with no real validation behind it whatsoever.
217
+ // Validate-ONLY (no llmAdapter passed): a repair attempt here would mean
218
+ // re-authoring the ingredient PLAN, not the transpiled A2UI JSON
219
+ // directly — this engine's whole paradigm is ingredient selection +
220
+ // deterministic transpilation, not direct JSON authoring, so a schema-
221
+ // failure repair prompt has no natural target to hand back to the LLM.
222
+ // The existing hallucination-guard retry loop above (ingredient-name
223
+ // grounding) is this engine's own repair mechanism.
224
+ //
225
+ // score stays 85 on the success path — unchanged from before — so the
226
+ // eval:diff floors (cov/avg/F1) tuned against that number don't shift as
227
+ // a side effect of adding a measurement; schemaValid/schemaScore/
228
+ // antiPatternFailures are new, additive fields a future increment (or
229
+ // the eval baseline itself, deliberately) can act on.
230
+ const schemaCheck = await validateAndRepair({ messages });
231
+ if (!schemaCheck.valid) {
232
+ warnings = warnings.concat([`schema/anti-pattern check failed (score ${schemaCheck.validation.score}): ${schemaCheck.validation.checks?.filter(c => !c.passed).map(c => c.name).join(', ') || 'see schemaValidation'}`]);
233
+ }
215
234
  const score = 85;
216
235
 
217
236
  return {
@@ -224,5 +243,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
224
243
  warnings,
225
244
  rationale: plan.rationale || null,
226
245
  emptyPlanRetryUsed,
246
+ schemaValid: schemaCheck.valid,
247
+ schemaValidation: schemaCheck.validation,
227
248
  };
228
249
  }
@@ -220,7 +220,12 @@ function buildRoot(plan, rootChildren, warnings) {
220
220
  }
221
221
 
222
222
  const base = {
223
- id: 'free-form-root',
223
+ // 'root' — not 'free-form-root' — matches the convention every other
224
+ // engine's root component uses (validator.js's checkHasRootComponent
225
+ // hard-fails without it: "at least one component with id: 'root'").
226
+ // This engine never ran real schema validation before Phase 3 of the
227
+ // A2UI overhaul plan, so the mismatch went unnoticed until then.
228
+ id: 'root',
224
229
  gap: '6',
225
230
  children: rootChildren,
226
231
  };
@@ -6,7 +6,7 @@
6
6
  * in strategies/monolithic/_shared.js.
7
7
  */
8
8
 
9
- import { validateSchema } from '../../../validator/validator.js';
9
+ import { validateAndRepair } from '../../shared/validate-and-repair.js';
10
10
  import { getContext, searchBlocks, lookupDomain } from '../../core/reference.js';
11
11
  import { decomposeIntent } from '../../../retrieval/intent/decomposer.js';
12
12
  import { assessClarity } from '../../../retrieval/intent/clarity.js';
@@ -403,18 +403,26 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
403
403
  });
404
404
 
405
405
  // ── Stage 5: Validate + repair ──
406
- let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
407
- if (!validation.valid) {
408
- try {
409
- const failedChecks = validation.checks.filter(c => !c.passed);
410
- const repairResponse = await llmAdapter.complete({
411
- messages: [{ role: 'user', content: buildRepairPrompt(failedChecks, messages) }],
412
- systemPrompt,
413
- });
414
- messages = parseA2UIResponse(repairResponse.content, { executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason });
415
- validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
416
- } catch { /* keep original */ }
417
- }
406
+ // Shared with every other LLM-calling engine see validate-and-repair.js.
407
+ // maxAttempts: 2 (vs. thinking's 3) reflects pro's cheaper cost tier.
408
+ // runCatalogCheck adds Ajv catalog-schema coverage pro never had before —
409
+ // buildRepairPrompt's catalogFailures param already defaults to [] so
410
+ // this is additive, not a signature break.
411
+ const repairResult = await validateAndRepair({
412
+ messages,
413
+ llmAdapter,
414
+ buildRepairPrompt,
415
+ parseResponse: (repairResponse) => parseA2UIResponse(
416
+ repairResponse.content,
417
+ { executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason },
418
+ ),
419
+ systemPrompt,
420
+ validateContext: { intent, context },
421
+ foldInAntiPatterns,
422
+ maxAttempts: 2,
423
+ });
424
+ messages = repairResult.messages;
425
+ const validation = repairResult.validation;
418
426
  engine.submitStage(execId, 'validate', { ...validation, confidence: validation.score / 100 });
419
427
 
420
428
  // ── Stage 6: Store ──
@@ -6,8 +6,7 @@
6
6
  * in strategies/monolithic/_shared.js.
7
7
  */
8
8
 
9
- import { validateSchema } from '../../../validator/validator.js';
10
- import { validateMessages as validateCatalog } from '../../../validator/catalog-validator.js';
9
+ import { validateAndRepair } from '../../shared/validate-and-repair.js';
11
10
  import { getContext, searchBlocksSemantic, lookupDomain } from '../../core/reference.js';
12
11
  import { assessClarity } from '../../../retrieval/intent/clarity.js';
13
12
  import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
@@ -122,35 +121,32 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
122
121
  });
123
122
 
124
123
  // ── Stage 5: Validate + repair loop ──
125
- // Two orthogonal validators run in parallel:
126
- // - scored: weighted heuristic checks (intent alignment, card model, etc.)
127
- // - catalog: AJV against v0.9 catalog schema (strict structural correctness)
128
- // Either failing triggers a repair attempt. Catalog failures are typically
129
- // small (unknown prop, wrong enum, missing required id) and fix cleanly in
130
- // one repair round. Three-attempt cap preserved from the original loop.
131
- let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
132
- let catalogValidation = await validateCatalog(messages);
133
- let attempts = 0;
134
-
135
- while ((!validation.valid || !catalogValidation.valid) && attempts < 3) {
136
- attempts++;
137
- const failedChecks = validation.checks.filter(c => !c.passed);
138
- const catalogFailures = catalogValidation.failures || [];
139
- const repairPrompt = buildRepairPrompt(failedChecks, messages, catalogFailures);
140
-
141
- try {
142
- const repairResponse = await llmAdapter.complete({
143
- messages: [{ role: 'user', content: repairPrompt }],
144
- systemPrompt,
145
- });
146
- messages = parseA2UIResponse(repairResponse.content, { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason });
124
+ // Two orthogonal validators run: scored (weighted heuristic checks —
125
+ // intent alignment, card model, etc.) and catalog (AJV against v0.9
126
+ // catalog schema strict structural correctness). Either failing
127
+ // triggers a repair attempt. Catalog failures are typically small
128
+ // (unknown prop, wrong enum, missing required id) and fix cleanly in one
129
+ // repair round. Three-attempt cap preserved from the original loop.
130
+ // Shared with every other LLM-calling engine see validate-and-repair.js.
131
+ const repairResult = await validateAndRepair({
132
+ messages,
133
+ llmAdapter,
134
+ buildRepairPrompt,
135
+ parseResponse: (repairResponse) => parseA2UIResponse(
136
+ repairResponse.content,
137
+ { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason },
138
+ ),
139
+ systemPrompt,
140
+ validateContext: { intent, context },
141
+ foldInAntiPatterns,
142
+ maxAttempts: 3,
143
+ onAttempt: (repairResponse) => {
147
144
  if (isRecording()) { lastRawResponse = repairResponse.content; lastTokens = repairResponse.usage || lastTokens; }
148
- validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
149
- catalogValidation = await validateCatalog(messages);
150
- } catch {
151
- break; // stop repair loop on adapter error
152
- }
153
- }
145
+ },
146
+ });
147
+ messages = repairResult.messages;
148
+ const validation = repairResult.validation;
149
+ const attempts = repairResult.attempts;
154
150
 
155
151
  engine.submitStage(execId, 'validate', {
156
152
  ...validation,
@@ -52,11 +52,42 @@ export function registerMonolithicEngines({ instant, pro, thinking }) {
52
52
  _monolithicThinking = thinking;
53
53
  }
54
54
 
55
+ // A2UI overhaul plan, Phase 4: instant mode never repairs (it makes no LLM
56
+ // calls at all — that's its whole cost/latency identity) and previously
57
+ // returned a hard-fail's broken output silently. Escalation to pro lives
58
+ // HERE, at the dispatcher, not inside generate-instant.js itself — it's the
59
+ // caller's policy decision, matching validate-and-repair.js's "escalation
60
+ // stays with the caller" contract, and this is the one place that already
61
+ // holds both `ctx.llmAdapter` and `_monolithicPro` in scope.
62
+ //
63
+ // Capped at exactly one hop by construction: generateProAdapter has no
64
+ // escalation logic of its own, so a pro-mode failure after an instant
65
+ // escalation returns straight to the original caller — it can never chain
66
+ // into thinking. Only escalates when an llmAdapter is actually available;
67
+ // many instant-mode callers pass none (that's the 0-cost tier's point), and
68
+ // validate-and-repair.js's own contract for that case is "report the
69
+ // failure, don't invent a repair path" — same discipline here.
70
+ //
71
+ // This silently converts a 0-cost request into a billed LLM call, which is
72
+ // a product/cost-visibility concern, not just an engineering one — hence
73
+ // the counter + the warn log, from day one, not added later once someone
74
+ // asks "why did our LLM spend spike".
75
+ let _instantEscalationCount = 0;
76
+ export function getInstantEscalationCount() {
77
+ return _instantEscalationCount;
78
+ }
79
+
55
80
  // Engine adapters — uniform call signature `(ctx) => Promise<result>` where
56
81
  // ctx is prepared by the public generateUI() orchestrator.
57
82
  async function generateInstantAdapter(ctx) {
58
83
  if (!_monolithicInstant) throw new Error('monolithic-instant engine not registered');
59
- return _monolithicInstant(ctx);
84
+ const result = await _monolithicInstant(ctx);
85
+ if (!result.validation?.valid && ctx.llmAdapter && _monolithicPro) {
86
+ _instantEscalationCount++;
87
+ console.warn(`[a2ui] monolithic-instant hard-failed validation (score=${result.validation?.score}) for intent "${ctx.intent}" — escalating to monolithic-pro (escalation #${_instantEscalationCount})`);
88
+ return _monolithicPro(ctx);
89
+ }
90
+ return result;
60
91
  }
61
92
  async function generateProAdapter(ctx) {
62
93
  if (!_monolithicPro) throw new Error('monolithic-pro engine not registered');
@@ -230,27 +261,42 @@ async function generateFreeFormAdapter(ctx) {
230
261
 
231
262
  async function generateChunkZettelAdapter(ctx) {
232
263
  const { composeFromIntent } = await import('./zettel/chunk-synthesizer.js');
264
+ const { validateAndRepair } = await import('../shared/validate-and-repair.js');
233
265
  const result = await composeFromIntent({
234
266
  intent: ctx.intent,
235
267
  llmAdapter: ctx.llmAdapter || null,
236
268
  maxAttempts: 2,
237
269
  });
238
270
 
239
- // Convert chunk-composition result to A2UI message shape.
240
- const messages = result.html
241
- ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
242
- : [];
271
+ // TKT-0009: composeFromIntent now returns a real component array
272
+ // (`result.template`) whenever it can, instead of only an HTML string —
273
+ // use it directly; the pre-fix fallback (a single node carrying an
274
+ // UNREGISTERED `component: 'article'` type) only fires if transpilation
275
+ // itself somehow produced nothing, which should not happen in practice.
276
+ const messages = result.template
277
+ ? [{ type: 'updateComponents', components: result.template }]
278
+ : result.html
279
+ ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
280
+ : [];
281
+
282
+ // Full schema/catalog/anti-pattern conformance — the same final pass
283
+ // every other engine runs (see validate-and-repair.js). Validate-only:
284
+ // no natural repair target here (the LLM authored a slot-binding PLAN,
285
+ // not this A2UI JSON directly) — chunk-composer's own `validatePlan`
286
+ // is this engine's repair-relevant check and already ran upstream.
287
+ const schemaCheck = messages.length ? await validateAndRepair({ messages }) : { valid: false, validation: { score: 0 } };
243
288
 
244
289
  return {
245
290
  executionId: ctx.executionId,
246
291
  messages,
247
- validation: { score: result.html ? 70 : 0 },
292
+ validation: schemaCheck.validation,
248
293
  strategy: result.source === 'retrieval' ? 'chunk-retrieval' : 'chunk-synthesis',
249
294
  engine: 'chunk-zettel',
250
295
  _debug: {
251
296
  plan: result.plan || null,
252
297
  warnings: result.warnings || [],
253
298
  scopeDrift: result.scopeDrift || null,
299
+ schemaValid: schemaCheck.valid,
254
300
  },
255
301
  };
256
302
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * registry.js — instant→pro escalation (A2UI overhaul plan, Phase 4).
3
+ *
4
+ * Instant mode makes zero LLM calls and previously returned a hard-fail's
5
+ * broken output silently. The dispatcher now escalates to pro exactly once
6
+ * when an llmAdapter is available; escalation policy lives here, not inside
7
+ * generate-instant.js itself (see registry.js's comment above
8
+ * generateInstantAdapter).
9
+ */
10
+ import { describe, it, expect, beforeEach } from 'vitest';
11
+ import { registerMonolithicEngines, ENGINES, getInstantEscalationCount } from './registry.js';
12
+
13
+ const HARD_FAIL_RESULT = { messages: [], validation: { valid: false, score: 0 }, strategy: 'fallback' };
14
+ const VALID_RESULT = { messages: [{ type: 'updateComponents', components: [{ id: 'root', component: 'Card' }] }], validation: { valid: true, score: 90 }, strategy: 'pattern-match' };
15
+
16
+ beforeEach(() => {
17
+ // Reset to known fakes before every case — registerMonolithicEngines is
18
+ // the same public injection API core/generator.js uses at boot.
19
+ registerMonolithicEngines({ instant: null, pro: null, thinking: null });
20
+ });
21
+
22
+ describe('generateInstantAdapter — escalation to pro on hard-fail', () => {
23
+ it('does NOT escalate when instant mode already returns a valid result', async () => {
24
+ let proCalls = 0;
25
+ registerMonolithicEngines({
26
+ instant: async () => VALID_RESULT,
27
+ pro: async () => { proCalls++; return VALID_RESULT; },
28
+ thinking: async () => VALID_RESULT,
29
+ });
30
+ const before = getInstantEscalationCount();
31
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
32
+ expect(result).toBe(VALID_RESULT);
33
+ expect(proCalls).toBe(0);
34
+ expect(getInstantEscalationCount()).toBe(before);
35
+ });
36
+
37
+ it('escalates to pro exactly once when instant hard-fails AND an llmAdapter is available', async () => {
38
+ let proCalls = 0;
39
+ registerMonolithicEngines({
40
+ instant: async () => HARD_FAIL_RESULT,
41
+ pro: async (ctx) => { proCalls++; return { ...VALID_RESULT, receivedCtx: ctx }; },
42
+ thinking: async () => VALID_RESULT,
43
+ });
44
+ const before = getInstantEscalationCount();
45
+ const fakeAdapter = { complete: async () => ({}) };
46
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', executionId: 'exec-1', storeId: 'store-1', llmAdapter: fakeAdapter });
47
+ expect(proCalls).toBe(1);
48
+ expect(result.validation.valid).toBe(true);
49
+ expect(getInstantEscalationCount()).toBe(before + 1);
50
+ // Same ctx (storeId/executionId/llmAdapter) threaded through unmodified —
51
+ // no session/store desync risk from a reconstructed context.
52
+ expect(result.receivedCtx.executionId).toBe('exec-1');
53
+ expect(result.receivedCtx.storeId).toBe('store-1');
54
+ expect(result.receivedCtx.llmAdapter).toBe(fakeAdapter);
55
+ });
56
+
57
+ it('does NOT escalate when instant hard-fails but no llmAdapter is available — reports the failure as-is', async () => {
58
+ let proCalls = 0;
59
+ registerMonolithicEngines({
60
+ instant: async () => HARD_FAIL_RESULT,
61
+ pro: async () => { proCalls++; return VALID_RESULT; },
62
+ thinking: async () => VALID_RESULT,
63
+ });
64
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: null });
65
+ expect(proCalls).toBe(0);
66
+ expect(result).toBe(HARD_FAIL_RESULT);
67
+ });
68
+
69
+ it('caps escalation at exactly one hop — a pro-mode failure never chains to thinking', async () => {
70
+ let proCalls = 0;
71
+ let thinkingCalls = 0;
72
+ registerMonolithicEngines({
73
+ instant: async () => HARD_FAIL_RESULT,
74
+ pro: async () => { proCalls++; return HARD_FAIL_RESULT; }, // pro ALSO fails
75
+ thinking: async () => { thinkingCalls++; return VALID_RESULT; },
76
+ });
77
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
78
+ expect(proCalls).toBe(1);
79
+ expect(thinkingCalls).toBe(0); // never chains further — pro's own failure returns to the original caller
80
+ expect(result.validation.valid).toBe(false);
81
+ });
82
+ });
@@ -27,6 +27,29 @@ import {
27
27
  getAllChunks,
28
28
  } from '../../../corpus/scripts/chunk-library.js';
29
29
  import { composeFromPlan, validatePlan } from './chunk-composer.js';
30
+ import { transpileHTML } from '../../transpiler/transpiler.js';
31
+
32
+ // TKT-0009: composeFromIntent used to return only a raw HTML string,
33
+ // which every caller wrapped in a single node carrying an UNREGISTERED
34
+ // A2UI type (`component: 'article'`) — meaning full schema/catalog
35
+ // validation against it always reported invalid, regardless of the
36
+ // composed content's actual quality (registry.has('article') is false).
37
+ // Ratified direction: reuse the SAME transpileHTML() infrastructure the
38
+ // docs-transpiler and harvest-chunks.mjs already run at scale, rather
39
+ // than inventing a new passthrough construct — this makes every
40
+ // composeFromIntent() result a REAL component graph, so it qualifies for
41
+ // the shared validate-and-repair loop like every other engine. A chunk's
42
+ // own precomputed `.template` (from harvest-chunks.mjs) is used directly
43
+ // when available (accurate, no re-transpile cost); multi-instance chunk
44
+ // records don't carry one per-instance, so this falls back to
45
+ // transpiling the HTML on demand either way — every return path leaves
46
+ // with a real template, never just a string.
47
+ async function ensureTemplate(html, precomputedTemplate) {
48
+ if (Array.isArray(precomputedTemplate) && precomputedTemplate.length) return precomputedTemplate;
49
+ if (!html) return null;
50
+ const { messages } = await transpileHTML(html);
51
+ return messages?.[0]?.components ?? null;
52
+ }
30
53
 
31
54
  const STRONG_RETRIEVAL_SCORE = 8; // search-score threshold for fast path
32
55
  const PRE_SEARCH_LIMIT = 30; // chunks shown to the LLM in the prompt
@@ -199,7 +222,11 @@ function extractJSON(raw) {
199
222
  * @param {string} opts.intent
200
223
  * @param {object} opts.llmAdapter — { complete: async ({ messages, systemPrompt }) => { content|text } }
201
224
  * @param {number} [opts.maxAttempts=2]
202
- * @returns {Promise<{ html: string, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
225
+ * @returns {Promise<{ html: string, template: object[]|null, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
226
+ * `template` (TKT-0009) is a real A2UI component array — derived from the
227
+ * chunk's own precomputed template when available, else transpiled from
228
+ * `html` on demand — so callers get a validatable component graph, not
229
+ * just an HTML string to wrap opaquely.
203
230
  */
204
231
  export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFAULT_MAX_ATTEMPTS }) {
205
232
  // Tier 1 — retrieval. Try semantic-blended hit first; fall back to keyword
@@ -228,6 +255,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
228
255
  const scopeDrift = computeScopeDrift(html, [top]);
229
256
  return {
230
257
  html,
258
+ template: await ensureTemplate(html, top.template),
231
259
  plan: null,
232
260
  source: 'retrieval',
233
261
  score: hit.score,
@@ -339,6 +367,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
339
367
 
340
368
  return {
341
369
  html: composed.html,
370
+ template: await ensureTemplate(composed.html, null),
342
371
  plan,
343
372
  source: 'synthesis',
344
373
  warnings: [...composed.warnings, ...driftWarnings],
@@ -47,11 +47,18 @@ import { validateSchema } from '../../../validator/validator.js';
47
47
  async function bridgeToChunkSynthesis({ intent, llmAdapter }) {
48
48
  const { composeFromIntent } = await import('./chunk-synthesizer.js');
49
49
  const result = await composeFromIntent({ intent, llmAdapter, maxAttempts: 2 });
50
- // Shape-adapt chunk-synthesizer's `{ html, plan, source, ... }` to the
51
- // synthesis-branch's prior `{ messages, template, synthesis }` contract.
52
- const messages = result.html
53
- ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
54
- : [];
50
+ // Shape-adapt chunk-synthesizer's `{ html, template, plan, source, ... }`
51
+ // to the synthesis-branch's prior `{ messages, template, synthesis }`
52
+ // contract. TKT-0009: prefer the real component array (`result.template`)
53
+ // over the pre-fix fallback of wrapping raw HTML in a single node with an
54
+ // UNREGISTERED `component: 'article'` type — that fallback made this
55
+ // caller's validate-and-repair pass below structurally unable to ever
56
+ // report a clean pass.
57
+ const messages = result.template
58
+ ? [{ type: 'updateComponents', components: result.template }]
59
+ : result.html
60
+ ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
61
+ : [];
55
62
  return {
56
63
  messages,
57
64
  template: result.plan ? [{ $chunk: 'composeFromIntent', plan: result.plan }] : [],
@@ -172,7 +179,13 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
172
179
  if (llmAdapter && mode !== 'instant-only') {
173
180
  try {
174
181
  const synth = await bridgeToChunkSynthesis({ intent, llmAdapter });
175
- const validation = validateSchema(synth.messages, { intent });
182
+ // Full schema/catalog/anti-pattern conformance (validate-only see
183
+ // validate-and-repair.js; TKT-0009 made this meaningful for the first
184
+ // time by giving synth.messages a real component graph instead of an
185
+ // opaque HTML wrapper).
186
+ const { validateAndRepair } = await import('../../shared/validate-and-repair.js');
187
+ const schemaCheck = synth.messages.length ? await validateAndRepair({ messages: synth.messages }) : { valid: false, validation: { score: 0 } };
188
+ const validation = schemaCheck.validation;
176
189
  // Chunk-bridge produces an html-bearing single component, not
177
190
  // resolved fragment instances. `fragments_used` is empty by design;
178
191
  // consumers tracking fragment usage should switch to the
@@ -7,7 +7,7 @@
7
7
  * 3. Engine auto-fire — internal failure paths trigger `autoReport(reason)`.
8
8
  * ctx.reporter = 'auto'. Suppressed when ctx.evalMode is true.
9
9
  *
10
- * Every issue lands as immutable JSON under `.brain/audit-history/issues/`.
10
+ * Every issue lands as immutable JSON under `qa/findings/issues/`.
11
11
  * Traces > 200KB spill to a sidecar `.trace.json` next to the issue.
12
12
  *
13
13
  * Spec: .claude/docs/specs/genui-multiturn-architecture.md §3.5 + §4.6 + §11.
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
 
24
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
25
  const REPO_ROOT = resolve(__dirname, '../../../../..');
26
- const DEFAULT_STORAGE_ROOT = resolve(REPO_ROOT, '.brain/audit-history/issues');
26
+ const DEFAULT_STORAGE_ROOT = resolve(REPO_ROOT, 'qa/findings/issues');
27
27
  const TRACE_INLINE_THRESHOLD_BYTES = 200 * 1024;
28
28
 
29
29
  const VALID_TYPES = new Set(['bug', 'training-gap', 'protocol-gap', 'ux-feedback']);
@@ -225,4 +225,39 @@ describe('§163 extractProps — catalog-driven prop fidelity', () => {
225
225
  expect(props.variant).toBe('outline');
226
226
  });
227
227
  });
228
+
229
+ describe('TKT-0004 — Tag label via text prop (attr(text) contract)', () => {
230
+ it('falls back to light-DOM textContent when no text= attribute', () => {
231
+ const el = elem({ size: 'sm', variant: 'success' }, { text: '~1 min · no tools' });
232
+ const props = extractProps(el, 'Tag');
233
+ expect(props.text).toBe('~1 min · no tools');
234
+ expect(props.size).toBe('sm');
235
+ });
236
+
237
+ it('an explicit text= attribute wins over textContent', () => {
238
+ const el = elem({ text: 'explicit' }, { text: 'ignored light-dom' });
239
+ const props = extractProps(el, 'Tag');
240
+ expect(props.text).toBe('explicit');
241
+ });
242
+ });
243
+
244
+ describe('TKT-0002 — [span] universal preservation', () => {
245
+ it('preserves span on a Card (grid-ui child), same as slot', () => {
246
+ const el = elem({ span: '3' });
247
+ const props = extractProps(el, 'Card');
248
+ expect(props.span).toBe('3');
249
+ });
250
+
251
+ it('preserves span="full" verbatim (not coerced to a number)', () => {
252
+ const el = elem({ span: 'full' });
253
+ const props = extractProps(el, 'Card');
254
+ expect(props.span).toBe('full');
255
+ });
256
+
257
+ it('omits span when absent — no stray empty prop', () => {
258
+ const el = elem({});
259
+ const props = extractProps(el, 'Card');
260
+ expect(props.span).toBeUndefined();
261
+ });
262
+ });
228
263
  });
@@ -39,6 +39,21 @@ import { registry } from '@adia-ai/a2ui-runtime';
39
39
  // alias map below so `extractProps(el, 'CheckBox')` resolves to the
40
40
  // `Check` catalog entry.
41
41
 
42
+ // TKT-0010: dynamic props are compiled to a bare `$ref` (DynamicString /
43
+ // DynamicNumber / DynamicBoolean / DynamicStringList / DynamicObjectList),
44
+ // which carries no inline `type` — so `pdef.type || 'string'` below silently
45
+ // treated EVERY dynamic array prop (Chart.data, Table.data) as a string,
46
+ // copying the raw `data='[...]'` attribute text through unparsed instead of
47
+ // JSON-parsing it into a real array. Resolve the ref name to its primitive
48
+ // kind first so extractCatalogProps' array branch actually fires.
49
+ const REF_TYPE_MAP = {
50
+ DynamicString: 'string',
51
+ DynamicNumber: 'number',
52
+ DynamicBoolean: 'boolean',
53
+ DynamicStringList: 'array',
54
+ DynamicObjectList: 'array',
55
+ };
56
+
42
57
  /** @type {Map<string, {props: Map<string, {type: string, enum?: string[]}>, canonical: string}>} */
43
58
  const CATALOG_PROPS = (() => {
44
59
  const map = new Map();
@@ -50,8 +65,13 @@ const CATALOG_PROPS = (() => {
50
65
  // Skip internal/computed props that aren't HTML attributes
51
66
  if (pname === 'component' || pname === 'textContent') continue;
52
67
  if (typeof pdef !== 'object' || !pdef) continue;
68
+ let type = pdef.type;
69
+ if (!type && typeof pdef.$ref === 'string') {
70
+ const refName = pdef.$ref.split('/').pop();
71
+ type = REF_TYPE_MAP[refName];
72
+ }
53
73
  propMap.set(pname, {
54
- type: pdef.type || 'string',
74
+ type: type || 'string',
55
75
  enum: pdef.enum || null,
56
76
  });
57
77
  }
@@ -153,6 +173,18 @@ function extractCatalogProps(el, a2uiType) {
153
173
  if (raw === '') continue;
154
174
  const n = Number(raw);
155
175
  props[pname] = Number.isFinite(n) ? n : raw;
176
+ } else if (pdef.type === 'array') {
177
+ // TKT-0010: declarative JSON-array attribute (e.g. chart-ui/table-ui's
178
+ // data='[{...}]'), hydrated once at connect per those components' own
179
+ // yaml docs. Parse it — the raw string never satisfies the catalog's
180
+ // array schema, and per chart.yaml's own contract a non-array value
181
+ // is meant to render empty, so a parse failure/non-array is skipped,
182
+ // not passed through.
183
+ if (raw === '') continue;
184
+ try {
185
+ const parsed = JSON.parse(raw);
186
+ if (Array.isArray(parsed)) props[pname] = parsed;
187
+ } catch { /* not valid JSON — skip */ }
156
188
  } else {
157
189
  // string (incl. enum) — skip empty
158
190
  if (raw === '') continue;
@@ -317,6 +349,17 @@ export function extractProps(el, a2uiType) {
317
349
  const slotAttr = attr('slot');
318
350
  if (slotAttr) props.slot = slotAttr;
319
351
 
352
+ // Universal: preserve [span] for every element. Span is a layout-bearing
353
+ // attribute grid-ui's child reads via a pure CSS attribute selector
354
+ // (grid.css: `& > [span="3"] { grid-column: span 3; }`) — it belongs to
355
+ // "whatever this grid child happens to be," never to a specific
356
+ // component's own prop schema, so no .yaml declares it and the
357
+ // catalog-driven pass below silently drops it (TKT-0002: collapsed the
358
+ // admin-dashboard Overview/Recent Sales 3:2 split to two equal, unspanned
359
+ // columns).
360
+ const spanAttr = attr('span');
361
+ if (spanAttr) props.span = spanAttr;
362
+
320
363
  // §163 (v0.5.4) — Catalog-driven generic extraction FIRST. Fills in
321
364
  // every prop the v0.9 catalog declares for this type (label, icon,
322
365
  // variant=, etc.). Legacy per-type blocks below run AFTER and can
@@ -351,6 +394,15 @@ export function extractProps(el, a2uiType) {
351
394
  }
352
395
  }
353
396
 
397
+ // ── Tag ── (TKT-0004: tag-ui renders its label via CSS attr(text) —
398
+ // tag.yaml documents textContent as routing to the text attribute. The
399
+ // authored form is light-DOM content (<tag-ui size="sm">label</tag-ui>),
400
+ // so fall back to textContent when no text= attribute is set.)
401
+ if (a2uiType === 'Tag' && props.text === undefined) {
402
+ const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
403
+ if (text) props.text = text;
404
+ }
405
+
354
406
  // ── Link ── (§47 — <a> tags map to Link, not Button; carries href, target, rel)
355
407
  if (a2uiType === 'Link') {
356
408
  const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
@@ -201,9 +201,13 @@ function walkNode(el, context) {
201
201
  return id;
202
202
  }
203
203
 
204
- // Leaf types: these render their own text, don't recurse into children
204
+ // Leaf types: these render their own text, don't recurse into children.
205
+ // Tag joined 2026-07-12 (TKT-0004): tag-ui renders its label via CSS
206
+ // attr(text) — as a container, its light-DOM label became a child Text
207
+ // at body size, inflating every converted chip site-wide (581 instances
208
+ // across 233 pages measured).
205
209
  const LEAF_TYPES = new Set([
206
- 'Text', 'Button', 'Kbd', 'Code', 'Badge', 'Image', 'Divider',
210
+ 'Text', 'Button', 'Kbd', 'Code', 'Badge', 'Tag', 'Image', 'Divider',
207
211
  'TextField', 'CheckBox', 'Toggle', 'Radio', 'Slider', 'Search',
208
212
  'Upload', 'ColorPicker', 'DateTimeInput', 'TextArea', 'OtpInput',
209
213
  ]);
@@ -244,7 +248,7 @@ function walkNode(el, context) {
244
248
  if (directText) {
245
249
  // Preserve the direct text on whichever prop the type uses
246
250
  if (a2uiType === 'Text' || a2uiType === 'Kbd' || a2uiType === 'Code') containerProps.textContent = directText;
247
- else if (a2uiType === 'Button' || a2uiType === 'Badge') {
251
+ else if (a2uiType === 'Button' || a2uiType === 'Badge' || a2uiType === 'Tag') {
248
252
  if (containerProps.text === undefined) containerProps.text = directText;
249
253
  }
250
254
  } else {