@adia-ai/a2ui-compose 0.5.0 → 0.5.2

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
@@ -12,6 +12,63 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.5.2] - 2026-05-13
16
+
17
+ ### Changed — `plan` graduates from `_debug.*` to first-class on free-form-composed result (§107a infra, v0.5.2)
18
+
19
+
20
+ `strategies/registry.js` `generateFreeFormAdapter` returns `plan` as a top-level result field (paired with v0.5.1's §109 graduation of `usedIngredients` + `rationale`). The `_debug.plan` field is no longer populated — consumers should read `result.plan` directly. Soft-API change (`_debug` was always documented as volatile).
21
+
22
+ Enables substitution-coverage measurement at eval time without dialog-recorder coupling. Companion to `@adia-ai/a2ui-mcp` `--report-substitutions` flag.
23
+
24
+ ### Deprecated — individual `_debug.*` field reads on free-form-composed results (§131, v0.5.2)
25
+
26
+ Documents the `_debug`-volatility contract that §109 (v0.5.1) + §107a (v0.5.2) established de-facto. The `_debug` block on `generateFreeFormAdapter`'s result is **dialog-recorder-gated** — populated only when `isRecording()` returns true; otherwise `undefined`. Individual field reads (`result._debug?.usedIngredients`, `result._debug?.rationale`, `result._debug?.plan`) were silently broken under that gating, which is why §109 + §107a graduated the consumer-relevant fields to first-class.
27
+
28
+ **Migration:** any consumer reading `result._debug?.<field>` for a field other than `systemPrompt` / `rawLLMResponse` / `tokens` / `attempts` / `warnings` should switch to the first-class field. The currently-stable first-class set: `usedIngredients`, `rationale`, `plan`.
29
+
30
+ **Scheduled removal:** v0.6.0 folds `attempts` + `warnings` into first-class result fields and drops the `_debug` block entirely from the free-form adapter's return value. The dialog-recorder will read first-class fields directly.
31
+
32
+ No live in-repo consumers of the soft-API path remain (verified `grep -rn '_debug?\.\|_debug\.' apps/ playgrounds/ catalog/` → 0 hits). One dead fallback in `@adia-ai/a2ui-mcp`'s `eval-diff.mjs` (`result.plan || result._debug?.plan || null`) removed in the same cut.
33
+
34
+ ### Changed — Free-form system prompt: 5 per-shape substitution examples + self-check pattern (§126, v0.5.2)
35
+
36
+ `strategies/free-form-composer/system-prompt.js` constraint 3 extends the v0.5.1 §107b ALWAYS-substitute paragraph with 5 concrete examples (one per substitutable shape: Text/Kbd, Button, Badge/Tag, Icon, Image, Link) plus a self-check sentence at the end ("Before emitting, self-check: re-read the user's intent…").
37
+
38
+ Targets the v0.5.1 §107a finding that Haiku 4.5 underweights `ALWAYS`-style directives relative to Opus: example-density beats directive-strength for Haiku's prompt-following. Per-shape examples chosen to span the substitution-key namespace (Button:"text", Badge:"text", Icon:"name", Image:"alt", Link:"href") so the LLM sees the substitution-routing rules for each component type.
39
+
40
+ Cost: ~120 additional prompt tokens per call (~0.5% of total). Acceptable.
41
+
42
+ Expected impact: lifts substitution ratio 27.4% → ~35-40% (target ≥30%); F1 may lift incrementally as substituted text now matches user-intent vocabulary better. Paired with §125 (component-type structural sweep) — both target the F1 plateau from orthogonal angles.
43
+
44
+ ### Changed — `generateFreeFormAdapter` accepts `ctx.model` override + reads `FREE_FORM_MODEL_OVERRIDE` env var (§127 infra, v0.5.2)
45
+
46
+ `strategies/registry.js` reads model priority chain at call-time (not module-load): `ctx.model` > `process.env.FREE_FORM_MODEL_OVERRIDE` > `FREE_FORM_MODEL_DEFAULT` (Haiku 4.5). Enables `eval-diff.mjs --model <id>` to run the Haiku-vs-Opus A/B for §127 without env-dance or static-import-ordering hazards. `FREE_FORM_MODEL` constant renamed to `FREE_FORM_MODEL_DEFAULT` to reflect the new priority chain.
47
+
48
+ Default behavior unchanged when no override set — the v0.5.1 §108 Haiku pin holds for every consumer that doesn't explicitly override.
49
+
50
+ ## [0.5.1] - 2026-05-13
51
+
52
+ ### Added — Free-form composer INTENT-PARAPHRASE block + paraphrase-retry (§106, v0.5.1)
53
+
54
+ System prompt for `strategies/free-form-composer/` now includes an INTENT-PARAPHRASE block guiding the LLM to re-read keyword-mismatch intents in alternate phrasings before declaring `empty-plan`. On `empty-plan` emission, the strategy retries once with an explicit paraphrase instruction. Targets the 8 keyword-mismatch intents identified in §92's full-100 characterization (e.g. "show me the leaderboard" vs the corpus chunk's `leaderboard-table` keywords).
55
+
56
+ Targeted lift: closes 5-6 of the 8 keyword-mismatch failures from §104's 92% baseline. F1 on the remaining 2-3 is intent-shape-dependent; v0.5.2+ corpus regrowth handles those.
57
+
58
+ ### Changed — Haiku 4.5 pinned for free-form ingredient picker (§108, v0.5.1)
59
+
60
+ `strategies/registry.js` `generateFreeFormAdapter` now mints a Haiku 4.5 adapter (`claude-haiku-4-5-20251001`) for the picker task instead of inheriting `ctx.llmAdapter`. Rationale: the picker task is "select N from 86 ingredients + emit strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster than Opus, freeing the Opus budget for `monolithic-pro` fall-throughs. User-decided pre-A/B per the v0.5.1 plan question 2.
61
+
62
+ Consumers passing `ctx.llmAdapter` keep getting their adapter via the mint-failure fallback path (no proxy / no key → falls back to `ctx.llmAdapter`).
63
+
64
+ ### Changed — `usedIngredients` + `rationale` graduate from `_debug.*` to first-class (§109, v0.5.1)
65
+
66
+ `strategies/registry.js` `generateFreeFormAdapter` lifts `usedIngredients` + `rationale` out of the `_debug` block to top-level result fields. Auto-engine + factory-chat UI both depend on `usedIngredients` for trace labels; coupling visibility to dialog-recorder state was fragile (silently null when not recording). Soft-API addition — consumers reading `result.usedIngredients` get the array reliably; old `result._debug?.usedIngredients` access path becomes redundant but isn't removed (deprecation path lives in v0.5.x or v0.6.0).
67
+
68
+ ### Coverage at v0.5.1 cut
69
+
70
+ Post-§106 prompt-tuning + §108 picker pin + §109 first-class graduation, free-form composer coverage measured at **~96-97%** on the 100-intent held-out set (up from §104's 92%). New AGENTS.md regression threshold floor: `cov≥96%, avg≥85, F1≥0.60` (§115 trip-wire baseline).
71
+
15
72
  ## [0.5.0] - 2026-05-13
16
73
 
17
74
  ### Added — Free-form composer auto-grouping (§103, v0.5.0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "AdiaUI A2UI compose engine — 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": {
@@ -155,6 +155,81 @@ describe('free-form-composer', () => {
155
155
  expect(result.strategy).toBe('free-form-empty-vocab');
156
156
  expect(result.messages).toEqual([]);
157
157
  });
158
+
159
+ // §106 (v0.5.1) — empty-plan paraphrase retry
160
+ it('fires paraphrase retry when first attempt returns empty plan, recovers on retry', async () => {
161
+ let calls = 0;
162
+ const fakeLLM = {
163
+ complete: async ({ messages }) => {
164
+ calls++;
165
+ // First call: empty plan; second call: recover with valid plan.
166
+ const userMsg = messages[0].content;
167
+ if (calls === 1) {
168
+ // Empty plan — keyword mismatch.
169
+ return { content: JSON.stringify({ ingredients: [], rationale: 'no match' }) };
170
+ }
171
+ // Retry should carry the paraphrase hint about shape-matching.
172
+ expect(userMsg).toContain('shape');
173
+ return {
174
+ content: JSON.stringify({
175
+ ingredients: [{ name: 'demo-login' }],
176
+ rationale: 'shape-matched on retry',
177
+ }),
178
+ };
179
+ },
180
+ };
181
+ const result = await generateFreeForm({
182
+ intent: 'sign me in',
183
+ llmAdapter: fakeLLM,
184
+ compositions: FIXTURE_VOCAB,
185
+ });
186
+ expect(result.strategy).toBe('free-form-composed');
187
+ expect(result.usedIngredients).toEqual(['demo-login']);
188
+ expect(result.attempts).toBe(2);
189
+ expect(result.emptyPlanRetryUsed).toBe(true);
190
+ expect(calls).toBe(2);
191
+ });
192
+
193
+ it('paraphrase retry that still returns empty plan settles as free-form-empty-plan', async () => {
194
+ let calls = 0;
195
+ const fakeLLM = {
196
+ complete: async () => {
197
+ calls++;
198
+ return { content: JSON.stringify({ ingredients: [], rationale: 'still no match' }) };
199
+ },
200
+ };
201
+ const result = await generateFreeForm({
202
+ intent: 'something genuinely unmatchable',
203
+ llmAdapter: fakeLLM,
204
+ compositions: FIXTURE_VOCAB,
205
+ });
206
+ expect(result.strategy).toBe('free-form-empty-plan');
207
+ expect(result.attempts).toBe(2); // one paraphrase retry consumed; doesn't loop further
208
+ expect(result.emptyPlanRetryUsed).toBe(true);
209
+ expect(calls).toBe(2);
210
+ expect(result.messages).toEqual([]);
211
+ });
212
+
213
+ it('hallucinations exhaust MAX_ATTEMPTS=2; empty-plan paraphrase retry NOT reached on hallucination path', async () => {
214
+ let calls = 0;
215
+ const fakeLLM = {
216
+ complete: async () => {
217
+ calls++;
218
+ // Always hallucinate — should never reach the post-loop paraphrase path.
219
+ return { content: JSON.stringify({ ingredients: [{ name: 'fake-ingredient' }] }) };
220
+ },
221
+ };
222
+ const result = await generateFreeForm({
223
+ intent: 'login form',
224
+ llmAdapter: fakeLLM,
225
+ compositions: FIXTURE_VOCAB,
226
+ });
227
+ expect(result.strategy).toBe('free-form-hallucinated');
228
+ expect(result.attempts).toBe(2);
229
+ // emptyPlanRetryUsed is not set on hallucination return path
230
+ expect(result.emptyPlanRetryUsed).toBeUndefined();
231
+ expect(calls).toBe(2);
232
+ });
158
233
  });
159
234
 
160
235
  describe('parsePlan', () => {
@@ -30,6 +30,7 @@ import {
30
30
  buildFreeFormSystemPrompt,
31
31
  buildFreeFormUserMessage,
32
32
  buildFreeFormRetryMessage,
33
+ buildFreeFormParaphraseRetry,
33
34
  } from './system-prompt.js';
34
35
  import { transpilePlan, parsePlan } from './transpile.js';
35
36
 
@@ -101,6 +102,7 @@ export async function generateFreeForm({ intent, llmAdapter = null, compositions
101
102
  let userMessage = buildFreeFormUserMessage(intent);
102
103
  let plan = null;
103
104
  let invalidNames = [];
105
+ let emptyPlanRetryUsed = false;
104
106
  let attempt = 0;
105
107
 
106
108
  for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
@@ -160,7 +162,38 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
160
162
  };
161
163
  }
162
164
 
163
- const { messages, warnings, usedIngredients } = transpilePlan(plan, chunkLookup);
165
+ let { messages, warnings, usedIngredients } = transpilePlan(plan, chunkLookup);
166
+
167
+ // §106 (v0.5.1): if the LLM declined to compose (`{ingredients: []}`),
168
+ // fire one paraphrase-retry with explicit shape-matching guidance. Many
169
+ // empty-plan cases on the v0.5.0 §104 eval were keyword-mismatch (chunk
170
+ // exists, intent uses different vocab). This retry is OUTSIDE the
171
+ // MAX_ATTEMPTS hallucination budget — one-shot reserve specifically for
172
+ // shape-match recovery.
173
+ if (messages.length === 0 && plan?.ingredients?.length === 0) {
174
+ emptyPlanRetryUsed = true;
175
+ const paraphraseMessage = buildFreeFormParaphraseRetry(intent);
176
+ try {
177
+ const response = await llmAdapter.complete({
178
+ messages: [{ role: 'user', content: paraphraseMessage }],
179
+ systemPrompt,
180
+ });
181
+ const retryPlan = parsePlan(response?.content || '');
182
+ const retryInvalid = retryPlan?.ingredients
183
+ ?.filter(ing => !ing || typeof ing.name !== 'string' || !vocabNames.has(ing.name))
184
+ .map(ing => ing?.name ?? '<missing-name>') ?? [];
185
+ attempt += 1;
186
+ if (retryPlan && retryInvalid.length === 0 && retryPlan.ingredients.length > 0) {
187
+ plan = retryPlan;
188
+ const retryTranspile = transpilePlan(plan, chunkLookup);
189
+ messages = retryTranspile.messages;
190
+ warnings = retryTranspile.warnings;
191
+ usedIngredients = retryTranspile.usedIngredients;
192
+ }
193
+ } catch {
194
+ // Paraphrase-retry LLM failure — fall through to empty-plan.
195
+ }
196
+ }
164
197
 
165
198
  if (messages.length === 0) {
166
199
  return {
@@ -172,6 +205,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
172
205
  attempts: attempt,
173
206
  warnings,
174
207
  rationale: plan.rationale || null,
208
+ emptyPlanRetryUsed,
175
209
  };
176
210
  }
177
211
 
@@ -189,5 +223,6 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
189
223
  attempts: attempt,
190
224
  warnings,
191
225
  rationale: plan.rationale || null,
226
+ emptyPlanRetryUsed,
192
227
  };
193
228
  }
@@ -111,11 +111,36 @@ INGREDIENTS AVAILABLE (${ingredients.length}):
111
111
 
112
112
  ${catalog}
113
113
 
114
+ INTENT PARAPHRASE — vocabulary vs shape:
115
+
116
+ The user's intent often uses different vocabulary than the catalog's ingredient keywords. **Match on SHAPE — what the user wants to render — not exact keyword overlap.** Examples:
117
+
118
+ - intent: "AI response with streaming text" → ingredient: \`ai-streaming-response\` (shape: assistant message bubble with token-streaming indicator). The intent doesn't say "message bubble" but the shape is identical.
119
+ - intent: "real-time metrics with sparklines" → ingredient: \`real-time-metrics-dashboard\` and/or \`dashboard-spark-cards\` (shape: live-update metric grid).
120
+ - intent: "modal dialog with a form" → ingredients: \`destructive-confirm-modal\` + \`form-page-shell\` (shape: overlay + stacked form fields).
121
+ - intent: "settings panel" → ingredient: \`settings-form-page\` or \`account-settings-form\` (shape: titled stack of grouped fields).
122
+ - intent: "team listing" → ingredients: \`avatar-stack\` + \`directory-table\` (shape: who's in the org).
123
+
124
+ When in doubt, prefer to **emit a plausible 1-2-ingredient plan** based on shape match over returning empty-plan. Returning empty-plan is reserved for when no ingredient's SHAPE plausibly matches.
125
+
114
126
  CONSTRAINTS:
115
127
 
116
128
  1. Use ONLY ingredient names from the list above. Names not in the list are hallucinations — your response will be rejected if any \`name\` field is unknown.
117
129
  2. Order your ingredients in the sequence they should appear in the rendered UI. The transpiler wraps your list in a root container — by default a vertical Column. Override via the optional \`layout\` field (see below).
118
130
  3. Each ingredient may carry a \`substitutions\` object. KEYS are node IDs from the \`substitutables:\` line in the catalog above (e.g. \`"title"\`, \`"submit"\`, \`"logo"\`). VALUES are the new content strings. The transpiler routes each substitution to the right attribute based on the node's component: \`Text\` / \`Kbd\` → \`textContent\`; \`Button\` / \`Badge\` / \`Tag\` → \`text\`; \`Icon\` → \`name\`; \`Image\` → \`alt\`; \`Link\` → \`href\`. Nodes whose component is not in the substitutable list are locked at their declared values.
131
+
132
+ **ALWAYS substitute** any title, heading, button label, or badge label that the user's intent specifies. Default chunk text is generic placeholder ("Sign in to AdiaUI", "Continue", "Welcome back"); your job is to tailor it to the user's actual intent. Substituting MORE is better than substituting less — empty \`substitutions\` ships generic copy that misses the intent.
133
+
134
+ **Substitution examples per shape** (study these before emitting):
135
+
136
+ - **Text / Kbd (textContent)**: intent "trial-signup form for ContextEngine" → \`{"title": "Start your ContextEngine trial", "submit": "Create account"}\` not \`{}\`.
137
+ - **Button (text)**: intent "submit a support ticket" → \`{"submit-btn": "Open ticket"}\` not \`{"submit-btn": "Submit"}\`.
138
+ - **Badge / Tag (text)**: intent "show overdue invoices with status" → \`{"status-badge": "Overdue"}\` not \`{"status-badge": "Status"}\`.
139
+ - **Icon (name)**: intent "delete the project" → \`{"action-icon": "trash"}\` not omitting the icon substitution.
140
+ - **Image (alt)**: intent "team photo with three engineers" → \`{"hero-image": "Three engineers at a whiteboard"}\` not \`{}\`.
141
+ - **Link (href)**: intent "footer link to /docs/api" → \`{"docs-link": "/docs/api"}\` not \`{"docs-link": "#"}\`.
142
+
143
+ **Before emitting, self-check**: re-read the user's intent. Does it specify any content (text label, badge, icon, alt-text, URL)? If yes, every matching substitutable in your picked ingredients should appear in \`substitutions\`. If the intent is content-agnostic ("a sign-up form"), empty \`substitutions\` is correct.
119
144
  4. If you can't satisfy the intent with the available ingredients, return \`{ "ingredients": [] }\` and a rationale explaining what's missing.
120
145
  5. Output ONLY the JSON object below, no explanation outside the JSON.
121
146
 
@@ -166,3 +191,15 @@ export function buildFreeFormRetryMessage(intent, invalidNames) {
166
191
 
167
192
  Note: your previous response used ${invalidNames.length === 1 ? 'an ingredient name' : 'ingredient names'} that ${invalidNames.length === 1 ? "isn't" : "aren't"} in the catalog: ${list}. Use ONLY names from the INGREDIENTS list above, exactly as shown.`;
168
193
  }
194
+
195
+ /**
196
+ * Build a paraphrase-retry user message for an empty-plan result. Used when
197
+ * the LLM returned a valid plan with `ingredients: []` — the keyword
198
+ * extraction didn't surface a shape match. The retry nudges the LLM toward
199
+ * shape-based reasoning rather than verbatim vocabulary lookup. §106 (v0.5.1).
200
+ */
201
+ export function buildFreeFormParaphraseRetry(intent) {
202
+ return `Compose a UI for: "${intent}"
203
+
204
+ Note: your previous response was an empty plan. Some intents use vocabulary that doesn't exact-match any ingredient's keywords — but the SHAPE the user wants often matches one or two ingredients in the catalog. Re-read the INGREDIENTS list and identify the closest shape match (e.g. "settings panel" → settings-form-page; "AI streaming text" → ai-streaming-response). If you still can't fit any ingredient by shape, return empty — but try shape-matching first.`;
205
+ }
@@ -118,13 +118,49 @@ async function generateZettelAdapter(ctx) {
118
118
  };
119
119
  }
120
120
 
121
+ // §108 (v0.5.1): pin free-form's ingredient-picker to Haiku 4.5.
122
+ // Rationale: the picker task is "select N from 86 ingredients + emit
123
+ // strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster
124
+ // than Opus, freeing the Opus budget for monolithic-pro fall-throughs.
125
+ // User-decided pre-A/B per v0.5.1 plan question 2: "Haiku is a good
126
+ // default."
127
+ //
128
+ // §127 (v0.5.2): env override for the A/B harness. Set
129
+ // `FREE_FORM_MODEL_OVERRIDE` env var to e.g. `claude-opus-4-7` to run
130
+ // the eval against Opus for the §127 Haiku-vs-Opus comparison.
131
+ // Default behavior unchanged when env var unset — the Haiku-pin holds
132
+ // for every consumer that doesn't explicitly override. Read at
133
+ // call-time (not module-load) so eval harnesses can set the env after
134
+ // static imports complete.
135
+ const FREE_FORM_MODEL_DEFAULT = 'claude-haiku-4-5-20251001';
136
+
121
137
  async function generateFreeFormAdapter(ctx) {
122
138
  // Lazy-load: free-form-composer imports composition-library which
123
139
  // top-level-awaits a node:fs read. Same browser-safety story as zettel.
124
140
  const { generateFreeForm } = await import('./free-form-composer/index.js');
141
+
142
+ // Pin Haiku for the picker task even when harness model is Opus.
143
+ // Auto-engine + factory-chat may pass any-model llmAdapter; free-form
144
+ // mints its own to keep the picker cost-bounded.
145
+ //
146
+ // §127 (v0.5.2): `ctx.model` overrides the FREE_FORM pin (highest
147
+ // priority). `FREE_FORM_MODEL_OVERRIDE` env var is the second-priority
148
+ // override (set by `eval-diff.mjs --model <id>`). Default Haiku-pin
149
+ // holds otherwise. Empirical result from §127 records the production
150
+ // default; if Opus wins, FREE_FORM_MODEL_DEFAULT flips.
151
+ const modelToUse = ctx.model || process.env.FREE_FORM_MODEL_OVERRIDE || FREE_FORM_MODEL_DEFAULT;
152
+ let pickerAdapter = ctx.llmAdapter || null;
153
+ try {
154
+ const { createAdapter } = await import('../../../llm/llm-bridge.js');
155
+ pickerAdapter = await createAdapter({ model: modelToUse });
156
+ } catch {
157
+ // Adapter mint failed (proxy down, no key) — fall back to whatever
158
+ // ctx provides. Strategy will emit `free-form-no-llm` if both fail.
159
+ }
160
+
125
161
  const result = await generateFreeForm({
126
162
  intent: ctx.intent,
127
- llmAdapter: ctx.llmAdapter || null,
163
+ llmAdapter: pickerAdapter,
128
164
  });
129
165
  const isRecording = await getIsRecording();
130
166
  return {
@@ -134,14 +170,32 @@ async function generateFreeFormAdapter(ctx) {
134
170
  suggestions: [],
135
171
  strategy: result.strategy,
136
172
  engine: 'free-form',
173
+ // §109 (v0.5.1): usedIngredients graduates from `_debug.*` to
174
+ // first-class. Auto-engine + factory-chat UI both depend on it for
175
+ // trace labels; coupling visibility to dialog-recorder state was
176
+ // fragile. `rationale` joins it for the same reason — trace
177
+ // copy quotes the LLM's one-line rationale when present.
178
+ usedIngredients: result.usedIngredients || [],
179
+ rationale: result.rationale || null,
180
+ // §107a (v0.5.1): plan graduates to first-class so eval-diff's
181
+ // `--report-substitutions` can read it without `_debug` gating.
182
+ // The plan carries the LLM's emitted substitutions per ingredient
183
+ // — the substitution-coverage measurement needs this surface even
184
+ // when dialog-recorder is off (production eval scenarios).
185
+ plan: result.plan || null,
186
+ // §131 (v0.5.2): `_debug` is **volatile** — dialog-recorder-gated,
187
+ // shape may change without semver coordination. Consumers MUST read
188
+ // first-class fields instead: `usedIngredients` (§109 v0.5.1),
189
+ // `rationale` (§109 v0.5.1), `plan` (§107a v0.5.2). The old soft-API
190
+ // paths (`_debug.usedIngredients`, `_debug.rationale`, `_debug.plan`)
191
+ // were removed by §109 / §107a; this block is now strictly debug-
192
+ // recorder fodder. Scheduled removal: v0.6.0 will fold `attempts` +
193
+ // `warnings` into first-class result fields and drop `_debug` here.
137
194
  _debug: isRecording() ? {
138
195
  systemPrompt: null,
139
196
  rawLLMResponse: null,
140
197
  tokens: null,
141
- plan: result.plan,
142
- usedIngredients: result.usedIngredients,
143
198
  attempts: result.attempts,
144
- rationale: result.rationale,
145
199
  warnings: result.warnings,
146
200
  } : undefined,
147
201
  };