@nick848/fet 1.1.7 → 1.1.8

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/dist/cli/index.js CHANGED
@@ -188,18 +188,18 @@ function toGitNexusState(detection, previous) {
188
188
  };
189
189
  }
190
190
  async function inspectGitNexusGraph(projectRoot, env = process.env) {
191
- const relative4 = env.FET_GITNEXUS_GRAPH_PATH?.trim() || DEFAULT_GRAPH_PATH;
192
- const graphPath = join5(projectRoot, relative4);
191
+ const relative5 = env.FET_GITNEXUS_GRAPH_PATH?.trim() || DEFAULT_GRAPH_PATH;
192
+ const graphPath = join5(projectRoot, relative5);
193
193
  try {
194
194
  const info = await stat2(graphPath);
195
195
  return {
196
- graphPath: relative4,
196
+ graphPath: relative5,
197
197
  graphExists: true,
198
198
  lastIndexedAt: info.mtime.toISOString()
199
199
  };
200
200
  } catch {
201
201
  return {
202
- graphPath: relative4,
202
+ graphPath: relative5,
203
203
  graphExists: false,
204
204
  lastIndexedAt: null
205
205
  };
@@ -2082,7 +2082,17 @@ function renderFetConfig(scan, language = "zh-CN") {
2082
2082
  },
2083
2083
  figmaGuard: {
2084
2084
  enabled: true,
2085
+ mode: "require_before_ui",
2085
2086
  onUncertainty: "stop_and_ask"
2087
+ },
2088
+ uiDisplayContract: {
2089
+ enabled: true
2090
+ },
2091
+ specLanguage: {
2092
+ style: "layered_bilingual",
2093
+ canonical: "en",
2094
+ notesLocale: "zh-CN",
2095
+ maintainZhNotesOnUpdate: true
2086
2096
  }
2087
2097
  }
2088
2098
  });
@@ -2248,6 +2258,75 @@ var FIGMA_URL_PATTERN = /https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\
2248
2258
  function figmaStopHandoffRelativePath(changeId) {
2249
2259
  return `openspec/changes/${changeId}/.fet/figma-stop.md`;
2250
2260
  }
2261
+ function figmaApplyInstructionsRelativePath(changeId) {
2262
+ return `openspec/changes/${changeId}/.fet/figma-apply-instructions.md`;
2263
+ }
2264
+ function renderFigmaRequireBeforeUiBody(language, changeId) {
2265
+ const stopPath = figmaStopHandoffRelativePath(changeId);
2266
+ if (language === "en") {
2267
+ return `## Mandatory before any UI implementation
2268
+
2269
+ Complete these steps **before** writing or editing UI code (components, pages, styles, layout):
2270
+
2271
+ 1. Read \`${stopPath}\` for detected Figma links and stop rules.
2272
+ 2. Use **Figma MCP/API** (or an approved Figma tool) to read every linked frame/node referenced by this change.
2273
+ 3. In your reply, briefly list design facts you confirmed from Figma (frames, colors, typography, spacing, components, states).
2274
+ 4. Only then implement UI tasks from \`tasks.md\`.
2275
+
2276
+ ## Forbidden
2277
+
2278
+ - Implementing or restyling UI without reading Figma when this change references design links
2279
+ - Filling gaps with "common UI patterns", guessed pixel values, or invented tokens
2280
+ - Marking UI tasks complete while design input is still unclear
2281
+
2282
+ ## When to stop and talk to the user
2283
+
2284
+ Follow the stop rules in \`${stopPath}\`. Pause implementation, explain what failed, and ask for a viewable link, screenshots, or explicit permission to infer\u2014do not continue UI work until the user answers or says to continue.`;
2285
+ }
2286
+ return `## \u5B9E\u65BD\u4EFB\u4F55 UI \u4EE3\u7801\u4E4B\u524D\u5FC5\u987B\u5B8C\u6210
2287
+
2288
+ \u5728\u7F16\u5199\u6216\u4FEE\u6539 UI \u4EE3\u7801\uFF08\u7EC4\u4EF6\u3001\u9875\u9762\u3001\u6837\u5F0F\u3001\u5E03\u5C40\uFF09**\u4E4B\u524D**\uFF0C\u6309\u987A\u5E8F\u5B8C\u6210\uFF1A
2289
+
2290
+ 1. \u9605\u8BFB \`${stopPath}\` \u4E2D\u7684 Figma \u94FE\u63A5\u4E0E\u505C\u6B62\u89C4\u5219\u3002
2291
+ 2. \u4F7F\u7528 **Figma MCP/API**\uFF08\u6216\u5DF2\u914D\u7F6E\u7684 Figma \u5DE5\u5177\uFF09\u8BFB\u53D6\u672C change \u5F15\u7528\u7684\u6BCF\u4E2A\u753B\u677F/\u8282\u70B9\u3002
2292
+ 3. \u5728\u56DE\u590D\u4E2D\u7B80\u8981\u5217\u51FA\u5DF2\u4ECE Figma \u786E\u8BA4\u7684\u8BBE\u8BA1\u4E8B\u5B9E\uFF08\u753B\u677F\u3001\u989C\u8272\u3001\u5B57\u53F7\u3001\u95F4\u8DDD\u3001\u7EC4\u4EF6\u3001\u72B6\u6001\u7B49\uFF09\u3002
2293
+ 4. \u5B8C\u6210\u4EE5\u4E0A\u6B65\u9AA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD UI \u4EFB\u52A1\u3002
2294
+
2295
+ ## \u7981\u6B62
2296
+
2297
+ - \u672C change \u5DF2\u5F15\u7528\u8BBE\u8BA1\u7A3F\u65F6\uFF0C\u672A\u8BFB Figma \u5C31\u5B9E\u73B0\u6216\u6539 UI \u6837\u5F0F
2298
+ - \u7528\u300C\u5E38\u89C1 UI \u505A\u6CD5\u300D\u3001\u731C\u6D4B\u7684\u50CF\u7D20\u503C\u6216\u81EA\u521B token \u586B\u8865\u7A7A\u767D
2299
+ - \u8BBE\u8BA1\u8F93\u5165\u4ECD\u4E0D\u6E05\u6670\u5C31\u628A UI \u4EFB\u52A1\u6807\u4E3A\u5B8C\u6210
2300
+
2301
+ ## \u4F55\u65F6\u5FC5\u987B\u505C\u4E0B\u5E76\u4E0E\u7528\u6237\u6C9F\u901A
2302
+
2303
+ \u9075\u5B88 \`${stopPath}\` \u4E2D\u7684\u505C\u6B62\u89C4\u5219\u3002\u6682\u505C\u5B9E\u65BD\uFF0C\u8BF4\u660E\u5361\u5728\u54EA\u4E00\u6B65\uFF0C\u5411\u7528\u6237\u7D22\u8981\u53EF\u67E5\u770B\u7684\u94FE\u63A5\u3001\u622A\u56FE\uFF0C\u6216\u660E\u786E\u5141\u8BB8\u6309\u67D0\u89C4\u5219\u63A8\u65AD\uFF1B\u5728\u7528\u6237\u56DE\u7B54\u6216\u660E\u786E\u8868\u793A\u7EE7\u7EED\u4E4B\u524D\uFF0C\u4E0D\u8981\u7EE7\u7EED UI \u5B9E\u73B0\u3002`;
2304
+ }
2305
+ function renderChangeFigmaApplyInstructions(options) {
2306
+ const linkList = options.urls.map((url) => `- ${url}`).join("\n");
2307
+ const title = options.language === "en" ? "Figma apply gate (this change)" : "Figma \u5B9E\u65BD\u95E8\u7981\uFF08\u672C change\uFF09";
2308
+ const intro = options.language === "en" ? "FET detected Figma links in this change. **UI work must follow the design**\u2014read Figma first, do not invent styles. If access or design details fail, stop and coordinate with the user." : "FET \u5728\u672C change \u4E2D\u68C0\u6D4B\u5230 Figma \u94FE\u63A5\u3002**UI \u5FC5\u987B\u6309\u8BBE\u8BA1\u7A3F\u5B9E\u73B0**\u2014\u5148\u8BFB Figma\uFF0C\u7981\u6B62\u81EA\u521B\u6837\u5F0F\uFF1B\u8BBF\u95EE\u5931\u8D25\u6216\u7EC6\u8282\u4E0D\u6E05\u65F6\u505C\u4E0B\u5E76\u4E0E\u7528\u6237\u6C9F\u901A\u3002";
2309
+ return `---
2310
+ schemaVersion: 1
2311
+ fetVersion: ${FET_VERSION}
2312
+ generatedAt: ${options.generatedAt}
2313
+ changeId: ${options.changeId}
2314
+ purpose: figma-apply
2315
+ ---
2316
+
2317
+ # ${title}
2318
+
2319
+ ${intro}
2320
+
2321
+ ## Detected Figma links
2322
+
2323
+ ${linkList}
2324
+
2325
+ ${renderFigmaRequireBeforeUiBody(options.language, options.changeId)}
2326
+
2327
+ ${renderFigmaStopProtocolBody(options.language)}
2328
+ `;
2329
+ }
2251
2330
  function renderFigmaStopProtocolBody(language) {
2252
2331
  if (language === "en") {
2253
2332
  return `## Stop immediately (do not write or change UI code) when
@@ -2306,7 +2385,7 @@ ${language === "en" ? "Apply when the user shares a Figma link, asks to implemen
2306
2385
 
2307
2386
  ${renderFigmaStopProtocolBody(language)}
2308
2387
 
2309
- ${language === "en" ? "If this change has `openspec/changes/<change-id>/.fet/figma-stop.md`, read it for detected links and repeat the same stop rules." : "\u82E5\u5F53\u524D change \u5B58\u5728 `openspec/changes/<change-id>/.fet/figma-stop.md`\uFF0C\u8BF7\u5148\u9605\u8BFB\u5176\u4E2D\u7684\u94FE\u63A5\u5217\u8868\uFF0C\u5E76\u9075\u5B88\u76F8\u540C\u7684\u505C\u6B62\u89C4\u5219\u3002"}
2388
+ ${language === "en" ? "If this change has `openspec/changes/<change-id>/.fet/figma-apply-instructions.md`, follow it before UI work. Also read `figma-stop.md` in the same folder for links and stop rules." : "\u82E5\u5F53\u524D change \u5B58\u5728 `openspec/changes/<change-id>/.fet/figma-apply-instructions.md`\uFF0C\u5B9E\u65BD UI \u524D\u5FC5\u987B\u9075\u5B88\uFF1B\u540C\u76EE\u5F55\u7684 `figma-stop.md` \u542B\u94FE\u63A5\u5217\u8868\u4E0E\u505C\u6B62\u89C4\u5219\u3002"}
2310
2389
  `;
2311
2390
  }
2312
2391
  function renderCodexFigmaStopGuide(language) {
@@ -2354,6 +2433,303 @@ function renderFigmaStopNextStep(changeId, language) {
2354
2433
  const path = figmaStopHandoffRelativePath(changeId);
2355
2434
  return language === "en" ? `Before UI implementation, read ${path}. If Figma access fails or design details are unclear, stop and ask the user\u2014do not guess styles.` : `\u5B9E\u65BD UI \u524D\u9605\u8BFB ${path}\u3002Figma \u8BBF\u95EE\u5931\u8D25\u6216\u8BBE\u8BA1\u7EC6\u8282\u4E0D\u660E\u786E\u65F6\u7ACB\u5373\u505C\u6B62\u5E76\u5411\u7528\u6237\u63D0\u95EE\uFF0C\u4E0D\u8981\u731C\u6D4B\u6837\u5F0F\u3002`;
2356
2435
  }
2436
+ function renderFigmaApplyNextSteps(changeId, language, mode) {
2437
+ const applyPath = figmaApplyInstructionsRelativePath(changeId);
2438
+ const stopPath = figmaStopHandoffRelativePath(changeId);
2439
+ if (mode === "require_before_ui") {
2440
+ return language === "en" ? [
2441
+ `Before any UI task: read and follow ${applyPath} (mandatory). Use Figma MCP/API for every linked frame\u2014do not invent styles.`,
2442
+ `If Figma access fails or design details are unclear, stop per ${stopPath} and ask the user before continuing.`,
2443
+ `After Figma is confirmed, read openspec/changes/${changeId}/tasks.md and implement pending tasks.`
2444
+ ] : [
2445
+ `\u5B9E\u65BD\u4EFB\u4F55 UI \u4EFB\u52A1\u524D\uFF1A\u5FC5\u987B\u9605\u8BFB\u5E76\u9075\u5B88 ${applyPath}\uFF1B\u7528 Figma MCP/API \u8BFB\u53D6\u6BCF\u4E2A\u94FE\u63A5\u7684\u753B\u677F\uFF0C\u7981\u6B62\u81EA\u521B\u6837\u5F0F\u3002`,
2446
+ `Figma \u8BBF\u95EE\u5931\u8D25\u6216\u8BBE\u8BA1\u7EC6\u8282\u4E0D\u6E05\uFF1A\u6309 ${stopPath} \u7ACB\u5373\u505C\u6B62\u5E76\u5411\u7528\u6237\u63D0\u95EE\uFF0C\u786E\u8BA4\u540E\u518D\u7EE7\u7EED\u3002`,
2447
+ `\u8BBE\u8BA1\u786E\u8BA4\u540E\uFF0C\u518D\u9605\u8BFB openspec/changes/${changeId}/tasks.md \u5E76\u5B9E\u65BD\u5F85\u529E\u4EFB\u52A1\u3002`
2448
+ ];
2449
+ }
2450
+ return [renderFigmaStopNextStep(changeId, language)];
2451
+ }
2452
+
2453
+ // src/templates/ui-display-contract.ts
2454
+ import { stringify as stringify2 } from "yaml";
2455
+ function uiDisplayContractRelativePath(changeId) {
2456
+ return `openspec/changes/${changeId}/.fet/ui-display-contract.yaml`;
2457
+ }
2458
+ function uiFieldApplyInstructionsRelativePath(changeId) {
2459
+ return `openspec/changes/${changeId}/.fet/ui-field-apply-instructions.md`;
2460
+ }
2461
+ function renderUiDisplayContractYaml(doc) {
2462
+ return stringify2(doc);
2463
+ }
2464
+ function renderUiFieldApplyInstructions(options) {
2465
+ const title = options.language === "en" ? "UI field apply gate (this change)" : "UI \u5B57\u6BB5\u5B9E\u65BD\u95E8\u7981\uFF08\u672C change\uFF09";
2466
+ const intro = options.language === "en" ? "FET generated a **UI display contract** draft. API docs are a superset; **only fields listed under `displayFields` may appear in the UI**. Figma defines what is visible; the contract records the binding." : "FET \u5DF2\u751F\u6210 **UI \u5C55\u793A\u5951\u7EA6** \u8349\u6848\u3002\u63A5\u53E3\u6587\u6863\u662F\u8D85\u96C6\uFF1B**\u53EA\u6709 `displayFields` \u4E2D\u7684\u5B57\u6BB5\u5141\u8BB8\u51FA\u73B0\u5728\u754C\u9762\u4E0A**\u3002Figma \u51B3\u5B9A\u53EF\u89C1\u6027\uFF1B\u5951\u7EA6\u8BB0\u5F55\u7ED1\u5B9A\u5173\u7CFB\u3002";
2467
+ const steps = options.language === "en" ? `## Before binding API data to UI
2468
+
2469
+ 1. Read and update \`${options.contractPath}\` (confirm or fill \`displayFields\` per screen).
2470
+ 2. ${options.hasFigma ? "Use Figma MCP/API to list visible labels, columns, and form fields; map them to API field names in `displayFields`." : "Infer visible fields from design artifacts in this change; document them in `displayFields`."}
2471
+ 3. Move API fields that exist in docs but are **not** on the design into \`omittedFromUi\` (or \`hiddenButUsed\` if needed for logic without rendering).
2472
+ 4. Resolve every entry in \`needsReview\` with the user before implementation.
2473
+
2474
+ ## Forbidden
2475
+
2476
+ - Rendering every property from an API response type because it appears in OpenAPI/Swagger
2477
+ - Using \`Object.keys(data)\`, schema iteration, or generic "show all fields" table/form generators for user-facing UI
2478
+ - Adding columns or form inputs for fields not in \`displayFields\` unless the user explicitly expands the contract
2479
+
2480
+ ## Data vs presentation
2481
+
2482
+ - **Data layer**: types, fetch, normalize may use the full API model.
2483
+ - **Presentation layer**: bind only \`displayFields\`; never surface \`omittedFromUi\` fields in lists, forms, cards, or detail views.` : `## \u5C06 API \u6570\u636E\u7ED1\u5B9A\u5230 UI \u4E4B\u524D
2484
+
2485
+ 1. \u9605\u8BFB\u5E76\u66F4\u65B0 \`${options.contractPath}\`\uFF08\u6309\u5C4F\u786E\u8BA4\u6216\u586B\u5199 \`displayFields\`\uFF09\u3002
2486
+ 2. ${options.hasFigma ? "\u7528 Figma MCP/API \u5217\u51FA\u53EF\u89C1\u6587\u6848\u3001\u8868\u683C\u5217\u3001\u8868\u5355\u9879\uFF1B\u6620\u5C04\u5230 API \u5B57\u6BB5\u540D\u5E76\u5199\u5165 `displayFields`\u3002" : "\u4ECE\u672C change \u7684\u8BBE\u8BA1\u4EA7\u7269\u63A8\u65AD\u53EF\u89C1\u5B57\u6BB5\uFF0C\u5199\u5165 `displayFields`\u3002"}
2487
+ 3. \u63A5\u53E3\u6587\u6863\u6709\u3001\u8BBE\u8BA1\u7A3F**\u6CA1\u6709**\u7684\u5B57\u6BB5\u653E\u5165 \`omittedFromUi\`\uFF08\u4EC5\u903B\u8F91\u4F7F\u7528\u4E14\u4E0D\u53EF\u89C1\u5219\u653E\u5165 \`hiddenButUsed\`\uFF09\u3002
2488
+ 4. \`needsReview\` \u4E2D\u6BCF\u4E00\u9879\u987B\u5728\u5B9E\u65BD\u524D\u4E0E\u7528\u6237\u786E\u8BA4\u3002
2489
+
2490
+ ## \u7981\u6B62
2491
+
2492
+ - \u56E0 OpenAPI/Swagger \u91CC\u6709\u5B57\u6BB5\u5C31\u5728\u9875\u9762\u4E0A\u5168\u90E8\u5C55\u793A
2493
+ - \u5BF9\u7528\u6237\u53EF\u89C1 UI \u4F7F\u7528 \`Object.keys(data)\`\u3001\u904D\u5386 schema \u6216\u300C\u81EA\u52A8\u5C55\u793A\u5168\u90E8\u5B57\u6BB5\u300D\u7684\u8868\u683C/\u8868\u5355
2494
+ - \u5728 \`displayFields\` \u672A\u5217\u5165\u65F6\u65B0\u589E\u5217\u3001\u8868\u5355\u9879\uFF08\u9664\u975E\u7528\u6237\u660E\u786E\u6269\u5C55\u5951\u7EA6\uFF09
2495
+
2496
+ ## \u6570\u636E\u5C42\u4E0E\u5C55\u793A\u5C42
2497
+
2498
+ - **\u6570\u636E\u5C42**\uFF1A\u7C7B\u578B\u3001\u8BF7\u6C42\u3001normalize \u53EF\u4F7F\u7528\u5B8C\u6574 API \u6A21\u578B\u3002
2499
+ - **\u5C55\u793A\u5C42**\uFF1A\u53EA\u7ED1\u5B9A \`displayFields\`\uFF1B\`omittedFromUi\` \u4E2D\u7684\u5B57\u6BB5\u4E0D\u5F97\u51FA\u73B0\u5728\u5217\u8868\u3001\u8868\u5355\u3001\u5361\u7247\u3001\u8BE6\u60C5\u7B49\u53EF\u89C1\u533A\u57DF\u3002`;
2500
+ return `---
2501
+ schemaVersion: 1
2502
+ fetVersion: ${FET_VERSION}
2503
+ generatedAt: ${options.generatedAt}
2504
+ changeId: ${options.changeId}
2505
+ purpose: ui-field-apply
2506
+ ---
2507
+
2508
+ # ${title}
2509
+
2510
+ ${intro}
2511
+
2512
+ ${steps}
2513
+ `;
2514
+ }
2515
+ function renderUiDisplayContractApplyNextSteps(changeId, language) {
2516
+ const contractPath = uiDisplayContractRelativePath(changeId);
2517
+ const applyPath = uiFieldApplyInstructionsRelativePath(changeId);
2518
+ if (language === "en") {
2519
+ return [
2520
+ `Read and confirm ${contractPath} before UI that binds API data (update displayFields from Figma; move extras to omittedFromUi).`,
2521
+ `Follow ${applyPath}: API schema is not a UI checklist\u2014do not render undocumented fields.`,
2522
+ `After the contract is confirmed, implement tasks from openspec/changes/${changeId}/tasks.md.`
2523
+ ];
2524
+ }
2525
+ return [
2526
+ `\u5728\u7ED1\u5B9A API \u7684 UI \u5B9E\u65BD\u524D\uFF1A\u9605\u8BFB\u5E76\u786E\u8BA4 ${contractPath}\uFF08\u6309 Figma \u586B\u5199 displayFields\uFF1B\u591A\u4F59\u5B57\u6BB5\u653E\u5165 omittedFromUi\uFF09\u3002`,
2527
+ `\u9075\u5B88 ${applyPath}\uFF1A\u63A5\u53E3\u6587\u6863\u4E0D\u662F UI \u6E05\u5355\uFF0C\u7981\u6B62\u5C55\u793A\u5951\u7EA6\u672A\u5217\u51FA\u7684\u5B57\u6BB5\u3002`,
2528
+ `\u5951\u7EA6\u786E\u8BA4\u540E\uFF0C\u518D\u5B9E\u65BD openspec/changes/${changeId}/tasks.md \u4E2D\u7684\u4EFB\u52A1\u3002`
2529
+ ];
2530
+ }
2531
+ function renderUiDisplayContractGuardrail(language) {
2532
+ if (language === "en") {
2533
+ return "- When a change has API docs and UI (especially with Figma), read `openspec/changes/<change-id>/.fet/ui-display-contract.yaml`. In `specs/**/spec.md`, add the UI display contract Requirement (see planning guide); API fields not in `displayFields` MUST NOT be rendered.";
2534
+ }
2535
+ return "- change \u542B\u63A5\u53E3\u6587\u6863\u4E14\u6D89\u53CA UI\uFF08\u5C24\u5176\u6709 Figma\uFF09\u65F6\uFF0C\u9605\u8BFB `openspec/changes/<change-id>/.fet/ui-display-contract.yaml`\uFF1B\u5728 `specs/**/spec.md` \u4E2D\u5199\u5165 UI \u5C55\u793A\u5951\u7EA6 Requirement\uFF08\u89C1\u89C4\u5212\u6307\u5F15\uFF09\uFF1B\u672A\u5217\u5165 `displayFields` \u7684\u63A5\u53E3\u5B57\u6BB5\u7981\u6B62\u51FA\u73B0\u5728\u754C\u9762\u4E0A\u3002";
2536
+ }
2537
+ function renderPlanningArtifactUiContractBlock(language, changeId) {
2538
+ const contractPath = uiDisplayContractRelativePath(changeId);
2539
+ if (language === "en") {
2540
+ return `## UI display contract (when API + UI)
2541
+
2542
+ If this change renders API-backed UI (especially with Figma links), \`${contractPath}\` may exist or will be generated on \`fet apply\`. When writing \`specs/<capability>/spec.md\`, **add** this requirement (adjust screen ids as needed):
2543
+
2544
+ \`\`\`markdown
2545
+ ### Requirement: UI displays only contracted fields
2546
+ <!-- \u4E2D\u6587\uFF1A\u754C\u9762\u53EA\u5C55\u793A ui-display-contract.yaml \u4E2D displayFields \u5217\u51FA\u7684\u5B57\u6BB5\uFF1B\u63A5\u53E3\u6587\u6863\u5176\u4F59\u5B57\u6BB5\u4EC5\u7528\u4E8E\u7C7B\u578B/\u6570\u636E\u5C42\uFF0C\u4E0D\u5F97\u51FA\u73B0\u5728\u5217\u8868\u3001\u8868\u5355\u3001\u8BE6\u60C5\u7B49\u53EF\u89C1 UI -->
2547
+
2548
+ The UI SHALL only render fields listed under \`displayFields\` for each screen in \`${contractPath}\`.
2549
+ Fields documented in API/OpenAPI but absent from the design MUST be listed under \`omittedFromUi\` and MUST NOT appear in user-visible UI.
2550
+ The system MAY use \`hiddenButUsed\` fields in logic without displaying them.
2551
+ On conflict between API schema completeness and Figma/contract, **Figma + ui-display-contract win** for presentation.
2552
+ \`\`\`
2553
+
2554
+ Update \`displayFields\` / \`omittedFromUi\` in the contract in the **same edit** when requirements change.`;
2555
+ }
2556
+ return `## UI \u5C55\u793A\u5951\u7EA6\uFF08\u63A5\u53E3 + UI \u65F6\uFF09
2557
+
2558
+ \u82E5\u672C change \u5B9E\u65BD\u7ED1\u5B9A\u63A5\u53E3\u7684 UI\uFF08\u5C24\u5176\u542B Figma \u94FE\u63A5\uFF09\uFF0C\`${contractPath}\` \u53EF\u80FD\u5DF2\u5B58\u5728\u6216\u5728 \`fet apply\` \u65F6\u751F\u6210\u3002\u7F16\u5199 \`specs/<capability>/spec.md\` \u65F6**\u5FC5\u987B\u52A0\u5165**\u5982\u4E0B Requirement\uFF08\u6309\u5B9E\u9645 screen \u8C03\u6574\uFF09\uFF1A
2559
+
2560
+ \`\`\`markdown
2561
+ ### Requirement: UI displays only contracted fields
2562
+ <!-- \u4E2D\u6587\uFF1A\u754C\u9762\u53EA\u5C55\u793A ui-display-contract.yaml \u4E2D displayFields \u5217\u51FA\u7684\u5B57\u6BB5\uFF1B\u63A5\u53E3\u6587\u6863\u5176\u4F59\u5B57\u6BB5\u4EC5\u7528\u4E8E\u7C7B\u578B/\u6570\u636E\u5C42\uFF0C\u4E0D\u5F97\u51FA\u73B0\u5728\u5217\u8868\u3001\u8868\u5355\u3001\u8BE6\u60C5\u7B49\u53EF\u89C1 UI -->
2563
+
2564
+ The UI SHALL only render fields listed under \`displayFields\` for each screen in \`${contractPath}\`.
2565
+ Fields documented in API/OpenAPI but absent from the design MUST be listed under \`omittedFromUi\` and MUST NOT appear in user-visible UI.
2566
+ The system MAY use \`hiddenButUsed\` fields in logic without displaying them.
2567
+ On conflict between API schema completeness and Figma/contract, **Figma + ui-display-contract win** for presentation.
2568
+ \`\`\`
2569
+
2570
+ \u4FEE\u6539 Requirement \u65F6\uFF0C**\u540C\u4E00\u6B21\u7F16\u8F91**\u987B\u540C\u6B65\u66F4\u65B0\u5951\u7EA6\u4E2D\u7684 \`displayFields\` / \`omittedFromUi\`\u3002`;
2571
+ }
2572
+ function renderCursorUiDisplayContractRule(language) {
2573
+ const description = language === "en" ? "UI display contract: API schemas are not UI checklists; only displayFields may render" : "UI \u5C55\u793A\u5951\u7EA6\uFF1A\u63A5\u53E3\u6587\u6863\u4E0D\u662F UI \u6E05\u5355\uFF0C\u4EC5 displayFields \u53EF\u5C55\u793A";
2574
+ return `<!-- FET:MANAGED
2575
+ schemaVersion: 1
2576
+ fetVersion: ${FET_VERSION}
2577
+ generator: cursor-adapter
2578
+ adapterVersion: 1
2579
+ FET:END -->
2580
+
2581
+ ---
2582
+ description: ${description}
2583
+ alwaysApply: false
2584
+ ---
2585
+
2586
+ ${language === "en" ? "Apply when implementing UI that binds API responses, or when `openspec/changes/<change-id>/.fet/ui-display-contract.yaml` exists." : "\u5728\u5B9E\u65BD\u7ED1\u5B9A\u63A5\u53E3\u54CD\u5E94\u7684 UI \u65F6\uFF0C\u6216\u5B58\u5728 `openspec/changes/<change-id>/.fet/ui-display-contract.yaml` \u65F6\u9002\u7528\u3002"}
2587
+
2588
+ ${renderUiDisplayContractGuardrail(language)}
2589
+
2590
+ ${language === "en" ? "Also read `ui-field-apply-instructions.md` in the same `.fet/` folder before UI work." : "\u5B9E\u65BD UI \u524D\u540C\u65F6\u9605\u8BFB\u540C\u76EE\u5F55 `.fet/ui-field-apply-instructions.md`\u3002"}
2591
+ `;
2592
+ }
2593
+ function renderCodexUiDisplayContractGuide(language) {
2594
+ return `<!-- FET:MANAGED
2595
+ schemaVersion: 1
2596
+ fetVersion: ${FET_VERSION}
2597
+ generator: codex-adapter
2598
+ adapterVersion: 1
2599
+ FET:END -->
2600
+
2601
+ # UI display contract (Codex)
2602
+
2603
+ ${renderUiDisplayContractGuardrail(language)}
2604
+
2605
+ ${language === "en" ? "Read `openspec/changes/<change-id>/.fet/ui-display-contract.yaml` and `ui-field-apply-instructions.md` when applying UI tasks that use API data." : "\u5BF9\u4F7F\u7528\u63A5\u53E3\u6570\u636E\u7684 UI \u4EFB\u52A1\uFF0C\u9605\u8BFB `openspec/changes/<change-id>/.fet/ui-display-contract.yaml` \u4E0E `ui-field-apply-instructions.md`\u3002"}
2606
+ `;
2607
+ }
2608
+
2609
+ // src/templates/spec-language.ts
2610
+ function renderSpecArtifactGuardrail(language) {
2611
+ if (language === "en") {
2612
+ return "- When creating or editing `openspec/changes/<change-id>/specs/**/spec.md` (or merging into `openspec/specs/**/spec.md`), keep English Requirements/Scenario headings and normative text; add or update the paired `<!-- \u4E2D\u6587\uFF1A... -->` note on the same edit\u2014never leave stale Chinese notes.";
2613
+ }
2614
+ return "- \u521B\u5EFA\u6216\u4FEE\u6539 `openspec/changes/<change-id>/specs/**/spec.md`\uFF08\u6216\u5408\u5E76\u5230 `openspec/specs/**/spec.md`\uFF09\u65F6\uFF1ARequirements/Scenario \u6807\u9898\u4E0E normative \u53E5\u4FDD\u6301\u82F1\u6587\uFF1B**\u540C\u4E00\u6B21\u7F16\u8F91**\u5FC5\u987B\u65B0\u589E\u6216\u66F4\u65B0\u5BF9\u5E94\u7684 `<!-- \u4E2D\u6587\uFF1A... -->` \u8BF4\u660E\uFF0C\u7981\u6B62\u7559\u4E0B\u8FC7\u65F6\u4E2D\u6587\u6CE8\u91CA\u3002";
2615
+ }
2616
+ function renderSpecLanguagePolicyBody(language) {
2617
+ if (language === "en") {
2618
+ return `## Layered bilingual spec (canonical English + Chinese notes)
2619
+
2620
+ This project uses **layered_bilingual** OpenSpec specs (see \`fet.specLanguage\` in \`openspec/config.yaml\`).
2621
+
2622
+ ### Structure
2623
+
2624
+ - **English (canonical)**: Keep OpenSpec section titles (\`## Requirements\`, \`### Requirement:\`, \`#### Scenario:\`) and normative sentences (SHALL/MUST, acceptance criteria) in English.
2625
+ - **Chinese (human notes)**: Immediately after each \`### Requirement:\` line (before the English body), add one HTML comment:
2626
+
2627
+ \`\`\`markdown
2628
+ ### Requirement: User can export report
2629
+ <!-- \u4E2D\u6587\uFF1A\u767B\u5F55\u7528\u6237\u53EF\u5728\u62A5\u8868\u9875\u5BFC\u51FA PDF\uFF1B\u9700\u5177\u5907 export \u6743\u9650\uFF1B\u5931\u8D25\u65F6\u63D0\u793A\u539F\u56E0 -->
2630
+
2631
+ The system SHALL ...
2632
+ \`\`\`
2633
+
2634
+ Optionally add \`<!-- \u4E2D\u6587\uFF1A... -->\` after \`#### Scenario:\` when the scenario needs extra business context.
2635
+
2636
+ ### When creating or updating specs
2637
+
2638
+ - **Same edit rule**: Any change to English normative text MUST update the paired Chinese comment in the **same** commit/edit session.
2639
+ - **Do not** remove Chinese notes when refactoring English unless the requirement was removed.
2640
+ - **Do not** let Chinese notes contradict English; if they disagree, fix both or ask the user. **On conflict, English Requirements win.**
2641
+ - **proposal.md** / **design.md** may be Chinese; **tasks.md** may be Chinese with English identifiers.
2642
+
2643
+ ### Applies to
2644
+
2645
+ - \`fet propose\`, \`fet continue\`, \`fet ff\` artifact writes
2646
+ - Manual edits to spec files in chat
2647
+ - \`fet sync\` merges into \`openspec/specs/**/spec.md\` (preserve or refresh Chinese notes for touched requirements)`;
2648
+ }
2649
+ return `## \u5206\u5C42\u53CC\u8BED spec\uFF08\u82F1\u6587\u89C4\u8303 + \u4E2D\u6587\u8BF4\u660E\uFF09
2650
+
2651
+ \u672C\u9879\u76EE OpenSpec spec \u91C7\u7528 **layered_bilingual**\uFF08\u89C1 \`openspec/config.yaml\` \u7684 \`fet.specLanguage\`\uFF09\u3002
2652
+
2653
+ ### \u7ED3\u6784
2654
+
2655
+ - **\u82F1\u6587\uFF08\u6743\u5A01\uFF09**\uFF1A\u4FDD\u7559 OpenSpec \u7AE0\u8282\u6807\u9898\uFF08\`## Requirements\`\u3001\`### Requirement:\`\u3001\`#### Scenario:\`\uFF09\u4E0E normative \u8BED\u53E5\uFF08SHALL/MUST\u3001\u9A8C\u6536\u6761\u4EF6\uFF09\u3002
2656
+ - **\u4E2D\u6587\uFF08\u7ED9\u4EBA\u8BFB\uFF09**\uFF1A\u5728\u6BCF\u4E2A \`### Requirement:\` \u6807\u9898\u884C\u4E4B\u540E\u3001\u82F1\u6587\u6B63\u6587\u4E4B\u524D\uFF0C\u6DFB\u52A0\u4E00\u6761 HTML \u6CE8\u91CA\uFF1A
2657
+
2658
+ \`\`\`markdown
2659
+ ### Requirement: User can export report
2660
+ <!-- \u4E2D\u6587\uFF1A\u767B\u5F55\u7528\u6237\u53EF\u5728\u62A5\u8868\u9875\u5BFC\u51FA PDF\uFF1B\u9700\u5177\u5907 export \u6743\u9650\uFF1B\u5931\u8D25\u65F6\u63D0\u793A\u539F\u56E0 -->
2661
+
2662
+ The system SHALL ...
2663
+ \`\`\`
2664
+
2665
+ \u82E5\u573A\u666F\u9700\u8981\u8865\u5145\u4E1A\u52A1\u8BED\u5883\uFF0C\u53EF\u5728 \`#### Scenario:\` \u540E\u540C\u6837\u6DFB\u52A0 \`<!-- \u4E2D\u6587\uFF1A... -->\`\u3002
2666
+
2667
+ ### \u521B\u5EFA\u6216\u66F4\u65B0 spec \u65F6\uFF08\u542B LLM \u6539\u6587\u6863\uFF09
2668
+
2669
+ - **\u540C\u6B21\u7F16\u8F91\u539F\u5219**\uFF1A\u4FEE\u6539\u82F1\u6587\u89C4\u8303\u53E5\u65F6\uFF0C**\u5FC5\u987B\u5728\u540C\u4E00\u6B21\u7F16\u8F91\u4E2D**\u540C\u6B65\u66F4\u65B0\u5BF9\u5E94\u7684\u4E2D\u6587\u6CE8\u91CA\uFF0C\u4E0D\u5F97\u9057\u7559\u8FC7\u65F6\u8BF4\u660E\u3002
2670
+ - \u91CD\u6784\u82F1\u6587\u65F6\u4E0D\u8981\u65E0\u6545\u5220\u9664\u4E2D\u6587\u6CE8\u91CA\uFF1B\u82E5\u5220\u9664 Requirement\uFF0C\u4E00\u5E76\u5220\u9664\u5176\u6CE8\u91CA\u3002
2671
+ - \u4E2D\u6587\u8BF4\u660E\u4E0D\u5F97\u4E0E\u82F1\u6587\u77DB\u76FE\uFF1B\u82E5\u6709\u51B2\u7A81\uFF0C\u540C\u65F6\u4FEE\u6B63\u6216\u8BE2\u95EE\u7528\u6237\u3002**\u51B2\u7A81\u65F6\u4EE5\u82F1\u6587 Requirements \u4E3A\u51C6\u3002**
2672
+ - **proposal.md** / **design.md** \u53EF\u7528\u4E2D\u6587\uFF1B**tasks.md** \u53EF\u7528\u4E2D\u6587\u5E76\u4FDD\u7559\u82F1\u6587\u6807\u8BC6\u7B26\u3002
2673
+
2674
+ ### \u9002\u7528\u573A\u666F
2675
+
2676
+ - \`fet propose\`\u3001\`fet continue\`\u3001\`fet ff\` \u5199\u5165\u7684\u4EA7\u7269
2677
+ - \u5BF9\u8BDD\u4E2D\u624B\u52A8\u4FEE\u6539 change \u4E0B spec
2678
+ - \`fet sync\` \u5408\u5E76\u5230 \`openspec/specs/**/spec.md\` \u65F6\uFF0C\u5BF9\u89E6\u53CA\u7684 Requirement \u4FDD\u7559\u6216\u5237\u65B0\u4E2D\u6587\u6CE8\u91CA`;
2679
+ }
2680
+ function renderCursorSpecLanguageRule(language) {
2681
+ const description = language === "en" ? "Layered bilingual OpenSpec specs: English canonical text with synced Chinese HTML comments" : "OpenSpec \u5206\u5C42\u53CC\u8BED\uFF1A\u82F1\u6587\u89C4\u8303 + \u540C\u6B65\u7EF4\u62A4\u7684\u4E2D\u6587\u6CE8\u91CA";
2682
+ return `<!-- FET:MANAGED
2683
+ schemaVersion: 1
2684
+ fetVersion: ${FET_VERSION}
2685
+ generator: cursor-adapter
2686
+ adapterVersion: 1
2687
+ FET:END -->
2688
+
2689
+ ---
2690
+ description: ${description}
2691
+ alwaysApply: false
2692
+ ---
2693
+
2694
+ ${language === "en" ? "Apply when creating or editing OpenSpec spec files under `openspec/changes/` or `openspec/specs/`, or when running `fet propose` / `fet continue` / `fet ff` / `fet sync`." : "\u5728\u521B\u5EFA\u6216\u7F16\u8F91 `openspec/changes/`\u3001`openspec/specs/` \u4E0B\u7684 spec\uFF0C\u6216\u8FD0\u884C `fet propose` / `fet continue` / `fet ff` / `fet sync` \u65F6\u9002\u7528\u3002"}
2695
+
2696
+ ${renderSpecLanguagePolicyBody(language)}
2697
+ `;
2698
+ }
2699
+ function renderCodexSpecLanguageGuide(language) {
2700
+ return `<!-- FET:MANAGED
2701
+ schemaVersion: 1
2702
+ fetVersion: ${FET_VERSION}
2703
+ generator: codex-adapter
2704
+ adapterVersion: 1
2705
+ FET:END -->
2706
+
2707
+ # OpenSpec spec language (layered bilingual)
2708
+
2709
+ ${renderSpecLanguagePolicyBody(language)}
2710
+ `;
2711
+ }
2712
+ function renderPlanningArtifactSpecBlock(language, changeId) {
2713
+ const uiBlock = changeId !== void 0 ? `
2714
+
2715
+ ${renderPlanningArtifactUiContractBlock(language, changeId)}` : "";
2716
+ if (language === "en") {
2717
+ return `## OpenSpec spec artifacts
2718
+
2719
+ When the artifact is \`specs/<capability>/spec.md\` (or you edit spec files in this change):
2720
+
2721
+ 1. Follow \`.cursor/rules/fet-spec-language.mdc\` or \`.codex/fet/spec-language.md\`.
2722
+ 2. English Requirements/Scenario + \`<!-- \u4E2D\u6587\uFF1A... -->\` after each Requirement title.
2723
+ 3. Update Chinese notes in the **same edit** whenever English normative text changes.${uiBlock}`;
2724
+ }
2725
+ return `## OpenSpec spec \u4EA7\u7269
2726
+
2727
+ \u5F53\u4EA7\u7269\u4E3A \`specs/<capability>/spec.md\`\uFF08\u6216\u4F60\u5728\u672C change \u4E2D\u4FEE\u6539 spec\uFF09\u65F6\uFF1A
2728
+
2729
+ 1. \u9075\u5B88 \`.cursor/rules/fet-spec-language.mdc\` \u6216 \`.codex/fet/spec-language.md\`\u3002
2730
+ 2. \u82F1\u6587 Requirements/Scenario + \u6BCF\u4E2A Requirement \u6807\u9898\u540E\u52A0 \`<!-- \u4E2D\u6587\uFF1A... -->\`\u3002
2731
+ 3. \u82F1\u6587\u89C4\u8303\u53E5\u6709\u4EFB\u4F55\u53D8\u52A8\u65F6\uFF0C**\u540C\u4E00\u6B21\u7F16\u8F91**\u5FC5\u987B\u540C\u6B65\u66F4\u65B0\u5BF9\u5E94\u4E2D\u6587\u6CE8\u91CA\u3002${uiBlock}`;
2732
+ }
2357
2733
 
2358
2734
  // src/commands/update-context.ts
2359
2735
  async function updateContextCommand(ctx) {
@@ -2516,8 +2892,8 @@ async function exists4(path) {
2516
2892
  }
2517
2893
 
2518
2894
  // src/commands/proxy.ts
2519
- import { readFile as readFile14 } from "fs/promises";
2520
- import { join as join18 } from "path";
2895
+ import { readFile as readFile15 } from "fs/promises";
2896
+ import { join as join19 } from "path";
2521
2897
 
2522
2898
  // src/figma-guard.ts
2523
2899
  import { readdir as readdir3, readFile as readFile11, stat as stat7 } from "fs/promises";
@@ -2525,6 +2901,7 @@ import { join as join16, relative as relative2 } from "path";
2525
2901
  import { parseDocument as parseDocument2 } from "yaml";
2526
2902
  var DEFAULT_CONFIG = {
2527
2903
  enabled: true,
2904
+ mode: "require_before_ui",
2528
2905
  onUncertainty: "stop_and_ask"
2529
2906
  };
2530
2907
  async function loadFigmaGuardConfig(projectRoot) {
@@ -2537,8 +2914,11 @@ async function loadFigmaGuardConfig(projectRoot) {
2537
2914
  return DEFAULT_CONFIG;
2538
2915
  }
2539
2916
  const enabled = node.get("enabled");
2917
+ const modeRaw = node.get("mode");
2918
+ const mode = modeRaw === "stop_and_ask" ? "stop_and_ask" : "require_before_ui";
2540
2919
  return {
2541
2920
  enabled: enabled === void 0 ? true : Boolean(enabled),
2921
+ mode,
2542
2922
  onUncertainty: "stop_and_ask"
2543
2923
  };
2544
2924
  } catch {
@@ -2585,7 +2965,11 @@ async function collectFigmaUrlsFromChange(projectRoot, changeId) {
2585
2965
  return { urls: [...urls], sources };
2586
2966
  }
2587
2967
  async function ensureChangeFigmaStopHandoff(options) {
2588
- const config = options.enabled === void 0 ? await loadFigmaGuardConfig(options.projectRoot) : { enabled: options.enabled, onUncertainty: "stop_and_ask" };
2968
+ const config = options.enabled === void 0 ? await loadFigmaGuardConfig(options.projectRoot) : {
2969
+ enabled: options.enabled,
2970
+ mode: options.mode ?? "require_before_ui",
2971
+ onUncertainty: "stop_and_ask"
2972
+ };
2589
2973
  if (!config.enabled) {
2590
2974
  return null;
2591
2975
  }
@@ -2593,22 +2977,47 @@ async function ensureChangeFigmaStopHandoff(options) {
2593
2977
  if (!urls.length) {
2594
2978
  return null;
2595
2979
  }
2596
- const relativePath = figmaStopHandoffRelativePath(options.changeId);
2597
- const absolutePath = join16(options.projectRoot, relativePath);
2598
2980
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
2599
- const content = renderChangeFigmaStopHandoff({
2981
+ let written = false;
2982
+ const stopRelativePath = figmaStopHandoffRelativePath(options.changeId);
2983
+ const stopAbsolutePath = join16(options.projectRoot, stopRelativePath);
2984
+ const stopContent = renderChangeFigmaStopHandoff({
2600
2985
  changeId: options.changeId,
2601
2986
  generatedAt,
2602
2987
  urls,
2603
2988
  sources,
2604
2989
  language: options.language
2605
2990
  });
2606
- const existing = await readOptional3(absolutePath);
2607
- const written = existing !== content;
2608
- if (written) {
2609
- await atomicWrite(absolutePath, content);
2991
+ const existingStop = await readOptional3(stopAbsolutePath);
2992
+ if (existingStop !== stopContent) {
2993
+ await atomicWrite(stopAbsolutePath, stopContent);
2994
+ written = true;
2995
+ }
2996
+ let applyInstructionsPath;
2997
+ if (config.mode === "require_before_ui") {
2998
+ applyInstructionsPath = figmaApplyInstructionsRelativePath(options.changeId);
2999
+ const applyAbsolutePath = join16(options.projectRoot, applyInstructionsPath);
3000
+ const applyContent = renderChangeFigmaApplyInstructions({
3001
+ changeId: options.changeId,
3002
+ generatedAt,
3003
+ urls,
3004
+ sources,
3005
+ language: options.language
3006
+ });
3007
+ const existingApply = await readOptional3(applyAbsolutePath);
3008
+ if (existingApply !== applyContent) {
3009
+ await atomicWrite(applyAbsolutePath, applyContent);
3010
+ written = true;
3011
+ }
2610
3012
  }
2611
- return { path: relativePath, written, urls, sources };
3013
+ return {
3014
+ path: stopRelativePath,
3015
+ applyInstructionsPath,
3016
+ written,
3017
+ urls,
3018
+ sources,
3019
+ mode: config.mode
3020
+ };
2612
3021
  }
2613
3022
  async function listMarkdownFiles(root) {
2614
3023
  const files = [];
@@ -2639,6 +3048,300 @@ async function readOptional3(path) {
2639
3048
  }
2640
3049
  }
2641
3050
 
3051
+ // src/ui-display-contract.ts
3052
+ import { existsSync as existsSync2 } from "fs";
3053
+ import { readdir as readdir4, readFile as readFile12, stat as stat8 } from "fs/promises";
3054
+ import { join as join17, relative as relative3 } from "path";
3055
+ import { parse as parse3, parseDocument as parseDocument3 } from "yaml";
3056
+ var DEFAULT_CONFIG2 = {
3057
+ enabled: true
3058
+ };
3059
+ var API_DOC_PATH_PATTERN = /(?:^|[\s(`])([\w./-]*(?:openapi|swagger|api-doc|api\/docs)[\w./-]*\.(?:ya?ml|json))(?:[\s)`.,;]|$)/gi;
3060
+ var MARKDOWN_LINK_PATH_PATTERN = /\[[^\]]*\]\(([^)]+\.(?:ya?ml|json))\)/gi;
3061
+ var BACKTICK_PATH_PATTERN = /`([^`]+\.(?:ya?ml|json))`/gi;
3062
+ var OPENAPI_BARE_PATTERN = /\b(openapi\.ya?ml|swagger\.ya?ml|swagger\.json)\b/gi;
3063
+ async function loadUiDisplayContractConfig(projectRoot) {
3064
+ try {
3065
+ const raw = await readFile12(join17(projectRoot, "openspec", "config.yaml"), "utf8");
3066
+ const doc = parseDocument3(raw);
3067
+ const fetNode = doc.get("fet", true);
3068
+ const node = fetNode?.get?.("uiDisplayContract");
3069
+ if (!node || typeof node.get !== "function") {
3070
+ return DEFAULT_CONFIG2;
3071
+ }
3072
+ const enabled = node.get("enabled");
3073
+ return {
3074
+ enabled: enabled === void 0 ? true : Boolean(enabled)
3075
+ };
3076
+ } catch {
3077
+ return DEFAULT_CONFIG2;
3078
+ }
3079
+ }
3080
+ async function collectApiSourcesFromChange(projectRoot, changeId) {
3081
+ const changePath = join17(projectRoot, "openspec", "changes", changeId);
3082
+ const paths = /* @__PURE__ */ new Set();
3083
+ const sources = [];
3084
+ const candidates = ["proposal.md", "tasks.md", "design.md"];
3085
+ for (const name of candidates) {
3086
+ const filePath = join17(changePath, name);
3087
+ const content = await readOptional4(filePath);
3088
+ if (!content) {
3089
+ continue;
3090
+ }
3091
+ const found = extractApiDocPaths(content, projectRoot);
3092
+ if (found.length) {
3093
+ sources.push(`openspec/changes/${changeId}/${name}`);
3094
+ for (const path of found) {
3095
+ paths.add(path);
3096
+ }
3097
+ }
3098
+ }
3099
+ const specsPath = join17(changePath, "specs");
3100
+ for (const filePath of await listMarkdownFiles2(specsPath)) {
3101
+ const content = await readOptional4(filePath);
3102
+ if (!content) {
3103
+ continue;
3104
+ }
3105
+ const found = extractApiDocPaths(content, projectRoot);
3106
+ if (found.length) {
3107
+ sources.push(relative3(projectRoot, filePath).replaceAll("\\", "/"));
3108
+ for (const path of found) {
3109
+ paths.add(path);
3110
+ }
3111
+ }
3112
+ }
3113
+ return { paths: [...paths], sources };
3114
+ }
3115
+ function extractApiDocPaths(content, projectRoot) {
3116
+ const found = /* @__PURE__ */ new Set();
3117
+ const patterns = [API_DOC_PATH_PATTERN, MARKDOWN_LINK_PATH_PATTERN, BACKTICK_PATH_PATTERN, OPENAPI_BARE_PATTERN];
3118
+ for (const pattern of patterns) {
3119
+ pattern.lastIndex = 0;
3120
+ let match;
3121
+ while ((match = pattern.exec(content)) !== null) {
3122
+ const raw = (match[1] ?? match[0]).trim().replace(/^["']|["']$/g, "");
3123
+ const normalized = normalizeRepoPath(raw, projectRoot);
3124
+ if (normalized) {
3125
+ found.add(normalized);
3126
+ }
3127
+ }
3128
+ }
3129
+ if (/\b(openapi|swagger|接口文档|API\s*文档)\b/i.test(content)) {
3130
+ const common = ["openapi.yaml", "openapi.yml", "docs/openapi.yaml", "swagger.yaml", "swagger.json"];
3131
+ for (const candidate of common) {
3132
+ const normalized = normalizeRepoPath(candidate, projectRoot);
3133
+ if (normalized && existsSync2(join17(projectRoot, normalized))) {
3134
+ found.add(normalized);
3135
+ }
3136
+ }
3137
+ }
3138
+ return [...found];
3139
+ }
3140
+ async function extractOpenApiSchemas(projectRoot, relativePaths) {
3141
+ const schemas = [];
3142
+ for (const rel of relativePaths) {
3143
+ const absolute = join17(projectRoot, rel);
3144
+ const content = await readOptional4(absolute);
3145
+ if (!content) {
3146
+ continue;
3147
+ }
3148
+ let doc;
3149
+ try {
3150
+ doc = rel.endsWith(".json") ? JSON.parse(content) : parse3(content);
3151
+ } catch {
3152
+ continue;
3153
+ }
3154
+ const names = collectOpenApiSchemaPropertyNames(doc);
3155
+ for (const [name, fields] of names) {
3156
+ if (fields.length) {
3157
+ schemas.push({ name, fields: [...new Set(fields)].sort(), sourcePath: rel });
3158
+ }
3159
+ }
3160
+ }
3161
+ return schemas;
3162
+ }
3163
+ function buildUiDisplayContractDocument(options) {
3164
+ const allApiFields = [...new Set(options.apiSchemas.flatMap((schema) => schema.fields))].sort();
3165
+ const screenId = options.figmaUrls.length ? "primary" : "default";
3166
+ const omittedCandidates = allApiFields.map((field) => {
3167
+ const schema = options.apiSchemas.find((item) => item.fields.includes(field))?.name;
3168
+ return {
3169
+ field,
3170
+ schema,
3171
+ reason: "Listed in API/OpenAPI schema; not yet in displayFields\u2014confirm with Figma before rendering in UI."
3172
+ };
3173
+ });
3174
+ return {
3175
+ schemaVersion: 1,
3176
+ fetVersion: options.fetVersion,
3177
+ generatedAt: options.generatedAt,
3178
+ changeId: options.changeId,
3179
+ purpose: "ui-display-contract",
3180
+ status: "draft",
3181
+ precedence: [
3182
+ "ui-display-contract.yaml displayFields",
3183
+ "Figma visible elements",
3184
+ "API/OpenAPI schema (data layer and types only)"
3185
+ ],
3186
+ sources: {
3187
+ figma: {
3188
+ urls: options.figmaUrls,
3189
+ artifactPaths: options.figmaSources
3190
+ },
3191
+ api: options.apiPaths.map((path) => ({
3192
+ path,
3193
+ artifactPaths: options.apiSources,
3194
+ schemas: options.apiSchemas.filter((schema) => schema.sourcePath === path)
3195
+ }))
3196
+ },
3197
+ screens: [
3198
+ {
3199
+ id: screenId,
3200
+ title: options.figmaUrls.length ? "Primary screen (confirm id with design)" : "Default screen",
3201
+ figmaUrls: options.figmaUrls,
3202
+ displayFields: [],
3203
+ hiddenButUsed: [],
3204
+ omittedFromUi: [],
3205
+ needsReview: allApiFields.length ? [
3206
+ `Fill displayFields for screen "${screenId}" from Figma before UI implementation.`,
3207
+ ...omittedCandidates.slice(0, 12).map((item) => `API field "${item.field}"\u2014omit from UI or add to displayFields?`)
3208
+ ] : options.figmaUrls.length ? [`Fill displayFields for screen "${screenId}" from Figma MCP/API.`] : ["Add API doc paths to change artifacts or confirm this change has no API-backed UI."]
3209
+ }
3210
+ ],
3211
+ omittedCandidates
3212
+ };
3213
+ }
3214
+ async function ensureChangeUiDisplayContract(options) {
3215
+ const config = options.enabled === void 0 ? await loadUiDisplayContractConfig(options.projectRoot) : { enabled: options.enabled };
3216
+ if (!config.enabled) {
3217
+ return null;
3218
+ }
3219
+ const figma = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
3220
+ const api = await collectApiSourcesFromChange(options.projectRoot, options.changeId);
3221
+ const hasFigma = figma.urls.length > 0;
3222
+ const hasApi = api.paths.length > 0;
3223
+ if (!hasFigma && !hasApi) {
3224
+ return null;
3225
+ }
3226
+ const apiSchemas = hasApi ? await extractOpenApiSchemas(options.projectRoot, api.paths) : [];
3227
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3228
+ const contractRelativePath = uiDisplayContractRelativePath(options.changeId);
3229
+ const applyRelativePath = uiFieldApplyInstructionsRelativePath(options.changeId);
3230
+ const doc = buildUiDisplayContractDocument({
3231
+ changeId: options.changeId,
3232
+ generatedAt,
3233
+ fetVersion: options.fetVersion,
3234
+ figmaUrls: figma.urls,
3235
+ figmaSources: figma.sources,
3236
+ apiPaths: api.paths,
3237
+ apiSources: api.sources,
3238
+ apiSchemas
3239
+ });
3240
+ const contractContent = renderUiDisplayContractYaml(doc);
3241
+ const contractAbsolutePath = join17(options.projectRoot, contractRelativePath);
3242
+ const existingContract = await readOptional4(contractAbsolutePath);
3243
+ let written = false;
3244
+ if (existingContract !== contractContent) {
3245
+ await atomicWrite(contractAbsolutePath, contractContent);
3246
+ written = true;
3247
+ }
3248
+ const applyContent = renderUiFieldApplyInstructions({
3249
+ changeId: options.changeId,
3250
+ generatedAt,
3251
+ contractPath: contractRelativePath,
3252
+ language: options.language,
3253
+ hasFigma,
3254
+ hasApi
3255
+ });
3256
+ const applyAbsolutePath = join17(options.projectRoot, applyRelativePath);
3257
+ const existingApply = await readOptional4(applyAbsolutePath);
3258
+ if (existingApply !== applyContent) {
3259
+ await atomicWrite(applyAbsolutePath, applyContent);
3260
+ written = true;
3261
+ }
3262
+ return {
3263
+ contractPath: contractRelativePath,
3264
+ applyInstructionsPath: applyRelativePath,
3265
+ written,
3266
+ hasFigma,
3267
+ hasApi,
3268
+ apiFieldCount: apiSchemas.reduce((sum, schema) => sum + schema.fields.length, 0)
3269
+ };
3270
+ }
3271
+ function collectOpenApiSchemaPropertyNames(doc) {
3272
+ const result = /* @__PURE__ */ new Map();
3273
+ if (!doc || typeof doc !== "object") {
3274
+ return result;
3275
+ }
3276
+ const root = doc;
3277
+ const components = root.components;
3278
+ const schemas = components?.schemas;
3279
+ if (schemas && typeof schemas === "object") {
3280
+ for (const [name, schema] of Object.entries(schemas)) {
3281
+ const fields = propertyNamesFromSchema(schema);
3282
+ if (fields.length) {
3283
+ result.set(name, fields);
3284
+ }
3285
+ }
3286
+ }
3287
+ return result;
3288
+ }
3289
+ function propertyNamesFromSchema(schema, depth = 0) {
3290
+ if (!schema || typeof schema !== "object" || depth > 4) {
3291
+ return [];
3292
+ }
3293
+ const node = schema;
3294
+ if (node.$ref && typeof node.$ref === "string") {
3295
+ return [];
3296
+ }
3297
+ const properties = node.properties;
3298
+ if (properties && typeof properties === "object") {
3299
+ return Object.keys(properties).sort();
3300
+ }
3301
+ if (node.items) {
3302
+ return propertyNamesFromSchema(node.items, depth + 1);
3303
+ }
3304
+ if (node.allOf && Array.isArray(node.allOf)) {
3305
+ return node.allOf.flatMap((part) => propertyNamesFromSchema(part, depth + 1));
3306
+ }
3307
+ return [];
3308
+ }
3309
+ function normalizeRepoPath(raw, projectRoot) {
3310
+ const cleaned = raw.replace(/^\.\//, "").replaceAll("\\", "/");
3311
+ if (!cleaned || cleaned.includes("..")) {
3312
+ return null;
3313
+ }
3314
+ return cleaned;
3315
+ }
3316
+ async function listMarkdownFiles2(root) {
3317
+ const files = [];
3318
+ await walk2(root, files);
3319
+ return files;
3320
+ }
3321
+ async function walk2(dir, files) {
3322
+ try {
3323
+ const entries = await readdir4(dir, { withFileTypes: true });
3324
+ for (const entry of entries) {
3325
+ const fullPath = join17(dir, entry.name);
3326
+ if (entry.isDirectory()) {
3327
+ await walk2(fullPath, files);
3328
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
3329
+ files.push(fullPath);
3330
+ }
3331
+ }
3332
+ } catch {
3333
+ return;
3334
+ }
3335
+ }
3336
+ async function readOptional4(path) {
3337
+ try {
3338
+ await stat8(path);
3339
+ return await readFile12(path, "utf8");
3340
+ } catch {
3341
+ return null;
3342
+ }
3343
+ }
3344
+
2642
3345
  // src/state/project.ts
2643
3346
  import { execFile as execFile2 } from "child_process";
2644
3347
  import { promisify as promisify2 } from "util";
@@ -2666,8 +3369,8 @@ async function git(cwd, args) {
2666
3369
  }
2667
3370
 
2668
3371
  // src/state/store.ts
2669
- import { mkdir as mkdir6, readFile as readFile12 } from "fs/promises";
2670
- import { join as join17 } from "path";
3372
+ import { mkdir as mkdir6, readFile as readFile13 } from "fs/promises";
3373
+ import { join as join18 } from "path";
2671
3374
 
2672
3375
  // src/language.ts
2673
3376
  var DEFAULT_LANGUAGE = "zh-CN";
@@ -2785,7 +3488,7 @@ var StateStore = class {
2785
3488
  project;
2786
3489
  async readGlobal() {
2787
3490
  try {
2788
- const value = JSON.parse(await readFile12(this.globalPath(), "utf8"));
3491
+ const value = JSON.parse(await readFile13(this.globalPath(), "utf8"));
2789
3492
  assertGlobalState(value);
2790
3493
  return value;
2791
3494
  } catch (error) {
@@ -2800,13 +3503,13 @@ var StateStore = class {
2800
3503
  }
2801
3504
  async writeGlobal(state) {
2802
3505
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2803
- await mkdir6(join17(this.projectRoot, "openspec"), { recursive: true });
3506
+ await mkdir6(join18(this.projectRoot, "openspec"), { recursive: true });
2804
3507
  await atomicWrite(this.globalPath(), `${JSON.stringify(state, null, 2)}
2805
3508
  `);
2806
3509
  }
2807
3510
  async readChange(changeId) {
2808
3511
  try {
2809
- const value = JSON.parse(await readFile12(this.changePath(changeId), "utf8"));
3512
+ const value = JSON.parse(await readFile13(this.changePath(changeId), "utf8"));
2810
3513
  assertChangeState(value);
2811
3514
  return value;
2812
3515
  } catch (error) {
@@ -2821,15 +3524,15 @@ var StateStore = class {
2821
3524
  }
2822
3525
  async writeChange(state) {
2823
3526
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2824
- await mkdir6(join17(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
3527
+ await mkdir6(join18(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
2825
3528
  await atomicWrite(this.changePath(state.changeId), `${JSON.stringify(state, null, 2)}
2826
3529
  `);
2827
3530
  }
2828
3531
  globalPath() {
2829
- return join17(this.projectRoot, "openspec", "fet-state.json");
3532
+ return join18(this.projectRoot, "openspec", "fet-state.json");
2830
3533
  }
2831
3534
  changePath(changeId) {
2832
- return join17(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
3535
+ return join18(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
2833
3536
  }
2834
3537
  };
2835
3538
  function isNotFound(error) {
@@ -2837,11 +3540,11 @@ function isNotFound(error) {
2837
3540
  }
2838
3541
 
2839
3542
  // src/state/tasks.ts
2840
- import { readFile as readFile13 } from "fs/promises";
3543
+ import { readFile as readFile14 } from "fs/promises";
2841
3544
  async function readCompletedTaskIds(tasksPath) {
2842
3545
  let content;
2843
3546
  try {
2844
- content = await readFile13(tasksPath, "utf8");
3547
+ content = await readFile14(tasksPath, "utf8");
2845
3548
  } catch {
2846
3549
  return [];
2847
3550
  }
@@ -2990,18 +3693,29 @@ async function applyWorkflowCommand(ctx, args) {
2990
3693
  exitCode: instructions.exitCode,
2991
3694
  phaseStatus: "in_progress"
2992
3695
  });
2993
- const figmaGuard = await ensureChangeFigmaStopHandoff({
2994
- projectRoot: ctx.projectRoot,
2995
- changeId,
2996
- language: ctx.language
2997
- });
3696
+ const [figmaGuard, uiContract] = await Promise.all([
3697
+ ensureChangeFigmaStopHandoff({
3698
+ projectRoot: ctx.projectRoot,
3699
+ changeId,
3700
+ language: ctx.language
3701
+ }),
3702
+ ensureChangeUiDisplayContract({
3703
+ projectRoot: ctx.projectRoot,
3704
+ changeId,
3705
+ language: ctx.language,
3706
+ fetVersion: ctx.fetVersion
3707
+ })
3708
+ ]);
2998
3709
  const applyNextSteps = [
2999
3710
  `Read openspec/changes/${changeId}/tasks.md and the instructions output.`,
3000
3711
  "Implement pending tasks and update task checkboxes only after the work is done.",
3001
3712
  `Run fet verify --change ${changeId}`
3002
3713
  ];
3714
+ if (uiContract) {
3715
+ applyNextSteps.unshift(...renderUiDisplayContractApplyNextSteps(changeId, ctx.language));
3716
+ }
3003
3717
  if (figmaGuard) {
3004
- applyNextSteps.unshift(renderFigmaStopNextStep(changeId, ctx.language));
3718
+ applyNextSteps.unshift(...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode));
3005
3719
  }
3006
3720
  ctx.output.result({
3007
3721
  ok: true,
@@ -3014,7 +3728,8 @@ async function applyWorkflowCommand(ctx, args) {
3014
3728
  instructions: instructions.data,
3015
3729
  status,
3016
3730
  graphContext: runState.graphContext,
3017
- figmaGuard: figmaGuard ?? void 0
3731
+ figmaGuard: figmaGuard ?? void 0,
3732
+ uiDisplayContract: uiContract ?? void 0
3018
3733
  }
3019
3734
  });
3020
3735
  });
@@ -3166,17 +3881,30 @@ async function artifactWorkflowCommand(ctx, command, args) {
3166
3881
  exitCode: instructions.exitCode
3167
3882
  });
3168
3883
  const status = await readOpenSpecStatus(ctx, changeId);
3169
- const figmaGuard = await ensureChangeFigmaStopHandoff({
3170
- projectRoot: ctx.projectRoot,
3171
- changeId,
3172
- language: ctx.language
3173
- });
3884
+ const [figmaGuard, uiContract] = await Promise.all([
3885
+ ensureChangeFigmaStopHandoff({
3886
+ projectRoot: ctx.projectRoot,
3887
+ changeId,
3888
+ language: ctx.language
3889
+ }),
3890
+ ensureChangeUiDisplayContract({
3891
+ projectRoot: ctx.projectRoot,
3892
+ changeId,
3893
+ language: ctx.language,
3894
+ fetVersion: ctx.fetVersion
3895
+ })
3896
+ ]);
3174
3897
  const planningNextSteps = [
3175
3898
  `Create or update openspec/changes/${changeId}/${resolveOutputPath(status, artifactId)}`,
3176
3899
  "Review the artifact with the user before generating the next planning file.",
3177
3900
  `Run fet passthrough status --change ${changeId}`,
3178
3901
  status.isComplete ? `Run fet apply --change ${changeId}` : `Run fet continue --change ${changeId} when ready for the next artifact`
3179
3902
  ];
3903
+ if (uiContract) {
3904
+ planningNextSteps.unshift(
3905
+ ctx.language === "en" ? `If writing specs for API-backed UI, add the UI display contract Requirement; draft at ${uiContract.contractPath}.` : `\u82E5\u7F16\u5199\u7ED1\u5B9A\u63A5\u53E3\u7684 UI spec\uFF0C\u987B\u52A0\u5165 UI \u5C55\u793A\u5951\u7EA6 Requirement\uFF1B\u8349\u6848\u89C1 ${uiContract.contractPath}\u3002`
3906
+ );
3907
+ }
3180
3908
  if (figmaGuard) {
3181
3909
  planningNextSteps.unshift(renderFigmaStopNextStep(changeId, ctx.language));
3182
3910
  }
@@ -3192,7 +3920,8 @@ async function artifactWorkflowCommand(ctx, command, args) {
3192
3920
  instructions: instructions.data,
3193
3921
  status,
3194
3922
  graphContext: runState.graphContext,
3195
- figmaGuard: figmaGuard ?? void 0
3923
+ figmaGuard: figmaGuard ?? void 0,
3924
+ uiDisplayContract: uiContract ?? void 0
3196
3925
  }
3197
3926
  });
3198
3927
  });
@@ -3398,8 +4127,8 @@ async function createChangelogEntry(projectRoot, changeId) {
3398
4127
  };
3399
4128
  }
3400
4129
  async function appendChangelog(projectRoot, entry) {
3401
- const changelogPath = join18(projectRoot, "CHANGELOG.md");
3402
- const existing = await readOptional4(changelogPath);
4130
+ const changelogPath = join19(projectRoot, "CHANGELOG.md");
4131
+ const existing = await readOptional5(changelogPath);
3403
4132
  const legacyContentLabel = "\u66F4\u65B0\u5185\u5BB9";
3404
4133
  const block = `updateTime: ${entry.updateTime}
3405
4134
  changeRequirement:${entry.content}
@@ -3411,12 +4140,12 @@ ${block}` : block;
3411
4140
  await atomicWrite(changelogPath, next);
3412
4141
  }
3413
4142
  async function readChangeRequirement(projectRoot, changeId) {
3414
- const changeRoot = join18(projectRoot, "openspec", "changes", changeId);
3415
- const proposal = await readOptional4(join18(changeRoot, "proposal.md"));
4143
+ const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
4144
+ const proposal = await readOptional5(join19(changeRoot, "proposal.md"));
3416
4145
  if (proposal) {
3417
4146
  return summarizeMarkdown(proposal);
3418
4147
  }
3419
- const readme = await readOptional4(join18(changeRoot, "README.md"));
4148
+ const readme = await readOptional5(join19(changeRoot, "README.md"));
3420
4149
  if (readme) {
3421
4150
  return summarizeMarkdown(readme);
3422
4151
  }
@@ -3426,9 +4155,9 @@ function summarizeMarkdown(content) {
3426
4155
  const normalized = content.replace(/\r\n/g, "\n").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("<!--") && !line.startsWith("---")).join(" ");
3427
4156
  return normalized || "No change requirement found.";
3428
4157
  }
3429
- async function readOptional4(path) {
4158
+ async function readOptional5(path) {
3430
4159
  try {
3431
- return await readFile14(path, "utf8");
4160
+ return await readFile15(path, "utf8");
3432
4161
  } catch {
3433
4162
  return null;
3434
4163
  }
@@ -3784,8 +4513,8 @@ async function updateCommand(ctx) {
3784
4513
 
3785
4514
  // src/commands/verify.ts
3786
4515
  import { createHash } from "crypto";
3787
- import { mkdir as mkdir7, readFile as readFile15, stat as stat8 } from "fs/promises";
3788
- import { join as join19 } from "path";
4516
+ import { mkdir as mkdir7, readFile as readFile16, stat as stat9 } from "fs/promises";
4517
+ import { join as join20 } from "path";
3789
4518
  async function verifyCommand(ctx, options) {
3790
4519
  if (options.auto) {
3791
4520
  const scan = await ctx.scanner.scan(ctx.projectRoot, {});
@@ -3852,8 +4581,8 @@ async function verifyCommand(ctx, options) {
3852
4581
  async function writeInstructions(ctx, changeId) {
3853
4582
  await assertChangeExists(ctx, changeId);
3854
4583
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3855
- const dir = join19(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
3856
- const instructionsPath = join19(dir, "verify-instructions.md");
4584
+ const dir = join20(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
4585
+ const instructionsPath = join20(dir, "verify-instructions.md");
3857
4586
  await mkdir7(dir, { recursive: true });
3858
4587
  await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
3859
4588
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -3870,7 +4599,7 @@ async function writeInstructions(ctx, changeId) {
3870
4599
  async function markDone(ctx, changeId) {
3871
4600
  await assertChangeExists(ctx, changeId);
3872
4601
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
3873
- const instructionsPath = join19(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
4602
+ const instructionsPath = join20(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
3874
4603
  const instructions = await readInstructions(instructionsPath, changeId);
3875
4604
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
3876
4605
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -3905,8 +4634,8 @@ async function assertChangeExists(ctx, changeId) {
3905
4634
  }
3906
4635
  async function readInstructions(path, changeId) {
3907
4636
  try {
3908
- await stat8(path);
3909
- const content = await readFile15(path, "utf8");
4637
+ await stat9(path);
4638
+ const content = await readFile16(path, "utf8");
3910
4639
  const fileChangeId = readFrontMatterValue(content, "changeId");
3911
4640
  if (fileChangeId !== changeId) {
3912
4641
  throw new FetError({
@@ -4044,9 +4773,9 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
4044
4773
  import { resolve } from "path";
4045
4774
 
4046
4775
  // src/adapters/codex/index.ts
4047
- import { mkdir as mkdir8, readFile as readFile16, stat as stat9 } from "fs/promises";
4776
+ import { mkdir as mkdir8, readFile as readFile17, stat as stat10 } from "fs/promises";
4048
4777
  import { homedir } from "os";
4049
- import { dirname as dirname8, join as join20 } from "path";
4778
+ import { dirname as dirname8, join as join21 } from "path";
4050
4779
 
4051
4780
  // src/adapters/commands.ts
4052
4781
  var FET_WORKFLOW_COMMANDS = [
@@ -4089,6 +4818,9 @@ Before doing FET or OpenSpec work in Codex, read:
4089
4818
  - openspec/config.yaml
4090
4819
  - .codex/fet/karpathy-guidelines.md
4091
4820
  - .codex/fet/figma-stop.md when implementing UI from Figma
4821
+ - .codex/fet/spec-language.md when writing or updating OpenSpec specs
4822
+ - openspec/changes/<change-id>/.fet/figma-apply-instructions.md before UI work when FET apply reports Figma links
4823
+ - .codex/fet/ui-display-contract.md when UI binds API data; openspec/changes/<change-id>/.fet/ui-display-contract.yaml when present
4092
4824
  - the active change files under openspec/changes/<change-id>/, when a change is selected
4093
4825
 
4094
4826
  If GitNexus code graph context is available in the IDE or MCP tools, prefer it before broad repository scans. Use it to identify relevant modules, dependencies, and insertion points, then read only the concrete source files needed. If GitNexus is unavailable, continue with the normal FET/OpenSpec workflow.
@@ -4107,7 +4839,9 @@ ${languageInstruction(language)}
4107
4839
  - AGENTS.md
4108
4840
  - openspec/config.yaml
4109
4841
  - .codex/fet/karpathy-guidelines.md
4110
- - \u6309 Figma \u5B9E\u73B0 UI \u65F6\u9605\u8BFB .codex/fet/figma-stop.md
4842
+ - \u6309 Figma \u5B9E\u73B0 UI \u65F6\u9605\u8BFB .codex/fet/figma-stop.md\uFF1B\u6709 figma-apply-instructions.md \u65F6\u5FC5\u987B\u5728\u6539 UI \u524D\u5B8C\u6574\u6267\u884C
4843
+ - \u7F16\u5199\u6216\u66F4\u65B0 spec \u65F6\u9605\u8BFB .codex/fet/spec-language.md\uFF08\u82F1\u6587\u89C4\u8303 + \u540C\u6B21\u7F16\u8F91\u7EF4\u62A4\u4E2D\u6587\u6CE8\u91CA\uFF09
4844
+ - \u7ED1\u5B9A\u63A5\u53E3\u7684 UI \u9605\u8BFB .codex/fet/ui-display-contract.md\uFF1B\u5B58\u5728 ui-display-contract.yaml \u65F6\u5B9E\u65BD\u524D\u987B\u786E\u8BA4 displayFields
4111
4845
  - \u5982\u679C\u5DF2\u9009\u62E9 change\uFF0C\u9605\u8BFB openspec/changes/<change-id>/ \u4E0B\u7684\u5F53\u524D\u4EA7\u7269
4112
4846
 
4113
4847
  \u5982\u679C IDE \u6216 MCP \u5DE5\u5177\u4E2D\u53EF\u7528 GitNexus \u4EE3\u7801\u56FE\u4E0A\u4E0B\u6587\uFF0C\u5148\u7528\u5B83\u7F29\u5C0F\u4ED3\u5E93\u626B\u63CF\u8303\u56F4\uFF1B\u7528\u56FE\u8BC6\u522B\u76F8\u5173\u6A21\u5757\u3001\u4F9D\u8D56\u548C\u63D2\u5165\u70B9\uFF0C\u518D\u53EA\u8BFB\u53D6\u9700\u8981\u786E\u8BA4\u884C\u4E3A\u7684\u5177\u4F53\u6E90\u7801\u6587\u4EF6\u3002GitNexus \u4E0D\u53EF\u7528\u65F6\uFF0C\u6309\u666E\u901A FET/OpenSpec \u5DE5\u4F5C\u6D41\u7EE7\u7EED\u3002
@@ -4134,10 +4868,24 @@ function codexFigmaStopFile(language = DEFAULT_LANGUAGE) {
4134
4868
  content: renderCodexFigmaStopGuide(language)
4135
4869
  };
4136
4870
  }
4871
+ function codexSpecLanguageFile(language = DEFAULT_LANGUAGE) {
4872
+ return {
4873
+ path: ".codex/fet/spec-language.md",
4874
+ content: renderCodexSpecLanguageGuide(language)
4875
+ };
4876
+ }
4877
+ function codexUiDisplayContractFile(language = DEFAULT_LANGUAGE) {
4878
+ return {
4879
+ path: ".codex/fet/ui-display-contract.md",
4880
+ content: renderCodexUiDisplayContractGuide(language)
4881
+ };
4882
+ }
4137
4883
  function codexCommandFiles(language = DEFAULT_LANGUAGE) {
4138
4884
  return [
4139
4885
  codexKarpathyGuidelinesFile(language),
4140
4886
  codexFigmaStopFile(language),
4887
+ codexUiDisplayContractFile(language),
4888
+ codexSpecLanguageFile(language),
4141
4889
  ...FET_ADAPTER_COMMANDS.map((command) => ({
4142
4890
  path: `.codex/fet/commands/${command}.md`,
4143
4891
  content: renderCommand(command, language)
@@ -4488,7 +5236,9 @@ ${commandGoalZh(command)}
4488
5236
  - \u9ED8\u8BA4\u4F7F\u7528\u4E2D\u6587\u4EA7\u51FA\u3002
4489
5237
  - \u4E0D\u8981\u7ED5\u8FC7 FET \u76F4\u63A5\u8C03\u7528 openspec\uFF0C\u9664\u975E FET \u547D\u4EE4\u672C\u8EAB\u4E0D\u53EF\u7528\u3002
4490
5238
  - change \u4E0D\u660E\u786E\u65F6\u5148\u8BE2\u95EE\u7528\u6237\u3002
4491
- ${command === "fill-context" ? "- fet fill-context \u53EF\u80FD\u5DF2\u5199\u5165\u5C0F\u7A0B\u5E8F\u5305\u4F53\u79EF\u4E0E 2MB \u7EA6\u675F\uFF0C\u4E0D\u8981\u8986\u76D6\u8BE5\u8282\u4E2D\u7684\u626B\u63CF\u7ED3\u679C\uFF0C\u9664\u975E\u4ED3\u5E93\u7ED3\u6784\u5DF2\u53D8\u3002\n- \u66FF\u6362 AGENTS.md \u4E2D\u5176\u4F59 [NEEDS LLM INPUT] \u5360\u4F4D\u7B26\uFF0C\u4FDD\u7559 FET \u6258\u7BA1\u6807\u8BB0\uFF0C\u4E0D\u8981\u4FEE\u6539\u4E1A\u52A1\u4EE3\u7801\u3002\n" : ""}${command === "propose" || command === "continue" ? "- \u4E00\u6B21\u53EA\u521B\u5EFA\u4E00\u4E2A ready artifact\uFF0C\u5E76\u5728\u5199\u5165\u524D\u9605\u8BFB\u4F9D\u8D56\u6587\u4EF6\u3002\n" : ""}${command === "propose" || command === "continue" ? "- \u4E0D\u8981\u5728\u7528\u6237\u5BA1\u9605\u5F53\u524D\u4EA7\u7269\u524D\u81EA\u52A8\u8FD0\u884C fet continue\u3001fet ff \u6216\u5FAA\u73AF\u751F\u6210\u540E\u7EED\u4EA7\u7269\uFF1B\u9700\u8981\u7528\u6237\u660E\u786E\u786E\u8BA4\u540E\u518D\u63A8\u8FDB\u3002\n" : ""}${command === "apply" ? "- \u4E0D\u8981\u5728\u672A\u5B8C\u6210\u771F\u5B9E\u5B9E\u73B0\u524D\u52FE\u9009 tasks.md\uFF1B\u4E0D\u8981\u4ECE apply \u9636\u6BB5\u76F4\u63A5 sync \u6216 archive\u3002\n" : ""}`;
5239
+ ${command === "fill-context" ? "- fet fill-context \u53EF\u80FD\u5DF2\u5199\u5165\u5C0F\u7A0B\u5E8F\u5305\u4F53\u79EF\u4E0E 2MB \u7EA6\u675F\uFF0C\u4E0D\u8981\u8986\u76D6\u8BE5\u8282\u4E2D\u7684\u626B\u63CF\u7ED3\u679C\uFF0C\u9664\u975E\u4ED3\u5E93\u7ED3\u6784\u5DF2\u53D8\u3002\n- \u66FF\u6362 AGENTS.md \u4E2D\u5176\u4F59 [NEEDS LLM INPUT] \u5360\u4F4D\u7B26\uFF0C\u4FDD\u7559 FET \u6258\u7BA1\u6807\u8BB0\uFF0C\u4E0D\u8981\u4FEE\u6539\u4E1A\u52A1\u4EE3\u7801\u3002\n" : ""}${command === "propose" || command === "continue" || command === "ff" ? "- \u4E00\u6B21\u53EA\u521B\u5EFA\u4E00\u4E2A ready artifact\uFF0C\u5E76\u5728\u5199\u5165\u524D\u9605\u8BFB\u4F9D\u8D56\u6587\u4EF6\u3002\n" : ""}${command === "propose" || command === "continue" || command === "ff" ? "- \u4E0D\u8981\u5728\u7528\u6237\u5BA1\u9605\u5F53\u524D\u4EA7\u7269\u524D\u81EA\u52A8\u8FD0\u884C fet continue\u3001fet ff \u6216\u5FAA\u73AF\u751F\u6210\u540E\u7EED\u4EA7\u7269\uFF1B\u9700\u8981\u7528\u6237\u660E\u786E\u786E\u8BA4\u540E\u518D\u63A8\u8FDB\u3002\n" : ""}${command === "propose" || command === "continue" || command === "ff" || command === "sync" ? `${renderSpecArtifactGuardrail("zh-CN")}
5240
+ ` : ""}${command === "propose" || command === "continue" || command === "ff" ? `${renderUiDisplayContractGuardrail("zh-CN")}
5241
+ ` : ""}${command === "apply" ? "- \u4E0D\u8981\u5728\u672A\u5B8C\u6210\u771F\u5B9E\u5B9E\u73B0\u524D\u52FE\u9009 tasks.md\uFF1B\u4E0D\u8981\u4ECE apply \u9636\u6BB5\u76F4\u63A5 sync \u6216 archive\u3002\n- \u82E5\u5B58\u5728 openspec/changes/<change-id>/.fet/figma-apply-instructions.md\uFF0C\u5FC5\u987B\u5148\u8BFB Figma\uFF08MCP/API\uFF09\u518D\u6539 UI\uFF1B\u5931\u8D25\u5219\u505C\u4E0B\u95EE\u7528\u6237\uFF0C\u7981\u6B62\u731C\u6837\u5F0F\u3002\n- \u82E5\u5B58\u5728 openspec/changes/<change-id>/.fet/ui-display-contract.yaml\uFF0C\u5B9E\u65BD\u7ED1\u5B9A\u63A5\u53E3\u7684 UI \u524D\u987B\u786E\u8BA4 displayFields\uFF0C\u7981\u6B62\u6309 OpenAPI \u5168\u91CF\u5C55\u793A\u3002\n" : ""}`;
4492
5242
  }
4493
5243
  function commandTitleZh(command) {
4494
5244
  const titles = {
@@ -4528,7 +5278,7 @@ function commandGoalZh(command) {
4528
5278
  return "\u5728\u7528\u6237\u660E\u786E\u8981\u6C42\u5FEB\u8FDB\u65F6\uFF0C\u4E00\u6B21\u6027\u751F\u6210 change \u6240\u9700\u7684\u5168\u90E8\u89C4\u5212\u4EA7\u7269\u3002";
4529
5279
  }
4530
5280
  if (command === "apply") {
4531
- return "\u8BFB\u53D6 OpenSpec \u4EA7\u7269\u5E76\u6309 tasks.md \u5B9E\u65BD\u4EE3\u7801\u53D8\u66F4\u3002";
5281
+ return "\u8BFB\u53D6 OpenSpec \u4EA7\u7269\uFF1B\u6709 Figma \u65F6\u5148\u8BFB\u7A3F\u518D\u5B9E\u65BD UI\uFF1B\u6709 ui-display-contract.yaml \u65F6\u53EA\u5C55\u793A displayFields\uFF0C\u7981\u6B62\u6309 OpenAPI \u5168\u91CF\u6E32\u67D3\u3002";
4532
5282
  }
4533
5283
  if (command.startsWith("graph-")) {
4534
5284
  return "\u7BA1\u7406\u53EF\u9009\u7684 GitNexus \u4EE3\u7801\u56FE\uFF0C\u8BA9 AI \u5728\u5927\u8303\u56F4\u626B\u63CF\u524D\u4F18\u5148\u83B7\u5F97\u7ED3\u6784\u5316\u4EE3\u7801\u4E0A\u4E0B\u6587\u3002";
@@ -4653,16 +5403,26 @@ Steps:
4653
5403
  fet apply --change <change-id> --json
4654
5404
  \`\`\`
4655
5405
  3. Follow the native apply output. If JSON output is unavailable, read the files referenced by the terminal output and the artifacts under openspec/changes/<change-id>/.
4656
- 4. If apply is blocked because required artifacts are missing, stop and suggest /prompts:fet-continue <change-id> or /prompts:fet-ff <change-id>.
4657
- 5. Implement pending tasks one by one:
5406
+ 4. If \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\` exists (or apply nextSteps mention Figma):
5407
+ - Read it and \`figma-stop.md\` in the same folder before any UI code.
5408
+ - Use Figma MCP/API to read every linked frame; briefly confirm design facts in your reply.
5409
+ - If Figma access fails or design details are unclear, stop and ask the user\u2014do not guess styles or implement UI.
5410
+ 5. If \`openspec/changes/<change-id>/.fet/ui-display-contract.yaml\` exists (or apply nextSteps mention UI display contract):
5411
+ - Read it and \`ui-field-apply-instructions.md\` in the same folder before UI that binds API data.
5412
+ - Confirm \`displayFields\` from Figma; list API-only fields under \`omittedFromUi\`.
5413
+ - Do not render every OpenAPI property\u2014only fields in \`displayFields\`.
5414
+ 6. If apply is blocked because required artifacts are missing, stop and suggest /prompts:fet-continue <change-id> or /prompts:fet-ff <change-id>.
5415
+ 7. Implement pending tasks one by one:
4658
5416
  - Keep code changes minimal and scoped to the task.
4659
5417
  - Follow proposal, specs, design, and tasks.
4660
5418
  - Mark each completed task checkbox in tasks.md from \`- [ ]\` to \`- [x]\`.
4661
5419
  - Pause and ask if a task is ambiguous or reveals a design conflict.
4662
- 6. After completing or pausing, summarize completed tasks, remaining tasks, and blockers.
5420
+ 8. After completing or pausing, summarize completed tasks, remaining tasks, and blockers.
4663
5421
 
4664
5422
  Guardrails:
4665
5423
  - Never skip reading OpenSpec artifacts before implementation.
5424
+ - When Figma links exist for this change, never implement or restyle UI without reading Figma first.
5425
+ - When ui-display-contract.yaml exists, API schemas are not UI checklists\u2014only displayFields may render.
4666
5426
  - Do not mark a task complete until the code change is actually done.
4667
5427
  - Do not run sync or archive from apply.`,
4668
5428
  void 0,
@@ -4723,6 +5483,7 @@ Steps:
4723
5483
  - Remove REMOVED requirements.
4724
5484
  - Apply RENAMED requirements.
4725
5485
  - Preserve main-spec content not mentioned in the delta.
5486
+ - Follow .codex/fet/spec-language.md: keep English normative text; carry over or refresh \`<!-- \u4E2D\u6587\uFF1A... -->\` for every touched Requirement in the same edit.
4726
5487
  5. If no delta specs exist, state that there is nothing to merge.
4727
5488
  6. Run the FET sync gate and strict OpenSpec validation:
4728
5489
  \`\`\`sh
@@ -4960,6 +5721,7 @@ Steps:
4960
5721
  4. Follow the native output. When it provides template, instruction, dependencies, and outputPath, use those fields.
4961
5722
  5. Read dependency files before writing.
4962
5723
  6. Create only that artifact file at outputPath. Do not copy context/rules wrapper text into the artifact.
5724
+ - If the artifact is specs/<capability>/spec.md, follow .codex/fet/spec-language.md: English Requirements/Scenario plus \`<!-- \u4E2D\u6587\uFF1A... -->\` after each Requirement title; update Chinese notes in the same edit when English changes.
4963
5725
  7. Verify the file exists, then run:
4964
5726
  \`\`\`sh
4965
5727
  fet passthrough status --change <change-id>
@@ -5004,6 +5766,7 @@ Steps:
5004
5766
  6. Follow the native continue output. When it provides template, instruction, dependencies, and outputPath, use those fields.
5005
5767
  7. Read dependency files before writing.
5006
5768
  8. Create the artifact file at outputPath. Do not copy context/rules wrapper text into the artifact; use those fields only as constraints.
5769
+ - If the artifact is specs/<capability>/spec.md, follow .codex/fet/spec-language.md: English Requirements/Scenario plus \`<!-- \u4E2D\u6587\uFF1A... -->\` after each Requirement title; update Chinese notes in the same edit when English changes.
5007
5770
  9. Verify the file exists, then run:
5008
5771
  \`\`\`sh
5009
5772
  fet passthrough status --change <change-id>
@@ -5048,6 +5811,7 @@ Artifact rules:
5048
5811
  - Follow the instruction field from OpenSpec/FET for each artifact.
5049
5812
  - Use template as structure, filling it with concrete project-specific content.
5050
5813
  - Do not copy context/rules wrapper text into artifact files.
5814
+ - For specs/<capability>/spec.md, follow .codex/fet/spec-language.md (English canonical + \`<!-- \u4E2D\u6587\uFF1A... -->\`; update Chinese notes in the same edit when English changes).
5051
5815
  - Verify each file exists after writing.
5052
5816
 
5053
5817
  Output:
@@ -5096,7 +5860,7 @@ var CodexAdapter = class {
5096
5860
  adapterVersion = 1;
5097
5861
  async detect(projectRoot) {
5098
5862
  return {
5099
- detected: await exists5(join20(projectRoot, ".codex")) || await exists5(join20(projectRoot, "AGENTS.md")),
5863
+ detected: await exists5(join21(projectRoot, ".codex")) || await exists5(join21(projectRoot, "AGENTS.md")),
5100
5864
  reason: "Codex adapter is available for projects that use AGENTS.md"
5101
5865
  };
5102
5866
  }
@@ -5162,9 +5926,9 @@ var CodexAdapter = class {
5162
5926
  };
5163
5927
  function resolveTarget(projectRoot, file) {
5164
5928
  if (file.root === "codex-home") {
5165
- return join20(resolveCodexHome(), file.path);
5929
+ return join21(resolveCodexHome(), file.path);
5166
5930
  }
5167
- return join20(projectRoot, file.path);
5931
+ return join21(projectRoot, file.path);
5168
5932
  }
5169
5933
  function displayPathFor(file) {
5170
5934
  if (file.root === "codex-home") {
@@ -5173,18 +5937,18 @@ function displayPathFor(file) {
5173
5937
  return file.path;
5174
5938
  }
5175
5939
  function resolveCodexHome() {
5176
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join20(homedir(), ".codex");
5940
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join21(homedir(), ".codex");
5177
5941
  }
5178
5942
  async function readExisting(path) {
5179
5943
  try {
5180
- return await readFile16(path, "utf8");
5944
+ return await readFile17(path, "utf8");
5181
5945
  } catch {
5182
5946
  return null;
5183
5947
  }
5184
5948
  }
5185
5949
  async function exists5(path) {
5186
5950
  try {
5187
- await stat9(path);
5951
+ await stat10(path);
5188
5952
  return true;
5189
5953
  } catch {
5190
5954
  return false;
@@ -5192,8 +5956,8 @@ async function exists5(path) {
5192
5956
  }
5193
5957
 
5194
5958
  // src/adapters/cursor/index.ts
5195
- import { mkdir as mkdir9, readFile as readFile17, stat as stat10 } from "fs/promises";
5196
- import { dirname as dirname9, join as join21 } from "path";
5959
+ import { mkdir as mkdir9, readFile as readFile18, stat as stat11 } from "fs/promises";
5960
+ import { dirname as dirname9, join as join22 } from "path";
5197
5961
 
5198
5962
  // src/adapters/cursor/templates.ts
5199
5963
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -5202,8 +5966,25 @@ function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
5202
5966
  content: renderCursorFigmaStopRule(language)
5203
5967
  };
5204
5968
  }
5969
+ function cursorSpecLanguageRuleFile(language = DEFAULT_LANGUAGE) {
5970
+ return {
5971
+ path: ".cursor/rules/fet-spec-language.mdc",
5972
+ content: renderCursorSpecLanguageRule(language)
5973
+ };
5974
+ }
5975
+ function cursorUiDisplayContractRuleFile(language = DEFAULT_LANGUAGE) {
5976
+ return {
5977
+ path: ".cursor/rules/fet-ui-display-contract.mdc",
5978
+ content: renderCursorUiDisplayContractRule(language)
5979
+ };
5980
+ }
5205
5981
  function cursorRuleFiles(language = DEFAULT_LANGUAGE) {
5206
- return [cursorRuleFile(language), cursorFigmaStopRuleFile(language)];
5982
+ return [
5983
+ cursorRuleFile(language),
5984
+ cursorFigmaStopRuleFile(language),
5985
+ cursorUiDisplayContractRuleFile(language),
5986
+ cursorSpecLanguageRuleFile(language)
5987
+ ];
5207
5988
  }
5208
5989
  function cursorSkillFiles(language = DEFAULT_LANGUAGE) {
5209
5990
  return FET_ADAPTER_COMMANDS.map((command) => ({
@@ -5234,7 +6015,9 @@ ${languageInstruction(language)}
5234
6015
  - openspec/config.yaml
5235
6016
  - \u53EF\u7528\u65F6\u7684 GitNexus \u4EE3\u7801\u56FE\u4E0A\u4E0B\u6587\u3002\u4F18\u5148\u7528\u5B83\u7F29\u5C0F\u8303\u56F4\uFF1B\u4E0D\u53EF\u7528\u65F6\u6309\u666E\u901A FET/OpenSpec \u5DE5\u4F5C\u6D41\u7EE7\u7EED\u3002
5236
6017
  - \u5F53\u524D change \u76EE\u5F55\u4E0B\u7684 OpenSpec \u89C4\u5212\u4EA7\u7269\u3002
5237
- - \u6309 Figma \u5B9E\u73B0 UI \u65F6\u9075\u5B88 \`.cursor/rules/fet-figma-stop.mdc\`\uFF1B\u82E5\u5B58\u5728 \`openspec/changes/<change-id>/.fet/figma-stop.md\` \u987B\u5148\u9605\u8BFB\u3002
6018
+ - \u6309 Figma \u5B9E\u73B0 UI \u65F6\u9075\u5B88 \`.cursor/rules/fet-figma-stop.mdc\`\uFF1B\u82E5\u5B58\u5728 \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\` \u5FC5\u987B\u5728\u6539 UI \u524D\u5B8C\u6574\u6267\u884C\uFF1B\u540C\u76EE\u5F55 \`figma-stop.md\` \u542B\u94FE\u63A5\u4E0E\u505C\u6B62\u89C4\u5219\u3002
6019
+ - \u7F16\u5199\u6216\u4FEE\u6539 OpenSpec \`specs/**/spec.md\` \u65F6\u9075\u5B88 \`.cursor/rules/fet-spec-language.mdc\`\uFF08\u82F1\u6587\u89C4\u8303 + \`<!-- \u4E2D\u6587\uFF1A... -->\`\uFF0C\u540C\u6B21\u7F16\u8F91\u540C\u6B65\u66F4\u65B0\u4E2D\u6587\u8BF4\u660E\uFF09\u3002
6020
+ - \u7ED1\u5B9A\u63A5\u53E3\u7684 UI \u9075\u5B88 \`.cursor/rules/fet-ui-display-contract.mdc\`\uFF1B\u5B58\u5728 \`openspec/changes/<change-id>/.fet/ui-display-contract.yaml\` \u65F6\u5B9E\u65BD\u524D\u987B\u786E\u8BA4 displayFields\u3002
5238
6021
 
5239
6022
  \u5982\u679C\u7528\u6237\u8F93\u5165\u7C7B\u4F3C \`/fet apply\` \u7684\u8BF7\u6C42\uFF0C\u8BF7\u628A\u5B83\u89C6\u4E3A\u5DE5\u4F5C\u6D41\u610F\u56FE\uFF0C\u5E76\u63D0\u793A\u6216\u6267\u884C\u5BF9\u5E94\u7684\u7EC8\u7AEF\u547D\u4EE4 \`fet <cmd>\`\u3002
5240
6023
  `
@@ -5303,6 +6086,12 @@ After installation, verify with \`gitnexus --version\`, then run \`fet graph ini
5303
6086
  \u5B89\u88C5\u540E\u7528 \`gitnexus --version\` \u9A8C\u8BC1\uFF1B\u5408\u9002\u65F6\u7EE7\u7EED\u8FD0\u884C \`fet graph init\` \u548C \`fet graph handoff\`\u3002\u5982\u679C\u5B89\u88C5\u5931\u8D25\uFF0C\u6C47\u603B\u5931\u8D25\u547D\u4EE4\u548C\u4E0B\u4E00\u6B65\u4EBA\u5DE5\u5904\u7406\u5EFA\u8BAE\u3002`}
5304
6087
  `;
5305
6088
  }
6089
+ if (command === "apply") {
6090
+ return renderApplySkill(usage, language);
6091
+ }
6092
+ if (command === "propose" || command === "continue" || command === "ff") {
6093
+ return renderPlanningSkill(command, usage, language);
6094
+ }
5306
6095
  return `<!-- FET:MANAGED
5307
6096
  schemaVersion: 1
5308
6097
  fetVersion: ${FET_VERSION}
@@ -5332,6 +6121,93 @@ ${usage}
5332
6121
  \u6267\u884C\u524D\u8BF7\u786E\u8BA4\u5DF2\u9605\u8BFB AGENTS.md \u548C openspec/config.yaml\u3002
5333
6122
  `;
5334
6123
  }
6124
+ function renderPlanningSkill(command, usage, language) {
6125
+ const title = command === "propose" ? language === "en" ? "Create OpenSpec change and first artifact" : "\u521B\u5EFA OpenSpec change \u5E76\u751F\u6210\u9996\u4E2A\u89C4\u5212\u4EA7\u7269" : command === "ff" ? language === "en" ? "Fast-forward OpenSpec artifacts" : "\u5FEB\u8FDB\u751F\u6210 OpenSpec \u89C4\u5212\u4EA7\u7269" : language === "en" ? "Create next OpenSpec artifact" : "\u751F\u6210\u4E0B\u4E00\u4E2A OpenSpec \u89C4\u5212\u4EA7\u7269";
6126
+ return `<!-- FET:MANAGED
6127
+ schemaVersion: 1
6128
+ fetVersion: ${FET_VERSION}
6129
+ generator: cursor-adapter
6130
+ adapterVersion: 1
6131
+ command: ${usage}
6132
+ FET:END -->
6133
+
6134
+ ---
6135
+ name: fet-${command}
6136
+ description: ${title}
6137
+ disable-model-invocation: true
6138
+ ---
6139
+
6140
+ ${renderIdeModelPolicy(command, language)}
6141
+
6142
+ ${languageInstruction(language)}
6143
+
6144
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
6145
+
6146
+ \`\`\`sh
6147
+ ${usage}
6148
+ \`\`\`
6149
+
6150
+ ${renderPlanningArtifactSpecBlock(language)}
6151
+
6152
+ ${renderSpecArtifactGuardrail(language)}
6153
+
6154
+ ${renderUiDisplayContractGuardrail(language)}
6155
+
6156
+ \u6267\u884C\u524D\u8BF7\u9605\u8BFB AGENTS.md\u3001openspec/config.yaml\uFF08\u542B \`fet.specLanguage\`\uFF09\u4E0E\u5F53\u524D change \u5DF2\u6709\u4EA7\u7269\u3002
6157
+ `;
6158
+ }
6159
+ function renderApplySkill(usage, language) {
6160
+ const uiContractBlock = language === "en" ? `If \`openspec/changes/<change-id>/.fet/ui-display-contract.yaml\` exists:
6161
+
6162
+ 1. Read it and \`ui-field-apply-instructions.md\` in the same folder before UI that binds API data.
6163
+ 2. Confirm \`displayFields\` from Figma; put API-only fields in \`omittedFromUi\`.
6164
+ 3. Do not render every OpenAPI property\u2014only contracted display fields.` : `\u82E5\u5B58\u5728 \`openspec/changes/<change-id>/.fet/ui-display-contract.yaml\`\uFF1A
6165
+
6166
+ 1. \u5728\u7ED1\u5B9A\u63A5\u53E3\u7684 UI \u5B9E\u65BD\u524D\u9605\u8BFB\u8BE5\u6587\u4EF6\u4E0E\u540C\u76EE\u5F55 \`ui-field-apply-instructions.md\`\u3002
6167
+ 2. \u6309 Figma \u786E\u8BA4 \`displayFields\`\uFF1B\u4EC5\u63A5\u53E3\u6709\u7684\u5B57\u6BB5\u653E\u5165 \`omittedFromUi\`\u3002
6168
+ 3. \u7981\u6B62\u6309 OpenAPI \u5168\u91CF\u5C55\u793A\u2014\u53EA\u6E32\u67D3\u5951\u7EA6\u4E2D\u7684 displayFields\u3002`;
6169
+ const figmaBlock = language === "en" ? `If \`fet apply\` output or the change has \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\`:
6170
+
6171
+ 1. Read that file and \`figma-stop.md\` in the same folder before any UI code.
6172
+ 2. Use Figma MCP/API to read every linked frame; do not invent colors, spacing, or layout.
6173
+ 3. If Figma fails or design is unclear, stop and ask the user\u2014do not guess styles or mark UI tasks done.
6174
+ 4. After design is confirmed, implement tasks from \`tasks.md\`.` : `\u82E5 \`fet apply\` \u8F93\u51FA\u6216 change \u4E2D\u5B58\u5728 \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\`\uFF1A
6175
+
6176
+ 1. \u5728\u6539\u4EFB\u4F55 UI \u4EE3\u7801\u524D\uFF0C\u5148\u9605\u8BFB\u8BE5\u6587\u4EF6\u4E0E\u540C\u76EE\u5F55 \`figma-stop.md\`\u3002
6177
+ 2. \u7528 Figma MCP/API \u8BFB\u53D6\u6BCF\u4E2A\u94FE\u63A5\u7684\u753B\u677F\uFF1B\u7981\u6B62\u81EA\u521B\u989C\u8272\u3001\u95F4\u8DDD\u3001\u5E03\u5C40\u3002
6178
+ 3. Figma \u5931\u8D25\u6216\u8BBE\u8BA1\u4E0D\u6E05\u65F6\u7ACB\u5373\u505C\u6B62\u5E76\u5411\u7528\u6237\u63D0\u95EE\uFF0C\u4E0D\u8981\u731C\u6837\u5F0F\uFF0C\u4E0D\u8981\u628A UI \u4EFB\u52A1\u6807\u4E3A\u5B8C\u6210\u3002
6179
+ 4. \u8BBE\u8BA1\u786E\u8BA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD\u4EFB\u52A1\u3002`;
6180
+ return `<!-- FET:MANAGED
6181
+ schemaVersion: 1
6182
+ fetVersion: ${FET_VERSION}
6183
+ generator: cursor-adapter
6184
+ adapterVersion: 1
6185
+ command: ${usage}
6186
+ FET:END -->
6187
+
6188
+ ---
6189
+ name: fet-apply
6190
+ description: ${language === "en" ? "Implement OpenSpec tasks; require Figma when design links exist" : "\u5B9E\u65BD OpenSpec \u4EFB\u52A1\uFF1B\u6709 Figma \u94FE\u63A5\u65F6\u5FC5\u987B\u5148\u6309\u7A3F\u5B9E\u73B0"}
6191
+ disable-model-invocation: true
6192
+ ---
6193
+
6194
+ ${renderIdeModelPolicy("apply", language)}
6195
+
6196
+ ${languageInstruction(language)}
6197
+
6198
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
6199
+
6200
+ \`\`\`sh
6201
+ ${usage}
6202
+ \`\`\`
6203
+
6204
+ ${figmaBlock}
6205
+
6206
+ ${uiContractBlock}
6207
+
6208
+ \u6267\u884C\u524D\u8BF7\u9605\u8BFB AGENTS.md\u3001openspec/config.yaml \u4E0E\u5F53\u524D change \u4E0B\u7684 OpenSpec \u4EA7\u7269\u3002
6209
+ `;
6210
+ }
5335
6211
 
5336
6212
  // src/adapters/cursor/index.ts
5337
6213
  var CursorAdapter = class {
@@ -5339,7 +6215,7 @@ var CursorAdapter = class {
5339
6215
  adapterVersion = 1;
5340
6216
  async detect(projectRoot) {
5341
6217
  return {
5342
- detected: await exists6(join21(projectRoot, ".cursor")),
6218
+ detected: await exists6(join22(projectRoot, ".cursor")),
5343
6219
  reason: "Cursor adapter is available for any project"
5344
6220
  };
5345
6221
  }
@@ -5356,7 +6232,7 @@ var CursorAdapter = class {
5356
6232
  const written = [];
5357
6233
  const skipped = [];
5358
6234
  for (const file of plan.files) {
5359
- const target = join21(projectRoot, file.path);
6235
+ const target = join22(projectRoot, file.path);
5360
6236
  const existing = await readExisting2(target);
5361
6237
  if (existing && !existing.includes("FET:MANAGED") && !force) {
5362
6238
  throw new FetError({
@@ -5379,7 +6255,7 @@ var CursorAdapter = class {
5379
6255
  const plan = await this.planInstall(projectRoot);
5380
6256
  const checks = [];
5381
6257
  for (const file of plan.files) {
5382
- const target = join21(projectRoot, file.path);
6258
+ const target = join22(projectRoot, file.path);
5383
6259
  const content = await readExisting2(target);
5384
6260
  const managed = Boolean(content?.includes("FET:MANAGED"));
5385
6261
  const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
@@ -5395,14 +6271,14 @@ var CursorAdapter = class {
5395
6271
  };
5396
6272
  async function readExisting2(path) {
5397
6273
  try {
5398
- return await readFile17(path, "utf8");
6274
+ return await readFile18(path, "utf8");
5399
6275
  } catch {
5400
6276
  return null;
5401
6277
  }
5402
6278
  }
5403
6279
  async function exists6(path) {
5404
6280
  try {
5405
- await stat10(path);
6281
+ await stat11(path);
5406
6282
  return true;
5407
6283
  } catch {
5408
6284
  return false;
@@ -5414,13 +6290,13 @@ import { execFile as execFile4 } from "child_process";
5414
6290
  import { promisify as promisify4 } from "util";
5415
6291
 
5416
6292
  // src/openspec/inspector.ts
5417
- import { readdir as readdir4, stat as stat11 } from "fs/promises";
5418
- import { join as join22 } from "path";
6293
+ import { readdir as readdir5, stat as stat12 } from "fs/promises";
6294
+ import { join as join23 } from "path";
5419
6295
  async function inspectOpenSpecProject(projectRoot) {
5420
- const openspecPath = join22(projectRoot, "openspec");
5421
- const changesPath = join22(openspecPath, "changes");
5422
- const legacyArchivePath = join22(openspecPath, "archive");
5423
- const changesArchivePath = join22(changesPath, "archive");
6296
+ const openspecPath = join23(projectRoot, "openspec");
6297
+ const changesPath = join23(openspecPath, "changes");
6298
+ const legacyArchivePath = join23(openspecPath, "archive");
6299
+ const changesArchivePath = join23(changesPath, "archive");
5424
6300
  return {
5425
6301
  exists: await exists7(openspecPath),
5426
6302
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
@@ -5428,13 +6304,13 @@ async function inspectOpenSpecProject(projectRoot) {
5428
6304
  };
5429
6305
  }
5430
6306
  async function inspectOpenSpecChange(projectRoot, changeId) {
5431
- const changePath = join22(projectRoot, "openspec", "changes", changeId);
5432
- const tasksPath = join22(changePath, "tasks.md");
5433
- const specsPath = join22(changePath, "specs");
6307
+ const changePath = join23(projectRoot, "openspec", "changes", changeId);
6308
+ const tasksPath = join23(changePath, "tasks.md");
6309
+ const specsPath = join23(changePath, "specs");
5434
6310
  return {
5435
6311
  changeId,
5436
6312
  exists: await exists7(changePath),
5437
- hasProposal: await exists7(join22(changePath, "proposal.md")),
6313
+ hasProposal: await exists7(join23(changePath, "proposal.md")),
5438
6314
  hasTasks: await exists7(tasksPath),
5439
6315
  hasSpecs: await exists7(specsPath),
5440
6316
  tasksPath,
@@ -5443,7 +6319,7 @@ async function inspectOpenSpecChange(projectRoot, changeId) {
5443
6319
  }
5444
6320
  async function listDirectories(path, options = {}) {
5445
6321
  try {
5446
- const entries = await readdir4(path, { withFileTypes: true });
6322
+ const entries = await readdir5(path, { withFileTypes: true });
5447
6323
  const excluded = new Set(options.exclude ?? []);
5448
6324
  return entries.filter((entry) => entry.isDirectory() && !excluded.has(entry.name)).map((entry) => entry.name);
5449
6325
  } catch {
@@ -5452,7 +6328,7 @@ async function listDirectories(path, options = {}) {
5452
6328
  }
5453
6329
  async function exists7(path) {
5454
6330
  try {
5455
- await stat11(path);
6331
+ await stat12(path);
5456
6332
  return true;
5457
6333
  } catch {
5458
6334
  return false;
@@ -5635,13 +6511,13 @@ function escapeRegExp(value) {
5635
6511
  }
5636
6512
 
5637
6513
  // src/scanner/routes.ts
5638
- import { readdir as readdir5, stat as stat12 } from "fs/promises";
5639
- import { join as join23, relative as relative3, sep } from "path";
6514
+ import { readdir as readdir6, stat as stat13 } from "fs/promises";
6515
+ import { join as join24, relative as relative4, sep } from "path";
5640
6516
  async function scanRoutes(projectRoot) {
5641
6517
  const candidates = ["src/routes", "src/pages", "app", "pages"];
5642
6518
  const routes = [];
5643
6519
  for (const candidate of candidates) {
5644
- const root = join23(projectRoot, candidate);
6520
+ const root = join24(projectRoot, candidate);
5645
6521
  if (!await exists8(root)) {
5646
6522
  continue;
5647
6523
  }
@@ -5650,8 +6526,8 @@ async function scanRoutes(projectRoot) {
5650
6526
  continue;
5651
6527
  }
5652
6528
  routes.push({
5653
- path: inferRoutePath(relative3(root, file)),
5654
- source: relative3(projectRoot, file).split(sep).join("/"),
6529
+ path: inferRoutePath(relative4(root, file)),
6530
+ source: relative4(projectRoot, file).split(sep).join("/"),
5655
6531
  inferred: true,
5656
6532
  confidence: "medium"
5657
6533
  });
@@ -5666,10 +6542,10 @@ function inferRoutePath(relativePath) {
5666
6542
  return `/${withoutIndex}`.replace(/\/+/g, "/");
5667
6543
  }
5668
6544
  async function listFiles(root) {
5669
- const entries = await readdir5(root, { withFileTypes: true });
6545
+ const entries = await readdir6(root, { withFileTypes: true });
5670
6546
  const files = [];
5671
6547
  for (const entry of entries) {
5672
- const path = join23(root, entry.name);
6548
+ const path = join24(root, entry.name);
5673
6549
  if (entry.isDirectory()) {
5674
6550
  files.push(...await listFiles(path));
5675
6551
  } else {
@@ -5680,7 +6556,7 @@ async function listFiles(root) {
5680
6556
  }
5681
6557
  async function exists8(path) {
5682
6558
  try {
5683
- await stat12(path);
6559
+ await stat13(path);
5684
6560
  return true;
5685
6561
  } catch {
5686
6562
  return false;
@@ -5837,9 +6713,9 @@ async function createCommandContext(command, options) {
5837
6713
  import { createInterface as createInterface2 } from "readline/promises";
5838
6714
 
5839
6715
  // src/update/check.ts
5840
- import { mkdir as mkdir10, readFile as readFile18, writeFile } from "fs/promises";
6716
+ import { mkdir as mkdir10, readFile as readFile19, writeFile } from "fs/promises";
5841
6717
  import { homedir as homedir2 } from "os";
5842
- import { dirname as dirname10, join as join24 } from "path";
6718
+ import { dirname as dirname10, join as join25 } from "path";
5843
6719
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
5844
6720
  function getFetUpdateCheckMode(env = process.env) {
5845
6721
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -5912,11 +6788,11 @@ function formatFetUpdateWarning(availability, language) {
5912
6788
  }
5913
6789
  function cachePath() {
5914
6790
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
5915
- return join24(home, ".fet", "update-check-cache.json");
6791
+ return join25(home, ".fet", "update-check-cache.json");
5916
6792
  }
5917
6793
  async function readUpdateCheckCache() {
5918
6794
  try {
5919
- const raw = await readFile18(cachePath(), "utf8");
6795
+ const raw = await readFile19(cachePath(), "utf8");
5920
6796
  const parsed = JSON.parse(raw);
5921
6797
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
5922
6798
  return null;