@nick848/fet 1.1.11 → 1.1.13

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
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  FetError,
4
4
  toFetError
5
- } from "../chunk-J5WB4KAL.js";
5
+ } from "../chunk-YKRUVIJ6.js";
6
6
 
7
7
  // src/cli/index.ts
8
8
  import { createInterface as createInterface3 } from "readline/promises";
@@ -2080,7 +2080,13 @@ function renderFetConfig(scan, language = "zh-CN") {
2080
2080
  figmaGuard: {
2081
2081
  enabled: true,
2082
2082
  mode: "require_before_ui",
2083
- onUncertainty: "stop_and_ask"
2083
+ onUncertainty: "stop_and_ask",
2084
+ sync: {
2085
+ enabled: true,
2086
+ apiKeyEnv: "FIGMA_ACCESS_TOKEN",
2087
+ inlineImageMaxBytes: 32768,
2088
+ requireBeforeUi: true
2089
+ }
2084
2090
  },
2085
2091
  uiDisplayContract: {
2086
2092
  enabled: true
@@ -2530,17 +2536,27 @@ function figmaStopHandoffRelativePath(changeId) {
2530
2536
  function figmaApplyInstructionsRelativePath(changeId) {
2531
2537
  return `openspec/changes/${changeId}/.fet/figma-apply-instructions.md`;
2532
2538
  }
2539
+ function figmaDesignManifestRelativePath(changeId) {
2540
+ return `openspec/changes/${changeId}/.fet/figma-design-manifest.yaml`;
2541
+ }
2542
+ function figmaSyncInstructionsRelativePath(changeId) {
2543
+ return `openspec/changes/${changeId}/.fet/figma-sync-instructions.md`;
2544
+ }
2533
2545
  function renderFigmaRequireBeforeUiBody(language, changeId) {
2534
2546
  const stopPath = figmaStopHandoffRelativePath(changeId);
2547
+ const syncPath = figmaSyncInstructionsRelativePath(changeId);
2548
+ const manifestPath3 = figmaDesignManifestRelativePath(changeId);
2535
2549
  if (language === "en") {
2536
2550
  return `## Mandatory before any UI implementation
2537
2551
 
2538
2552
  Complete these steps **before** writing or editing UI code (components, pages, styles, layout):
2539
2553
 
2540
- 1. Read \`${stopPath}\` for detected Figma links and stop rules.
2541
- 2. Use **Figma MCP/API** (or an approved Figma tool) to read every linked frame/node referenced by this change.
2542
- 3. In your reply, briefly list design facts you confirmed from Figma (frames, colors, typography, spacing, components, states).
2543
- 4. Only then implement UI tasks from \`tasks.md\`.
2554
+ 1. Run \`fet figma sync --change ${changeId}\` (requires \`FIGMA_ACCESS_TOKEN\` or \`--token\`) so FET downloads **all nodes** and **image assets** into \`.fet/figma-assets/\`.
2555
+ 2. Read \`${syncPath}\`, \`${manifestPath3}\`, and \`figma-nodes.json\` in the same folder\u2014use on-disk asset paths for backgrounds and large images (not base64 in source).
2556
+ 3. Read \`${stopPath}\` for detected Figma links and stop rules.
2557
+ 4. If sync is impossible, use **Figma MCP/API** to read every linked frame/node; do not skip image fills or exportable layers.
2558
+ 5. In your reply, briefly list design facts you confirmed (frames, colors, typography, spacing, components, states, asset paths).
2559
+ 6. Only then implement UI tasks from \`tasks.md\`.
2544
2560
 
2545
2561
  ## Forbidden
2546
2562
 
@@ -2556,10 +2572,12 @@ Follow the stop rules in \`${stopPath}\`. Pause implementation, explain what fai
2556
2572
 
2557
2573
  \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
2558
2574
 
2559
- 1. \u9605\u8BFB \`${stopPath}\` \u4E2D\u7684 Figma \u94FE\u63A5\u4E0E\u505C\u6B62\u89C4\u5219\u3002
2560
- 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
2561
- 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
2562
- 4. \u5B8C\u6210\u4EE5\u4E0A\u6B65\u9AA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD UI \u4EFB\u52A1\u3002
2575
+ 1. \u6267\u884C \`fet figma sync --change ${changeId}\`\uFF08\u9700 \`FIGMA_ACCESS_TOKEN\` \u6216 \`--token\`\uFF09\uFF0C\u7531 FET \u62C9\u53D6**\u5168\u90E8\u8282\u70B9**\u5E76\u5C06\u56FE\u7247\u7D20\u6750\u843D\u76D8\u5230 \`.fet/figma-assets/\`\u3002
2576
+ 2. \u9605\u8BFB\u540C\u76EE\u5F55 \`${syncPath}\`\u3001\`${manifestPath3}\`\u3001\`figma-nodes.json\`\uFF1B\u5927\u56FE\u7528\u6587\u4EF6\u8DEF\u5F84\u5F15\u7528\uFF0C\u7981\u6B62\u5728\u6E90\u7801\u4E2D\u5185\u8054\u5927\u4F53\u79EF base64\u3002
2577
+ 3. \u9605\u8BFB \`${stopPath}\` \u4E2D\u7684 Figma \u94FE\u63A5\u4E0E\u505C\u6B62\u89C4\u5219\u3002
2578
+ 4. \u82E5\u65E0\u6CD5 sync\uFF0C\u5219\u7528 **Figma MCP/API** \u8BFB\u53D6\u6BCF\u4E2A\u753B\u677F/\u8282\u70B9\uFF0C\u4E0D\u5F97\u9057\u6F0F\u56FE\u7247\u586B\u5145\u4E0E\u53EF\u5BFC\u51FA\u56FE\u5C42\u3002
2579
+ 5. \u5728\u56DE\u590D\u4E2D\u7B80\u8981\u5217\u51FA\u5DF2\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\u3001\u7D20\u6750\u8DEF\u5F84\uFF09\u3002
2580
+ 6. \u5B8C\u6210\u4EE5\u4E0A\u6B65\u9AA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD UI \u4EFB\u52A1\u3002
2563
2581
 
2564
2582
  ## \u7981\u6B62
2565
2583
 
@@ -2702,16 +2720,21 @@ function renderFigmaStopNextStep(changeId, language) {
2702
2720
  const path = figmaStopHandoffRelativePath(changeId);
2703
2721
  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`;
2704
2722
  }
2705
- function renderFigmaApplyNextSteps(changeId, language, mode) {
2723
+ function renderFigmaApplyNextSteps(changeId, language, mode, options) {
2706
2724
  const applyPath = figmaApplyInstructionsRelativePath(changeId);
2707
2725
  const stopPath = figmaStopHandoffRelativePath(changeId);
2726
+ const syncPath = figmaSyncInstructionsRelativePath(changeId);
2727
+ const apiKeyEnv = options?.apiKeyEnv ?? "FIGMA_ACCESS_TOKEN";
2708
2728
  if (mode === "require_before_ui") {
2729
+ const syncStep = options?.syncDone === true ? language === "en" ? `Figma design data is synced. Read ${syncPath} and figma-design-manifest.yaml before UI work.` : `Figma \u8BBE\u8BA1\u6570\u636E\u5DF2\u540C\u6B65\u3002\u5B9E\u65BD UI \u524D\u9605\u8BFB ${syncPath} \u4E0E figma-design-manifest.yaml\u3002` : options?.syncMissingToken ? language === "en" ? `Run fet figma sync --change ${changeId} with ${apiKeyEnv} set to fetch all nodes and assets before UI work.` : `\u5B9E\u65BD UI \u524D\u6267\u884C fet figma sync --change ${changeId}\uFF08\u914D\u7F6E ${apiKeyEnv}\uFF09\uFF0C\u62C9\u53D6\u5168\u90E8\u8282\u70B9\u4E0E\u7D20\u6750\u3002` : language === "en" ? `Run fet figma sync --change ${changeId} if not already synced; then read ${syncPath}.` : `\u82E5\u5C1A\u672A\u540C\u6B65\uFF0C\u6267\u884C fet figma sync --change ${changeId}\uFF0C\u5E76\u9605\u8BFB ${syncPath}\u3002`;
2709
2730
  return language === "en" ? [
2710
- `Before any UI task: read and follow ${applyPath} (mandatory). Use Figma MCP/API for every linked frame\u2014do not invent styles.`,
2731
+ syncStep,
2732
+ `Before any UI task: read and follow ${applyPath} (mandatory). Use synced assets and node tree\u2014do not invent styles.`,
2711
2733
  `If Figma access fails or design details are unclear, stop per ${stopPath} and ask the user before continuing.`,
2712
2734
  `After Figma is confirmed, read openspec/changes/${changeId}/tasks.md and implement pending tasks.`
2713
2735
  ] : [
2714
- `\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`,
2736
+ syncStep,
2737
+ `\u5B9E\u65BD\u4EFB\u4F55 UI \u4EFB\u52A1\u524D\uFF1A\u5FC5\u987B\u9605\u8BFB\u5E76\u9075\u5B88 ${applyPath}\uFF1B\u4F7F\u7528\u5DF2\u540C\u6B65\u7684\u8282\u70B9\u6811\u4E0E\u7D20\u6750\u8DEF\u5F84\uFF0C\u7981\u6B62\u81EA\u521B\u6837\u5F0F\u3002`,
2715
2738
  `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`,
2716
2739
  `\u8BBE\u8BA1\u786E\u8BA4\u540E\uFF0C\u518D\u9605\u8BFB openspec/changes/${changeId}/tasks.md \u5E76\u5B9E\u65BD\u5F85\u529E\u4EFB\u52A1\u3002`
2717
2740
  ];
@@ -3000,6 +3023,390 @@ When the artifact is \`specs/<capability>/spec.md\` (or you edit spec files in t
3000
3023
  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}`;
3001
3024
  }
3002
3025
 
3026
+ // src/templates/write-boundary.ts
3027
+ var WRITE_BOUNDARY_ALLOW_PREFIXES = ["src/", "openspec/"];
3028
+ var WRITE_BOUNDARY_ROOT_CONFIG_EXACT = [
3029
+ ".gitignore",
3030
+ ".gitattributes",
3031
+ ".stylelintignore",
3032
+ ".eslintignore",
3033
+ ".prettierignore",
3034
+ ".editorconfig",
3035
+ ".npmrc",
3036
+ ".nvmrc",
3037
+ ".node-version",
3038
+ ".checkrc.js",
3039
+ "package.json",
3040
+ "package-lock.json",
3041
+ "pnpm-lock.yaml",
3042
+ "yarn.lock",
3043
+ "bun.lockb",
3044
+ "npm-shrinkwrap.json"
3045
+ ];
3046
+ var WRITE_BOUNDARY_ROOT_CONFIG_BASENAME_PREFIXES = [
3047
+ ".eslintrc",
3048
+ ".stylelintrc",
3049
+ ".prettierrc",
3050
+ ".stylelint",
3051
+ "eslint.config",
3052
+ "stylelint.config",
3053
+ "prettier.config",
3054
+ "tsconfig",
3055
+ "vitest.config",
3056
+ "vite.config",
3057
+ "tsup.config",
3058
+ "jest.config",
3059
+ "rollup.config",
3060
+ "webpack.config",
3061
+ "babel.config",
3062
+ "biome.json",
3063
+ "deno.json",
3064
+ "deno.jsonc"
3065
+ ];
3066
+ function renderRootConfigPathList(language) {
3067
+ const samples = language === "en" ? "`.gitignore`, `.stylelintignore`, `.stylelintrc*`, `.eslintrc*`, `eslint.config.*`, `.checkrc.js`, `package.json`, `package-lock.json`, `tsconfig*.json`, `.prettierrc*`, tool configs at repo root (`vitest.config.*`, `vite.config.*`, \u2026)" : "`.gitignore`\u3001`.stylelintignore`\u3001`.stylelintrc*`\u3001`.eslintrc*`\u3001`eslint.config.*`\u3001`.checkrc.js`\u3001`package.json`\u3001`package-lock.json`\u3001`tsconfig*.json`\u3001`.prettierrc*`\u3001\u4EE5\u53CA\u4ED3\u5E93\u6839\u76EE\u5F55\u4E0B\u7684 `vitest.config.*`\u3001`vite.config.*` \u7B49\u5DE5\u5177\u914D\u7F6E";
3068
+ return samples;
3069
+ }
3070
+ function renderWriteBoundaryGuardrail(language) {
3071
+ const rootList = renderRootConfigPathList(language);
3072
+ if (language === "en") {
3073
+ return `- Default write scope: \`src/**\`, \`openspec/**\`, and any \`**/.fet/**\`. All other paths need explicit user approval first.
3074
+ - **Repo-root config is high risk**: ${rootList} \u2014 list each file and why before editing; never change these silently.`;
3075
+ }
3076
+ return `- \u9ED8\u8BA4\u53EA\u5141\u8BB8\u4FEE\u6539 \`src/**\`\u3001\`openspec/**\` \u53CA\u4EFB\u610F \`**/.fet/**\`\uFF1B\u5176\u4F59\u8DEF\u5F84\u987B\u5148\u83B7\u7528\u6237\u660E\u786E\u540C\u610F\u3002
3077
+ - **\u4ED3\u5E93\u6839\u76EE\u5F55\u914D\u7F6E\u6587\u4EF6\u9AD8\u98CE\u9669**\uFF1A${rootList} \u2014 \u4FEE\u6539\u524D\u987B\u5217\u51FA\u6587\u4EF6\u4E0E\u539F\u56E0\uFF0C\u7981\u6B62\u64C5\u81EA\u6539\u52A8\u3002`;
3078
+ }
3079
+ function renderWriteBoundaryPolicyBody(language) {
3080
+ if (language === "en") {
3081
+ return `## Default allowed write scope
3082
+
3083
+ - \`src/**\` \u2014 application/library source
3084
+ - \`openspec/**\` \u2014 OpenSpec specs and change artifacts
3085
+ - \`**/.fet/**\` \u2014 per-change FET handoff files (including \`openspec/changes/<id>/.fet/\`)
3086
+
3087
+ ## Ask the user first
3088
+
3089
+ Before creating, editing, or deleting files **outside** the allowed scope:
3090
+
3091
+ 1. List every path you need to touch and why.
3092
+ 2. Wait for explicit user approval (do not assume silence means yes).
3093
+ 3. Prefer minimal diffs.
3094
+
3095
+ Common paths that need approval: \`tests/**\`, \`.github/**\`, \`.workflow/**\`, \`.cursor/**\`, \`.codex/**\`, \`AGENTS.md\`, \`README*\`, \`.env*\`.
3096
+
3097
+ ## Repo-root config (always ask \u2014 never silent edit)
3098
+
3099
+ These live at the **repository root** (no subdirectory). Treat every change as infrastructure impact:
3100
+
3101
+ ${renderRootConfigPathList(language)}
3102
+
3103
+ Workflow:
3104
+
3105
+ 1. State **exact filenames** (e.g. \`package.json\`, \`.eslintrc.js\`, \`.gitignore\`).
3106
+ 2. Explain **why** each file must change.
3107
+ 3. Wait for **explicit user approval** before writing.
3108
+ 4. Do not \u201Cfix lint/format\u201D by editing root configs unless the user requested that scope.
3109
+
3110
+ ## Forbidden without approval
3111
+
3112
+ - Dependency or lockfile changes (\`package.json\`, \`package-lock.json\`, etc.) unless the user asked
3113
+ - Lint/format/tooling config at repo root (\`.eslintrc*\`, \`.stylelint*\`, \`.checkrc.js\`, \`.gitignore\`, \u2026) unless the user asked
3114
+ - Secrets or credential files
3115
+ - Using shell redirects or destructive git commands to bypass this policy
3116
+
3117
+ ## Cursor enforcement
3118
+
3119
+ When \`.cursor/hooks/fet-guard-write-paths.mjs\` is installed, out-of-scope **write tools** trigger an approval prompt (\`permission: ask\`). Shell writes are gated by \`fet-guard-shell-writes.mjs\`. Rules alone are not sufficient\u2014keep hooks enabled after \`fet init\`.`;
3120
+ }
3121
+ return `## \u9ED8\u8BA4\u5141\u8BB8\u4FEE\u6539\u7684\u8303\u56F4
3122
+
3123
+ - \`src/**\` \u2014 \u4E1A\u52A1/\u5E93\u6E90\u7801
3124
+ - \`openspec/**\` \u2014 OpenSpec \u89C4\u8303\u4E0E change \u4EA7\u7269
3125
+ - \`**/.fet/**\` \u2014 \u5404 change \u7684 FET \u4EA4\u63A5\u6587\u4EF6\uFF08\u542B \`openspec/changes/<id>/.fet/\`\uFF09
3126
+
3127
+ ## \u987B\u5148\u5F81\u5F97\u7528\u6237\u540C\u610F
3128
+
3129
+ \u5728**\u5141\u8BB8\u8303\u56F4\u5916**\u521B\u5EFA\u3001\u4FEE\u6539\u6216\u5220\u9664\u6587\u4EF6\u4E4B\u524D\uFF1A
3130
+
3131
+ 1. \u5217\u51FA\u5C06\u8981\u4FEE\u6539\u7684\u8DEF\u5F84\u53CA\u539F\u56E0\u3002
3132
+ 2. \u7B49\u5F85\u7528\u6237\u660E\u786E\u540C\u610F\uFF08\u4E0D\u8981\u9ED8\u8BA4\u6C89\u9ED8\u5373\u540C\u610F\uFF09\u3002
3133
+ 3. \u4FDD\u6301\u6700\u5C0F\u6539\u52A8\u3002
3134
+
3135
+ \u5E38\u89C1\u9700\u5BA1\u6279\u8DEF\u5F84\uFF1A\`tests/**\`\u3001\`.github/**\`\u3001\`.workflow/**\`\u3001\`.cursor/**\`\u3001\`.codex/**\`\u3001\`AGENTS.md\`\u3001\`README*\`\u3001\`.env*\`\u3002
3136
+
3137
+ ## \u4ED3\u5E93\u6839\u76EE\u5F55\u914D\u7F6E\uFF08\u59CB\u7EC8\u987B\u8BE2\u95EE\uFF0C\u7981\u6B62\u9759\u9ED8\u4FEE\u6539\uFF09
3138
+
3139
+ \u4EE5\u4E0B\u6587\u4EF6\u4F4D\u4E8E**\u9879\u76EE\u6839\u76EE\u5F55**\uFF08\u8DEF\u5F84\u4E2D\u65E0\u5B50\u76EE\u5F55\uFF09\uFF0C\u4E00\u5F8B\u89C6\u4E3A\u57FA\u7840\u8BBE\u65BD\u7EA7\u6539\u52A8\uFF1A
3140
+
3141
+ ${renderRootConfigPathList(language)}
3142
+
3143
+ \u6D41\u7A0B\uFF1A
3144
+
3145
+ 1. \u660E\u786E\u5217\u51FA**\u5B8C\u6574\u6587\u4EF6\u540D**\uFF08\u5982 \`package.json\`\u3001\`.eslintrc.js\`\u3001\`.gitignore\`\uFF09\u3002
3146
+ 2. \u8BF4\u660E**\u6BCF\u9879\u4FEE\u6539\u7684\u539F\u56E0**\u3002
3147
+ 3. \u83B7\u5F97\u7528\u6237**\u660E\u786E\u540C\u610F**\u540E\u518D\u5199\u5165\u3002
3148
+ 4. \u4E0D\u8981\u4EE5\u300C\u987A\u4FBF\u4FEE lint/\u683C\u5F0F\u300D\u4E3A\u7531\u64C5\u81EA\u6539\u6839\u76EE\u5F55\u914D\u7F6E\u3002
3149
+
3150
+ ## \u672A\u7ECF\u540C\u610F\u7981\u6B62
3151
+
3152
+ - \u64C5\u81EA\u6539\u4F9D\u8D56\u6216\u9501\u6587\u4EF6\uFF08\`package.json\`\u3001\`package-lock.json\` \u7B49\uFF09\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42
3153
+ - \u64C5\u81EA\u6539\u6839\u76EE\u5F55 lint/\u683C\u5F0F\u5316/\u5DE5\u5177\u94FE\u914D\u7F6E\uFF08\`.eslintrc*\`\u3001\`.stylelint*\`\u3001\`.checkrc.js\`\u3001\`.gitignore\` \u7B49\uFF09\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42
3154
+ - \u4FEE\u6539\u5BC6\u94A5\u6216\u51ED\u8BC1\u6587\u4EF6
3155
+ - \u7528 shell \u91CD\u5B9A\u5411\u6216\u7834\u574F\u6027 git \u547D\u4EE4\u7ED5\u8FC7\u672C\u7B56\u7565
3156
+
3157
+ ## Cursor \u786C\u95E8\u7981
3158
+
3159
+ \u5B89\u88C5 \`.cursor/hooks/fet-guard-write-paths.mjs\` \u540E\uFF0C\u8D85\u51FA\u8303\u56F4\u7684**\u5199\u5DE5\u5177**\u4F1A\u5F39\u51FA\u5BA1\u6279\uFF08\`permission: ask\`\uFF09\uFF1B\`fet-guard-shell-writes.mjs\` \u7EA6\u675F\u53EF\u80FD\u5199\u6587\u4EF6\u7684 shell\u3002\u4EC5\u89C4\u5219\u4E0D\u591F\u2014\u2014\`fet init\` \u540E\u8BF7\u4FDD\u6301 hooks \u542F\u7528\u3002`;
3160
+ }
3161
+ function renderCursorWriteBoundaryRule(language) {
3162
+ const description = language === "en" ? "FET write boundary: default edits only under src/, openspec/, and .fet/" : "FET \u5199\u8DEF\u5F84\u8FB9\u754C\uFF1A\u9ED8\u8BA4\u4EC5\u53EF\u6539 src/\u3001openspec/\u3001.fet/";
3163
+ return `<!-- FET:MANAGED
3164
+ schemaVersion: 1
3165
+ fetVersion: ${FET_VERSION}
3166
+ generator: cursor-adapter
3167
+ adapterVersion: 1
3168
+ FET:END -->
3169
+
3170
+ ---
3171
+ description: ${description}
3172
+ alwaysApply: true
3173
+ ---
3174
+
3175
+ ${renderWriteBoundaryPolicyBody(language)}
3176
+
3177
+ ${renderWriteBoundaryGuardrail(language)}
3178
+ `;
3179
+ }
3180
+ function renderCodexWriteBoundaryGuide(language) {
3181
+ return `<!-- FET:MANAGED
3182
+ schemaVersion: 1
3183
+ fetVersion: ${FET_VERSION}
3184
+ generator: codex-adapter
3185
+ adapterVersion: 1
3186
+ FET:END -->
3187
+
3188
+ # ${language === "en" ? "Write path boundary (Codex)" : "\u5199\u8DEF\u5F84\u8FB9\u754C\uFF08Codex\uFF09"}
3189
+
3190
+ ${renderWriteBoundaryPolicyBody(language)}
3191
+
3192
+ ${renderWriteBoundaryGuardrail(language)}
3193
+ `;
3194
+ }
3195
+ function renderCursorWritePathsHookMjs() {
3196
+ const allowPrefixes = [...WRITE_BOUNDARY_ALLOW_PREFIXES];
3197
+ const rootExact = [...WRITE_BOUNDARY_ROOT_CONFIG_EXACT];
3198
+ const rootPrefixes = [...WRITE_BOUNDARY_ROOT_CONFIG_BASENAME_PREFIXES];
3199
+ return `#!/usr/bin/env node
3200
+ /**
3201
+ * FET:MANAGED
3202
+ * adapterVersion: 1
3203
+ * write-path guard for Cursor preToolUse (Write/StrReplace/EditNotebook/Delete).
3204
+ */
3205
+ import { readFileSync } from "node:fs";
3206
+
3207
+ const ALLOW_PREFIXES = ${JSON.stringify(allowPrefixes)};
3208
+ const ROOT_CONFIG_EXACT = ${JSON.stringify(rootExact)};
3209
+ const ROOT_CONFIG_PREFIXES = ${JSON.stringify(rootPrefixes)};
3210
+
3211
+ function normalizePath(path) {
3212
+ return String(path ?? "")
3213
+ .replaceAll("\\\\", "/")
3214
+ .replace(/^\\.\\/+/, "")
3215
+ .replace(/^\\/+/u, "");
3216
+ }
3217
+
3218
+ function isRootConfigPath(path) {
3219
+ const normalized = normalizePath(path);
3220
+ if (!normalized || normalized.includes("/")) return false;
3221
+ const base = normalized.toLowerCase();
3222
+ if (ROOT_CONFIG_EXACT.includes(base)) return true;
3223
+ return ROOT_CONFIG_PREFIXES.some((prefix) => base === prefix || base.startsWith(prefix));
3224
+ }
3225
+
3226
+ function classifyPath(path) {
3227
+ const normalized = normalizePath(path);
3228
+ if (!normalized) return "ask";
3229
+ for (const prefix of ALLOW_PREFIXES) {
3230
+ const bare = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
3231
+ if (normalized === bare || normalized.startsWith(prefix)) return "allow";
3232
+ }
3233
+ if (normalized.startsWith(".fet/") || normalized.includes("/.fet/")) return "allow";
3234
+ if (isRootConfigPath(normalized)) return "root_config";
3235
+ return "ask";
3236
+ }
3237
+
3238
+ function extractPaths(payload) {
3239
+ const paths = [];
3240
+ const tool = payload?.tool_name ?? payload?.toolName ?? "";
3241
+ const input = payload?.tool_input ?? payload?.toolInput ?? payload?.input ?? {};
3242
+ if (tool === "Write" || tool === "StrReplace" || tool === "Delete") {
3243
+ if (input.path) paths.push(input.path);
3244
+ }
3245
+ if (tool === "EditNotebook" && input.target_notebook) {
3246
+ paths.push(input.target_notebook);
3247
+ }
3248
+ return paths;
3249
+ }
3250
+
3251
+ function respond(decision, paths) {
3252
+ if (decision === "allow") {
3253
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
3254
+ return;
3255
+ }
3256
+ const list = paths.map((p) => normalizePath(p)).filter(Boolean).join(", ") || "(unknown path)";
3257
+ const hasRootConfig = paths.some((p) => classifyPath(p) === "root_config");
3258
+ const msg = hasRootConfig
3259
+ ? "FET: repo-root config file(s) require your explicit approval (.gitignore, package.json, .eslintrc*, .stylelint*, .checkrc.js, lockfiles, etc.). Approve only if you requested this tooling change."
3260
+ : "FET write boundary: this edit is outside src/, openspec/, or **/.fet/. Approve only if you intend to modify protected paths.";
3261
+ process.stdout.write(
3262
+ JSON.stringify({
3263
+ permission: "ask",
3264
+ user_message: msg + " Paths: " + list,
3265
+ agent_message: hasRootConfig
3266
+ ? "Repo-root config edit blocked. Explain why each root config file must change and wait for user approval."
3267
+ : "Out-of-scope write blocked pending user approval. List why each path is needed, then retry after approval."
3268
+ })
3269
+ );
3270
+ }
3271
+
3272
+ const raw = readFileSync(0, "utf8");
3273
+ let payload = {};
3274
+ try {
3275
+ payload = JSON.parse(raw || "{}");
3276
+ } catch {
3277
+ process.stdout.write(JSON.stringify({ permission: "ask", user_message: "FET write guard: invalid hook payload." }));
3278
+ process.exit(0);
3279
+ }
3280
+
3281
+ const paths = extractPaths(payload);
3282
+ if (paths.length === 0) {
3283
+ respond("allow", paths);
3284
+ process.exit(0);
3285
+ }
3286
+
3287
+ const decisions = paths.map((p) => classifyPath(p));
3288
+ if (decisions.every((d) => d === "allow")) {
3289
+ respond("allow", paths);
3290
+ } else {
3291
+ respond("ask", paths);
3292
+ }
3293
+ process.exit(0);
3294
+ `;
3295
+ }
3296
+ function renderCursorShellWriteHookMjs() {
3297
+ return `#!/usr/bin/env node
3298
+ /**
3299
+ * FET:MANAGED
3300
+ * adapterVersion: 1
3301
+ * shell guard for commands that may write outside the FET write boundary.
3302
+ */
3303
+ import { readFileSync } from "node:fs";
3304
+
3305
+ const REDIRECT = />\\s*[^\\s|&;]+/;
3306
+ const GIT_WRITE = /\\bgit\\s+(checkout|restore|reset|clean|apply)\\b/i;
3307
+ const PKG_WRITE = /\\b(npm|pnpm|yarn|bun)\\s+(install|ci|add|remove|update)\\b/i;
3308
+ const STREAM_EDIT = /\\b(sed|perl)\\s+[^\\n]*-i\\b/i;
3309
+ const TEE = /\\btee\\s+[^|;&\\n]+/i;
3310
+
3311
+ function respondAsk(command, reason) {
3312
+ process.stdout.write(
3313
+ JSON.stringify({
3314
+ permission: "ask",
3315
+ user_message: "FET shell guard: " + reason,
3316
+ agent_message: "Shell command may modify files outside src/openspec/.fet. Command: " + command
3317
+ })
3318
+ );
3319
+ }
3320
+
3321
+ const raw = readFileSync(0, "utf8");
3322
+ let payload = {};
3323
+ try {
3324
+ payload = JSON.parse(raw || "{}");
3325
+ } catch {
3326
+ respondAsk("", "invalid hook payload");
3327
+ process.exit(0);
3328
+ }
3329
+
3330
+ const command = String(payload?.command ?? "");
3331
+ if (!command.trim()) {
3332
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
3333
+ process.exit(0);
3334
+ }
3335
+
3336
+ if (
3337
+ REDIRECT.test(command) ||
3338
+ GIT_WRITE.test(command) ||
3339
+ PKG_WRITE.test(command) ||
3340
+ STREAM_EDIT.test(command) ||
3341
+ TEE.test(command)
3342
+ ) {
3343
+ respondAsk(command, "command may write or replace project files outside the default FET scope");
3344
+ process.exit(0);
3345
+ }
3346
+
3347
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
3348
+ process.exit(0);
3349
+ `;
3350
+ }
3351
+ function renderCursorHooksJson() {
3352
+ return JSON.stringify(
3353
+ {
3354
+ version: 1,
3355
+ _fet: {
3356
+ writeBoundary: true,
3357
+ fetVersion: FET_VERSION
3358
+ },
3359
+ hooks: {
3360
+ preToolUse: [
3361
+ {
3362
+ command: ".cursor/hooks/fet-guard-write-paths.mjs",
3363
+ matcher: "Write|StrReplace|EditNotebook|Delete",
3364
+ failClosed: true
3365
+ }
3366
+ ],
3367
+ beforeShellExecution: [
3368
+ {
3369
+ command: ".cursor/hooks/fet-guard-shell-writes.mjs",
3370
+ failClosed: true
3371
+ }
3372
+ ]
3373
+ }
3374
+ },
3375
+ null,
3376
+ 2
3377
+ );
3378
+ }
3379
+ function mergeCursorHooksJson(existingContent, fetContent) {
3380
+ const fet = JSON.parse(fetContent);
3381
+ if (!existingContent?.trim()) {
3382
+ return fetContent;
3383
+ }
3384
+ let existing;
3385
+ try {
3386
+ existing = JSON.parse(existingContent);
3387
+ } catch {
3388
+ return fetContent;
3389
+ }
3390
+ const merged = {
3391
+ version: existing.version ?? fet.version ?? 1,
3392
+ _fet: { ...existing._fet, ...fet._fet, writeBoundary: true },
3393
+ hooks: { ...existing.hooks }
3394
+ };
3395
+ for (const [event, fetEntries] of Object.entries(fet.hooks ?? {})) {
3396
+ const current = [...merged.hooks?.[event] ?? []];
3397
+ for (const entry of fetEntries) {
3398
+ const duplicate = current.some((item) => item.command === entry.command);
3399
+ if (!duplicate) {
3400
+ current.push(entry);
3401
+ }
3402
+ }
3403
+ merged.hooks = merged.hooks ?? {};
3404
+ merged.hooks[event] = current;
3405
+ }
3406
+ return `${JSON.stringify(merged, null, 2)}
3407
+ `;
3408
+ }
3409
+
3003
3410
  // src/commands/update-context.ts
3004
3411
  async function updateContextCommand(ctx) {
3005
3412
  let contextResult = { warnings: [] };
@@ -3161,8 +3568,8 @@ async function exists4(path) {
3161
3568
  }
3162
3569
 
3163
3570
  // src/commands/proxy.ts
3164
- import { readFile as readFile17 } from "fs/promises";
3165
- import { join as join22 } from "path";
3571
+ import { readFile as readFile19 } from "fs/promises";
3572
+ import { join as join26 } from "path";
3166
3573
 
3167
3574
  // src/figma-guard.ts
3168
3575
  import { readdir as readdir3, readFile as readFile10, stat as stat7 } from "fs/promises";
@@ -3262,66 +3669,812 @@ async function ensureChangeFigmaStopHandoff(options) {
3262
3669
  await atomicWrite(stopAbsolutePath, stopContent);
3263
3670
  written = true;
3264
3671
  }
3265
- let applyInstructionsPath;
3266
- if (config.mode === "require_before_ui") {
3267
- applyInstructionsPath = figmaApplyInstructionsRelativePath(options.changeId);
3268
- const applyAbsolutePath = join16(options.projectRoot, applyInstructionsPath);
3269
- const applyContent = renderChangeFigmaApplyInstructions({
3672
+ let applyInstructionsPath;
3673
+ if (config.mode === "require_before_ui") {
3674
+ applyInstructionsPath = figmaApplyInstructionsRelativePath(options.changeId);
3675
+ const applyAbsolutePath = join16(options.projectRoot, applyInstructionsPath);
3676
+ const applyContent = renderChangeFigmaApplyInstructions({
3677
+ changeId: options.changeId,
3678
+ generatedAt,
3679
+ urls,
3680
+ sources,
3681
+ language: options.language
3682
+ });
3683
+ const existingApply = await readOptional3(applyAbsolutePath);
3684
+ if (existingApply !== applyContent) {
3685
+ await atomicWrite(applyAbsolutePath, applyContent);
3686
+ written = true;
3687
+ }
3688
+ }
3689
+ return {
3690
+ path: stopRelativePath,
3691
+ applyInstructionsPath,
3692
+ written,
3693
+ urls,
3694
+ sources,
3695
+ mode: config.mode
3696
+ };
3697
+ }
3698
+ async function listMarkdownFiles(root) {
3699
+ const files = [];
3700
+ await walk(root, files);
3701
+ return files;
3702
+ }
3703
+ async function walk(dir, files) {
3704
+ try {
3705
+ const entries = await readdir3(dir, { withFileTypes: true });
3706
+ for (const entry of entries) {
3707
+ const fullPath = join16(dir, entry.name);
3708
+ if (entry.isDirectory()) {
3709
+ await walk(fullPath, files);
3710
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
3711
+ files.push(fullPath);
3712
+ }
3713
+ }
3714
+ } catch {
3715
+ return;
3716
+ }
3717
+ }
3718
+ async function readOptional3(path) {
3719
+ try {
3720
+ await stat7(path);
3721
+ return await readFile10(path, "utf8");
3722
+ } catch {
3723
+ return null;
3724
+ }
3725
+ }
3726
+
3727
+ // src/figma/config.ts
3728
+ import { readFile as readFile11 } from "fs/promises";
3729
+ import { join as join17 } from "path";
3730
+ import { parseDocument as parseDocument3 } from "yaml";
3731
+ var DEFAULT_FIGMA_SYNC_CONFIG = {
3732
+ enabled: true,
3733
+ apiKeyEnv: "FIGMA_ACCESS_TOKEN",
3734
+ inlineImageMaxBytes: 32768,
3735
+ requireBeforeUi: true
3736
+ };
3737
+ async function loadFigmaSyncConfig(projectRoot) {
3738
+ try {
3739
+ const raw = await readFile11(join17(projectRoot, "openspec", "config.yaml"), "utf8");
3740
+ const doc = parseDocument3(raw);
3741
+ const fetNode = doc.get("fet", true);
3742
+ const guardNode = fetNode?.get?.("figmaGuard");
3743
+ const syncNode = guardNode?.get?.("sync");
3744
+ if (!syncNode || typeof syncNode.get !== "function") {
3745
+ return DEFAULT_FIGMA_SYNC_CONFIG;
3746
+ }
3747
+ const enabled = syncNode.get("enabled");
3748
+ const apiKeyEnv = syncNode.get("apiKeyEnv");
3749
+ const inlineImageMaxBytes = syncNode.get("inlineImageMaxBytes");
3750
+ const requireBeforeUi = syncNode.get("requireBeforeUi");
3751
+ return {
3752
+ enabled: enabled === void 0 ? true : Boolean(enabled),
3753
+ apiKeyEnv: typeof apiKeyEnv === "string" && apiKeyEnv.trim() ? apiKeyEnv.trim() : DEFAULT_FIGMA_SYNC_CONFIG.apiKeyEnv,
3754
+ inlineImageMaxBytes: typeof inlineImageMaxBytes === "number" && inlineImageMaxBytes > 0 ? inlineImageMaxBytes : DEFAULT_FIGMA_SYNC_CONFIG.inlineImageMaxBytes,
3755
+ requireBeforeUi: requireBeforeUi === void 0 ? true : Boolean(requireBeforeUi)
3756
+ };
3757
+ } catch {
3758
+ return DEFAULT_FIGMA_SYNC_CONFIG;
3759
+ }
3760
+ }
3761
+ function resolveFigmaAccessToken(config, explicitToken) {
3762
+ if (explicitToken?.trim()) {
3763
+ return explicitToken.trim();
3764
+ }
3765
+ const fromEnv = process.env[config.apiKeyEnv];
3766
+ return fromEnv?.trim() ? fromEnv.trim() : null;
3767
+ }
3768
+
3769
+ // src/figma/sync.ts
3770
+ import { mkdir as mkdir7, writeFile as writeFile2 } from "fs/promises";
3771
+ import { join as join21 } from "path";
3772
+
3773
+ // src/tdd/fingerprint.ts
3774
+ import { createHash } from "crypto";
3775
+ import { readdir as readdir4, readFile as readFile12, stat as stat8 } from "fs/promises";
3776
+ import { join as join18, relative as relative3 } from "path";
3777
+ async function collectPlanningSources(projectRoot, changeId) {
3778
+ const changeRoot = join18(projectRoot, "openspec", "changes", changeId);
3779
+ const sources = [];
3780
+ const rootFiles = ["proposal.md", "tasks.md", "design.md"];
3781
+ for (const name of rootFiles) {
3782
+ const path = join18(changeRoot, name);
3783
+ if (await exists5(path)) {
3784
+ sources.push(relative3(projectRoot, path).replace(/\\/g, "/"));
3785
+ }
3786
+ }
3787
+ const specsDir = join18(changeRoot, "specs");
3788
+ if (await exists5(specsDir)) {
3789
+ for (const file of await walkFiles(specsDir)) {
3790
+ if (file.endsWith(".md")) {
3791
+ sources.push(relative3(projectRoot, file).replace(/\\/g, "/"));
3792
+ }
3793
+ }
3794
+ }
3795
+ return sources.sort();
3796
+ }
3797
+ async function computePlanningFingerprint(projectRoot, changeId) {
3798
+ const sources = await collectPlanningSources(projectRoot, changeId);
3799
+ const hash = createHash("sha256");
3800
+ for (const source of sources) {
3801
+ const content = await readFile12(join18(projectRoot, source), "utf8");
3802
+ hash.update(source);
3803
+ hash.update("\0");
3804
+ hash.update(content);
3805
+ hash.update("\0");
3806
+ }
3807
+ return `sha256:${hash.digest("hex")}`;
3808
+ }
3809
+ async function walkFiles(dir) {
3810
+ const entries = await readdir4(dir, { withFileTypes: true });
3811
+ const files = [];
3812
+ for (const entry of entries) {
3813
+ const path = join18(dir, entry.name);
3814
+ if (entry.isDirectory()) {
3815
+ files.push(...await walkFiles(path));
3816
+ } else if (entry.isFile()) {
3817
+ files.push(path);
3818
+ }
3819
+ }
3820
+ return files;
3821
+ }
3822
+ async function exists5(path) {
3823
+ try {
3824
+ await stat8(path);
3825
+ return true;
3826
+ } catch {
3827
+ return false;
3828
+ }
3829
+ }
3830
+
3831
+ // src/figma/api.ts
3832
+ var FIGMA_API_BASE = "https://api.figma.com/v1";
3833
+ async function fetchFigmaFile(fileKey, accessToken, nodeIds) {
3834
+ const params = new URLSearchParams();
3835
+ if (nodeIds?.length) {
3836
+ params.set("ids", nodeIds.join(","));
3837
+ params.set("depth", "8");
3838
+ }
3839
+ const query = params.size ? `?${params.toString()}` : "";
3840
+ const url = nodeIds?.length ? `${FIGMA_API_BASE}/files/${fileKey}/nodes${query}` : `${FIGMA_API_BASE}/files/${fileKey}${query}`;
3841
+ const response = await fetch(url, {
3842
+ headers: { "X-Figma-Token": accessToken }
3843
+ });
3844
+ if (!response.ok) {
3845
+ throw new FetError({
3846
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3847
+ message: `Figma API request failed (${response.status})`,
3848
+ details: { fileKey, status: response.status, statusText: response.statusText },
3849
+ recoverable: true,
3850
+ suggestedCommand: "Check FIGMA_ACCESS_TOKEN and file permissions, then rerun fet figma sync."
3851
+ });
3852
+ }
3853
+ const body = await response.json();
3854
+ if (body.nodes && nodeIds?.length) {
3855
+ const first = nodeIds.map((id) => body.nodes?.[id]?.document).find(Boolean);
3856
+ if (!first) {
3857
+ throw new FetError({
3858
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3859
+ message: "Figma API returned no nodes for the requested node-id.",
3860
+ details: { fileKey, nodeIds },
3861
+ recoverable: true
3862
+ });
3863
+ }
3864
+ return {
3865
+ name: body.name ?? fileKey,
3866
+ document: first,
3867
+ components: body.components,
3868
+ styles: body.styles
3869
+ };
3870
+ }
3871
+ if (!body.document) {
3872
+ throw new FetError({
3873
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3874
+ message: "Figma API returned an empty document.",
3875
+ details: { fileKey },
3876
+ recoverable: true
3877
+ });
3878
+ }
3879
+ return {
3880
+ name: body.name ?? fileKey,
3881
+ document: body.document,
3882
+ components: body.components,
3883
+ styles: body.styles
3884
+ };
3885
+ }
3886
+ async function fetchFigmaImageUrls(fileKey, accessToken, imageRefs) {
3887
+ if (!imageRefs.length) {
3888
+ return {};
3889
+ }
3890
+ const response = await fetch(`${FIGMA_API_BASE}/files/${fileKey}/images`, {
3891
+ headers: { "X-Figma-Token": accessToken }
3892
+ });
3893
+ if (!response.ok) {
3894
+ throw new FetError({
3895
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
3896
+ message: `Figma image metadata request failed (${response.status})`,
3897
+ details: { fileKey, status: response.status },
3898
+ recoverable: true
3899
+ });
3900
+ }
3901
+ const body = await response.json();
3902
+ const result = {};
3903
+ for (const ref of imageRefs) {
3904
+ const url = body.images?.[ref];
3905
+ if (url) {
3906
+ result[ref] = url;
3907
+ }
3908
+ }
3909
+ return result;
3910
+ }
3911
+ async function fetchFigmaNodeExports(fileKey, accessToken, nodeIds, format = "png") {
3912
+ if (!nodeIds.length) {
3913
+ return {};
3914
+ }
3915
+ const params = new URLSearchParams({
3916
+ ids: nodeIds.join(","),
3917
+ format,
3918
+ scale: "2"
3919
+ });
3920
+ const response = await fetch(`${FIGMA_API_BASE}/images/${fileKey}?${params.toString()}`, {
3921
+ headers: { "X-Figma-Token": accessToken }
3922
+ });
3923
+ if (!response.ok) {
3924
+ return {};
3925
+ }
3926
+ const body = await response.json();
3927
+ const result = {};
3928
+ for (const id of nodeIds) {
3929
+ const url = body.images?.[id];
3930
+ if (url) {
3931
+ result[id] = url;
3932
+ }
3933
+ }
3934
+ return result;
3935
+ }
3936
+ async function downloadBinary(url) {
3937
+ const response = await fetch(url);
3938
+ if (!response.ok) {
3939
+ throw new FetError({
3940
+ code: "FILE_SYSTEM_ERROR" /* FileSystemError */,
3941
+ message: `Failed to download Figma asset (${response.status})`,
3942
+ details: { url },
3943
+ recoverable: true
3944
+ });
3945
+ }
3946
+ const buffer = new Uint8Array(await response.arrayBuffer());
3947
+ const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
3948
+ return { bytes: buffer, mimeType };
3949
+ }
3950
+
3951
+ // src/figma/download-assets.ts
3952
+ import { mkdir as mkdir6, writeFile } from "fs/promises";
3953
+ import { join as join19 } from "path";
3954
+
3955
+ // src/figma/paths.ts
3956
+ function figmaFetDirRelative(changeId) {
3957
+ return `openspec/changes/${changeId}/.fet`;
3958
+ }
3959
+ function figmaDesignManifestRelativePath2(changeId) {
3960
+ return `${figmaFetDirRelative(changeId)}/figma-design-manifest.yaml`;
3961
+ }
3962
+ function figmaNodesRelativePath(changeId) {
3963
+ return `${figmaFetDirRelative(changeId)}/figma-nodes.json`;
3964
+ }
3965
+ function figmaSyncInstructionsRelativePath2(changeId) {
3966
+ return `${figmaFetDirRelative(changeId)}/figma-sync-instructions.md`;
3967
+ }
3968
+ function figmaAssetsDirRelative(changeId) {
3969
+ return `${figmaFetDirRelative(changeId)}/figma-assets`;
3970
+ }
3971
+ function figmaAssetFileRelative(changeId, fileKey, assetId, ext) {
3972
+ const safeId = assetId.replace(/:/g, "-");
3973
+ return `${figmaAssetsDirRelative(changeId)}/${fileKey}/${safeId}.${ext}`;
3974
+ }
3975
+
3976
+ // src/figma/download-assets.ts
3977
+ function extensionForMime(mimeType) {
3978
+ if (mimeType.includes("svg")) {
3979
+ return "svg";
3980
+ }
3981
+ if (mimeType.includes("jpeg") || mimeType.includes("jpg")) {
3982
+ return "jpg";
3983
+ }
3984
+ if (mimeType.includes("webp")) {
3985
+ return "webp";
3986
+ }
3987
+ return "png";
3988
+ }
3989
+ function toBase64(bytes) {
3990
+ return Buffer.from(bytes).toString("base64");
3991
+ }
3992
+ async function downloadFigmaAssets(options) {
3993
+ const assets = [];
3994
+ const assetsRoot = join19(options.projectRoot, "openspec", "changes", options.changeId, ".fet", "figma-assets", options.fileKey);
3995
+ await mkdir6(assetsRoot, { recursive: true });
3996
+ const refUrls = await fetchFigmaImageUrls(options.fileKey, options.accessToken, [
3997
+ ...new Set(options.imageRefs.map((item) => item.ref))
3998
+ ]);
3999
+ for (const item of options.imageRefs) {
4000
+ const url = refUrls[item.ref];
4001
+ if (!url) {
4002
+ continue;
4003
+ }
4004
+ const { bytes, mimeType } = await downloadBinary(url);
4005
+ const ext = extensionForMime(mimeType);
4006
+ const assetId = `ref-${item.ref}`;
4007
+ const relativePath = figmaAssetFileRelative(options.changeId, options.fileKey, assetId, ext);
4008
+ const entry = await persistAsset({
4009
+ projectRoot: options.projectRoot,
4010
+ relativePath,
4011
+ assetId,
4012
+ name: item.name,
4013
+ nodeId: item.nodeId,
4014
+ imageRef: item.ref,
4015
+ kind: "image-ref",
4016
+ bytes,
4017
+ mimeType,
4018
+ config: options.config
4019
+ });
4020
+ assets.push(entry);
4021
+ }
4022
+ const exportIds = options.exportNodeIds.map((item) => item.nodeId).filter((id) => !assets.some((asset) => asset.nodeId === id));
4023
+ if (!exportIds.length) {
4024
+ return assets;
4025
+ }
4026
+ const exportUrls = await fetchFigmaNodeExports(options.fileKey, options.accessToken, exportIds.slice(0, 30));
4027
+ for (const item of options.exportNodeIds) {
4028
+ const url = exportUrls[item.nodeId];
4029
+ if (!url) {
4030
+ continue;
4031
+ }
4032
+ const { bytes, mimeType } = await downloadBinary(url);
4033
+ const ext = extensionForMime(mimeType);
4034
+ const relativePath = figmaAssetFileRelative(options.changeId, options.fileKey, item.nodeId, ext);
4035
+ const entry = await persistAsset({
4036
+ projectRoot: options.projectRoot,
4037
+ relativePath,
4038
+ assetId: item.nodeId,
4039
+ name: item.name,
4040
+ nodeId: item.nodeId,
4041
+ kind: "node-export",
4042
+ bytes,
4043
+ mimeType,
4044
+ config: options.config
4045
+ });
4046
+ assets.push(entry);
4047
+ }
4048
+ return assets;
4049
+ }
4050
+ async function persistAsset(options) {
4051
+ const absolutePath = join19(options.projectRoot, options.relativePath);
4052
+ await mkdir6(join19(absolutePath, ".."), { recursive: true });
4053
+ if (options.bytes.byteLength <= options.config.inlineImageMaxBytes) {
4054
+ const inlinePath = `${options.relativePath}.inline.json`;
4055
+ await writeFile(
4056
+ join19(options.projectRoot, inlinePath),
4057
+ JSON.stringify({
4058
+ mimeType: options.mimeType,
4059
+ base64: toBase64(options.bytes)
4060
+ }),
4061
+ "utf8"
4062
+ );
4063
+ return {
4064
+ id: options.assetId,
4065
+ kind: options.kind,
4066
+ name: options.name,
4067
+ nodeId: options.nodeId,
4068
+ imageRef: options.imageRef,
4069
+ storage: "inline",
4070
+ relativePath: inlinePath,
4071
+ mimeType: options.mimeType,
4072
+ bytes: options.bytes.byteLength
4073
+ };
4074
+ }
4075
+ await writeFile(absolutePath, options.bytes);
4076
+ return {
4077
+ id: options.assetId,
4078
+ kind: options.kind,
4079
+ name: options.name,
4080
+ nodeId: options.nodeId,
4081
+ imageRef: options.imageRef,
4082
+ storage: "file",
4083
+ relativePath: options.relativePath,
4084
+ mimeType: options.mimeType,
4085
+ bytes: options.bytes.byteLength
4086
+ };
4087
+ }
4088
+
4089
+ // src/figma/manifest.ts
4090
+ import { readFile as readFile13 } from "fs/promises";
4091
+ import { join as join20 } from "path";
4092
+ import { parseDocument as parseDocument4, stringify as stringify3 } from "yaml";
4093
+ async function readFigmaDesignManifest(projectRoot, changeId) {
4094
+ try {
4095
+ const raw = await readFile13(join20(projectRoot, figmaDesignManifestRelativePath2(changeId)), "utf8");
4096
+ const doc = parseDocument4(raw).toJSON();
4097
+ if (!doc?.schemaVersion) {
4098
+ return null;
4099
+ }
4100
+ return doc;
4101
+ } catch {
4102
+ return null;
4103
+ }
4104
+ }
4105
+ function renderFigmaDesignManifestYaml(manifest) {
4106
+ return stringify3(manifest);
4107
+ }
4108
+
4109
+ // src/templates/figma-sync.ts
4110
+ function renderFigmaSyncInstructions(options) {
4111
+ const title = options.language === "en" ? "Figma sync (this change)" : "Figma \u540C\u6B65\uFF08\u672C change\uFF09";
4112
+ const intro = options.language === "en" ? `FET synced **${options.nodeCount}** nodes and **${options.assetCount}** assets (${options.fileAssetCount} saved as files on disk). Use this data before UI implementation\u2014do not guess visuals.` : `FET \u5DF2\u540C\u6B65 **${options.nodeCount}** \u4E2A\u8282\u70B9\u3001**${options.assetCount}** \u4E2A\u7D20\u6750\uFF08\u5176\u4E2D **${options.fileAssetCount}** \u4E2A\u5927\u56FE\u5DF2\u843D\u76D8\uFF09\u3002\u5B9E\u65BD UI \u524D\u5FC5\u987B\u57FA\u4E8E\u8FD9\u4E9B\u6570\u636E\uFF0C\u7981\u6B62\u731C\u6D4B\u89C6\u89C9\u6548\u679C\u3002`;
4113
+ const readSteps = options.language === "en" ? `## Required reading order (before UI code)
4114
+
4115
+ 1. \`${options.manifestPath}\` \u2014 node summaries, colors, typography, asset paths.
4116
+ 2. \`${options.nodesPath}\` \u2014 full Figma node tree (JSON) for layout and nested structure.
4117
+ 3. \`openspec/changes/${options.changeId}/.fet/figma-assets/\` \u2014 background images and exports (use file paths in code, not base64 for large assets).
4118
+ 4. \`figma-apply-instructions.md\` and \`figma-stop.md\` in the same folder \u2014 guard rules.
4119
+
4120
+ ## Asset usage in code
4121
+
4122
+ - **storage: file** \u2192 import or reference the \`relativePath\` under the project root (e.g. \`public/\` or \`src/assets/\` after copy).
4123
+ - **storage: inline** \u2192 small icons only; read \`.inline.json\` if needed.
4124
+ - Never embed multi\u2011hundred\u2011KB images as base64 in source.` : `## \u5B9E\u65BD UI \u524D\u5FC5\u8BFB\uFF08\u987A\u5E8F\uFF09
4125
+
4126
+ 1. \`${options.manifestPath}\` \u2014 \u8282\u70B9\u6458\u8981\u3001\u989C\u8272\u3001\u5B57\u53F7\u3001\u7D20\u6750\u8DEF\u5F84\u3002
4127
+ 2. \`${options.nodesPath}\` \u2014 \u5B8C\u6574 Figma \u8282\u70B9\u6811\uFF08JSON\uFF09\uFF0C\u7528\u4E8E\u5E03\u5C40\u4E0E\u5D4C\u5957\u7ED3\u6784\u3002
4128
+ 3. \`openspec/changes/${options.changeId}/.fet/figma-assets/\` \u2014 \u80CC\u666F\u56FE\u4E0E\u5BFC\u51FA\u56FE\uFF08\u4EE3\u7801\u4E2D\u7528\u6587\u4EF6\u8DEF\u5F84\uFF0C\u5927\u56FE\u7981\u6B62 base64\uFF09\u3002
4129
+ 4. \u540C\u76EE\u5F55 \`figma-apply-instructions.md\`\u3001\`figma-stop.md\` \u2014 \u5B88\u536B\u89C4\u5219\u3002
4130
+
4131
+ ## \u4EE3\u7801\u4E2D\u5982\u4F55\u4F7F\u7528\u7D20\u6750
4132
+
4133
+ - **storage: file** \u2192 \u5C06 \`relativePath\` \u590D\u5236\u5230 \`public/\` \u6216 \`src/assets/\` \u540E\u5F15\u7528\u3002
4134
+ - **storage: inline** \u2192 \u4EC5\u7528\u4E8E\u5C0F\u56FE\u6807\uFF1B\u5FC5\u8981\u65F6\u8BFB\u53D6 \`.inline.json\`\u3002
4135
+ - \u7981\u6B62\u5728\u6E90\u7801\u4E2D\u5185\u8054\u6570\u767E KB \u4EE5\u4E0A\u7684 base64 \u56FE\u7247\u3002`;
4136
+ return `---
4137
+ schemaVersion: 1
4138
+ fetVersion: ${FET_VERSION}
4139
+ generatedAt: ${options.generatedAt}
4140
+ changeId: ${options.changeId}
4141
+ purpose: figma-sync
4142
+ ---
4143
+
4144
+ # ${title}
4145
+
4146
+ ${intro}
4147
+
4148
+ ${readSteps}
4149
+ `;
4150
+ }
4151
+ function renderFigmaSyncNextSteps(changeId, language, apiKeyEnv) {
4152
+ if (language === "en") {
4153
+ return [
4154
+ `Run fet figma sync --change ${changeId} (set ${apiKeyEnv}) to fetch all Figma nodes and download assets before UI work.`,
4155
+ `Read openspec/changes/${changeId}/.fet/figma-sync-instructions.md and figma-design-manifest.yaml after sync.`
4156
+ ];
4157
+ }
4158
+ return [
4159
+ `\u5B9E\u65BD UI \u524D\u6267\u884C fet figma sync --change ${changeId}\uFF08\u914D\u7F6E ${apiKeyEnv}\uFF09\uFF0C\u62C9\u53D6\u5168\u90E8\u8282\u70B9\u5E76\u4E0B\u8F7D\u7D20\u6750\u3002`,
4160
+ `\u540C\u6B65\u540E\u9605\u8BFB openspec/changes/${changeId}/.fet/figma-sync-instructions.md \u4E0E figma-design-manifest.yaml\u3002`
4161
+ ];
4162
+ }
4163
+
4164
+ // src/figma/url.ts
4165
+ var FILE_KEY_PATTERN = /figma\.com\/(?:file|design|proto)\/([a-zA-Z0-9]+)/i;
4166
+ var NODE_ID_PATTERN = /[?&]node-id=([\d]+)-([\d]+)/i;
4167
+ function parseFigmaUrl(url) {
4168
+ const fileMatch = url.match(FILE_KEY_PATTERN);
4169
+ if (!fileMatch?.[1]) {
4170
+ return null;
4171
+ }
4172
+ const nodeMatch = url.match(NODE_ID_PATTERN);
4173
+ return {
4174
+ fileKey: fileMatch[1],
4175
+ nodeId: nodeMatch ? `${nodeMatch[1]}:${nodeMatch[2]}` : void 0,
4176
+ url
4177
+ };
4178
+ }
4179
+ function parseFigmaUrls(urls) {
4180
+ const seen = /* @__PURE__ */ new Set();
4181
+ const parsed = [];
4182
+ for (const url of urls) {
4183
+ const item = parseFigmaUrl(url);
4184
+ if (!item) {
4185
+ continue;
4186
+ }
4187
+ const key = `${item.fileKey}:${item.nodeId ?? ""}`;
4188
+ if (seen.has(key)) {
4189
+ continue;
4190
+ }
4191
+ seen.add(key);
4192
+ parsed.push(item);
4193
+ }
4194
+ return parsed;
4195
+ }
4196
+
4197
+ // src/figma/walk.ts
4198
+ function rgbaFill(fill) {
4199
+ if (fill.type !== "SOLID" || !fill.color) {
4200
+ return null;
4201
+ }
4202
+ const { r, g, b, a = 1 } = fill.color;
4203
+ const alpha = Math.round(a * 100) / 100;
4204
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})`;
4205
+ }
4206
+ function summarizeFills(fills) {
4207
+ if (!fills?.length) {
4208
+ return void 0;
4209
+ }
4210
+ const out = [];
4211
+ for (const fill of fills) {
4212
+ if (fill.visible === false) {
4213
+ continue;
4214
+ }
4215
+ if (fill.type === "IMAGE" && fill.imageRef) {
4216
+ out.push(`image:${fill.imageRef}`);
4217
+ } else {
4218
+ const color = rgbaFill(fill);
4219
+ if (color) {
4220
+ out.push(color);
4221
+ }
4222
+ }
4223
+ }
4224
+ return out.length ? out : void 0;
4225
+ }
4226
+ function summarizeNode(node) {
4227
+ const summary = {
4228
+ id: node.id,
4229
+ name: node.name,
4230
+ type: node.type,
4231
+ visible: node.visible !== false
4232
+ };
4233
+ if (node.absoluteBoundingBox) {
4234
+ summary.width = node.absoluteBoundingBox.width;
4235
+ summary.height = node.absoluteBoundingBox.height;
4236
+ }
4237
+ if (node.layoutMode) {
4238
+ summary.layoutMode = node.layoutMode;
4239
+ }
4240
+ const fills = summarizeFills(node.fills);
4241
+ if (fills) {
4242
+ summary.fills = fills;
4243
+ }
4244
+ if (node.characters) {
4245
+ summary.characters = node.characters.slice(0, 200);
4246
+ }
4247
+ if (node.style) {
4248
+ summary.textStyle = {
4249
+ fontFamily: node.style.fontFamily,
4250
+ fontSize: node.style.fontSize,
4251
+ fontWeight: node.style.fontWeight,
4252
+ lineHeight: node.style.lineHeightPx
4253
+ };
4254
+ }
4255
+ if (node.children?.length) {
4256
+ summary.children = node.children.map((child) => child.id);
4257
+ }
4258
+ return summary;
4259
+ }
4260
+ function walkFigmaDocument(root) {
4261
+ const nodes = [];
4262
+ const imageRefs = [];
4263
+ const exportNodeIds = [];
4264
+ const seenRefs = /* @__PURE__ */ new Set();
4265
+ function visit(node) {
4266
+ nodes.push(summarizeNode(node));
4267
+ for (const fill of node.fills ?? []) {
4268
+ if (fill.type === "IMAGE" && fill.imageRef && fill.visible !== false && !seenRefs.has(fill.imageRef)) {
4269
+ seenRefs.add(fill.imageRef);
4270
+ imageRefs.push({ ref: fill.imageRef, nodeId: node.id, name: node.name });
4271
+ }
4272
+ }
4273
+ const hasImageFill = node.fills?.some((fill) => fill.type === "IMAGE" && fill.visible !== false);
4274
+ if (hasImageFill && (node.type === "RECTANGLE" || node.type === "FRAME" || node.type === "COMPONENT" || node.type === "INSTANCE")) {
4275
+ exportNodeIds.push({ nodeId: node.id, name: node.name });
4276
+ }
4277
+ for (const child of node.children ?? []) {
4278
+ visit(child);
4279
+ }
4280
+ }
4281
+ visit(root);
4282
+ return { nodes, imageRefs, exportNodeIds };
4283
+ }
4284
+
4285
+ // src/figma/sync.ts
4286
+ async function syncFigmaAssets(options) {
4287
+ const syncConfig = await loadFigmaSyncConfig(options.projectRoot);
4288
+ if (!syncConfig.enabled) {
4289
+ return null;
4290
+ }
4291
+ const { urls, sources } = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
4292
+ if (!urls.length) {
4293
+ throw new FetError({
4294
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4295
+ message: "No Figma URLs found in this change.",
4296
+ details: { changeId: options.changeId },
4297
+ recoverable: true,
4298
+ suggestedCommand: `Add a Figma link to openspec/changes/${options.changeId}/proposal.md or design.md`
4299
+ });
4300
+ }
4301
+ const token = resolveFigmaAccessToken(syncConfig, options.accessToken);
4302
+ if (!token) {
4303
+ throw new FetError({
4304
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4305
+ message: `Figma access token missing. Set ${syncConfig.apiKeyEnv} or pass --token.`,
4306
+ details: { apiKeyEnv: syncConfig.apiKeyEnv },
4307
+ recoverable: true,
4308
+ suggestedCommand: `FIGMA_ACCESS_TOKEN=*** fet figma sync --change ${options.changeId}`
4309
+ });
4310
+ }
4311
+ const parsed = parseFigmaUrls(urls);
4312
+ if (!parsed.length) {
4313
+ throw new FetError({
4314
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4315
+ message: "Could not parse any Figma file keys from the detected URLs.",
4316
+ details: { urls },
4317
+ recoverable: true
4318
+ });
4319
+ }
4320
+ const planningFingerprint = await computePlanningFingerprint(options.projectRoot, options.changeId);
4321
+ const existing = await readFigmaDesignManifest(options.projectRoot, options.changeId);
4322
+ if (!options.force && existing && existing.planningFingerprint === planningFingerprint && existing.figmaUrls.join(",") === urls.join(",")) {
4323
+ return {
4324
+ manifestPath: figmaDesignManifestRelativePath2(options.changeId),
4325
+ nodesPath: figmaNodesRelativePath(options.changeId),
4326
+ instructionsPath: figmaSyncInstructionsRelativePath2(options.changeId),
4327
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4328
+ manifest: existing,
4329
+ written: false
4330
+ };
4331
+ }
4332
+ if (options.plan) {
4333
+ return {
4334
+ manifestPath: figmaDesignManifestRelativePath2(options.changeId),
4335
+ nodesPath: figmaNodesRelativePath(options.changeId),
4336
+ instructionsPath: figmaSyncInstructionsRelativePath2(options.changeId),
4337
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4338
+ manifest: {
4339
+ schemaVersion: 1,
4340
+ fetVersion: FET_VERSION,
4341
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4342
+ changeId: options.changeId,
4343
+ planningFingerprint,
4344
+ figmaUrls: urls,
4345
+ figmaSources: sources,
4346
+ files: parsed.map((item) => ({
4347
+ fileKey: item.fileKey,
4348
+ url: item.url,
4349
+ rootNodeId: item.nodeId,
4350
+ nodeCount: 0,
4351
+ assetCount: 0
4352
+ })),
4353
+ nodes: [],
4354
+ assets: []
4355
+ },
4356
+ written: false
4357
+ };
4358
+ }
4359
+ const allNodes = [];
4360
+ const allAssets = [];
4361
+ const files = [];
4362
+ const nodesByFile = {};
4363
+ for (const item of parsed) {
4364
+ const file = await fetchFigmaFile(item.fileKey, token, item.nodeId ? [item.nodeId] : void 0);
4365
+ const walked = walkFigmaDocument(file.document);
4366
+ allNodes.push(...walked.nodes);
4367
+ nodesByFile[item.fileKey] = {
4368
+ name: file.name,
4369
+ rootNodeId: item.nodeId ?? file.document.id,
4370
+ document: file.document,
4371
+ components: file.components ?? {},
4372
+ styles: file.styles ?? {}
4373
+ };
4374
+ const assets = await downloadFigmaAssets({
4375
+ projectRoot: options.projectRoot,
3270
4376
  changeId: options.changeId,
3271
- generatedAt,
3272
- urls,
3273
- sources,
3274
- language: options.language
4377
+ fileKey: item.fileKey,
4378
+ accessToken: token,
4379
+ config: syncConfig,
4380
+ imageRefs: walked.imageRefs,
4381
+ exportNodeIds: walked.exportNodeIds.slice(0, 20)
4382
+ });
4383
+ allAssets.push(...assets);
4384
+ files.push({
4385
+ fileKey: item.fileKey,
4386
+ url: item.url,
4387
+ rootNodeId: item.nodeId,
4388
+ nodeCount: walked.nodes.length,
4389
+ assetCount: assets.length
3275
4390
  });
3276
- const existingApply = await readOptional3(applyAbsolutePath);
3277
- if (existingApply !== applyContent) {
3278
- await atomicWrite(applyAbsolutePath, applyContent);
3279
- written = true;
3280
- }
3281
4391
  }
4392
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
4393
+ const manifest = {
4394
+ schemaVersion: 1,
4395
+ fetVersion: FET_VERSION,
4396
+ generatedAt,
4397
+ changeId: options.changeId,
4398
+ planningFingerprint,
4399
+ figmaUrls: urls,
4400
+ figmaSources: sources,
4401
+ files,
4402
+ nodes: allNodes,
4403
+ assets: allAssets
4404
+ };
4405
+ const fetDir = join21(options.projectRoot, "openspec", "changes", options.changeId, ".fet");
4406
+ await mkdir7(fetDir, { recursive: true });
4407
+ const manifestPath3 = figmaDesignManifestRelativePath2(options.changeId);
4408
+ const nodesPath = figmaNodesRelativePath(options.changeId);
4409
+ const instructionsPath = figmaSyncInstructionsRelativePath2(options.changeId);
4410
+ await atomicWrite(join21(options.projectRoot, manifestPath3), renderFigmaDesignManifestYaml(manifest));
4411
+ await writeFile2(join21(options.projectRoot, nodesPath), JSON.stringify(nodesByFile, null, 2), "utf8");
4412
+ await atomicWrite(
4413
+ join21(options.projectRoot, instructionsPath),
4414
+ renderFigmaSyncInstructions({
4415
+ changeId: options.changeId,
4416
+ generatedAt,
4417
+ manifestPath: manifestPath3,
4418
+ nodesPath,
4419
+ language: options.language,
4420
+ nodeCount: allNodes.length,
4421
+ assetCount: allAssets.length,
4422
+ fileAssetCount: allAssets.filter((asset) => asset.storage === "file").length
4423
+ })
4424
+ );
3282
4425
  return {
3283
- path: stopRelativePath,
3284
- applyInstructionsPath,
3285
- written,
3286
- urls,
3287
- sources,
3288
- mode: config.mode
4426
+ manifestPath: manifestPath3,
4427
+ nodesPath,
4428
+ instructionsPath,
4429
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4430
+ manifest,
4431
+ written: true
3289
4432
  };
3290
4433
  }
3291
- async function listMarkdownFiles(root) {
3292
- const files = [];
3293
- await walk(root, files);
3294
- return files;
3295
- }
3296
- async function walk(dir, files) {
3297
- try {
3298
- const entries = await readdir3(dir, { withFileTypes: true });
3299
- for (const entry of entries) {
3300
- const fullPath = join16(dir, entry.name);
3301
- if (entry.isDirectory()) {
3302
- await walk(fullPath, files);
3303
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
3304
- files.push(fullPath);
3305
- }
3306
- }
3307
- } catch {
3308
- return;
4434
+ async function tryAutoSyncFigmaAssets(options) {
4435
+ const syncConfig = await loadFigmaSyncConfig(options.projectRoot);
4436
+ if (!syncConfig.enabled) {
4437
+ return { synced: false, skipped: true, missingToken: false };
3309
4438
  }
3310
- }
3311
- async function readOptional3(path) {
3312
- try {
3313
- await stat7(path);
3314
- return await readFile10(path, "utf8");
3315
- } catch {
3316
- return null;
4439
+ const { urls } = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
4440
+ if (!urls.length) {
4441
+ return { synced: false, skipped: true, missingToken: false };
4442
+ }
4443
+ const token = resolveFigmaAccessToken(syncConfig);
4444
+ if (!token) {
4445
+ return { synced: false, skipped: false, missingToken: true };
4446
+ }
4447
+ const existing = await readFigmaDesignManifest(options.projectRoot, options.changeId);
4448
+ const planningFingerprint = await computePlanningFingerprint(options.projectRoot, options.changeId);
4449
+ if (existing && existing.planningFingerprint === planningFingerprint && existing.figmaUrls.join(",") === urls.join(",")) {
4450
+ return {
4451
+ synced: false,
4452
+ skipped: true,
4453
+ missingToken: false,
4454
+ result: {
4455
+ manifestPath: figmaDesignManifestRelativePath2(options.changeId),
4456
+ nodesPath: figmaNodesRelativePath(options.changeId),
4457
+ instructionsPath: figmaSyncInstructionsRelativePath2(options.changeId),
4458
+ assetsDir: join21("openspec", "changes", options.changeId, ".fet", "figma-assets"),
4459
+ manifest: existing,
4460
+ written: false
4461
+ }
4462
+ };
3317
4463
  }
4464
+ const result = await syncFigmaAssets({
4465
+ projectRoot: options.projectRoot,
4466
+ changeId: options.changeId,
4467
+ language: options.language,
4468
+ accessToken: token
4469
+ });
4470
+ return { synced: Boolean(result?.written), skipped: false, missingToken: false, result: result ?? void 0 };
3318
4471
  }
3319
4472
 
3320
4473
  // src/ui-display-contract.ts
3321
4474
  import { existsSync as existsSync2 } from "fs";
3322
- import { readdir as readdir4, readFile as readFile11, stat as stat8 } from "fs/promises";
3323
- import { join as join17, relative as relative3 } from "path";
3324
- import { parse as parse3, parseDocument as parseDocument3 } from "yaml";
4475
+ import { readdir as readdir5, readFile as readFile14, stat as stat9 } from "fs/promises";
4476
+ import { join as join22, relative as relative4 } from "path";
4477
+ import { parse as parse3, parseDocument as parseDocument5 } from "yaml";
3325
4478
  var DEFAULT_CONFIG2 = {
3326
4479
  enabled: true
3327
4480
  };
@@ -3331,8 +4484,8 @@ var BACKTICK_PATH_PATTERN = /`([^`]+\.(?:ya?ml|json))`/gi;
3331
4484
  var OPENAPI_BARE_PATTERN = /\b(openapi\.ya?ml|swagger\.ya?ml|swagger\.json)\b/gi;
3332
4485
  async function loadUiDisplayContractConfig(projectRoot) {
3333
4486
  try {
3334
- const raw = await readFile11(join17(projectRoot, "openspec", "config.yaml"), "utf8");
3335
- const doc = parseDocument3(raw);
4487
+ const raw = await readFile14(join22(projectRoot, "openspec", "config.yaml"), "utf8");
4488
+ const doc = parseDocument5(raw);
3336
4489
  const fetNode = doc.get("fet", true);
3337
4490
  const node = fetNode?.get?.("uiDisplayContract");
3338
4491
  if (!node || typeof node.get !== "function") {
@@ -3347,12 +4500,12 @@ async function loadUiDisplayContractConfig(projectRoot) {
3347
4500
  }
3348
4501
  }
3349
4502
  async function collectApiSourcesFromChange(projectRoot, changeId) {
3350
- const changePath = join17(projectRoot, "openspec", "changes", changeId);
4503
+ const changePath = join22(projectRoot, "openspec", "changes", changeId);
3351
4504
  const paths = /* @__PURE__ */ new Set();
3352
4505
  const sources = [];
3353
4506
  const candidates = ["proposal.md", "tasks.md", "design.md"];
3354
4507
  for (const name of candidates) {
3355
- const filePath = join17(changePath, name);
4508
+ const filePath = join22(changePath, name);
3356
4509
  const content = await readOptional4(filePath);
3357
4510
  if (!content) {
3358
4511
  continue;
@@ -3365,7 +4518,7 @@ async function collectApiSourcesFromChange(projectRoot, changeId) {
3365
4518
  }
3366
4519
  }
3367
4520
  }
3368
- const specsPath = join17(changePath, "specs");
4521
+ const specsPath = join22(changePath, "specs");
3369
4522
  for (const filePath of await listMarkdownFiles2(specsPath)) {
3370
4523
  const content = await readOptional4(filePath);
3371
4524
  if (!content) {
@@ -3373,7 +4526,7 @@ async function collectApiSourcesFromChange(projectRoot, changeId) {
3373
4526
  }
3374
4527
  const found = extractApiDocPaths(content, projectRoot);
3375
4528
  if (found.length) {
3376
- sources.push(relative3(projectRoot, filePath).replaceAll("\\", "/"));
4529
+ sources.push(relative4(projectRoot, filePath).replaceAll("\\", "/"));
3377
4530
  for (const path of found) {
3378
4531
  paths.add(path);
3379
4532
  }
@@ -3399,7 +4552,7 @@ function extractApiDocPaths(content, projectRoot) {
3399
4552
  const common = ["openapi.yaml", "openapi.yml", "docs/openapi.yaml", "swagger.yaml", "swagger.json"];
3400
4553
  for (const candidate of common) {
3401
4554
  const normalized = normalizeRepoPath(candidate, projectRoot);
3402
- if (normalized && existsSync2(join17(projectRoot, normalized))) {
4555
+ if (normalized && existsSync2(join22(projectRoot, normalized))) {
3403
4556
  found.add(normalized);
3404
4557
  }
3405
4558
  }
@@ -3409,7 +4562,7 @@ function extractApiDocPaths(content, projectRoot) {
3409
4562
  async function extractOpenApiSchemas(projectRoot, relativePaths) {
3410
4563
  const schemas = [];
3411
4564
  for (const rel of relativePaths) {
3412
- const absolute = join17(projectRoot, rel);
4565
+ const absolute = join22(projectRoot, rel);
3413
4566
  const content = await readOptional4(absolute);
3414
4567
  if (!content) {
3415
4568
  continue;
@@ -3485,9 +4638,9 @@ async function ensureChangeUiDisplayContract(options) {
3485
4638
  if (!config.enabled) {
3486
4639
  return null;
3487
4640
  }
3488
- const figma = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
4641
+ const figma2 = await collectFigmaUrlsFromChange(options.projectRoot, options.changeId);
3489
4642
  const api = await collectApiSourcesFromChange(options.projectRoot, options.changeId);
3490
- const hasFigma = figma.urls.length > 0;
4643
+ const hasFigma = figma2.urls.length > 0;
3491
4644
  const hasApi = api.paths.length > 0;
3492
4645
  if (!hasFigma && !hasApi) {
3493
4646
  return null;
@@ -3500,14 +4653,14 @@ async function ensureChangeUiDisplayContract(options) {
3500
4653
  changeId: options.changeId,
3501
4654
  generatedAt,
3502
4655
  fetVersion: options.fetVersion,
3503
- figmaUrls: figma.urls,
3504
- figmaSources: figma.sources,
4656
+ figmaUrls: figma2.urls,
4657
+ figmaSources: figma2.sources,
3505
4658
  apiPaths: api.paths,
3506
4659
  apiSources: api.sources,
3507
4660
  apiSchemas
3508
4661
  });
3509
4662
  const contractContent = renderUiDisplayContractYaml(doc);
3510
- const contractAbsolutePath = join17(options.projectRoot, contractRelativePath);
4663
+ const contractAbsolutePath = join22(options.projectRoot, contractRelativePath);
3511
4664
  const existingContract = await readOptional4(contractAbsolutePath);
3512
4665
  let written = false;
3513
4666
  if (existingContract !== contractContent) {
@@ -3522,7 +4675,7 @@ async function ensureChangeUiDisplayContract(options) {
3522
4675
  hasFigma,
3523
4676
  hasApi
3524
4677
  });
3525
- const applyAbsolutePath = join17(options.projectRoot, applyRelativePath);
4678
+ const applyAbsolutePath = join22(options.projectRoot, applyRelativePath);
3526
4679
  const existingApply = await readOptional4(applyAbsolutePath);
3527
4680
  if (existingApply !== applyContent) {
3528
4681
  await atomicWrite(applyAbsolutePath, applyContent);
@@ -3589,9 +4742,9 @@ async function listMarkdownFiles2(root) {
3589
4742
  }
3590
4743
  async function walk2(dir, files) {
3591
4744
  try {
3592
- const entries = await readdir4(dir, { withFileTypes: true });
4745
+ const entries = await readdir5(dir, { withFileTypes: true });
3593
4746
  for (const entry of entries) {
3594
- const fullPath = join17(dir, entry.name);
4747
+ const fullPath = join22(dir, entry.name);
3595
4748
  if (entry.isDirectory()) {
3596
4749
  await walk2(fullPath, files);
3597
4750
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
@@ -3604,8 +4757,8 @@ async function walk2(dir, files) {
3604
4757
  }
3605
4758
  async function readOptional4(path) {
3606
4759
  try {
3607
- await stat8(path);
3608
- return await readFile11(path, "utf8");
4760
+ await stat9(path);
4761
+ return await readFile14(path, "utf8");
3609
4762
  } catch {
3610
4763
  return null;
3611
4764
  }
@@ -3650,9 +4803,9 @@ async function assertChangeExists(ctx, changeId) {
3650
4803
  }
3651
4804
 
3652
4805
  // src/tdd/config.ts
3653
- import { readFile as readFile12 } from "fs/promises";
3654
- import { join as join18 } from "path";
3655
- import { parseDocument as parseDocument4 } from "yaml";
4806
+ import { readFile as readFile15 } from "fs/promises";
4807
+ import { join as join23 } from "path";
4808
+ import { parseDocument as parseDocument6 } from "yaml";
3656
4809
  var DEFAULT_CONFIG3 = {
3657
4810
  enabled: true,
3658
4811
  mode: "require_before_apply",
@@ -3660,8 +4813,8 @@ var DEFAULT_CONFIG3 = {
3660
4813
  };
3661
4814
  async function loadTddConfig(projectRoot) {
3662
4815
  try {
3663
- const raw = await readFile12(join18(projectRoot, "openspec", "config.yaml"), "utf8");
3664
- const doc = parseDocument4(raw);
4816
+ const raw = await readFile15(join23(projectRoot, "openspec", "config.yaml"), "utf8");
4817
+ const doc = parseDocument6(raw);
3665
4818
  const fetNode = doc.get("fet", true);
3666
4819
  const node = fetNode?.get?.("tdd");
3667
4820
  if (!node || typeof node.get !== "function") {
@@ -3695,70 +4848,12 @@ function isTddRequired(config) {
3695
4848
  return config.enabled && config.mode === "require_before_apply";
3696
4849
  }
3697
4850
 
3698
- // src/tdd/fingerprint.ts
3699
- import { createHash } from "crypto";
3700
- import { readdir as readdir5, readFile as readFile13, stat as stat9 } from "fs/promises";
3701
- import { join as join19, relative as relative4 } from "path";
3702
- async function collectPlanningSources(projectRoot, changeId) {
3703
- const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
3704
- const sources = [];
3705
- const rootFiles = ["proposal.md", "tasks.md", "design.md"];
3706
- for (const name of rootFiles) {
3707
- const path = join19(changeRoot, name);
3708
- if (await exists5(path)) {
3709
- sources.push(relative4(projectRoot, path).replace(/\\/g, "/"));
3710
- }
3711
- }
3712
- const specsDir = join19(changeRoot, "specs");
3713
- if (await exists5(specsDir)) {
3714
- for (const file of await walkFiles(specsDir)) {
3715
- if (file.endsWith(".md")) {
3716
- sources.push(relative4(projectRoot, file).replace(/\\/g, "/"));
3717
- }
3718
- }
3719
- }
3720
- return sources.sort();
3721
- }
3722
- async function computePlanningFingerprint(projectRoot, changeId) {
3723
- const sources = await collectPlanningSources(projectRoot, changeId);
3724
- const hash = createHash("sha256");
3725
- for (const source of sources) {
3726
- const content = await readFile13(join19(projectRoot, source), "utf8");
3727
- hash.update(source);
3728
- hash.update("\0");
3729
- hash.update(content);
3730
- hash.update("\0");
3731
- }
3732
- return `sha256:${hash.digest("hex")}`;
3733
- }
3734
- async function walkFiles(dir) {
3735
- const entries = await readdir5(dir, { withFileTypes: true });
3736
- const files = [];
3737
- for (const entry of entries) {
3738
- const path = join19(dir, entry.name);
3739
- if (entry.isDirectory()) {
3740
- files.push(...await walkFiles(path));
3741
- } else if (entry.isFile()) {
3742
- files.push(path);
3743
- }
3744
- }
3745
- return files;
3746
- }
3747
- async function exists5(path) {
3748
- try {
3749
- await stat9(path);
3750
- return true;
3751
- } catch {
3752
- return false;
3753
- }
3754
- }
3755
-
3756
4851
  // src/tdd/manifest.ts
3757
- import { mkdir as mkdir6, readFile as readFile14, stat as stat10 } from "fs/promises";
3758
- import { dirname as dirname8, join as join20 } from "path";
3759
- import { parse as parse4, stringify as stringify3 } from "yaml";
4852
+ import { mkdir as mkdir8, readFile as readFile16, stat as stat10 } from "fs/promises";
4853
+ import { dirname as dirname8, join as join24 } from "path";
4854
+ import { parse as parse4, stringify as stringify4 } from "yaml";
3760
4855
  function tddManifestPath(projectRoot, changeId) {
3761
- return join20(projectRoot, tddManifestRelativePath(changeId));
4856
+ return join24(projectRoot, tddManifestRelativePath(changeId));
3762
4857
  }
3763
4858
  async function readTddManifest(projectRoot, changeId) {
3764
4859
  const path = tddManifestPath(projectRoot, changeId);
@@ -3767,7 +4862,7 @@ async function readTddManifest(projectRoot, changeId) {
3767
4862
  } catch {
3768
4863
  return null;
3769
4864
  }
3770
- const doc = parse4(await readFile14(path, "utf8"));
4865
+ const doc = parse4(await readFile16(path, "utf8"));
3771
4866
  if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
3772
4867
  return null;
3773
4868
  }
@@ -3775,15 +4870,15 @@ async function readTddManifest(projectRoot, changeId) {
3775
4870
  }
3776
4871
  async function writeTddManifest(projectRoot, manifest) {
3777
4872
  const relative6 = tddManifestRelativePath(manifest.changeId);
3778
- const path = join20(projectRoot, relative6);
3779
- await mkdir6(dirname8(path), { recursive: true });
3780
- await atomicWrite(path, stringify3(manifest));
4873
+ const path = join24(projectRoot, relative6);
4874
+ await mkdir8(dirname8(path), { recursive: true });
4875
+ await atomicWrite(path, stringify4(manifest));
3781
4876
  return relative6;
3782
4877
  }
3783
4878
  async function writeTddResults(projectRoot, results) {
3784
4879
  const relative6 = tddResultsRelativePath(results.changeId);
3785
- const path = join20(projectRoot, relative6);
3786
- await mkdir6(dirname8(path), { recursive: true });
4880
+ const path = join24(projectRoot, relative6);
4881
+ await mkdir8(dirname8(path), { recursive: true });
3787
4882
  await atomicWrite(path, `${JSON.stringify(results, null, 2)}
3788
4883
  `);
3789
4884
  return relative6;
@@ -3891,8 +4986,8 @@ async function git(cwd, args) {
3891
4986
  }
3892
4987
 
3893
4988
  // src/state/store.ts
3894
- import { mkdir as mkdir7, readFile as readFile15 } from "fs/promises";
3895
- import { join as join21 } from "path";
4989
+ import { mkdir as mkdir9, readFile as readFile17 } from "fs/promises";
4990
+ import { join as join25 } from "path";
3896
4991
 
3897
4992
  // src/language.ts
3898
4993
  var DEFAULT_LANGUAGE = "zh-CN";
@@ -4014,7 +5109,7 @@ var StateStore = class {
4014
5109
  project;
4015
5110
  async readGlobal() {
4016
5111
  try {
4017
- const value = JSON.parse(await readFile15(this.globalPath(), "utf8"));
5112
+ const value = JSON.parse(await readFile17(this.globalPath(), "utf8"));
4018
5113
  assertGlobalState(value);
4019
5114
  return value;
4020
5115
  } catch (error) {
@@ -4029,13 +5124,13 @@ var StateStore = class {
4029
5124
  }
4030
5125
  async writeGlobal(state) {
4031
5126
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4032
- await mkdir7(join21(this.projectRoot, "openspec"), { recursive: true });
5127
+ await mkdir9(join25(this.projectRoot, "openspec"), { recursive: true });
4033
5128
  await atomicWrite(this.globalPath(), `${JSON.stringify(state, null, 2)}
4034
5129
  `);
4035
5130
  }
4036
5131
  async readChange(changeId) {
4037
5132
  try {
4038
- const value = JSON.parse(await readFile15(this.changePath(changeId), "utf8"));
5133
+ const value = JSON.parse(await readFile17(this.changePath(changeId), "utf8"));
4039
5134
  assertChangeState(value);
4040
5135
  return value;
4041
5136
  } catch (error) {
@@ -4050,15 +5145,15 @@ var StateStore = class {
4050
5145
  }
4051
5146
  async writeChange(state) {
4052
5147
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4053
- await mkdir7(join21(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
5148
+ await mkdir9(join25(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
4054
5149
  await atomicWrite(this.changePath(state.changeId), `${JSON.stringify(state, null, 2)}
4055
5150
  `);
4056
5151
  }
4057
5152
  globalPath() {
4058
- return join21(this.projectRoot, "openspec", "fet-state.json");
5153
+ return join25(this.projectRoot, "openspec", "fet-state.json");
4059
5154
  }
4060
5155
  changePath(changeId) {
4061
- return join21(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
5156
+ return join25(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
4062
5157
  }
4063
5158
  };
4064
5159
  function isNotFound(error) {
@@ -4066,11 +5161,11 @@ function isNotFound(error) {
4066
5161
  }
4067
5162
 
4068
5163
  // src/state/tasks.ts
4069
- import { readFile as readFile16 } from "fs/promises";
5164
+ import { readFile as readFile18 } from "fs/promises";
4070
5165
  async function readCompletedTaskIds(tasksPath) {
4071
5166
  let content;
4072
5167
  try {
4073
- content = await readFile16(tasksPath, "utf8");
5168
+ content = await readFile18(tasksPath, "utf8");
4074
5169
  } catch {
4075
5170
  return [];
4076
5171
  }
@@ -4233,6 +5328,8 @@ async function applyWorkflowCommand(ctx, args) {
4233
5328
  fetVersion: ctx.fetVersion
4234
5329
  })
4235
5330
  ]);
5331
+ const figmaSyncConfig = await loadFigmaSyncConfig(ctx.projectRoot);
5332
+ const figmaAutoSync = figmaGuard ? await tryAutoSyncFigmaAssets({ projectRoot: ctx.projectRoot, changeId, language: ctx.language }) : null;
4236
5333
  const applyNextSteps = [
4237
5334
  `Read openspec/changes/${changeId}/tasks.md and the instructions output.`,
4238
5335
  "Implement pending tasks and update task checkboxes only after the work is done.",
@@ -4243,7 +5340,13 @@ async function applyWorkflowCommand(ctx, args) {
4243
5340
  applyNextSteps.unshift(...renderUiDisplayContractApplyNextSteps(changeId, ctx.language));
4244
5341
  }
4245
5342
  if (figmaGuard) {
4246
- applyNextSteps.unshift(...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode));
5343
+ applyNextSteps.unshift(
5344
+ ...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode, {
5345
+ syncMissingToken: figmaAutoSync?.missingToken,
5346
+ syncDone: figmaAutoSync?.synced || figmaAutoSync?.skipped && Boolean(figmaAutoSync.result),
5347
+ apiKeyEnv: figmaSyncConfig.apiKeyEnv
5348
+ })
5349
+ );
4247
5350
  applyNextSteps.splice(applyNextSteps.length - 1, 0, ...renderVisualVerifyNextSteps(changeId, ctx.language));
4248
5351
  }
4249
5352
  ctx.output.result({
@@ -4258,6 +5361,7 @@ async function applyWorkflowCommand(ctx, args) {
4258
5361
  status,
4259
5362
  graphContext: runState.graphContext,
4260
5363
  figmaGuard: figmaGuard ?? void 0,
5364
+ figmaSync: figmaAutoSync ?? void 0,
4261
5365
  uiDisplayContract: uiContract ?? void 0
4262
5366
  }
4263
5367
  });
@@ -4656,7 +5760,7 @@ async function createChangelogEntry(projectRoot, changeId) {
4656
5760
  };
4657
5761
  }
4658
5762
  async function appendChangelog(projectRoot, entry) {
4659
- const changelogPath = join22(projectRoot, "CHANGELOG.md");
5763
+ const changelogPath = join26(projectRoot, "CHANGELOG.md");
4660
5764
  const existing = await readOptional5(changelogPath);
4661
5765
  const legacyContentLabel = "\u66F4\u65B0\u5185\u5BB9";
4662
5766
  const block = `updateTime: ${entry.updateTime}
@@ -4669,12 +5773,12 @@ ${block}` : block;
4669
5773
  await atomicWrite(changelogPath, next);
4670
5774
  }
4671
5775
  async function readChangeRequirement(projectRoot, changeId) {
4672
- const changeRoot = join22(projectRoot, "openspec", "changes", changeId);
4673
- const proposal = await readOptional5(join22(changeRoot, "proposal.md"));
5776
+ const changeRoot = join26(projectRoot, "openspec", "changes", changeId);
5777
+ const proposal = await readOptional5(join26(changeRoot, "proposal.md"));
4674
5778
  if (proposal) {
4675
5779
  return summarizeMarkdown(proposal);
4676
5780
  }
4677
- const readme = await readOptional5(join22(changeRoot, "README.md"));
5781
+ const readme = await readOptional5(join26(changeRoot, "README.md"));
4678
5782
  if (readme) {
4679
5783
  return summarizeMarkdown(readme);
4680
5784
  }
@@ -4686,7 +5790,7 @@ function summarizeMarkdown(content) {
4686
5790
  }
4687
5791
  async function readOptional5(path) {
4688
5792
  try {
4689
- return await readFile17(path, "utf8");
5793
+ return await readFile19(path, "utf8");
4690
5794
  } catch {
4691
5795
  return null;
4692
5796
  }
@@ -5041,17 +6145,17 @@ async function updateCommand(ctx) {
5041
6145
  }
5042
6146
 
5043
6147
  // src/commands/tdd.ts
5044
- import { mkdir as mkdir8 } from "fs/promises";
5045
- import { join as join24 } from "path";
6148
+ import { mkdir as mkdir10 } from "fs/promises";
6149
+ import { join as join28 } from "path";
5046
6150
 
5047
6151
  // src/tdd/extract-cases.ts
5048
- import { readFile as readFile18 } from "fs/promises";
5049
- import { join as join23 } from "path";
6152
+ import { readFile as readFile20 } from "fs/promises";
6153
+ import { join as join27 } from "path";
5050
6154
  async function extractCasesFromChange(projectRoot, changeId, sources) {
5051
6155
  const cases = [];
5052
6156
  const seen = /* @__PURE__ */ new Set();
5053
6157
  for (const source of sources) {
5054
- const content = await readFile18(join23(projectRoot, source), "utf8");
6158
+ const content = await readFile20(join27(projectRoot, source), "utf8");
5055
6159
  if (source.endsWith("tasks.md")) {
5056
6160
  for (const item of extractTaskCases(content, changeId)) {
5057
6161
  if (!seen.has(item.id)) {
@@ -5185,12 +6289,12 @@ async function tddCommand(ctx) {
5185
6289
  testCommand: testCommand2
5186
6290
  });
5187
6291
  const manifestPath3 = await writeTddManifest(ctx.projectRoot, manifest);
5188
- const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5189
- await mkdir8(fetDir, { recursive: true });
6292
+ const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6293
+ await mkdir10(fetDir, { recursive: true });
5190
6294
  const instructionsPath = tddInstructionsRelativePath(changeId);
5191
6295
  const specPath = tddSpecRelativePath(changeId);
5192
- await atomicWrite(join24(ctx.projectRoot, instructionsPath), renderTddInstructions(changeId, manifest, ctx.language));
5193
- await atomicWrite(join24(ctx.projectRoot, specPath), renderTddSpec(changeId, manifest, ctx.language));
6296
+ await atomicWrite(join28(ctx.projectRoot, instructionsPath), renderTddInstructions(changeId, manifest, ctx.language));
6297
+ await atomicWrite(join28(ctx.projectRoot, specPath), renderTddSpec(changeId, manifest, ctx.language));
5194
6298
  const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5195
6299
  changeState.tdd = {
5196
6300
  status: "ready",
@@ -5432,14 +6536,81 @@ function truncate(value, max = 2e3) {
5432
6536
  return value.length > max ? `${value.slice(0, max)}\u2026` : value;
5433
6537
  }
5434
6538
 
6539
+ // src/commands/figma.ts
6540
+ import { mkdir as mkdir11 } from "fs/promises";
6541
+ import { join as join29 } from "path";
6542
+ async function figmaSyncCommand(ctx, options) {
6543
+ await withProjectLock(ctx.projectRoot, { command: "figma", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
6544
+ const changeId = await resolveChangeId(ctx);
6545
+ await assertChangeExists(ctx, changeId);
6546
+ const syncConfig = await loadFigmaSyncConfig(ctx.projectRoot);
6547
+ const result = await syncFigmaAssets({
6548
+ projectRoot: ctx.projectRoot,
6549
+ changeId,
6550
+ language: ctx.language,
6551
+ accessToken: options.token,
6552
+ force: Boolean(options.force),
6553
+ plan: Boolean(options.plan)
6554
+ });
6555
+ if (!result) {
6556
+ ctx.output.result({
6557
+ ok: true,
6558
+ command: "figma",
6559
+ summary: msg(ctx.language, "Figma \u540C\u6B65\u5DF2\u7981\u7528\uFF08fet.figmaGuard.sync.enabled: false\uFF09\u3002", "Figma sync is disabled (fet.figmaGuard.sync.enabled: false)."),
6560
+ data: { changeId, enabled: false }
6561
+ });
6562
+ return;
6563
+ }
6564
+ if (options.plan) {
6565
+ ctx.output.result({
6566
+ ok: true,
6567
+ command: "figma",
6568
+ summary: msg(ctx.language, "Figma \u540C\u6B65\u8BA1\u5212\u5DF2\u751F\u6210\u3002", "Figma sync plan ready."),
6569
+ data: {
6570
+ changeId,
6571
+ files: result.manifest.files,
6572
+ figmaUrls: result.manifest.figmaUrls
6573
+ },
6574
+ nextSteps: [`fet figma sync --change ${changeId}`]
6575
+ });
6576
+ return;
6577
+ }
6578
+ await mkdir11(join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet"), { recursive: true });
6579
+ ctx.output.result({
6580
+ ok: true,
6581
+ command: "figma",
6582
+ summary: result.written ? msg(
6583
+ ctx.language,
6584
+ `\u5DF2\u540C\u6B65 ${result.manifest.nodes.length} \u4E2A\u8282\u70B9\u3001${result.manifest.assets.length} \u4E2A\u7D20\u6750\u3002`,
6585
+ `Synced ${result.manifest.nodes.length} node(s) and ${result.manifest.assets.length} asset(s).`
6586
+ ) : msg(ctx.language, "Figma \u8BBE\u8BA1\u6570\u636E\u5DF2\u662F\u6700\u65B0\uFF0C\u8DF3\u8FC7\u91CD\u65B0\u4E0B\u8F7D\u3002", "Figma design data is up to date; skipped re-download."),
6587
+ data: {
6588
+ changeId,
6589
+ written: result.written,
6590
+ manifestPath: result.manifestPath,
6591
+ nodesPath: result.nodesPath,
6592
+ instructionsPath: result.instructionsPath,
6593
+ assetsDir: result.assetsDir,
6594
+ nodeCount: result.manifest.nodes.length,
6595
+ assetCount: result.manifest.assets.length,
6596
+ fileAssetCount: result.manifest.assets.filter((asset) => asset.storage === "file").length
6597
+ },
6598
+ nextSteps: result.written ? [
6599
+ `Read ${result.instructionsPath} before UI implementation.`,
6600
+ ...renderFigmaSyncNextSteps(changeId, ctx.language, syncConfig.apiKeyEnv).slice(1)
6601
+ ] : [`Read ${result.instructionsPath} before UI implementation.`]
6602
+ });
6603
+ });
6604
+ }
6605
+
5435
6606
  // src/commands/visual.ts
5436
- import { mkdir as mkdir10 } from "fs/promises";
5437
- import { join as join28 } from "path";
6607
+ import { mkdir as mkdir13 } from "fs/promises";
6608
+ import { join as join33 } from "path";
5438
6609
 
5439
6610
  // src/visual/config.ts
5440
- import { readFile as readFile19 } from "fs/promises";
5441
- import { join as join25 } from "path";
5442
- import { parseDocument as parseDocument5 } from "yaml";
6611
+ import { readFile as readFile21 } from "fs/promises";
6612
+ import { join as join30 } from "path";
6613
+ import { parseDocument as parseDocument7 } from "yaml";
5443
6614
  var DEFAULT_CONFIG4 = {
5444
6615
  enabled: true,
5445
6616
  compareMode: "layout-only",
@@ -5448,8 +6619,8 @@ var DEFAULT_CONFIG4 = {
5448
6619
  };
5449
6620
  async function loadVisualConfig(projectRoot) {
5450
6621
  try {
5451
- const raw = await readFile19(join25(projectRoot, "openspec", "config.yaml"), "utf8");
5452
- const doc = parseDocument5(raw);
6622
+ const raw = await readFile21(join30(projectRoot, "openspec", "config.yaml"), "utf8");
6623
+ const doc = parseDocument7(raw);
5453
6624
  const fetNode = doc.get("fet", true);
5454
6625
  const node = fetNode?.get?.("visual");
5455
6626
  if (!node || typeof node.get !== "function") {
@@ -5495,10 +6666,10 @@ function isVisualRequiredForVerify(config, hasFigma) {
5495
6666
 
5496
6667
  // src/visual/playwright.ts
5497
6668
  import { createRequire } from "module";
5498
- import { dirname as dirname9, join as join26 } from "path";
6669
+ import { dirname as dirname9, join as join31 } from "path";
5499
6670
  import { pathToFileURL } from "url";
5500
6671
  async function resolvePlaywright(projectRoot) {
5501
- const require2 = createRequire(join26(projectRoot, "package.json"));
6672
+ const require2 = createRequire(join31(projectRoot, "package.json"));
5502
6673
  const candidates = ["playwright", "@playwright/test"];
5503
6674
  for (const name of candidates) {
5504
6675
  try {
@@ -5530,11 +6701,11 @@ async function capturePagesWithPlaywright(projectRoot, manifest, baseUrl) {
5530
6701
  return results;
5531
6702
  }
5532
6703
  async function captureSinglePage(projectRoot, browser, changeId, pageDef, baseUrl) {
5533
- const { mkdir: mkdir15 } = await import("fs/promises");
6704
+ const { mkdir: mkdir18 } = await import("fs/promises");
5534
6705
  const { join: joinPath } = await import("path");
5535
6706
  const screenshotRelative = visualPageScreenshotRelative(changeId, pageDef.id);
5536
6707
  const screenshotAbsolute = joinPath(projectRoot, screenshotRelative);
5537
- await mkdir15(dirname9(screenshotAbsolute), { recursive: true });
6708
+ await mkdir18(dirname9(screenshotAbsolute), { recursive: true });
5538
6709
  const url = new URL(pageDef.route, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
5539
6710
  const pwPage = await browser.newPage();
5540
6711
  try {
@@ -5599,11 +6770,11 @@ async function extractRegions(page, pageDef) {
5599
6770
  }
5600
6771
 
5601
6772
  // src/visual/manifest.ts
5602
- import { mkdir as mkdir9, readFile as readFile20, stat as stat11 } from "fs/promises";
5603
- import { dirname as dirname10, join as join27 } from "path";
5604
- import { parse as parse5, stringify as stringify4 } from "yaml";
6773
+ import { mkdir as mkdir12, readFile as readFile22, stat as stat11 } from "fs/promises";
6774
+ import { dirname as dirname10, join as join32 } from "path";
6775
+ import { parse as parse5, stringify as stringify5 } from "yaml";
5605
6776
  function visualManifestPath(projectRoot, changeId) {
5606
- return join27(projectRoot, visualManifestRelativePath(changeId));
6777
+ return join32(projectRoot, visualManifestRelativePath(changeId));
5607
6778
  }
5608
6779
  async function readVisualManifest(projectRoot, changeId) {
5609
6780
  const path = visualManifestPath(projectRoot, changeId);
@@ -5612,7 +6783,7 @@ async function readVisualManifest(projectRoot, changeId) {
5612
6783
  } catch {
5613
6784
  return null;
5614
6785
  }
5615
- const doc = parse5(await readFile20(path, "utf8"));
6786
+ const doc = parse5(await readFile22(path, "utf8"));
5616
6787
  if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
5617
6788
  return null;
5618
6789
  }
@@ -5620,15 +6791,15 @@ async function readVisualManifest(projectRoot, changeId) {
5620
6791
  }
5621
6792
  async function writeVisualManifest(projectRoot, manifest) {
5622
6793
  const relative6 = visualManifestRelativePath(manifest.changeId);
5623
- const path = join27(projectRoot, relative6);
5624
- await mkdir9(dirname10(path), { recursive: true });
5625
- await atomicWrite(path, stringify4(manifest));
6794
+ const path = join32(projectRoot, relative6);
6795
+ await mkdir12(dirname10(path), { recursive: true });
6796
+ await atomicWrite(path, stringify5(manifest));
5626
6797
  return relative6;
5627
6798
  }
5628
6799
  async function readVisualCapture(projectRoot, changeId) {
5629
- const path = join27(projectRoot, visualCaptureRelativePath(changeId));
6800
+ const path = join32(projectRoot, visualCaptureRelativePath(changeId));
5630
6801
  try {
5631
- const doc = JSON.parse(await readFile20(path, "utf8"));
6802
+ const doc = JSON.parse(await readFile22(path, "utf8"));
5632
6803
  if (doc?.schemaVersion === 1 && doc.changeId === changeId) {
5633
6804
  return doc;
5634
6805
  }
@@ -5639,16 +6810,16 @@ async function readVisualCapture(projectRoot, changeId) {
5639
6810
  }
5640
6811
  async function writeVisualCapture(projectRoot, capture) {
5641
6812
  const relative6 = visualCaptureRelativePath(capture.changeId);
5642
- const path = join27(projectRoot, relative6);
5643
- await mkdir9(dirname10(path), { recursive: true });
6813
+ const path = join32(projectRoot, relative6);
6814
+ await mkdir12(dirname10(path), { recursive: true });
5644
6815
  await atomicWrite(path, `${JSON.stringify(capture, null, 2)}
5645
6816
  `);
5646
6817
  return relative6;
5647
6818
  }
5648
6819
  async function writeVisualResults(projectRoot, results) {
5649
6820
  const relative6 = visualResultsRelativePath(results.changeId);
5650
- const path = join27(projectRoot, relative6);
5651
- await mkdir9(dirname10(path), { recursive: true });
6821
+ const path = join32(projectRoot, relative6);
6822
+ await mkdir12(dirname10(path), { recursive: true });
5652
6823
  await atomicWrite(path, `${JSON.stringify(results, null, 2)}
5653
6824
  `);
5654
6825
  return relative6;
@@ -6042,10 +7213,10 @@ async function ensureManifest(ctx, changeId, baseUrl) {
6042
7213
  baseUrl
6043
7214
  );
6044
7215
  await writeVisualManifest(ctx.projectRoot, manifest);
6045
- const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6046
- await mkdir10(fetDir, { recursive: true });
6047
- await atomicWrite(join28(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
6048
- await atomicWrite(join28(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
7216
+ const fetDir = join33(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
7217
+ await mkdir13(fetDir, { recursive: true });
7218
+ await atomicWrite(join33(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
7219
+ await atomicWrite(join33(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
6049
7220
  const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6050
7221
  changeState.visual = {
6051
7222
  status: "ready",
@@ -6098,8 +7269,8 @@ async function recordVisualFailure(ctx, changeId, planningFingerprint, steps, ca
6098
7269
 
6099
7270
  // src/commands/verify.ts
6100
7271
  import { createHash as createHash2 } from "crypto";
6101
- import { mkdir as mkdir11, readFile as readFile21, stat as stat12 } from "fs/promises";
6102
- import { join as join29 } from "path";
7272
+ import { mkdir as mkdir14, readFile as readFile23, stat as stat12 } from "fs/promises";
7273
+ import { join as join34 } from "path";
6103
7274
  async function verifyCommand(ctx, options) {
6104
7275
  if (options.auto) {
6105
7276
  const scan = await ctx.scanner.scan(ctx.projectRoot, {});
@@ -6168,9 +7339,9 @@ async function writeInstructions(ctx, changeId) {
6168
7339
  await assertTestPassed(ctx, changeId);
6169
7340
  await assertVisualPassed(ctx, changeId);
6170
7341
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
6171
- const dir = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6172
- const instructionsPath = join29(dir, "verify-instructions.md");
6173
- await mkdir11(dir, { recursive: true });
7342
+ const dir = join34(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
7343
+ const instructionsPath = join34(dir, "verify-instructions.md");
7344
+ await mkdir14(dir, { recursive: true });
6174
7345
  await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
6175
7346
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6176
7347
  state.currentPhase = "verify";
@@ -6188,7 +7359,7 @@ async function markDone(ctx, changeId) {
6188
7359
  await assertTestPassed(ctx, changeId);
6189
7360
  await assertVisualPassed(ctx, changeId);
6190
7361
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
6191
- const instructionsPath = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
7362
+ const instructionsPath = join34(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
6192
7363
  const instructions = await readInstructions(ctx, instructionsPath, changeId);
6193
7364
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
6194
7365
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -6213,7 +7384,7 @@ async function markDone(ctx, changeId) {
6213
7384
  async function readInstructions(ctx, path, changeId) {
6214
7385
  try {
6215
7386
  await stat12(path);
6216
- const content = await readFile21(path, "utf8");
7387
+ const content = await readFile23(path, "utf8");
6217
7388
  const fileChangeId = readFrontMatterValue(content, "changeId");
6218
7389
  if (fileChangeId !== changeId) {
6219
7390
  throw new FetError({
@@ -6336,9 +7507,9 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
6336
7507
  import { resolve } from "path";
6337
7508
 
6338
7509
  // src/adapters/codex/index.ts
6339
- import { mkdir as mkdir12, readFile as readFile22, stat as stat13 } from "fs/promises";
7510
+ import { mkdir as mkdir15, readFile as readFile24, stat as stat13 } from "fs/promises";
6340
7511
  import { homedir } from "os";
6341
- import { dirname as dirname11, join as join30 } from "path";
7512
+ import { dirname as dirname11, join as join35 } from "path";
6342
7513
 
6343
7514
  // src/adapters/commands.ts
6344
7515
  var FET_STANDALONE_COMMANDS = ["tdd", "test", "visual"];
@@ -6385,6 +7556,7 @@ Before doing FET or OpenSpec work in Codex, read:
6385
7556
  - .codex/fet/spec-language.md when writing or updating OpenSpec specs
6386
7557
  - openspec/changes/<change-id>/.fet/figma-apply-instructions.md before UI work when FET apply reports Figma links
6387
7558
  - .codex/fet/ui-display-contract.md when UI binds API data; openspec/changes/<change-id>/.fet/ui-display-contract.yaml when present
7559
+ - .codex/fet/write-boundary.md for default edit scope (src/, openspec/, .fet/ only; ask before other paths)
6388
7560
  - the active change files under openspec/changes/<change-id>/, when a change is selected
6389
7561
 
6390
7562
  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.
@@ -6406,6 +7578,7 @@ ${languageInstruction(language)}
6406
7578
  - \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
6407
7579
  - \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
6408
7580
  - \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
7581
+ - \u4FEE\u6539\u6587\u4EF6\u524D\u9605\u8BFB .codex/fet/write-boundary.md\uFF08\u9ED8\u8BA4\u4EC5 src/\u3001openspec/\u3001.fet/\uFF1B\u5176\u5B83\u8DEF\u5F84\u987B\u7528\u6237\u660E\u786E\u540C\u610F\uFF09
6409
7582
  - \u5982\u679C\u5DF2\u9009\u62E9 change\uFF0C\u9605\u8BFB openspec/changes/<change-id>/ \u4E0B\u7684\u5F53\u524D\u4EA7\u7269
6410
7583
 
6411
7584
  \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
@@ -6444,12 +7617,19 @@ function codexUiDisplayContractFile(language = DEFAULT_LANGUAGE) {
6444
7617
  content: renderCodexUiDisplayContractGuide(language)
6445
7618
  };
6446
7619
  }
7620
+ function codexWriteBoundaryFile(language = DEFAULT_LANGUAGE) {
7621
+ return {
7622
+ path: ".codex/fet/write-boundary.md",
7623
+ content: renderCodexWriteBoundaryGuide(language)
7624
+ };
7625
+ }
6447
7626
  function codexCommandFiles(language = DEFAULT_LANGUAGE) {
6448
7627
  return [
6449
7628
  codexKarpathyGuidelinesFile(language),
6450
7629
  codexFigmaStopFile(language),
6451
7630
  codexUiDisplayContractFile(language),
6452
7631
  codexSpecLanguageFile(language),
7632
+ codexWriteBoundaryFile(language),
6453
7633
  ...FET_ADAPTER_COMMANDS.map((command) => ({
6454
7634
  path: `.codex/fet/commands/${command}.md`,
6455
7635
  content: renderCommand(command, language)
@@ -6491,6 +7671,9 @@ function renderCommand(command, language) {
6491
7671
  if (command.startsWith("graph-")) {
6492
7672
  return renderGraphCommand(command, language);
6493
7673
  }
7674
+ if (command === "tdd" || command === "test" || command === "visual") {
7675
+ return renderStandaloneWorkflowCommand(command, language);
7676
+ }
6494
7677
  const usage = renderFetAdapterUsage(command, "");
6495
7678
  return `<!-- FET:MANAGED
6496
7679
  schemaVersion: 1
@@ -6521,11 +7704,16 @@ ${usage}
6521
7704
  If the command needs a change id, pass it with \`--change <change-id>\` or use the active OpenSpec change from the user's request.
6522
7705
 
6523
7706
  After the command completes, report the important next steps from the FET output and keep any generated OpenSpec artifacts in the normal project workflow.
7707
+
7708
+ ${renderWriteBoundaryGuardrail(language)}
6524
7709
  `;
6525
7710
  }
6526
7711
  function renderCommandZh(command) {
6527
7712
  const usage = renderFetAdapterUsage(command, command === "fill-context" ? "" : command === "passthrough" ? "<openspec-command> [...args]" : "");
6528
7713
  const title = commandTitleZh(command);
7714
+ if (command === "tdd" || command === "test" || command === "visual") {
7715
+ return renderStandaloneWorkflowCommand(command, "zh-CN");
7716
+ }
6529
7717
  if (command === "graph-setup") {
6530
7718
  return `<!-- FET:MANAGED
6531
7719
  schemaVersion: 1
@@ -6583,6 +7771,8 @@ ${usage}
6583
7771
  \u5982\u679C\u547D\u4EE4\u9700\u8981 change id\uFF0C\u4F18\u5148\u4F7F\u7528\u7528\u6237\u8F93\u5165\u3001\`--change <change-id>\`\u3001FET active change \u6216\u552F\u4E00\u6253\u5F00\u7684 change\u3002\u5B58\u5728\u6B67\u4E49\u65F6\u5148\u8BE2\u95EE\u7528\u6237\u3002
6584
7772
 
6585
7773
  \u6267\u884C\u5B8C\u6210\u540E\uFF0C\u7528\u4E2D\u6587\u603B\u7ED3\u5173\u952E\u8F93\u51FA\u3001\u751F\u6210\u6216\u66F4\u65B0\u7684\u6587\u4EF6\uFF0C\u4EE5\u53CA\u4E0B\u4E00\u6B65\u5EFA\u8BAE\u3002
7774
+
7775
+ ${renderWriteBoundaryGuardrail("zh-CN")}
6586
7776
  `;
6587
7777
  }
6588
7778
  function renderPassthroughCommand(language) {
@@ -6699,6 +7889,9 @@ function renderSlashPrompt(command, language) {
6699
7889
  if (command === "apply") {
6700
7890
  return renderApplySlashPrompt(language);
6701
7891
  }
7892
+ if (command === "tdd" || command === "test" || command === "visual") {
7893
+ return renderStandaloneWorkflowSlashPrompt(command, language);
7894
+ }
6702
7895
  if (command === "verify") {
6703
7896
  return renderVerifySlashPrompt(language);
6704
7897
  }
@@ -6760,6 +7953,9 @@ function renderSlashPromptZh(command) {
6760
7953
  if (command === "graph-setup") {
6761
7954
  return renderGraphSetupSlashPrompt("zh-CN");
6762
7955
  }
7956
+ if (command === "tdd" || command === "test" || command === "visual") {
7957
+ return renderStandaloneWorkflowSlashPrompt(command, "zh-CN");
7958
+ }
6763
7959
  const usage = renderFetAdapterUsage(command, command === "fill-context" ? "" : command === "passthrough" ? "<openspec-command> [...args]" : "[...args]");
6764
7960
  const argumentHint = command === "passthrough" ? "openspec-command [...args]" : void 0;
6765
7961
  const argumentHintLine = argumentHint ? `argument-hint: ${argumentHint}
@@ -6800,9 +7996,10 @@ ${commandGoalZh(command)}
6800
7996
  - \u9ED8\u8BA4\u4F7F\u7528\u4E2D\u6587\u4EA7\u51FA\u3002
6801
7997
  - \u4E0D\u8981\u7ED5\u8FC7 FET \u76F4\u63A5\u8C03\u7528 openspec\uFF0C\u9664\u975E FET \u547D\u4EE4\u672C\u8EAB\u4E0D\u53EF\u7528\u3002
6802
7998
  - change \u4E0D\u660E\u786E\u65F6\u5148\u8BE2\u95EE\u7528\u6237\u3002
6803
- ${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")}
7999
+ ${renderWriteBoundaryGuardrail("zh-CN")}
8000
+ ${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\uFF1B\u4FEE\u6539 AGENTS.md \u5C5E\u4E8E\u5141\u8BB8\u8303\u56F4\u5916\u8DEF\u5F84\uFF0C\u987B\u5728\u672C\u8F6E\u5DF2\u83B7\u5F97\u7528\u6237\u540C\u610F\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")}
6804
8001
  ` : ""}${command === "propose" || command === "continue" || command === "ff" ? `${renderUiDisplayContractGuardrail("zh-CN")}
6805
- ` : ""}${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" : ""}`;
8002
+ ` : ""}${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- \u5B9E\u65BD\u524D\u987B\u5DF2\u8FD0\u884C fet tdd\uFF1B\u5B58\u5728 tdd-manifest.yaml \u65F6\u6309\u6E05\u5355\u7F16\u5199\u6D4B\u8BD5\u4E0E\u5B9E\u73B0\uFF0Cfet verify \u524D\u5148 fet test\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" : ""}`;
6806
8003
  }
6807
8004
  function commandTitleZh(command) {
6808
8005
  const titles = {
@@ -6876,7 +8073,9 @@ First run:
6876
8073
  fet fill-context
6877
8074
  \`\`\`
6878
8075
 
6879
- Then read AGENTS.md and openspec/config.yaml, inspect the project, and replace every [NEEDS LLM INPUT] or [NEED LLM INPUT] placeholder in AGENTS.md with concrete project-specific content. Preserve FET managed markers and do not modify business code.
8076
+ Then read AGENTS.md and openspec/config.yaml, inspect the project, and replace every [NEEDS LLM INPUT] or [NEED LLM INPUT] placeholder in AGENTS.md with concrete project-specific content. Preserve FET managed markers and do not modify business code. Editing AGENTS.md is outside the default write scope\u2014confirm user approval first.
8077
+
8078
+ ${renderWriteBoundaryGuardrail(language)}
6880
8079
  `;
6881
8080
  }
6882
8081
  function renderFillContextSlashPrompt(language) {
@@ -6912,7 +8111,9 @@ Steps:
6912
8111
  Guardrails:
6913
8112
  - Do not invent facts that cannot be inferred from the repo.
6914
8113
  - Use [UNKNOWN] only when the repository does not contain enough evidence.
6915
- - Keep generated context stable and useful for future AI coding sessions.`,
8114
+ - Keep generated context stable and useful for future AI coding sessions.
8115
+ - Editing AGENTS.md is outside the default write scope; confirm user approval first (see .codex/fet/write-boundary.md).
8116
+ ${renderWriteBoundaryGuardrail(language)}`,
6916
8117
  void 0,
6917
8118
  language
6918
8119
  );
@@ -6969,7 +8170,7 @@ Steps:
6969
8170
  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>/.
6970
8171
  4. If \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\` exists (or apply nextSteps mention Figma):
6971
8172
  - Read it and \`figma-stop.md\` in the same folder before any UI code.
6972
- - Use Figma MCP/API to read every linked frame; briefly confirm design facts in your reply.
8173
+ - Run \`fet figma sync\` when possible; read \`figma-design-manifest.yaml\`, \`figma-nodes.json\`, and \`.fet/figma-assets/\` (file paths for large images). Otherwise use Figma MCP/API for every linked frame; briefly confirm design facts in your reply.
6973
8174
  - If Figma access fails or design details are unclear, stop and ask the user\u2014do not guess styles or implement UI.
6974
8175
  5. If \`openspec/changes/<change-id>/.fet/ui-display-contract.yaml\` exists (or apply nextSteps mention UI display contract):
6975
8176
  - Read it and \`ui-field-apply-instructions.md\` in the same folder before UI that binds API data.
@@ -6981,14 +8182,16 @@ Steps:
6981
8182
  - Follow proposal, specs, design, and tasks.
6982
8183
  - Mark each completed task checkbox in tasks.md from \`- [ ]\` to \`- [x]\`.
6983
8184
  - Pause and ask if a task is ambiguous or reveals a design conflict.
6984
- 8. After completing or pausing, summarize completed tasks, remaining tasks, and blockers.
8185
+ 8. Run \`fet tdd --change <change-id>\` before implementation when no \`tdd-manifest.yaml\` exists yet; after coding run \`fet test --change <change-id>\` before \`fet verify\`.
8186
+ 9. After completing or pausing, summarize completed tasks, remaining tasks, and blockers.
6985
8187
 
6986
8188
  Guardrails:
6987
8189
  - Never skip reading OpenSpec artifacts before implementation.
6988
8190
  - When Figma links exist for this change, never implement or restyle UI without reading Figma first.
6989
8191
  - When ui-display-contract.yaml exists, API schemas are not UI checklists\u2014only displayFields may render.
6990
8192
  - Do not mark a task complete until the code change is actually done.
6991
- - Do not run sync or archive from apply.`,
8193
+ - Do not run sync or archive from apply.
8194
+ ${renderWriteBoundaryGuardrail(language)}`,
6992
8195
  void 0,
6993
8196
  language
6994
8197
  );
@@ -7415,8 +8618,97 @@ ${languageInstruction(language)}
7415
8618
  ${graphContextInstruction}
7416
8619
 
7417
8620
  ${body}
8621
+
8622
+ ${renderWriteBoundaryGuardrail(language)}
8623
+ `;
8624
+ }
8625
+ function renderStandaloneWorkflowCommand(command, language) {
8626
+ const usage = renderFetAdapterUsage(command, "");
8627
+ const { title, body } = standaloneWorkflowCopy(command, language);
8628
+ return `<!-- FET:MANAGED
8629
+ schemaVersion: 1
8630
+ fetVersion: ${FET_VERSION}
8631
+ generator: codex-adapter
8632
+ adapterVersion: 1
8633
+ command: ${usage}
8634
+ FET:END -->
8635
+
8636
+ # ${usage}
8637
+
8638
+ ${renderIdeModelPolicy(command, language)}
8639
+
8640
+ ${languageInstruction(language)}
8641
+
8642
+ ## ${language === "en" ? "Purpose" : "\u7528\u9014"}
8643
+
8644
+ ${title}
8645
+
8646
+ ## ${language === "en" ? "Workflow" : "\u5DE5\u4F5C\u6D41"}
8647
+
8648
+ ${body}
8649
+
8650
+ ${renderWriteBoundaryGuardrail(language)}
7418
8651
  `;
7419
8652
  }
8653
+ function renderStandaloneWorkflowSlashPrompt(command, language) {
8654
+ const usage = renderFetAdapterUsage(command, "[...args]");
8655
+ const { title, body, description } = standaloneWorkflowCopy(command, language);
8656
+ return renderManagedSlashPrompt(
8657
+ usage,
8658
+ description,
8659
+ `${title}
8660
+
8661
+ Steps:
8662
+
8663
+ 1. Read AGENTS.md, openspec/config.yaml, .codex/fet/karpathy-guidelines.md, and .codex/fet/write-boundary.md.
8664
+ 2. Resolve the change id when needed (\`--change <change-id>\` or active change).
8665
+ 3. Run:
8666
+ \`\`\`sh
8667
+ ${renderFetAdapterUsage(command, "")}
8668
+ \`\`\`
8669
+ 4. ${body}
8670
+ 5. Summarize outputs, paths written under openspec/changes/<change-id>/.fet/, and next steps.
8671
+
8672
+ Guardrails:
8673
+ - ${body.split("\n")[0]}
8674
+ ${renderWriteBoundaryGuardrail(language)}`,
8675
+ void 0,
8676
+ language
8677
+ );
8678
+ }
8679
+ function standaloneWorkflowCopy(command, language) {
8680
+ if (command === "tdd") {
8681
+ return language === "en" ? {
8682
+ description: "Generate per-change TDD manifest and test instructions",
8683
+ title: "Generate the TDD manifest before implementation.",
8684
+ body: "After planning artifacts exist, run this before `fet apply`. It writes `openspec/changes/<change-id>/.fet/tdd-manifest.yaml`, `tdd-spec.md`, and `tdd-instructions.md`. Then add failing tests in the repo before implementation (tests/ edits need user approval per write-boundary)."
8685
+ } : {
8686
+ description: "\u751F\u6210\u672C change \u7684 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15",
8687
+ title: "\u5728\u5B9E\u65BD\u524D\u751F\u6210 TDD \u6E05\u5355\u3002",
8688
+ body: "\u89C4\u5212\u4EA7\u7269\u5C31\u7EEA\u540E\u3001\u6267\u884C `fet apply` \u4E4B\u524D\u8FD0\u884C\u3002\u4F1A\u5199\u5165 `openspec/changes/<change-id>/.fet/tdd-manifest.yaml`\u3001`tdd-spec.md`\u3001`tdd-instructions.md`\uFF0C\u518D\u5728\u4ED3\u5E93\u4E2D\u7F16\u5199\u9884\u671F\u5931\u8D25\u7684\u6D4B\u8BD5\uFF08\u4FEE\u6539 tests/ \u987B\u7B26\u5408 write-boundary\uFF0C\u5148\u83B7\u7528\u6237\u540C\u610F\uFF09\u3002"
8689
+ };
8690
+ }
8691
+ if (command === "test") {
8692
+ return language === "en" ? {
8693
+ description: "Run unit tests scoped to the change TDD manifest",
8694
+ title: "Run change-scoped tests after implementation.",
8695
+ body: "Requires `tdd-manifest.yaml`. Records pass/fail in FET state; `fet verify` is blocked until this passes (unless configured to skip)."
8696
+ } : {
8697
+ description: "\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B",
8698
+ title: "\u5B9E\u73B0\u5B8C\u6210\u540E\u6309 change \u8FD0\u884C\u6D4B\u8BD5\u3002",
8699
+ body: "\u9700\u8981\u6709\u6548\u7684 `tdd-manifest.yaml`\u3002\u7ED3\u679C\u5199\u5165 FET \u72B6\u6001\uFF1B\u672A\u901A\u8FC7\u524D `fet verify` \u4F1A\u88AB\u62E6\u622A\uFF08\u9664\u975E\u914D\u7F6E\u4E3A skip\uFF09\u3002"
8700
+ };
8701
+ }
8702
+ return language === "en" ? {
8703
+ description: "Layout-only visual verification for a change",
8704
+ title: "Run layout-only visual checks for the change.",
8705
+ body: "Default `fet visual` refreshes the manifest, captures with Playwright (`--base-url` required), and runs layout-only checks (no pixel match on dynamic API content). Use `--plan`, `--capture-only`, or `--check-layout-only` only when debugging."
8706
+ } : {
8707
+ description: "change \u7EA7 layout-only \u89C6\u89C9\u9A8C\u6536",
8708
+ title: "\u5BF9 change \u505A layout-only \u89C6\u89C9\u9A8C\u6536\u3002",
8709
+ body: "\u9ED8\u8BA4 `fet visual` \u4F1A\u66F4\u65B0\u6E05\u5355\u3001Playwright \u622A\u56FE\uFF08\u9700 `--base-url`\uFF09\u5E76\u505A layout-only \u68C0\u67E5\uFF08\u4E0D\u5BF9\u52A8\u6001\u63A5\u53E3\u5185\u5BB9\u505A\u50CF\u7D20\u5BF9\u6BD4\uFF09\u3002\u4EC5\u5728\u8C03\u8BD5\u65F6\u4F7F\u7528 `--plan`\u3001`--capture-only`\u3001`--check-layout-only`\u3002"
8710
+ };
8711
+ }
7420
8712
 
7421
8713
  // src/adapters/codex/index.ts
7422
8714
  var CodexAdapter = class {
@@ -7424,7 +8716,7 @@ var CodexAdapter = class {
7424
8716
  adapterVersion = 1;
7425
8717
  async detect(projectRoot) {
7426
8718
  return {
7427
- detected: await exists6(join30(projectRoot, ".codex")) || await exists6(join30(projectRoot, "AGENTS.md")),
8719
+ detected: await exists6(join35(projectRoot, ".codex")) || await exists6(join35(projectRoot, "AGENTS.md")),
7428
8720
  reason: "Codex adapter is available for projects that use AGENTS.md"
7429
8721
  };
7430
8722
  }
@@ -7463,7 +8755,7 @@ var CodexAdapter = class {
7463
8755
  if (existing && !existing.includes("FET:MANAGED") && force) {
7464
8756
  await createBackup(target);
7465
8757
  }
7466
- await mkdir12(dirname11(target), { recursive: true });
8758
+ await mkdir15(dirname11(target), { recursive: true });
7467
8759
  await atomicWrite(target, file.content);
7468
8760
  written.push(displayPath);
7469
8761
  }
@@ -7490,9 +8782,9 @@ var CodexAdapter = class {
7490
8782
  };
7491
8783
  function resolveTarget(projectRoot, file) {
7492
8784
  if (file.root === "codex-home") {
7493
- return join30(resolveCodexHome(), file.path);
8785
+ return join35(resolveCodexHome(), file.path);
7494
8786
  }
7495
- return join30(projectRoot, file.path);
8787
+ return join35(projectRoot, file.path);
7496
8788
  }
7497
8789
  function displayPathFor(file) {
7498
8790
  if (file.root === "codex-home") {
@@ -7501,11 +8793,11 @@ function displayPathFor(file) {
7501
8793
  return file.path;
7502
8794
  }
7503
8795
  function resolveCodexHome() {
7504
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join30(homedir(), ".codex");
8796
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join35(homedir(), ".codex");
7505
8797
  }
7506
8798
  async function readExisting(path) {
7507
8799
  try {
7508
- return await readFile22(path, "utf8");
8800
+ return await readFile24(path, "utf8");
7509
8801
  } catch {
7510
8802
  return null;
7511
8803
  }
@@ -7520,8 +8812,8 @@ async function exists6(path) {
7520
8812
  }
7521
8813
 
7522
8814
  // src/adapters/cursor/index.ts
7523
- import { mkdir as mkdir13, readFile as readFile23, stat as stat14 } from "fs/promises";
7524
- import { dirname as dirname12, join as join31 } from "path";
8815
+ import { mkdir as mkdir16, readFile as readFile25, stat as stat14 } from "fs/promises";
8816
+ import { dirname as dirname12, join as join36 } from "path";
7525
8817
 
7526
8818
  // src/adapters/cursor/templates.ts
7527
8819
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -7536,6 +8828,28 @@ function cursorSpecLanguageRuleFile(language = DEFAULT_LANGUAGE) {
7536
8828
  content: renderCursorSpecLanguageRule(language)
7537
8829
  };
7538
8830
  }
8831
+ function cursorWriteBoundaryRuleFile(language = DEFAULT_LANGUAGE) {
8832
+ return {
8833
+ path: ".cursor/rules/fet-write-boundary.mdc",
8834
+ content: renderCursorWriteBoundaryRule(language)
8835
+ };
8836
+ }
8837
+ function cursorHookFiles() {
8838
+ return [
8839
+ {
8840
+ path: ".cursor/hooks/fet-guard-write-paths.mjs",
8841
+ content: renderCursorWritePathsHookMjs()
8842
+ },
8843
+ {
8844
+ path: ".cursor/hooks/fet-guard-shell-writes.mjs",
8845
+ content: renderCursorShellWriteHookMjs()
8846
+ },
8847
+ {
8848
+ path: ".cursor/hooks.json",
8849
+ content: renderCursorHooksJson()
8850
+ }
8851
+ ];
8852
+ }
7539
8853
  function cursorUiDisplayContractRuleFile(language = DEFAULT_LANGUAGE) {
7540
8854
  return {
7541
8855
  path: ".cursor/rules/fet-ui-display-contract.mdc",
@@ -7545,6 +8859,7 @@ function cursorUiDisplayContractRuleFile(language = DEFAULT_LANGUAGE) {
7545
8859
  function cursorRuleFiles(language = DEFAULT_LANGUAGE) {
7546
8860
  return [
7547
8861
  cursorRuleFile(language),
8862
+ cursorWriteBoundaryRuleFile(language),
7548
8863
  cursorFigmaStopRuleFile(language),
7549
8864
  cursorUiDisplayContractRuleFile(language),
7550
8865
  cursorSpecLanguageRuleFile(language)
@@ -7582,6 +8897,7 @@ ${languageInstruction(language)}
7582
8897
  - \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
7583
8898
  - \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
7584
8899
  - \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
8900
+ - \u4FEE\u6539\u6587\u4EF6\u65F6\u9075\u5B88 \`.cursor/rules/fet-write-boundary.mdc\`\uFF08\u9ED8\u8BA4\u4EC5 \`src/**\`\u3001\`openspec/**\`\u3001\`**/.fet/**\`\uFF1B\u5176\u5B83\u8DEF\u5F84\u987B\u7528\u6237\u660E\u786E\u540C\u610F\uFF09\u3002Cursor hooks \u4F1A\u5BF9\u8D8A\u754C\u5199\u5165\u5F39\u51FA\u5BA1\u6279\u3002
7585
8901
 
7586
8902
  \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
7587
8903
  `
@@ -7617,7 +8933,9 @@ ${languageInstruction(language)}
7617
8933
 
7618
8934
  \`fet fill-context\` \u53EF\u80FD\u5DF2\u5199\u5165\u5C0F\u7A0B\u5E8F\u5305\u4F53\u79EF\u8868\u4E0E 2MB \u5F00\u53D1\u7EA6\u675F\uFF0C\u4E0D\u8981\u8986\u76D6 AGENTS.md\u300C\u5C0F\u7A0B\u5E8F\u300D\u8282\u4E2D\u7684\u626B\u63CF\u7ED3\u679C\uFF0C\u9664\u975E\u4ED3\u5E93\u7ED3\u6784\u5DF2\u53D8\u3002
7619
8935
 
7620
- \u68C0\u67E5 README\u3001package scripts\u3001\u8DEF\u7531\u3001\u6D4B\u8BD5\u3001\u6E90\u7801\u7ED3\u6784\u548C\u73B0\u6709\u7EA6\u5B9A\u540E\uFF0C\u628A AGENTS.md \u4E2D**\u5176\u4F59** \`[NEEDS LLM INPUT]\` \u6216 \`[NEED LLM INPUT]\` \u5360\u4F4D\u7B26\u66FF\u6362\u4E3A\u5177\u4F53\u3001\u7B80\u6D01\u3001\u9879\u76EE\u76F8\u5173\u7684\u5185\u5BB9\u3002\u4FDD\u7559 FET \u6258\u7BA1\u6807\u8BB0\uFF0C\u4E0D\u8981\u4FEE\u6539\u4E1A\u52A1\u4EE3\u7801\u3002
8936
+ \u68C0\u67E5 README\u3001package scripts\u3001\u8DEF\u7531\u3001\u6D4B\u8BD5\u3001\u6E90\u7801\u7ED3\u6784\u548C\u73B0\u6709\u7EA6\u5B9A\u540E\uFF0C\u628A AGENTS.md \u4E2D**\u5176\u4F59** \`[NEEDS LLM INPUT]\` \u6216 \`[NEED LLM INPUT]\` \u5360\u4F4D\u7B26\u66FF\u6362\u4E3A\u5177\u4F53\u3001\u7B80\u6D01\u3001\u9879\u76EE\u76F8\u5173\u7684\u5185\u5BB9\u3002\u4FDD\u7559 FET \u6258\u7BA1\u6807\u8BB0\uFF0C\u4E0D\u8981\u4FEE\u6539\u4E1A\u52A1\u4EE3\u7801\u3002\u4FEE\u6539 \`AGENTS.md\` \u4E0D\u5728\u9ED8\u8BA4\u5199\u8DEF\u5F84\u5185\uFF0C\u987B\u786E\u8BA4\u7528\u6237\u5DF2\u540C\u610F\uFF08\u6216\u7531 hooks \u5BA1\u6279\uFF09\u3002
8937
+
8938
+ ${renderWriteBoundaryGuardrail(language)}
7621
8939
  `;
7622
8940
  }
7623
8941
  if (command === "graph-setup") {
@@ -7723,6 +9041,8 @@ ${renderSpecArtifactGuardrail(language)}
7723
9041
 
7724
9042
  ${renderUiDisplayContractGuardrail(language)}
7725
9043
 
9044
+ ${renderWriteBoundaryGuardrail(language)}
9045
+
7726
9046
  \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
7727
9047
  `;
7728
9048
  }
@@ -7739,12 +9059,14 @@ function renderApplySkill(usage, language) {
7739
9059
  const figmaBlock = language === "en" ? `If \`fet apply\` output or the change has \`openspec/changes/<change-id>/.fet/figma-apply-instructions.md\`:
7740
9060
 
7741
9061
  1. Read that file and \`figma-stop.md\` in the same folder before any UI code.
7742
- 2. Use Figma MCP/API to read every linked frame; do not invent colors, spacing, or layout.
9062
+ 2. Run \`fet figma sync\` when possible; read \`figma-design-manifest.yaml\`, \`figma-nodes.json\`, and \`.fet/figma-assets/\` (use file paths for large images, not base64).
9063
+ 3. Otherwise use Figma MCP/API for every linked frame; do not invent colors, spacing, or layout.
7743
9064
  3. If Figma fails or design is unclear, stop and ask the user\u2014do not guess styles or mark UI tasks done.
7744
9065
  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
7745
9066
 
7746
9067
  1. \u5728\u6539\u4EFB\u4F55 UI \u4EE3\u7801\u524D\uFF0C\u5148\u9605\u8BFB\u8BE5\u6587\u4EF6\u4E0E\u540C\u76EE\u5F55 \`figma-stop.md\`\u3002
7747
- 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
9068
+ 2. \u4F18\u5148\u6267\u884C \`fet figma sync\`\uFF1B\u9605\u8BFB \`figma-design-manifest.yaml\`\u3001\`figma-nodes.json\` \u4E0E \`.fet/figma-assets/\`\uFF08\u5927\u56FE\u7528\u6587\u4EF6\u8DEF\u5F84\uFF0C\u7981\u6B62 base64\uFF09\u3002
9069
+ 3. \u5426\u5219\u7528 Figma MCP/API \u8BFB\u53D6\u6BCF\u4E2A\u753B\u677F\uFF1B\u7981\u6B62\u81EA\u521B\u989C\u8272\u3001\u95F4\u8DDD\u3001\u5E03\u5C40\u3002
7748
9070
  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
7749
9071
  4. \u8BBE\u8BA1\u786E\u8BA4\u540E\uFF0C\u518D\u6309 \`tasks.md\` \u5B9E\u65BD\u4EFB\u52A1\u3002`;
7750
9072
  return `<!-- FET:MANAGED
@@ -7778,6 +9100,8 @@ ${uiContractBlock}
7778
9100
  \u6267\u884C\u524D\u8BF7\u9605\u8BFB AGENTS.md\u3001openspec/config.yaml \u4E0E\u5F53\u524D change \u4E0B\u7684 OpenSpec \u4EA7\u7269\u3002
7779
9101
 
7780
9102
  \u5B9E\u65BD\u524D\u987B\u5DF2\u8FD0\u884C \`fet tdd\`\uFF1B\u5B58\u5728 \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\` \u65F6\u6309\u6E05\u5355\u7F16\u5199\u6D4B\u8BD5\u4E0E\u5B9E\u73B0\uFF0C\u5E76\u5728 \`fet verify\` \u524D\u5148 \`fet test\`\u3002
9103
+
9104
+ ${renderWriteBoundaryGuardrail(language)}
7781
9105
  `;
7782
9106
  }
7783
9107
  function renderVisualSkill(usage, language) {
@@ -7846,14 +9170,14 @@ var CursorAdapter = class {
7846
9170
  adapterVersion = 1;
7847
9171
  async detect(projectRoot) {
7848
9172
  return {
7849
- detected: await exists7(join31(projectRoot, ".cursor")),
9173
+ detected: await exists7(join36(projectRoot, ".cursor")),
7850
9174
  reason: "Cursor adapter is available for any project"
7851
9175
  };
7852
9176
  }
7853
9177
  async planInstall(_projectRoot, language) {
7854
9178
  return {
7855
9179
  tool: this.tool,
7856
- files: [...cursorSkillFiles(language), ...cursorRuleFiles(language)].map((file) => ({
9180
+ files: [...cursorSkillFiles(language), ...cursorRuleFiles(language), ...cursorHookFiles()].map((file) => ({
7857
9181
  ...file,
7858
9182
  managed: true
7859
9183
  }))
@@ -7863,8 +9187,14 @@ var CursorAdapter = class {
7863
9187
  const written = [];
7864
9188
  const skipped = [];
7865
9189
  for (const file of plan.files) {
7866
- const target = join31(projectRoot, file.path);
9190
+ const target = join36(projectRoot, file.path);
7867
9191
  const existing = await readExisting2(target);
9192
+ if (file.path === ".cursor/hooks.json") {
9193
+ await mkdir16(dirname12(target), { recursive: true });
9194
+ await atomicWrite(target, mergeCursorHooksJson(existing, file.content));
9195
+ written.push(file.path);
9196
+ continue;
9197
+ }
7868
9198
  if (existing && !existing.includes("FET:MANAGED") && !force) {
7869
9199
  throw new FetError({
7870
9200
  code: "TOOL_ADAPTER_CONFLICT" /* ToolAdapterConflict */,
@@ -7876,7 +9206,7 @@ var CursorAdapter = class {
7876
9206
  if (existing && !existing.includes("FET:MANAGED") && force) {
7877
9207
  await createBackup(target);
7878
9208
  }
7879
- await mkdir13(dirname12(target), { recursive: true });
9209
+ await mkdir16(dirname12(target), { recursive: true });
7880
9210
  await atomicWrite(target, file.content);
7881
9211
  written.push(file.path);
7882
9212
  }
@@ -7886,14 +9216,16 @@ var CursorAdapter = class {
7886
9216
  const plan = await this.planInstall(projectRoot);
7887
9217
  const checks = [];
7888
9218
  for (const file of plan.files) {
7889
- const target = join31(projectRoot, file.path);
9219
+ const target = join36(projectRoot, file.path);
7890
9220
  const content = await readExisting2(target);
7891
- const managed = Boolean(content?.includes("FET:MANAGED"));
7892
- const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
9221
+ const hooksManaged = file.path === ".cursor/hooks.json" && Boolean(content?.includes('"writeBoundary"'));
9222
+ const hookScript = file.path.startsWith(".cursor/hooks/fet-guard-") && file.path.endsWith(".mjs");
9223
+ const managed = hooksManaged || hookScript || Boolean(content?.includes("FET:MANAGED"));
9224
+ const versionMatches = file.path === ".cursor/hooks.json" ? Boolean(content?.includes('"writeBoundary"')) : hookScript ? Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`)) : Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
7893
9225
  checks.push({
7894
9226
  id: `cursor:${file.path}`,
7895
9227
  status: !content ? "warn" : managed && versionMatches ? "pass" : "warn",
7896
- message: !content ? `${file.path} \u7F3A\u5931` : !managed ? `${file.path} \u5DF2\u5B58\u5728\uFF0C\u4F46\u4E0D\u7531 FET \u7BA1\u7406` : !versionMatches ? `${file.path} adapterVersion \u5DF2\u8FC7\u671F` : `${file.path} \u5DF2\u5B58\u5728\u4E14\u7248\u672C\u5339\u914D`,
9228
+ message: !content ? `${file.path} \u7F3A\u5931` : !managed ? `${file.path} \u5DF2\u5B58\u5728\uFF0C\u4F46\u4E0D\u7531 FET \u7BA1\u7406` : !versionMatches ? `${file.path} \u7248\u672C\u5DF2\u8FC7\u671F\uFF08\u8BF7\u8FD0\u884C fet init \u66F4\u65B0\uFF09` : `${file.path} \u5DF2\u5B58\u5728\u4E14\u7248\u672C\u5339\u914D`,
7897
9229
  suggestedCommand: !content || !managed || !versionMatches ? "fet init" : void 0
7898
9230
  });
7899
9231
  }
@@ -7902,7 +9234,7 @@ var CursorAdapter = class {
7902
9234
  };
7903
9235
  async function readExisting2(path) {
7904
9236
  try {
7905
- return await readFile23(path, "utf8");
9237
+ return await readFile25(path, "utf8");
7906
9238
  } catch {
7907
9239
  return null;
7908
9240
  }
@@ -7922,12 +9254,12 @@ import { promisify as promisify4 } from "util";
7922
9254
 
7923
9255
  // src/openspec/inspector.ts
7924
9256
  import { readdir as readdir6, stat as stat15 } from "fs/promises";
7925
- import { join as join32 } from "path";
9257
+ import { join as join37 } from "path";
7926
9258
  async function inspectOpenSpecProject(projectRoot) {
7927
- const openspecPath = join32(projectRoot, "openspec");
7928
- const changesPath = join32(openspecPath, "changes");
7929
- const legacyArchivePath = join32(openspecPath, "archive");
7930
- const changesArchivePath = join32(changesPath, "archive");
9259
+ const openspecPath = join37(projectRoot, "openspec");
9260
+ const changesPath = join37(openspecPath, "changes");
9261
+ const legacyArchivePath = join37(openspecPath, "archive");
9262
+ const changesArchivePath = join37(changesPath, "archive");
7931
9263
  return {
7932
9264
  exists: await exists8(openspecPath),
7933
9265
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
@@ -7935,13 +9267,13 @@ async function inspectOpenSpecProject(projectRoot) {
7935
9267
  };
7936
9268
  }
7937
9269
  async function inspectOpenSpecChange(projectRoot, changeId) {
7938
- const changePath = join32(projectRoot, "openspec", "changes", changeId);
7939
- const tasksPath = join32(changePath, "tasks.md");
7940
- const specsPath = join32(changePath, "specs");
9270
+ const changePath = join37(projectRoot, "openspec", "changes", changeId);
9271
+ const tasksPath = join37(changePath, "tasks.md");
9272
+ const specsPath = join37(changePath, "specs");
7941
9273
  return {
7942
9274
  changeId,
7943
9275
  exists: await exists8(changePath),
7944
- hasProposal: await exists8(join32(changePath, "proposal.md")),
9276
+ hasProposal: await exists8(join37(changePath, "proposal.md")),
7945
9277
  hasTasks: await exists8(tasksPath),
7946
9278
  hasSpecs: await exists8(specsPath),
7947
9279
  tasksPath,
@@ -8143,12 +9475,12 @@ function escapeRegExp(value) {
8143
9475
 
8144
9476
  // src/scanner/routes.ts
8145
9477
  import { readdir as readdir7, stat as stat16 } from "fs/promises";
8146
- import { join as join33, relative as relative5, sep } from "path";
9478
+ import { join as join38, relative as relative5, sep } from "path";
8147
9479
  async function scanRoutes(projectRoot) {
8148
9480
  const candidates = ["src/routes", "src/pages", "app", "pages"];
8149
9481
  const routes = [];
8150
9482
  for (const candidate of candidates) {
8151
- const root = join33(projectRoot, candidate);
9483
+ const root = join38(projectRoot, candidate);
8152
9484
  if (!await exists9(root)) {
8153
9485
  continue;
8154
9486
  }
@@ -8176,7 +9508,7 @@ async function listFiles(root) {
8176
9508
  const entries = await readdir7(root, { withFileTypes: true });
8177
9509
  const files = [];
8178
9510
  for (const entry of entries) {
8179
- const path = join33(root, entry.name);
9511
+ const path = join38(root, entry.name);
8180
9512
  if (entry.isDirectory()) {
8181
9513
  files.push(...await listFiles(path));
8182
9514
  } else {
@@ -8344,9 +9676,9 @@ async function createCommandContext(command, options) {
8344
9676
  import { createInterface as createInterface2 } from "readline/promises";
8345
9677
 
8346
9678
  // src/update/check.ts
8347
- import { mkdir as mkdir14, readFile as readFile24, writeFile } from "fs/promises";
9679
+ import { mkdir as mkdir17, readFile as readFile26, writeFile as writeFile3 } from "fs/promises";
8348
9680
  import { homedir as homedir2 } from "os";
8349
- import { dirname as dirname13, join as join34 } from "path";
9681
+ import { dirname as dirname13, join as join39 } from "path";
8350
9682
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
8351
9683
  function getFetUpdateCheckMode(env = process.env) {
8352
9684
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -8419,11 +9751,11 @@ function formatFetUpdateWarning(availability, language) {
8419
9751
  }
8420
9752
  function cachePath() {
8421
9753
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
8422
- return join34(home, ".fet", "update-check-cache.json");
9754
+ return join39(home, ".fet", "update-check-cache.json");
8423
9755
  }
8424
9756
  async function readUpdateCheckCache() {
8425
9757
  try {
8426
- const raw = await readFile24(cachePath(), "utf8");
9758
+ const raw = await readFile26(cachePath(), "utf8");
8427
9759
  const parsed = JSON.parse(raw);
8428
9760
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
8429
9761
  return null;
@@ -8439,8 +9771,8 @@ async function readUpdateCheckCache() {
8439
9771
  }
8440
9772
  async function writeUpdateCheckCache(cache) {
8441
9773
  const path = cachePath();
8442
- await mkdir14(dirname13(path), { recursive: true });
8443
- await writeFile(path, `${JSON.stringify(cache, null, 2)}
9774
+ await mkdir17(dirname13(path), { recursive: true });
9775
+ await writeFile3(path, `${JSON.stringify(cache, null, 2)}
8444
9776
  `, "utf8");
8445
9777
  }
8446
9778
 
@@ -8527,6 +9859,19 @@ addGlobalOptions(program.command("doctor").description("\u8BCA\u65AD\u72B6\u6001
8527
9859
  wrap("doctor", (ctx, options) => doctorCommand(ctx, { fixLock: Boolean(options.fixLock) }))
8528
9860
  );
8529
9861
  addGlobalOptions(program.command("tdd").description("\u6839\u636E\u89C4\u5212\u4EA7\u7269\u751F\u6210 change \u7EA7 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15")).action(wrap("tdd", tddCommand));
9862
+ var figma = addGlobalOptions(program.command("figma").description("Figma \u8BBE\u8BA1\u7A3F\u540C\u6B65\uFF08\u62C9\u53D6\u8282\u70B9\u6811\u5E76\u4E0B\u8F7D\u7D20\u6750\uFF09"));
9863
+ addGlobalOptions(
9864
+ figma.command("sync").description("\u4ECE Figma API \u540C\u6B65\u5168\u90E8\u8282\u70B9\u4E0E\u56FE\u7247\u7D20\u6750\u5230 change/.fet/").option("--token <token>", "Figma Personal Access Token\uFF08\u4E5F\u53EF\u7528 FIGMA_ACCESS_TOKEN\uFF09").option("--force", "\u5F3A\u5236\u91CD\u65B0\u4E0B\u8F7D\uFF0C\u5FFD\u7565\u5DF2\u6709\u540C\u6B65\u7ED3\u679C").option("--plan", "\u4EC5\u8F93\u51FA\u540C\u6B65\u8BA1\u5212\uFF0C\u4E0D\u8C03\u7528 Figma API")
9865
+ ).action(
9866
+ wrap(
9867
+ "figma",
9868
+ (ctx, options) => figmaSyncCommand(ctx, {
9869
+ token: options.token,
9870
+ force: Boolean(options.force),
9871
+ plan: Boolean(options.plan)
9872
+ })
9873
+ )
9874
+ );
8530
9875
  addGlobalOptions(program.command("test").description("\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B\u5E76\u8BB0\u5F55\u7EFF\u706F\u72B6\u6001").option("--plan", "\u4EC5\u8F93\u51FA\u5C06\u6267\u884C\u7684\u6D4B\u8BD5\u547D\u4EE4\uFF0C\u4E0D\u8FD0\u884C")).action(
8531
9876
  wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
8532
9877
  );
@@ -8659,4 +10004,3 @@ function extractGlobalOptions(args) {
8659
10004
  }
8660
10005
  return options;
8661
10006
  }
8662
- //# sourceMappingURL=index.js.map