canicode 0.10.2 → 0.10.4

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.
@@ -11,7 +11,7 @@ Orchestrate the full design-to-code roundtrip: analyze a Figma design for readin
11
11
  ## Prerequisites
12
12
 
13
13
  - **Figma MCP server** installed (provides `get_design_context`, `get_screenshot`, `use_figma`, and other Figma tools) — REQUIRED, there is no CLI fallback for `use_figma`
14
- - **canicode MCP server** (preferred): `claude mcp add canicode -e FIGMA_TOKEN=figd_xxx -- npx -y -p canicode canicode-mcp`
14
+ - **canicode MCP server** (preferred): `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp` — long-form flags only; the short-form `-y -p` collides with `claude mcp add`'s parser (#366). The MCP server reads `FIGMA_TOKEN` from `~/.canicode/config.json` or the host environment, so do **not** pass `-e FIGMA_TOKEN=…` here (#364).
15
15
  - **Without canicode MCP** (fallback): Steps 1 (analyze) and 3 (gotcha-survey) shell out to `npx canicode <command> --json` — same JSON shape as the MCP tools. Step 4 (apply to Figma) still requires Figma MCP `use_figma`.
16
16
  - **FIGMA_TOKEN** configured for live Figma URLs
17
17
  - **Figma Full seat + file edit permission** (required for `use_figma` to modify the design)
@@ -82,39 +82,91 @@ npx canicode gotcha-survey "<figma-url>" --json
82
82
 
83
83
  If `questions` is empty, skip to **Step 6**.
84
84
 
85
- For each question in the `questions` array, present it to the user one at a time.
85
+ #### Step 3a: Why the response carries a pre-grouped+batched view
86
86
 
87
- Build the message from the question fields. **If `question.instanceContext` is present**, prepend one line before the question body:
87
+ The naive "one-question-at-a-time" loop produces two well-known UX failures on real designs:
88
88
 
89
- ```
90
- _Instance note: This layer is inside an instance. Layout and size fixes may need to be applied on source component **{sourceComponentName or sourceComponentId or "unknown"}** (definition node `sourceNodeId`) and propagate to all instances you will be asked to confirm before any definition-level write._
91
- ```
89
+ - **Repeated Instance note (#370)** — when 10 consecutive questions share the same `instanceContext.sourceComponentId`, the standard "_Instance note: …source component **X**…_" paragraph prints 10 times. After the first occurrence it adds zero new information and consumes ~2 screens of vertical space.
90
+ - **Repeated identical answer (#369)** when 7 consecutive questions all carry the same `ruleId` (e.g. `missing-size-constraint`) and the user's reasonable answer would be the same for all of them (e.g. `min-width: 320px, max-width: 1200px`), the user types the same thing 7 times in a row.
92
91
 
93
- Then the standard block:
92
+ `gotcha-survey` already ships the resolution on its `groupedQuestions` field. Sort key (`(sourceComponentId ?? "_no-source", ruleId, nodeName)`), source-component grouping, and the batchable-rule whitelist (`missing-size-constraint`, `irregular-spacing`, `no-auto-layout`, `fixed-size-in-auto-layout`) all live in `core/gotcha/group-and-batch-questions.ts` with vitest coverage. Per ADR-016, do **not** re-implement the sort, partition, or whitelist in prose — iterate over `groupedQuestions.groups[].batches[]` directly.
94
93
 
95
- ```
96
- **[{severity}] {ruleId}** — node: {nodeName}
94
+ #### Step 3b: Prompt each group, then each batch within it
97
95
 
98
- {question}
96
+ For each `group` in `response.groupedQuestions.groups`:
99
97
 
100
- > Hint: {hint}
101
- > Example: {example}
102
- ```
98
+ - **`group.instanceContext === null`** — this is the trailing group of non-instance questions. Skip the header and prompt each batch directly.
99
+ - **`group.instanceContext !== null`** — emit the Instance note **once** as a group header (#370):
100
+
101
+ ```
102
+ ─────────────────────────────────────────
103
+ The next {sum of batch.questions.length} questions all target instance children of source component **{instanceContext.sourceComponentName ?? instanceContext.sourceComponentId ?? "unknown"}** (definition node `{instanceContext.sourceNodeId}`). Layout and size fixes may need to apply on the source and propagate to all instances — you will be asked to confirm before any definition-level write.
104
+ ─────────────────────────────────────────
105
+ ```
106
+
107
+ For each `batch` inside the group:
108
+
109
+ - **`batch.questions.length === 1`** — render the standard single-question block for `batch.questions[0]`:
110
+
111
+ ```
112
+ **[{severity}] {ruleId}** — node: {nodeName}
113
+
114
+ {question}
115
+
116
+ > Hint: {hint}
117
+ > Example: {example}
118
+ ```
119
+
120
+ **If `question.replicas` is present (#356 dedup)**, prepend one note above the standard block:
103
121
 
104
- Wait for the user's answer before moving to the next question. The user may:
105
- - Answer the question directly
106
- - Say "skip" to skip a question
107
- - Say "n/a" if the question is not applicable
122
+ ```
123
+ _Replicas: This question represents **{replicas} instances** of the same source-component child sharing the same rule. Your single answer will be applied to all of them in Step 4 (one annotation/write per instance scene)._
124
+ ```
125
+
126
+ - **`batch.questions.length >= 2 && batch.batchable === true`** (#369) — render one batch prompt covering all members. Use `batch.totalScenes` (already summed across each member's `replicas`) for the Figma-scene fan-out hint:
127
+
128
+ ```
129
+ **[{severity}] {batch.ruleId}** — {batch.questions.length} instances:
130
+ - {nodeName₁}{ruleSpecificContext₁}
131
+ - {nodeName₂}{ruleSpecificContext₂}
132
+ - …
108
133
 
109
- After all questions are answered, **upsert this design's gotcha section** into `.claude/skills/canicode-gotchas/SKILL.md` in the user's project. Read the existing file, then either replace the section whose `Design key` matches this run (same Figma URL → fileKey+nodeId) or append a new numbered section under `# Collected Gotchas`. Never modify anything above the `# Collected Gotchas` heading — the region above it (frontmatter + workflow prose) is the skill loader contract installed by `canicode init`. See the `/canicode-gotchas` skill's "Upsert the gotcha section" step (Step 4) for the exact section format and matching rule.
134
+ {sharedQuestionPrompt}
135
+
136
+ Reply with one answer to apply to all {batch.questions.length}, or **split** to answer each individually.
137
+
138
+ > Hint: {hint}
139
+ > Example: {example}
140
+ ```
141
+
142
+ Where:
143
+ - `sharedQuestionPrompt` is the rule's `question` text with the per-node noun replaced by the rule's plural noun (e.g. "These layers all use FILL sizing without min/max constraints. What size boundaries should they share?" instead of repeating "What size boundaries should this layer have?" N times).
144
+ - `ruleSpecificContext` is short and rule-specific: e.g. for `missing-size-constraint` show the current `width`/`height` if the question has them; for `irregular-spacing` show the current `itemSpacing`; otherwise omit.
145
+ - On `split`, fall back to the per-question loop for that batch only — keep the rest of the group's batches as-is.
146
+
147
+ When `batch.totalScenes > batch.questions.length` (at least one member carries replicas), append one note so the user knows their single answer fans out further than the listed nodes:
148
+
149
+ ```
150
+ _Replicas: your one answer will land on **{batch.totalScenes}** Figma scenes total in Step 4 (some of these {batch.questions.length} questions already represent multiple instances of the same source-component child)._
151
+ ```
152
+
153
+ - **`batch.batchable === false`** is always rendered as a single-question prompt — the helper guarantees `questions.length === 1` for those (identity-typed answers like `non-semantic-name`, structural-mod rules).
154
+
155
+ Wait for the user's answer before moving to the next batch. For each batch, the user may:
156
+ - Answer the question directly (single value covers all batch members)
157
+ - Say **split** (batch only) to fall back to per-question prompting for that batch
158
+ - Say **skip** to skip the question / the entire batch
159
+ - Say **n/a** if the question / the entire batch is not applicable
160
+
161
+ When applying the batched answer, expand back to per-question records before storing — the gotcha section format and Step 4 apply loop both expect one record per `nodeId`.
162
+
163
+ After all questions are answered, **upsert this design's gotcha section** into `.claude/skills/canicode-gotchas/SKILL.md` in the user's project. Read the existing file, then either replace the section whose `Design key` matches `survey.designKey` (the canonical identifier the gotcha-survey response carries — see `/canicode-gotchas` Step 4a) or append a new numbered section under `# Collected Gotchas`. Never modify anything above the `# Collected Gotchas` heading — the region above it (frontmatter + workflow prose) is the skill loader contract installed by `canicode init`. See the `/canicode-gotchas` skill's "Upsert the gotcha section" step (Step 4) for the exact section format and matching rule.
110
164
 
111
165
  Then proceed to **Step 4** to apply answers to the Figma design.
112
166
 
113
167
  ### Step 4: Apply gotcha answers to Figma design
114
168
 
115
- Extract the `fileKey` from the Figma URL (format: `figma.com/design/:fileKey/...`).
116
-
117
- For each answered gotcha (skip questions answered with "skip" or "n/a"), branch on the pre-computed `question.applyStrategy`. The routing table, target properties, and instance-child resolution are resolved server-side by `canicode` — do NOT re-derive them from the rule id.
169
+ For each answered gotcha (skip questions answered with "skip" or "n/a"), branch on the pre-computed `question.applyStrategy`. The routing table, target properties, and instance-child resolution are resolved server-side by `canicode` — do NOT re-derive them from the rule id. The `fileKey` is not needed at this step — the bundled helpers operate on `nodeId` directly.
118
170
 
119
171
  Use the **`nodeId` from the answered question**. When `question.isInstanceChild` is `true`, treat layout and size-constraint changes as **high impact**: applying them on the source definition affects **every instance** of that component in the file. Ask for explicit user confirmation before writing to the definition node.
120
172
 
@@ -131,6 +183,8 @@ Every gotcha-survey question (and every entry in `analyzeResult.issues[]`) carri
131
183
  | `isInstanceChild` | `boolean` | Whether the `nodeId` targets a node inside an INSTANCE subtree. |
132
184
  | `sourceChildId` | `string` \| (absent) | Definition node id inside the source component. Use directly with `figma.getNodeByIdAsync`. |
133
185
  | `instanceContext` | object \| (absent) | Survey questions only. `{ parentInstanceNodeId, sourceNodeId, sourceComponentId?, sourceComponentName? }` for the Step 3 user-facing note. |
186
+ | `replicas` | `number` \| (absent) | Survey questions only (#356). Total instance count when this one question represents N instance-child issues sharing the same `(sourceComponentId, sourceNodeId, ruleId)` tuple. Absent for single-instance questions. |
187
+ | `replicaNodeIds` | `string[]` \| (absent) | Survey questions only (#356). All OTHER instance scene node ids the answer should land on. The apply step iterates `[nodeId, ...replicaNodeIds]`. Absent when `replicas` is absent. |
134
188
 
135
189
  #### Instance-child property overridability (Plugin API)
136
190
 
@@ -177,6 +231,51 @@ The helper walks the tiers in order; variable binding is an alternative writeFn
177
231
 
178
232
  **Confirmation is a batch-level concern — and only needed when opting in.** A `use_figma` call runs one JavaScript batch and cannot pause mid-batch for user input. Under the ADR-012 default (`allowDefinitionWrite: false`), no propagation happens, so no confirmation is required — override-errors annotate and move on. The orchestrator sets `allowDefinitionWrite: true` only after enumerating the likely propagation set to the user up-front and collecting **one confirmation for the whole batch** that names the source component(s) and the affected instance set. When describing impact, note that the write reaches every **non-overridden** instance — any instance with a local override for the same property keeps its override. The helper below never prompts — it assumes that if the flag is on, confirmation already happened.
179
233
 
234
+ **Pre-flight writability probe (#357).** Before showing the user the Definition write picker, call `CanICodeRoundtrip.probeDefinitionWritability(questions)` inside a small `use_figma` batch. The probe loads every distinct `sourceChildId` once and classifies it as writable or unwritable using the same detection as the runtime fallback (Experiment 10 `remote === true` and Experiment 11 unresolved-`null`). The result decides which version of the picker to show:
235
+
236
+ ```javascript
237
+ // Inside a use_figma batch:
238
+ const probe = await CanICodeRoundtrip.probeDefinitionWritability(questions);
239
+ return { events: [], probe };
240
+ ```
241
+
242
+ Branches on `probe`:
243
+
244
+ - **`allUnwritable === true`** — every candidate source is in an external library (or unresolved). Opting in is structurally a no-op; every write would throw "Cannot write to internal and read-only node" and fall through to scene annotation anyway. Show the user a single-option picker:
245
+
246
+ ```
247
+ Definition write policy
248
+
249
+ This file's source components live in an external library and are
250
+ read-only from here ({unwritableSourceNames.join(", ")}). Tier 2
251
+ propagation cannot fire — every "opt-in" write would fall through
252
+ to a scene annotation regardless.
253
+
254
+ ❯ 1. Annotate only (only viable option for this file)
255
+ 2. Cancel — duplicate the library locally first to enable propagation
256
+ ```
257
+
258
+ Skip the opt-in branch entirely and call the helpers with the default `allowDefinitionWrite: false`.
259
+
260
+ - **`partiallyUnwritable === true`** — some sources are local, some remote. Surface the split:
261
+
262
+ ```
263
+ Definition write policy
264
+
265
+ {unwritableCount} of {totalCount} source components are remote
266
+ (read-only) and will fall through to annotation; the remaining
267
+ {totalCount - unwritableCount} are local and will propagate.
268
+ Remote sources: {unwritableSourceNames.join(", ")}.
269
+
270
+ Continue with allowDefinitionWrite: true?
271
+ ```
272
+
273
+ When confirmed, propagate to the local sources and let the helper's runtime fallback annotate the remote ones — the existing Experiment-10 retry path absorbs them without aborting the batch.
274
+
275
+ - **`allUnwritable === false && partiallyUnwritable === false`** (the all-local / no-candidates case) — show the existing batch-level picker prose. No probe-driven adjustment needed.
276
+
277
+ The probe is read-only and idempotent; running it before the picker adds one round-trip but saves the user a confusing "I opted in, why did I get annotations?" moment that #342 surfaced live on Simple Design System (Community).
278
+
180
279
  **Shared helpers (bundled)** — the deterministic helpers live in TypeScript at `src/core/roundtrip/*.ts` and are bundled to a single IIFE at `.claude/skills/canicode-roundtrip/helpers.js`. `use_figma` only accepts a self-contained JS string, so the source of truth is TypeScript (with vitest coverage) and the bundle is the delivery artifact.
181
280
 
182
281
  **Usage in a roundtrip session:**
@@ -185,11 +284,18 @@ The helper walks the tiers in order; variable binding is an alternative writeFn
185
284
  2. Prepend its contents verbatim at the top of every `use_figma` batch body — it registers a single global `CanICodeRoundtrip`.
186
285
  3. Reference exposed globals as `CanICodeRoundtrip.*`:
187
286
  - `stripAnnotations(annotations)` — normalizes the D1 label/labelMarkdown mutex on readback.
188
- - `ensureCanicodeCategories()` — returns `{ gotcha, autoFix, fallback }` category id map (D4); idempotent, safe to call at the top of every batch.
287
+ - `ensureCanicodeCategories()` — returns `{ gotcha, flag, fallback }` category id map (D4); idempotent, safe to call at the top of every batch. May also include `legacyAutoFix` when the file already carries the pre-#355 `canicode:auto-fix` category from earlier roundtrips — read-only on the canicode side, used only by Step 5 cleanup to sweep old annotations.
189
288
  - `upsertCanicodeAnnotation(node, { ruleId, markdown, categoryId, properties })` — idempotent annotation upsert. Handles D1 mutex, D2 in-place replace by ruleId prefix, and the D3 `properties` node-type retry.
190
289
  - `applyWithInstanceFallback(question, writeFn, { categories, allowDefinitionWrite, telemetry })` — three-tier write policy with silent-ignore detection. `allowDefinitionWrite` defaults to `false` per ADR-012 — override-errors and silent-ignores annotate the scene naming the source component instead of writing the definition. Set `true` only after a batch-level confirmation. `telemetry` is an optional `(event, props) => void` callback fired when a definition write is skipped (wiring point for future Node-side opt-in usage data). The `writeFn` may return `false` to signal "write accepted but value unchanged" so the helper can route to the next tier.
191
290
  - `applyPropertyMod(question, answerValue, { categories, allowDefinitionWrite, telemetry })` — Strategy A entry point. Branches on `targetProperty` (single vs array) and answer shape (scalar, per-property object, `{ variable: "name" }` binding). Uses `setBoundVariableForPaint` for `fills` / `strokes` and `setBoundVariable` for scalar fields. Passes the full context through to `applyWithInstanceFallback`.
192
291
  - `resolveVariableByName(name)` — local-variable exact-name lookup; returns `null` for remote library variables not imported into this file.
292
+ - `probeDefinitionWritability(questions)` — async pre-flight (#357). Returns `{ totalCount, unwritableCount, unwritableSourceNames, allUnwritable, partiallyUnwritable }`. Use BEFORE the Definition write picker so the picker can drop the opt-in branch when every candidate is in an external library / unresolved (saves the user a wasted "I opted in, why did I get annotations?" decision). Read-only probe, dedupes by `sourceChildId`.
293
+ - `extractAcknowledgmentsFromNode(node, canicodeCategoryIds?)` — synchronous pure helper (#371). Reads one node's annotations and returns `{ nodeId, ruleId }[]` for entries gated by canicode `categoryId` plus a recognisable `— *<ruleId>*` footer (or legacy `**[canicode] <ruleId>**` prefix). When `canicodeCategoryIds` is omitted, footer-text matching alone is sufficient (test mode).
294
+ - `readCanicodeAcknowledgments(rootNodeId, categories?)` — async tree walker (#371). Loads `rootNodeId` via `figma.getNodeByIdAsync`, recurses through `children`, and accumulates one acknowledgment per recognised entry. Used at the top of Step 5a to harvest the side channel that lets the analysis pipeline distinguish "still broken" from "the designer has a plan" — pass the result straight to `analyze({ acknowledgments })`. Errors on individual nodes are swallowed so locked / external nodes don't abort the sweep.
295
+ - `computeRoundtripTally({ stepFourReport, reanalyzeResponse })` — pure helper (#383). Takes the structured Step 4 outcome counts (`{ resolved, annotated, definitionWritten, skipped }`) plus a narrowed re-analyze view (`{ issueCount, acknowledgedCount }`) and returns `{ X, Y, Z, W, N, V, V_ack, V_open }`. Replaces the LLM-side emoji-bullet re-counting in Step 5 — render the returned object directly into the wrap-up templates. Throws when `acknowledgedCount > issueCount` (impossible state).
296
+ - `applyAutoFix(issue, { categories, allowDefinitionWrite?, telemetry? })` — Strategy D entry point (#386). Branches on `targetProperty === "name" && suggestedName` once: renames the node via `applyWithInstanceFallback` (so naming auto-fixes share the same tier-1/2/3 policy as Strategy A) or writes a `categories.flag` annotation carrying `issue.message` and `issue.annotationProperties`. Returns one `AutoFixOutcome` (`{ outcome, nodeId, nodeName, ruleId, label }`) where `outcome` is `🔧` / `🌐` / `📝` so Step 4 can bump the structured `stepFourReport` counters without parsing prose. Replaces the inline JS the SKILL used to carry (per ADR-016).
297
+ - `applyAutoFixes(issues, { categories, allowDefinitionWrite?, telemetry? })` — loop wrapper (#386). Filters `issues` to `applyStrategy === "auto-fix"` (skipped entries surface as `⏭️` outcomes for symmetry) and applies each one in sequence. Returns the full `AutoFixOutcome[]`.
298
+ - `removeCanicodeAnnotations(annotations, categories)` — pure filter. Returns `annotations` with every canicode-authored entry removed (gates on `categories.gotcha` / `flag` / `fallback` / `legacyAutoFix` plus the legacy `**[canicode]` body prefix). Use after `stripAnnotations` in the Step 5 cleanup loop — replaces the inline filter predicate the SKILL used to carry. `isCanicodeAnnotation(annotation, categories)` is the single-entry version, exported for callers that need the predicate alone.
193
299
 
194
300
  Keep each `writeFn` small so a throw does not abort unrelated writes. Experiment 08 findings informed every branch in the bundled helpers, and the batch-level confirmation contract still applies *when opting in*: if the orchestrator passes `allowDefinitionWrite: true`, it must have already collected one confirmation covering every potential definition write in the batch. Under the default, no confirmation is needed — the helper annotates the scene instead of propagating.
195
301
 
@@ -203,6 +309,16 @@ Rules with `applyStrategy === "property-mod"`. Call the bundled helper — it br
203
309
  await CanICodeRoundtrip.applyPropertyMod(question, answerValue, { categories });
204
310
  ```
205
311
 
312
+ **Replicas (#356)** — when `question.replicaNodeIds` is present, the same answer must land on every replica instance. Iterate the merged set so each scene gets its own per-node failure routing (under the ADR-012 default each replica annotates independently; with `allowDefinitionWrite: true` they share the one definition write because they share the source):
313
+
314
+ <!-- adr-016-ack: fan-out over an explicit small array of node IDs; the deterministic work lives inside applyPropertyMod -->
315
+ ```javascript
316
+ const targets = [question.nodeId, ...(question.replicaNodeIds ?? [])];
317
+ for (const nodeId of targets) {
318
+ await CanICodeRoundtrip.applyPropertyMod({ ...question, nodeId }, answerValue, { categories });
319
+ }
320
+ ```
321
+
206
322
  Answer shape guide (LLM judgment — the user's answer is prose; parse accordingly):
207
323
  - **`non-semantic-name`**: string — the new node name.
208
324
  - **`irregular-spacing`**: number for gap (subType `gap`), or `{ paddingTop, paddingRight, paddingBottom, paddingLeft }` for padding.
@@ -218,6 +334,23 @@ The name must match **the variable's `name` field exactly** — including any sl
218
334
 
219
335
  Rules with `applyStrategy === "structural-mod"`. Show the proposed change and **ask for user confirmation** before applying.
220
336
 
337
+ > **Instance-child guard (#368).** Strategy B mutations restructure the layer tree — `createComponentFromNode`, `flatten`, wrapper removal, instance-link reconnection. None of these compose safely with the Plugin API's instance-override rules: on a node where `question.isInstanceChild === true`, calling `createComponentFromNode` either throws *"Cannot create a component from a node inside an instance"* or detaches the parent instance entirely (the picked instance is replaced by a one-off frame, severing every existing override and propagation link). Restructuring deep-nested wrappers inside an instance child has the same risk surface — even when the call doesn't throw, the resulting structure cannot ride the source-component's propagation in future updates.
338
+ >
339
+ > Before showing the per-rule prompt below, check `question.isInstanceChild`. If it is true, **do not run the destructive call**. Surface this a/b prompt instead and default to **(a)**:
340
+ >
341
+ > ```
342
+ > **{ruleId}** would normally restructure **{nodeName}** here, but this node lives inside instance **{instanceContext.parentInstanceNodeId}** of source component **{instanceContext.sourceComponentName or sourceComponentId or "unknown"}** (definition node `{instanceContext.sourceNodeId}`). On instance children Plugin API restructuring either fails outright or detaches the parent instance.
343
+ >
344
+ > ❯ a) Annotate the scene with a recommendation to apply the change on the source definition (safe — picks up via canicode-gotchas in code-gen, source designer can act on it later)
345
+ > b) Detach the parent instance and attempt the restructuring on the resulting one-off frame (destructive — every existing instance override is lost and the node no longer rides the source component's propagation)
346
+ > ```
347
+ >
348
+ > On **(a)**, route to Strategy C — call `upsertCanicodeAnnotation(scene, { ruleId: question.ruleId, markdown: "**Q:** … **A:** Apply on source definition `${instanceContext.sourceNodeId}` (`${instanceContext.sourceComponentName ?? "unknown"}`) — instance-child restructuring would detach the parent instance.", categoryId: categories.gotcha })`. Reference `instanceContext.sourceComponentName` and `instanceContext.sourceNodeId` in the body so the source designer can locate the target.
349
+ >
350
+ > On **(b)**, gate behind a second confirmation that explicitly names the side effects ("This will detach instance **{parentInstanceNodeId}** — all overrides on it will be lost and it will stop receiving updates from **{sourceComponentName}**. Type the parent instance name to confirm."). Only then execute the per-rule destructive call below.
351
+ >
352
+ > The same posture as ADR-012's `allowDefinitionWrite: false` default: instance-child structural mutations are off-by-default and require explicit user opt-in *per node*, not per batch — the destructive call here doesn't have a quiet fallback the way Strategy A's `applyWithInstanceFallback` does.
353
+
221
354
  **`non-layout-container`** — Convert Group/Section to Auto Layout frame:
222
355
  - Prompt: "I'll convert **{nodeName}** to an Auto Layout frame with {direction} layout and {spacing}px gap. Proceed?"
223
356
  - If confirmed: `applyPropertyMod(question, { layoutMode: "VERTICAL", itemSpacing: 12 })`.
@@ -240,64 +373,44 @@ if (scene && scene.type === "FRAME") {
240
373
  - Prompt: "I'll reconnect **{nodeName}** to its original component. Any overrides will be preserved. Proceed?"
241
374
  - Requires finding the original component — if not identifiable, fall back to annotation.
242
375
 
243
- If the user **declines** any structural modification, add an annotation instead (same as Strategy C).
376
+ If the user **declines** any structural modification (or the instance-child guard above routes to **(a)**), add an annotation instead (same as Strategy C).
244
377
 
245
378
  #### Strategy C: Annotation — record on the design for designer reference
246
379
 
247
- Rules with `applyStrategy === "annotation"` cannot be auto-fixed via Plugin API. Add the gotcha answer as a Figma annotation so designers see it in Dev Mode. Use the helper — it handles the D1 mutex, D2 in-place upsert, and D4 category assignment.
380
+ Rules with `applyStrategy === "annotation"` cannot be auto-fixed via Plugin API. Add the gotcha answer as a Figma annotation so designers see it in Dev Mode. Use the helper — it handles the D1 mutex, D2 in-place upsert, and D4 category assignment. When `question.replicaNodeIds` is present (#356), iterate the merged set so every replica instance gets the annotation:
248
381
 
382
+ <!-- adr-016-ack: fan-out over an explicit small array of node IDs; the deterministic work lives inside upsertCanicodeAnnotation -->
249
383
  ```javascript
250
- const scene = await figma.getNodeByIdAsync(question.nodeId);
251
- CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
252
- ruleId: question.ruleId,
253
- markdown: `**Q:** ${question.question}\n**A:** ${answer}`,
254
- categoryId: categories.gotcha,
255
- // Optional: surface live property values in Dev Mode alongside the note.
256
- // Only include types the node supports (FRAME vs TEXT — see matrix above).
257
- properties: question.annotationProperties,
258
- });
384
+ const targets = [question.nodeId, ...(question.replicaNodeIds ?? [])];
385
+ for (const nodeId of targets) {
386
+ const scene = await figma.getNodeByIdAsync(nodeId);
387
+ CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
388
+ ruleId: question.ruleId,
389
+ markdown: `**Q:** ${question.question}\n**A:** ${answer}`,
390
+ categoryId: categories.gotcha,
391
+ // Optional: surface live property values in Dev Mode alongside the note.
392
+ // Only include types the node supports (FRAME vs TEXT — see matrix above).
393
+ properties: question.annotationProperties,
394
+ });
395
+ }
259
396
  ```
260
397
 
261
398
  Notes:
262
- - `upsertCanicodeAnnotation` replaces an existing `**[canicode] <ruleId>**` entry on the same node instead of appending reruns don't accumulate duplicates.
399
+ - `upsertCanicodeAnnotation` writes the recommendation directly as the body and appends an italic `— *<ruleId>*` footer. The footer is the dedup marker — reruns replace the existing entry in place. The category badge (`canicode:gotcha` / `canicode:flag` / `canicode:fallback`) above the body already brands the annotation, so the body no longer leads with `**[canicode] <ruleId>**` (#353). Pre-#353 entries are still recognised on rerun and replaced with the new format.
263
400
  - `label` and `labelMarkdown` are mutually exclusive on write, but Figma returns both on readback. Never spread `scene.annotations` directly; always call `CanICodeRoundtrip.upsertCanicodeAnnotation` (or `CanICodeRoundtrip.stripAnnotations` if you truly need the normalized array).
264
401
  - Prefer annotating the **scene** instance child so designers see the note where they work; mention in the markdown if the fix belongs on the source component but could not be applied (library/external).
265
402
 
266
403
  #### Strategy D: Auto-fix lower-severity issues from analysis
267
404
 
268
- The gotcha survey covers only blocking/risk severity. Lower-severity rules appear in `analyzeResult.issues[]` without a survey question. Each issue carries the same pre-computed fields (`applyStrategy`, `targetProperty`, `annotationProperties`, `suggestedName`, `isInstanceChild`, `sourceChildId`). Loop over them:
405
+ The gotcha survey covers only blocking/risk severity. Lower-severity rules appear in `analyzeResult.issues[]` without a survey question. Each issue carries the same pre-computed fields (`applyStrategy`, `targetProperty`, `annotationProperties`, `suggestedName`, `isInstanceChild`, `sourceChildId`). The bundled helper handles the loop, the filter (`applyStrategy === "auto-fix"`), the naming-vs-annotation branch, and the per-issue outcome accumulator in one call:
269
406
 
270
407
  ```javascript
271
- for (const issue of analyzeResult.issues) {
272
- if (issue.applyStrategy !== "auto-fix") continue;
273
-
274
- // Shape an ad-hoc question-like object so the same helpers apply.
275
- const q = {
276
- nodeId: issue.nodeId,
277
- ruleId: issue.ruleId,
278
- ...(issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}),
279
- };
280
-
281
- if (issue.targetProperty === "name" && issue.suggestedName) {
282
- // Naming rules — rename to the pre-computed suggestedName.
283
- await CanICodeRoundtrip.applyWithInstanceFallback(q, async (target) => {
284
- if (target) target.name = issue.suggestedName;
285
- }, { categories });
286
- } else {
287
- // raw-value, missing-interaction-state, missing-prototype — designer judgment; annotate.
288
- const scene = await figma.getNodeByIdAsync(issue.nodeId);
289
- CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
290
- ruleId: issue.ruleId,
291
- markdown: issue.message,
292
- categoryId: categories.autoFix,
293
- // Optional: surface the live value for the affected property in Dev Mode.
294
- properties: issue.annotationProperties,
295
- });
296
- }
297
- }
408
+ const outcomes = await CanICodeRoundtrip.applyAutoFixes(analyzeResult.issues, { categories });
298
409
  ```
299
410
 
300
- `suggestedName` is already capitalized for direct Plugin-API use (e.g. `"Hover"`, `"Default"`, `"Pressed"`). Do not transform it further.
411
+ `outcomes` is an array of `{ outcome, nodeId, nodeName, ruleId, label }`. `outcome` is one of `🔧` (rename succeeded), `🌐` (definition write propagated — only when `allowDefinitionWrite: true`), `📝` (annotation written, including the fallback path), or `⏭️` (issue's `applyStrategy` was not `"auto-fix"` so it was skipped). Bump the matching `stepFourReport` counter for each entry — `🔧` → `resolved`, `🌐` → `definitionWritten`, `📝` → `annotated`, `⏭️` → `skipped` — so the Step 5 tally (`CanICodeRoundtrip.computeRoundtripTally`, #383) consumes the same structured shape as Strategies A/B/C.
412
+
413
+ `suggestedName` is already capitalized for direct Plugin-API use (e.g. `"Hover"`, `"Default"`, `"Pressed"`). The helper writes it through `applyWithInstanceFallback` so locked / read-only / instance-override nodes annotate cleanly instead of aborting the batch — see the source at `src/core/roundtrip/apply-auto-fix.ts` (#386, ADR-016).
301
414
 
302
415
  #### Execution order
303
416
 
@@ -305,9 +418,9 @@ for (const issue of analyzeResult.issues) {
305
418
  1. **Batch all property modifications** (Strategy A) into a single `use_figma` call for efficiency. Pass `{ categories }` to `applyWithInstanceFallback` so fallbacks land in the correct category.
306
419
  2. **Present structural modifications** (Strategy B) one by one, apply confirmed ones.
307
420
  3. **Batch all annotations** (Strategy C + declined structural mods) into a single `use_figma` call — use `categories.gotcha` for the category id.
308
- 4. **Batch all auto-fixes and annotations for lower-severity issues** (Strategy D) — use `categories.autoFix` for annotated ones, `categories.fallback` is reserved for errors surfaced by `applyWithInstanceFallback` itself.
421
+ 4. **Batch all auto-fixes and annotations for lower-severity issues** (Strategy D) — use `categories.flag` for annotated ones (renamed from `autoFix` per #355 — the category means "flagged for designer attention", not "fixed"), `categories.fallback` is reserved for errors surfaced by `applyWithInstanceFallback` itself.
309
422
 
310
- After applying, report what was done:
423
+ After applying, **emit a structured `stepFourReport`** alongside the human-readable per-question lines. Step 5 reads from this object — it does **not** re-parse the per-question lines (per ADR-016). Increment each counter as Strategy A/B/C/D complete:
311
424
 
312
425
  ```
313
426
  Applied {N} changes to the Figma design:
@@ -318,30 +431,74 @@ Applied {N} changes to the Figma design:
318
431
  - ⏭️ {nodeName}: declined by user, added annotation (deep-nesting)
319
432
  - 📝 {nodeName}: annotation added to canicode:gotcha (absolute-position-in-auto-layout)
320
433
  - 🔧 {nodeName}: auto-fixed to "Hover" (non-standard-naming)
321
- - 📝 {nodeName}: annotation added to canicode:auto-fix — raw color needs token binding (raw-value)
434
+ - 📝 {nodeName}: annotation added to canicode:flag — raw color needs token binding (raw-value)
435
+
436
+ stepFourReport = {
437
+ resolved: <count of ✅ + 🔧 + 🔗 lines>, // scene writes, auto-fix renames, variable bindings
438
+ annotated: <count of 📝 lines>, // including ⏭️ declines that fell back to annotation
439
+ definitionWritten: <count of 🌐 lines>, // only non-zero with allowDefinitionWrite: true
440
+ skipped: <count of ⏭️ lines + Step 3 skip/n/a> // user-declined questions
441
+ }
322
442
  ```
323
443
 
444
+ Hold `stepFourReport` in scope through Step 5 — it is the input to `CanICodeRoundtrip.computeRoundtripTally` below.
445
+
324
446
  ### Step 5: Re-analyze and report what the roundtrip addressed
325
447
 
326
- Run `analyze` again on the same Figma URL:
448
+ #### Step 5a: Harvest canicode-authored annotations as acknowledgments (#371)
449
+
450
+ Before re-running `analyze`, collect every `(nodeId, ruleId)` pair that Step 4 wrote as a Figma annotation. The REST API does not expose annotations, so this side channel is the only way the analysis pipeline learns that a roundtrip-touched issue is "the designer has a plan" rather than "still broken". Without it the grade and issue count look identical to the pre-roundtrip state — `32 → 32` — even when every gotcha has been captured per ADR-012.
327
451
 
452
+ Run a short `use_figma` batch that walks the same subtree the original `analyze` covered (`targetNodeId` if you used one, else `figma.root.id`), reads canicode-categorised annotations, and serialises the result:
453
+
454
+ ```javascript
455
+ // Inside a use_figma batch:
456
+ const categories = await CanICodeRoundtrip.ensureCanicodeCategories();
457
+ const acknowledgments = await CanICodeRoundtrip.readCanicodeAcknowledgments(
458
+ targetNodeId ?? figma.root.id,
459
+ categories
460
+ );
461
+ return { events: [], acknowledgments };
328
462
  ```
329
- analyze({ input: "<figma-url>" })
463
+
464
+ `readCanicodeAcknowledgments` walks `node.children` recursively, gates on the `canicode:gotcha` / `canicode:flag` / `canicode:fallback` (and legacy `canicode:auto-fix`) category ids, and extracts the ruleId from the annotation footer (`— *<ruleId>*`) or the legacy `**[canicode] <ruleId>**` prefix. The categoryId guard keeps user-authored notes that happen to end in italic kebab-case from being mistaken for canicode acknowledgments.
465
+
466
+ #### Step 5b: Re-analyze with acknowledgments
467
+
468
+ Pass the harvested array straight into `analyze` so the engine flags matching issues as `acknowledged: true` and the density score gives them half weight:
469
+
470
+ ```
471
+ analyze({ input: "<figma-url>", acknowledgments })
330
472
  ```
331
473
 
332
- Under ADR-012's annotate-by-default policy, most instance-child gotchas route to 📝 annotations and do **not** move the numeric grade — so the headline for this step is the **issues-delta** (what the roundtrip captured), not a grade comparison. Grade is kept as a footnote so the Row 8 regression guardrail still applies.
474
+ **Without canicode MCP** the CLI accepts the same input via `--acknowledgments <path>` (JSON file containing the array). Write the array to a temp file from the `use_figma` return, then:
333
475
 
334
- **Tally inputs** — derive the counts from the data you already have:
335
- - `X` (✅ resolved): count of ✅ + 🔧 + 🔗 markers from the Step 4 report block you just emitted (scene/instance-child writes, auto-fix renames, and variable bindings all successfully landed the value).
336
- - `Y` (📝 annotated): count of 📝 markers from Step 4 — gotcha answers captured as Figma annotations for code-gen reference.
337
- - `Z` (🌐 definition writes): count of 🌐 markers from Step 4 — only non-zero when the orchestrator opted in with `allowDefinitionWrite: true` (helper context option, not a CLI flag).
338
- - `W` (⏭️ skipped): count of ⏭️ markers from Step 4 plus any Step 3 questions the user answered with `skip` or `n/a`.
339
- - `V` (remaining): `issues.length` from the re-analyze response — unresolved gotchas plus non-actionable rules still flagged by the design.
340
- - `N` (addressed) = `X + Y + Z + W`.
476
+ ```bash
477
+ npx canicode analyze "<figma-url>" --json --acknowledgments /tmp/canicode-acks.json
478
+ ```
341
479
 
342
- If Step 4 produced no report block (e.g. user skipped every question, or no gotcha survey ran), all four counts are zero — that is a legitimate outcome; report the breakdown with zeros rather than treating it as an error.
480
+ The response now carries:
481
+ - `acknowledgedCount` (top level) — how many issues matched an acknowledgment.
482
+ - `issues[i].acknowledged: true` (per matched issue) — survives into the report and downstream skills.
483
+ - `summary` text — when `acknowledgedCount > 0`, the Total line reads `Total: N (A acknowledged via canicode annotations / N-A unaddressed)`.
343
484
 
344
- **All gotcha issues resolved** (`V == 0`, i.e. re-analyze surfaces no remaining issues note this is independent of grade since ADR-012 annotations do not move the score):
485
+ Under ADR-012's annotate-by-default policy, most instance-child gotchas route to 📝 annotations and do **not** move the numeric grade but the half-weight density now produces a small visible movement when annotations are recognised. The headline for this step remains the **issues-delta** (what the roundtrip captured); grade movement is a secondary signal.
486
+
487
+ **Tally** — call `CanICodeRoundtrip.computeRoundtripTally` with the structured `stepFourReport` you assembled in Step 4 and the re-analyze response from Step 5b. The helper handles every count derivation (`N = X + Y + Z + W`, `V_open = V - V_ack`) and validates that `acknowledgedCount` cannot exceed `issueCount`. Render the returned `{ X, Y, Z, W, N, V, V_ack, V_open }` straight into the templates below — do **not** re-derive any of these from the Step 4 prose:
488
+
489
+ ```javascript
490
+ const tally = CanICodeRoundtrip.computeRoundtripTally({
491
+ stepFourReport, // the object emitted at the end of Step 4
492
+ reanalyzeResponse: { // narrowed view of the re-analyze response
493
+ issueCount: response.issueCount,
494
+ acknowledgedCount: response.acknowledgedCount,
495
+ },
496
+ });
497
+ ```
498
+
499
+ If Step 4 produced no `stepFourReport` (e.g. user skipped every question, or no gotcha survey ran), pass an all-zero object — `tally.N === 0`, `tally.V_open === tally.V`, and the templates below render the breakdown with zeros rather than treating it as an error. (Skipping Step 5a and passing no `acknowledgments` argument is also valid in this case — the response simply has `acknowledgedCount: 0`.)
500
+
501
+ **All gotcha issues resolved** (`V == 0`, i.e. re-analyze surfaces no remaining issues — note this is mostly independent of grade since ADR-012 annotations only move the score by the half-weight reduction enabled in Step 5b):
345
502
  - Tell the user (fill in the counts from the tally above):
346
503
 
347
504
  ```
@@ -355,14 +512,16 @@ If Step 4 produced no report block (e.g. user skipped every question, or no gotc
355
512
 
356
513
  Grade: {oldGrade} → {newGrade}. Ready for code generation.
357
514
  ```
358
- - Clean up canicode annotations: remove annotations with `[canicode]` prefix from fixed nodes via `use_figma`. Apply `stripAnnotations` to avoid the D1 mutex:
515
+ - Clean up canicode annotations on fixed nodes via `use_figma`. Use the bundled `removeCanicodeAnnotations` helper — it gates on **categoryId** (the durable canicode-side identifier — the body no longer carries a `[canicode]` prefix per #353), includes `legacyAutoFix` if `ensureCanicodeCategories` returned it (pre-#355 `canicode:auto-fix` sweep), and also matches the legacy `**[canicode]` body prefix as a secondary marker for entries on files that have not been re-roundtripped yet. The match logic lives in `src/core/roundtrip/remove-canicode-annotations.ts` with vitest coverage so prose stays ADR-016-compliant:
516
+ <!-- adr-016-ack: fan-out over an explicit small array of node IDs; the deterministic work lives inside removeCanicodeAnnotations -->
359
517
  ```javascript
360
518
  const nodeIds = ["id1", "id2"]; // nodes that now pass
361
519
  for (const id of nodeIds) {
362
520
  const node = await figma.getNodeByIdAsync(id);
363
521
  if (node && "annotations" in node) {
364
- node.annotations = CanICodeRoundtrip.stripAnnotations(node.annotations).filter(
365
- a => !a.labelMarkdown?.startsWith("**[canicode]")
522
+ node.annotations = CanICodeRoundtrip.removeCanicodeAnnotations(
523
+ CanICodeRoundtrip.stripAnnotations(node.annotations),
524
+ categories,
366
525
  );
367
526
  }
368
527
  }
@@ -370,7 +529,7 @@ for (const id of nodeIds) {
370
529
  - Proceed to **Step 6**.
371
530
 
372
531
  **Some issues remain** (`V > 0`):
373
- - Show the same breakdown and ask whether to proceed:
532
+ - Show the same breakdown and ask whether to proceed. When `V_ack > 0`, expand the remaining line into the acknowledged/unaddressed split surfaced by the re-analyze (#371) so the user can see how much of `V` is "captured for code-gen" vs "still on the user's plate":
374
533
 
375
534
  ```
376
535
  Roundtrip complete — N issues addressed:
@@ -379,12 +538,36 @@ for (const id of nodeIds) {
379
538
  🌐 Z definition writes propagated (only when allowDefinitionWrite: true)
380
539
  ⏭️ W skipped (user declined or "skip")
381
540
 
382
- V issues remaining (unresolved gotchas + non-actionable rules)
541
+ V issues remaining
542
+ ↳ V_ack acknowledged via canicode annotations (carried into code-gen)
543
+ ↳ V_open unaddressed (no annotation — your follow-up backlog)
383
544
 
384
545
  Grade: {oldGrade} → {newGrade}. Proceed to code generation with remaining context?
385
546
  ```
547
+
548
+ When `V_ack == 0` (re-analyze returned `acknowledgedCount: 0`), keep the single `V issues remaining (unresolved gotchas + non-actionable rules)` line.
386
549
  - If yes → proceed to **Step 6** with remaining gotcha context.
387
- - If no → stop and let the user address remaining issues manually.
550
+ - If no → stop and emit the **Stop wrap-up** below; do **not** restate the grade as the lead.
551
+
552
+ #### Wrap-up message rubric (Stop branch)
553
+
554
+ When the user picks **Stop** here, the closing message is the *last thing the user sees of canicode* in this session. Keep the issues-delta as the headline (`✅ X / 📝 Y / 🌐 Z / ⏭️ W / V remaining`) — grade movement, if any, belongs as a footnote line **after** the delta, not as the lead bullet. Reason: the value canicode delivers under the ADR-012 default is the annotation count carried into code-gen, not score movement (per [#341](https://github.com/let-sunny/canicode/issues/341), [#352](https://github.com/let-sunny/canicode/issues/352)).
555
+
556
+ ```
557
+ Stopped — N issues addressed, V remaining for manual follow-up:
558
+ ✅ X resolved
559
+ 📝 Y annotated on Figma (carried into code-gen via canicode-gotchas)
560
+ 🌐 Z definition writes propagated
561
+ ⏭️ W skipped
562
+
563
+ V remaining
564
+ ↳ V_ack acknowledged via canicode annotations
565
+ ↳ V_open unaddressed
566
+
567
+ Grade: {oldGrade} → {newGrade}.
568
+ ```
569
+
570
+ When `V_ack == 0`, drop the `↳` lines and leave a single `V remaining` row. Anti-pattern (do **not** lead with a grade-only sentence like "Grade: C → C+. Most size-constraint gotchas are now annotations…"). Lead with the delta block; mention grade once, on its own footnote line, plain prose only.
388
571
 
389
572
  ### Step 6: Implement with Figma MCP
390
573
 
@@ -395,10 +578,31 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
395
578
  - Gotchas with severity **blocking** MUST be addressed — the design cannot be implemented correctly without this information
396
579
  - Gotchas with severity **risk** SHOULD be addressed — they indicate potential issues that will surface later
397
580
  - Reference the specific node IDs from gotcha answers to locate the affected elements in the design
398
- - Pass the Figma URL (or `designKey` = `<fileKey>#<nodeId>`) to `figma-implement-design` so it can grep the matching `## #NNN — …` section in `.claude/skills/canicode-gotchas/SKILL.md` instead of reading the whole accumulated file
581
+ - Pass the Figma URL or `survey.designKey` to `figma-implement-design` so it can grep the matching `## #NNN — …` section in `.claude/skills/canicode-gotchas/SKILL.md` instead of reading the whole accumulated file
399
582
 
400
583
  **If all issues were resolved in Steps 4-5**, no additional gotcha context is needed — the design speaks for itself.
401
584
 
585
+ #### Wrap-up message rubric (post-handoff)
586
+
587
+ After `figma-implement-design` returns, summarise the roundtrip in the same shape as the Step 5 / Stop wrap-up — issues-delta first, grade as a footnote, then the code-gen outcome. Do **not** lead with grade movement (per [#352](https://github.com/let-sunny/canicode/issues/352)):
588
+
589
+ ```
590
+ Roundtrip complete — N issues addressed, code generated:
591
+ ✅ X resolved
592
+ 📝 Y annotated on Figma (referenced during code-gen)
593
+ 🌐 Z definition writes propagated
594
+ ⏭️ W skipped
595
+
596
+ V issues remaining
597
+ ↳ V_ack acknowledged via canicode annotations
598
+ ↳ V_open unaddressed
599
+
600
+ Grade: {oldGrade} → {newGrade}.
601
+ Code: <files generated / next-step pointer from figma-implement-design>
602
+ ```
603
+
604
+ (Drop the `↳` lines when `V_ack == 0`.)
605
+
402
606
  ## Edge Cases
403
607
 
404
608
  - **No canicode MCP server**: Fall back to `npx canicode analyze --json` and `npx canicode gotcha-survey --json` — both CLI commands return the same shape as the MCP tools. The Figma MCP is still required for `use_figma` in Step 4; there is no CLI fallback for Figma design edits.
@@ -410,4 +614,4 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
410
614
  - **use_figma call fails for a node**: Report the error for that specific node, continue with other nodes. Failed property modifications become annotations so the context is not lost.
411
615
  - **Re-analyze shows new issues**: Only address issues from the original gotcha survey. New issues may appear due to structural changes — report them but do not re-enter the gotcha loop.
412
616
  - **Very large design (many gotchas)**: The gotcha survey already deduplicates sibling nodes and filters to blocking/risk severity only. If there are still many questions, ask the user if they want to focus on blocking issues only.
413
- - **External library components**: Applies only when the orchestrator has set `allowDefinitionWrite: true`. Experiment 10's observed case is `getMainComponentAsync()` resolving with `mainComponent.remote === true` — writes then throw *"Cannot write to internal and read-only node"*. The `mainComponent === null` case is documented in the Plugin API but was not reproduced live in Experiment 10; Experiment 11 (#309) unit-test-covers the helper's routing for that branch (override-error + no `sourceChildId` → annotate with `could not apply automatically:` markdown — see ADR-011 Verification), so the code path is regression-locked while live Figma reproduction remains a manual fixture-seeding follow-up. Under the default (`allowDefinitionWrite: false`), the definition write never fires and this throw cannot surface.
617
+ - **External library components**: Applies only when the orchestrator has set `allowDefinitionWrite: true`. Experiment 10's observed case is `getMainComponentAsync()` resolving with `mainComponent.remote === true` — writes then throw *"Cannot write to internal and read-only node"*. The `mainComponent === null` case is documented in the Plugin API but was not reproduced live in Experiment 10; Experiment 11 (#309) unit-test-covers the helper's routing for that branch (override-error + no `sourceChildId` → annotate with `could not apply automatically:` markdown — see ADR-011 Verification), so the code path is regression-locked while live Figma reproduction remains a manual fixture-seeding follow-up. Under the default (`allowDefinitionWrite: false`), the definition write never fires and this throw cannot surface. **The pre-flight `probeDefinitionWritability` (#357) detects both branches up-front** so the Definition write picker can drop the opt-in option entirely when every candidate is unwritable, saving the user a wasted decision before the runtime fallback kicks in.