figma-cache-toolchain 2.0.4 → 2.0.7

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.
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Auto-link related cacheKeys into figma-cache/index.json flows using
6
+ * high-confidence heuristics based on raw.json.iconMetrics overlaps.
7
+ *
8
+ * Goal: reduce manual relatedCacheKeys maintenance and support "agent only mentions nodeId".
9
+ *
10
+ * Usage:
11
+ * node scripts/auto-link-related-from-batch.cjs --batch=./figma-e2e-batch.json
12
+ *
13
+ * Options:
14
+ * --flow=<flowId> (default: env FIGMA_DEFAULT_FLOW || "auto-related")
15
+ * --min-shared=<n> (default: 3) min shared iconMetrics nodeIds
16
+ * --min-shared-instance=<n> (default: 2) min shared instance-path nodeIds (contain ';')
17
+ * --min-jaccard=<ratio> (default: 0.35)
18
+ * --suggest-min-shared=<n> (default: 1) suggestion threshold (won't auto-link)
19
+ * --suggest-min-shared-instance=<n> (default: 1)
20
+ * --suggest-min-jaccard=<ratio> (default: 0.12)
21
+ * --suggest-out=<path> (default: figma-cache/reports/runtime/auto-related-suggestions.json)
22
+ * --promote-min-jaccard=<ratio> (default: 0.55) if met (with shared instance), promote to auto-link
23
+ * --promote-min-shared-instance=<n> (default: 2)
24
+ * --dry-run don't write index.json
25
+ *
26
+ * Strict mode:
27
+ * FIGMA_UI_AUTOLINK_STRICT=1 will exit(3) if suggestions are present.
28
+ */
29
+
30
+ const fs = require("fs");
31
+ const path = require("path");
32
+
33
+ const ROOT = process.cwd();
34
+ const INDEX_ABS = path.join(ROOT, "figma-cache", "index.json");
35
+ const DEFAULT_SUGGEST_OUT = path.join(
36
+ ROOT,
37
+ "figma-cache",
38
+ "reports",
39
+ "runtime",
40
+ "auto-related-suggestions.json"
41
+ );
42
+
43
+ function safeReadJson(abs) {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(abs, "utf8"));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function writeJson(abs, value) {
52
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
53
+ fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, "utf8");
54
+ }
55
+
56
+ function normalizeNodeId(input) {
57
+ const value = String(input || "").trim();
58
+ if (!value) return "";
59
+ return value.includes(":") ? value : value.replace(/-/g, ":");
60
+ }
61
+
62
+ function normalizeCacheKey(input) {
63
+ const value = String(input || "").trim();
64
+ if (!value) return "";
65
+ const parts = value.split("#");
66
+ if (parts.length !== 2) return value;
67
+ return `${parts[0]}#${normalizeNodeId(parts[1])}`;
68
+ }
69
+
70
+ function cacheKeyFromItem(item) {
71
+ const fileKey = String(item && item.fileKey ? item.fileKey : "").trim();
72
+ const nodeId = String(item && item.nodeId ? item.nodeId : "").trim();
73
+ if (!fileKey || !nodeId) return "";
74
+ return `${fileKey}#${normalizeNodeId(nodeId)}`;
75
+ }
76
+
77
+ function parseArgs(argv) {
78
+ const out = {
79
+ batch: path.join(ROOT, "figma-e2e-batch.json"),
80
+ flowId: process.env.FIGMA_DEFAULT_FLOW || "auto-related",
81
+ minShared: 2,
82
+ minSharedInstance: 1,
83
+ minJaccard: 0.2,
84
+ suggestMinShared: 1,
85
+ suggestMinSharedInstance: 1,
86
+ suggestMinJaccard: 0.12,
87
+ suggestOut: process.env.FIGMA_UI_AUTOLINK_SUGGEST_OUT || DEFAULT_SUGGEST_OUT,
88
+ promoteMinJaccard: 0.55,
89
+ promoteMinSharedInstance: 2,
90
+ dryRun: false,
91
+ };
92
+ argv.slice(2).forEach((arg) => {
93
+ if (arg.startsWith("--batch=")) out.batch = arg.split("=").slice(1).join("=").trim();
94
+ if (arg.startsWith("--flow=")) out.flowId = arg.split("=").slice(1).join("=").trim();
95
+ if (arg.startsWith("--min-shared=")) out.minShared = Number(arg.split("=").slice(1).join("=").trim());
96
+ if (arg.startsWith("--min-shared-instance="))
97
+ out.minSharedInstance = Number(arg.split("=").slice(1).join("=").trim());
98
+ if (arg.startsWith("--min-jaccard=")) out.minJaccard = Number(arg.split("=").slice(1).join("=").trim());
99
+ if (arg.startsWith("--suggest-min-shared="))
100
+ out.suggestMinShared = Number(arg.split("=").slice(1).join("=").trim());
101
+ if (arg.startsWith("--suggest-min-shared-instance="))
102
+ out.suggestMinSharedInstance = Number(arg.split("=").slice(1).join("=").trim());
103
+ if (arg.startsWith("--suggest-min-jaccard="))
104
+ out.suggestMinJaccard = Number(arg.split("=").slice(1).join("=").trim());
105
+ if (arg.startsWith("--suggest-out=")) out.suggestOut = arg.split("=").slice(1).join("=").trim();
106
+ if (arg.startsWith("--promote-min-jaccard="))
107
+ out.promoteMinJaccard = Number(arg.split("=").slice(1).join("=").trim());
108
+ if (arg.startsWith("--promote-min-shared-instance="))
109
+ out.promoteMinSharedInstance = Number(arg.split("=").slice(1).join("=").trim());
110
+ if (arg === "--dry-run") out.dryRun = true;
111
+ });
112
+ return out;
113
+ }
114
+
115
+ function setFromIconMetrics(raw) {
116
+ const list = raw && Array.isArray(raw.iconMetrics) ? raw.iconMetrics : [];
117
+ const all = new Set();
118
+ const instance = new Set();
119
+ list.forEach((m) => {
120
+ const id = m && m.nodeId ? String(m.nodeId).trim() : "";
121
+ if (!id) return;
122
+ all.add(id);
123
+ if (id.includes(";")) instance.add(id);
124
+ });
125
+ return { all, instance };
126
+ }
127
+
128
+ function intersectionSize(a, b) {
129
+ let c = 0;
130
+ a.forEach((x) => {
131
+ if (b.has(x)) c += 1;
132
+ });
133
+ return c;
134
+ }
135
+
136
+ function jaccard(a, b) {
137
+ const inter = intersectionSize(a, b);
138
+ const union = a.size + b.size - inter;
139
+ return union <= 0 ? 0 : inter / union;
140
+ }
141
+
142
+ function ensureFlow(index, flowId) {
143
+ index.flows = index.flows || {};
144
+ if (!index.flows[flowId]) {
145
+ index.flows[flowId] = {
146
+ id: flowId,
147
+ title: flowId,
148
+ description: "Auto-linked by iconMetrics overlap heuristics",
149
+ createdAt: new Date().toISOString(),
150
+ updatedAt: new Date().toISOString(),
151
+ nodes: [],
152
+ edges: [],
153
+ assumptions: [],
154
+ openQuestions: [],
155
+ };
156
+ }
157
+ return index.flows[flowId];
158
+ }
159
+
160
+ function addNode(flow, cacheKey) {
161
+ flow.nodes = flow.nodes || [];
162
+ if (!flow.nodes.includes(cacheKey)) flow.nodes.push(cacheKey);
163
+ }
164
+
165
+ function hasEdge(flow, from, to, type) {
166
+ const edges = Array.isArray(flow.edges) ? flow.edges : [];
167
+ return edges.some((e) => e && e.from === from && e.to === to && e.type === type);
168
+ }
169
+
170
+ function addEdge(flow, from, to, type, note) {
171
+ flow.edges = flow.edges || [];
172
+ if (hasEdge(flow, from, to, type)) return false;
173
+ flow.edges.push({
174
+ id: `${from}->${to}:${type}:${Date.now()}`,
175
+ from,
176
+ to,
177
+ type,
178
+ note: note || "",
179
+ createdAt: new Date().toISOString(),
180
+ });
181
+ return true;
182
+ }
183
+
184
+ function main() {
185
+ const args = parseArgs(process.argv);
186
+ const batchAbs = path.isAbsolute(args.batch) ? args.batch : path.join(ROOT, args.batch);
187
+ const index = safeReadJson(INDEX_ABS);
188
+ if (!index || !index.items) {
189
+ console.error(`[auto-link-related-from-batch] missing/invalid index: ${INDEX_ABS}`);
190
+ process.exit(2);
191
+ }
192
+ if (!fs.existsSync(batchAbs)) {
193
+ console.error(`[auto-link-related-from-batch] batch not found: ${batchAbs}`);
194
+ process.exit(2);
195
+ }
196
+ const batch = JSON.parse(fs.readFileSync(batchAbs, "utf8"));
197
+ if (!Array.isArray(batch) || batch.length === 0) {
198
+ console.error("[auto-link-related-from-batch] batch must be a non-empty array");
199
+ process.exit(2);
200
+ }
201
+
202
+ const flow = ensureFlow(index, args.flowId);
203
+ let links = 0;
204
+ const suggestions = [];
205
+
206
+ batch.forEach((b) => {
207
+ const primary = normalizeCacheKey(String(b && (b.cacheKey || cacheKeyFromItem(b)) || ""));
208
+ if (!primary) return;
209
+ const primaryItem = index.items[primary];
210
+ if (!primaryItem || !primaryItem.paths || !primaryItem.paths.raw) return;
211
+ const primaryRawAbs = path.isAbsolute(primaryItem.paths.raw)
212
+ ? primaryItem.paths.raw
213
+ : path.join(ROOT, primaryItem.paths.raw);
214
+ const primaryRaw = safeReadJson(primaryRawAbs);
215
+ const primarySet = setFromIconMetrics(primaryRaw);
216
+ if (primarySet.all.size === 0) return;
217
+
218
+ const fileKey = String(primaryItem.fileKey || "").trim();
219
+ const candidates = Object.keys(index.items)
220
+ .filter((k) => k !== primary && index.items[k] && index.items[k].fileKey === fileKey)
221
+ .map((k) => ({ key: k, item: index.items[k] }));
222
+
223
+ candidates.forEach((cand) => {
224
+ const rawPath = cand.item && cand.item.paths ? cand.item.paths.raw : "";
225
+ if (!rawPath) return;
226
+ const rawAbs = path.isAbsolute(rawPath) ? rawPath : path.join(ROOT, rawPath);
227
+ const raw = safeReadJson(rawAbs);
228
+ const set = setFromIconMetrics(raw);
229
+ if (set.all.size === 0) return;
230
+
231
+ const shared = intersectionSize(primarySet.all, set.all);
232
+ const sharedInstance = intersectionSize(primarySet.instance, set.instance);
233
+ const score = jaccard(primarySet.all, set.all);
234
+
235
+ const promote =
236
+ sharedInstance >= args.promoteMinSharedInstance &&
237
+ score >= args.promoteMinJaccard;
238
+
239
+ const ok =
240
+ promote ||
241
+ (shared >= args.minShared &&
242
+ sharedInstance >= args.minSharedInstance &&
243
+ score >= args.minJaccard);
244
+ if (!ok) {
245
+ const suggestOk =
246
+ shared >= args.suggestMinShared &&
247
+ sharedInstance >= args.suggestMinSharedInstance &&
248
+ score >= args.suggestMinJaccard;
249
+ if (suggestOk) {
250
+ suggestions.push({
251
+ from: primary,
252
+ to: cand.key,
253
+ fileKey,
254
+ shared,
255
+ sharedInstance,
256
+ jaccard: Number(score.toFixed(4)),
257
+ });
258
+ }
259
+ return;
260
+ }
261
+
262
+ addNode(flow, primary);
263
+ addNode(flow, cand.key);
264
+ const note = `auto: shared=${shared}, sharedInstance=${sharedInstance}, jaccard=${Number(
265
+ score.toFixed(3)
266
+ )}`;
267
+ if (addEdge(flow, primary, cand.key, "related_auto", note)) {
268
+ links += 1;
269
+ }
270
+ });
271
+ });
272
+
273
+ flow.updatedAt = new Date().toISOString();
274
+ index.updatedAt = new Date().toISOString();
275
+
276
+ if (!args.dryRun) {
277
+ writeJson(INDEX_ABS, index);
278
+ }
279
+ const suggestOutAbs = path.isAbsolute(args.suggestOut) ? args.suggestOut : path.join(ROOT, args.suggestOut);
280
+ writeJson(suggestOutAbs, {
281
+ generatedAt: new Date().toISOString(),
282
+ flowId: args.flowId,
283
+ strict: process.env.FIGMA_UI_AUTOLINK_STRICT === "1",
284
+ thresholds: {
285
+ auto: { minShared: args.minShared, minSharedInstance: args.minSharedInstance, minJaccard: args.minJaccard },
286
+ promote: {
287
+ minSharedInstance: args.promoteMinSharedInstance,
288
+ minJaccard: args.promoteMinJaccard,
289
+ },
290
+ suggest: {
291
+ minShared: args.suggestMinShared,
292
+ minSharedInstance: args.suggestMinSharedInstance,
293
+ minJaccard: args.suggestMinJaccard,
294
+ },
295
+ },
296
+ links,
297
+ suggestions,
298
+ });
299
+
300
+ console.log(
301
+ `[auto-link-related-from-batch] ok links=${links} suggestions=${suggestions.length} flow=${args.flowId} dryRun=${args.dryRun ? "1" : "0"} suggestOut=${suggestOutAbs}`
302
+ );
303
+
304
+ if (process.env.FIGMA_UI_AUTOLINK_STRICT === "1" && suggestions.length) {
305
+ process.exit(3);
306
+ }
307
+ }
308
+
309
+ main();
310
+
@@ -16,6 +16,21 @@ function resolveMaybeAbsolutePath(input) {
16
16
  return path.isAbsolute(input) ? path.normalize(input) : path.join(ROOT, input);
17
17
  }
18
18
 
19
+ /** Batch / CLI `target` paths are relative to the target business project root (not toolchain cwd). */
20
+ function resolveTargetInProject(rawTarget, targetProject) {
21
+ if (!rawTarget) {
22
+ return "";
23
+ }
24
+ const trimmed = String(rawTarget).trim();
25
+ if (!trimmed) {
26
+ return "";
27
+ }
28
+ if (path.isAbsolute(trimmed)) {
29
+ return path.normalize(trimmed);
30
+ }
31
+ return path.join(targetProject, trimmed);
32
+ }
33
+
19
34
  function parseArgs(argv) {
20
35
  const options = {
21
36
  targetProject: "",
@@ -31,9 +46,13 @@ function parseArgs(argv) {
31
46
  autoBootstrapContract: true,
32
47
  autoEnsureOnMiss: false,
33
48
  allowSkeletonWithFigmaMcp: false,
34
- completeness: "layout,text,tokens,interactions,states,accessibility",
49
+ // Default to "best effort" evidence to support 1:1 audits out-of-the-box.
50
+ completeness: "layout,text,tokens,assets,interactions,states,accessibility",
35
51
  batchFile: "",
36
52
  fixLoop: 0,
53
+ emitAgentTaskOnFail: false,
54
+ agentTaskPath: "",
55
+ allowSkippedCodeLevelComparison: false,
37
56
  };
38
57
 
39
58
  argv.forEach((arg) => {
@@ -103,6 +122,18 @@ function parseArgs(argv) {
103
122
  if (arg.startsWith("--fix-loop=")) {
104
123
  const n = Number(arg.split("=").slice(1).join("=").trim());
105
124
  options.fixLoop = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
125
+ return;
126
+ }
127
+ if (arg === "--emit-agent-task-on-fail") {
128
+ options.emitAgentTaskOnFail = true;
129
+ return;
130
+ }
131
+ if (arg.startsWith("--agent-task-path=")) {
132
+ options.agentTaskPath = arg.split("=").slice(1).join("=").trim();
133
+ return;
134
+ }
135
+ if (arg === "--allow-skipped-code-level-comparison") {
136
+ options.allowSkippedCodeLevelComparison = true;
106
137
  }
107
138
  });
108
139
 
@@ -164,6 +195,63 @@ function normalizeSlash(input) {
164
195
  return String(input || "").replace(/\\/g, "/");
165
196
  }
166
197
 
198
+ function writeAgentTask(targetProject, options, payload) {
199
+ const defaultPath = path.join(targetProject, "agent-task.md");
200
+ const taskPath = options.agentTaskPath
201
+ ? resolveMaybeAbsolutePath(options.agentTaskPath)
202
+ : defaultPath;
203
+ const lines = [];
204
+ lines.push("# Agent Task: UI E2E Recovery");
205
+ lines.push("");
206
+ lines.push("## Goal");
207
+ lines.push("Fix target project implementation so ui acceptance passes.");
208
+ lines.push("");
209
+ lines.push("## Constraints");
210
+ lines.push("- Must run ui acceptance after code changes.");
211
+ lines.push("- Do not bypass by lowering thresholds unless explicitly requested.");
212
+ lines.push("- Prioritize real component/contract/recipe fixes.");
213
+ lines.push("");
214
+ lines.push("## Context");
215
+ lines.push(`- targetProject: ${normalizeSlash(payload.targetProject || "")}`);
216
+ lines.push(`- mode: ${payload.mode || "single"}`);
217
+ lines.push(`- profile: ${payload.profile || "standard"}`);
218
+ lines.push(`- autoEnsureOnMiss: ${payload.autoEnsureOnMiss ? "true" : "false"}`);
219
+ lines.push(`- fixLoop: ${Number(payload.fixLoop || 0)}`);
220
+ lines.push("");
221
+ lines.push("## Cases");
222
+ (payload.cases || []).forEach((entry, idx) => {
223
+ lines.push(`### Case ${idx + 1}`);
224
+ lines.push(`- cacheKey: ${entry.cacheKey || ""}`);
225
+ lines.push(`- targetPath: ${normalizeSlash(entry.targetPath || "")}`);
226
+ lines.push(`- reason: ${entry.reason || "unknown"}`);
227
+ if (entry.attemptLogs && entry.attemptLogs.length) {
228
+ lines.push("- attemptLogs:");
229
+ entry.attemptLogs.forEach((log) => {
230
+ lines.push(
231
+ ` - attempt ${log.attempt}: ${log.ok ? "ok" : "fail"}${log.reason ? ` (${log.reason})` : ""}`
232
+ );
233
+ });
234
+ }
235
+ lines.push("");
236
+ });
237
+ lines.push("## Required Command");
238
+ lines.push("Run this command in toolchain repo after fixes:");
239
+ lines.push("");
240
+ lines.push("```bash");
241
+ lines.push(payload.retryCommand || "npm run figma:ui:e2e:cross -- --target-project=<...>");
242
+ lines.push("```");
243
+ lines.push("");
244
+ lines.push("## Completion Criteria");
245
+ lines.push("- e2e command exits with code 0");
246
+ lines.push("- summaryStatus is healthy");
247
+ lines.push("- no unresolved blocking items");
248
+ lines.push("");
249
+
250
+ fs.mkdirSync(path.dirname(taskPath), { recursive: true });
251
+ fs.writeFileSync(taskPath, `${lines.join("\n")}\n`, "utf8");
252
+ return taskPath;
253
+ }
254
+
167
255
  function parseCacheKey(cacheKey) {
168
256
  const value = String(cacheKey || "").trim();
169
257
  const [fileKey, nodeId] = value.split("#");
@@ -256,10 +344,13 @@ function runSingleCase(input, context) {
256
344
  if (!cacheKey) {
257
345
  throw new Error("batch item missing cacheKey or (fileKey + nodeId)");
258
346
  }
259
- const targetPath = item.target ? resolveMaybeAbsolutePath(item.target) : "";
347
+ const targetPath = item.target ? resolveTargetInProject(item.target, targetProject) : "";
260
348
  if (!targetPath) {
261
349
  throw new Error(`batch item ${cacheKey} missing target path`);
262
350
  }
351
+ if (!fs.existsSync(targetPath)) {
352
+ throw new Error(`batch item ${cacheKey} target path does not exist: ${targetPath}`);
353
+ }
263
354
 
264
355
  const acceptArgs = [
265
356
  `--cacheKey=${cacheKey}`,
@@ -301,6 +392,18 @@ function runSingleCase(input, context) {
301
392
  try {
302
393
  acceptanceJson = JSON.parse(acceptanceOutput);
303
394
  } catch {}
395
+ if (
396
+ !options.allowSkippedCodeLevelComparison &&
397
+ acceptanceJson &&
398
+ Array.isArray(acceptanceJson.warnings) &&
399
+ acceptanceJson.warnings.some((entry) =>
400
+ /code-level comparison skipped/i.test(String(entry || ""))
401
+ )
402
+ ) {
403
+ throw new Error(
404
+ `acceptance produced skipped code-level comparison for ${cacheKey}; target linkage is invalid`
405
+ );
406
+ }
304
407
  attemptLogs.push({ attempt, ok: true });
305
408
  return {
306
409
  ok: true,
@@ -339,11 +442,15 @@ function run() {
339
442
  }
340
443
  const isBatchMode = !!options.batchFile;
341
444
  if (!isBatchMode) {
342
- const targetPath = options.target ? resolveMaybeAbsolutePath(options.target) : "";
445
+ const targetPath = options.target ? resolveTargetInProject(options.target, targetProject) : "";
343
446
  if (!targetPath) {
344
447
  console.error("cross-project-e2e failed: --target is required for real component validation");
345
448
  process.exit(FAIL_EXIT_CODE);
346
449
  }
450
+ if (!fs.existsSync(targetPath)) {
451
+ console.error(`cross-project-e2e failed: --target path does not exist: ${targetPath}`);
452
+ process.exit(FAIL_EXIT_CODE);
453
+ }
347
454
  const cacheKey = resolveCacheKey(options);
348
455
  if (!cacheKey) {
349
456
  console.error("cross-project-e2e failed: provide --cacheKey or (--fileKey + --nodeId)");
@@ -352,6 +459,7 @@ function run() {
352
459
  }
353
460
 
354
461
  let tarballPath = "";
462
+ let taskPayload = null;
355
463
  try {
356
464
  tarballPath = npmPackAndGetTarball();
357
465
  runCommand(`npm i -D "${tarballPath}"`, targetProject);
@@ -404,11 +512,26 @@ function run() {
404
512
  caseFailures.push({
405
513
  index: indexNo,
406
514
  cacheKey: entry && (entry.cacheKey || resolveCacheKey(entry)),
515
+ targetPath: entry && entry.target,
516
+ attemptLogs: [],
407
517
  reason: error.message,
408
518
  });
409
519
  }
410
520
  });
411
521
  if (caseFailures.length) {
522
+ taskPayload = {
523
+ targetProject,
524
+ mode: isBatchMode ? "batch" : "single",
525
+ profile: options.profile || "standard",
526
+ autoEnsureOnMiss: options.autoEnsureOnMiss,
527
+ fixLoop: options.fixLoop,
528
+ cases: caseFailures,
529
+ retryCommand: `npm run figma:ui:e2e:cross -- --target-project=${normalizeSlash(
530
+ targetProject
531
+ )}${options.batchFile ? ` --batch-file=${normalizeSlash(options.batchFile)}` : ""}${
532
+ options.autoEnsureOnMiss ? " --auto-ensure-on-miss" : ""
533
+ }${options.fixLoop ? ` --fix-loop=${options.fixLoop}` : ""}`,
534
+ };
412
535
  throw new Error(`batch cases failed: ${JSON.stringify(caseFailures)}`);
413
536
  }
414
537
 
@@ -424,9 +547,9 @@ function run() {
424
547
  completeness: options.completeness,
425
548
  tarballPath,
426
549
  reports: {
427
- preflight: path.join(reportBase, "ui-preflight-report.json"),
428
- audit: path.join(reportBase, "ui-1to1-report.json"),
429
- summary: path.join(reportBase, "ui-quality-summary.json"),
550
+ preflight: path.join(reportBase, "runtime", "ui-preflight-report.json"),
551
+ audit: path.join(reportBase, "runtime", "ui-1to1-report.json"),
552
+ summary: path.join(reportBase, "runtime", "ui-quality-summary.json"),
430
553
  },
431
554
  cases: caseResults,
432
555
  };
@@ -438,8 +561,42 @@ function run() {
438
561
  }
439
562
  console.log(JSON.stringify(output, null, 2));
440
563
  } catch (error) {
564
+ let taskPath = "";
565
+ if (options.emitAgentTaskOnFail) {
566
+ try {
567
+ const payload =
568
+ taskPayload ||
569
+ ({
570
+ targetProject,
571
+ mode: isBatchMode ? "batch" : "single",
572
+ profile: options.profile || "standard",
573
+ autoEnsureOnMiss: options.autoEnsureOnMiss,
574
+ fixLoop: options.fixLoop,
575
+ cases: [
576
+ {
577
+ cacheKey: resolveCacheKey(options),
578
+ targetPath: options.target,
579
+ reason: error.message,
580
+ },
581
+ ],
582
+ retryCommand: `npm run figma:ui:e2e:cross -- --target-project=${normalizeSlash(
583
+ targetProject
584
+ )}${options.batchFile ? ` --batch-file=${normalizeSlash(options.batchFile)}` : ""}${
585
+ options.cacheKey ? ` --cacheKey=${options.cacheKey}` : ""
586
+ }${options.fileKey ? ` --fileKey=${options.fileKey}` : ""}${
587
+ options.nodeId ? ` --nodeId=${options.nodeId}` : ""
588
+ }${options.target ? ` --target=${normalizeSlash(options.target)}` : ""}${
589
+ options.autoEnsureOnMiss ? " --auto-ensure-on-miss" : ""
590
+ }${options.fixLoop ? ` --fix-loop=${options.fixLoop}` : ""} --emit-agent-task-on-fail`,
591
+ });
592
+ taskPath = writeAgentTask(targetProject, options, payload);
593
+ } catch {}
594
+ }
441
595
  console.error("cross-project-e2e failed:");
442
596
  console.error(`- ${error.message}`);
597
+ if (taskPath) {
598
+ console.error(`- agent task emitted: ${normalizeSlash(taskPath)}`);
599
+ }
443
600
  process.exit(FAIL_EXIT_CODE);
444
601
  } finally {
445
602
  if (tarballPath && !options.keepPackage) {