@nick848/fet 1.1.10 → 1.1.12

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
@@ -2090,6 +2090,12 @@ function renderFetConfig(scan, language = "zh-CN") {
2090
2090
  mode: "require_before_apply",
2091
2091
  whenNoTestScript: "block"
2092
2092
  },
2093
+ visual: {
2094
+ enabled: true,
2095
+ compareMode: "layout-only",
2096
+ requireBeforeVerify: "when_figma",
2097
+ whenNoCapture: "warn"
2098
+ },
2093
2099
  specLanguage: {
2094
2100
  style: "layered_bilingual",
2095
2101
  canonical: "en",
@@ -2274,7 +2280,7 @@ function tddResultsRelativePath(changeId) {
2274
2280
 
2275
2281
  // src/templates/tdd.ts
2276
2282
  function renderTddInstructions(changeId, manifest, language) {
2277
- const manifestPath2 = tddManifestRelativePath(changeId);
2283
+ const manifestPath3 = tddManifestRelativePath(changeId);
2278
2284
  const specPath = tddSpecRelativePath(changeId);
2279
2285
  const caseList = manifest.cases.map((item) => `- \`${item.id}\`: ${item.title} \u2192 \`${item.testFile}\` (${item.testIds.join(", ")})`).join("\n");
2280
2286
  if (language === "en") {
@@ -2293,13 +2299,13 @@ Create or update unit tests **before** marking implementation tasks done in \`ta
2293
2299
  ## Sources
2294
2300
  ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2295
2301
 
2296
- ## Cases (from ${manifestPath2})
2302
+ ## Cases (from ${manifestPath3})
2297
2303
  ${caseList}
2298
2304
 
2299
2305
  ## Rules
2300
2306
  1. Each case must map to a real test file under the repo test tree.
2301
2307
  2. Tests should fail until implementation lands (red \u2192 green).
2302
- 3. Do not edit \`${manifestPath2}\` by hand unless fixing IDs; re-run \`fet tdd --change ${changeId}\` after planning changes.
2308
+ 3. Do not edit \`${manifestPath3}\` by hand unless fixing IDs; re-run \`fet tdd --change ${changeId}\` after planning changes.
2303
2309
  4. When tests exist, run \`fet test --change ${changeId}\` before \`fet verify\`.
2304
2310
 
2305
2311
  Human-readable matrix: \`${specPath}\`
@@ -2320,13 +2326,13 @@ generatedAt: ${manifest.generatedAt}
2320
2326
  ## \u6765\u6E90
2321
2327
  ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2322
2328
 
2323
- ## \u7528\u4F8B\uFF08\u89C1 ${manifestPath2}\uFF09
2329
+ ## \u7528\u4F8B\uFF08\u89C1 ${manifestPath3}\uFF09
2324
2330
  ${caseList}
2325
2331
 
2326
2332
  ## \u89C4\u5219
2327
2333
  1. \u6BCF\u4E2A\u7528\u4F8B\u5FC5\u987B\u5BF9\u5E94\u4ED3\u5E93\u5185\u771F\u5B9E\u6D4B\u8BD5\u6587\u4EF6\u3002
2328
2334
  2. \u5B9E\u73B0\u843D\u5730\u524D\u6D4B\u8BD5\u5E94\u5904\u4E8E\u5931\u8D25\uFF08\u7EA2\uFF09\u72B6\u6001\uFF0C\u843D\u5730\u540E\u5E94\u53D8\u7EFF\u3002
2329
- 3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${manifestPath2}\`\uFF1B\u89C4\u5212\u53D8\u66F4\u540E\u8BF7\u91CD\u65B0\u6267\u884C \`fet tdd --change ${changeId}\`\u3002
2335
+ 3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${manifestPath3}\`\uFF1B\u89C4\u5212\u53D8\u66F4\u540E\u8BF7\u91CD\u65B0\u6267\u884C \`fet tdd --change ${changeId}\`\u3002
2330
2336
  4. \u6D4B\u8BD5\u5C31\u7EEA\u540E\u5148 \`fet test --change ${changeId}\`\uFF0C\u518D \`fet verify\`\u3002
2331
2337
 
2332
2338
  \u53EF\u8BFB\u77E9\u9635\u89C1 \`${specPath}\`
@@ -2371,19 +2377,151 @@ function escapeTable(value) {
2371
2377
  return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
2372
2378
  }
2373
2379
  function renderTddApplyNextSteps(changeId, language) {
2374
- const manifestPath2 = tddManifestRelativePath(changeId);
2380
+ const manifestPath3 = tddManifestRelativePath(changeId);
2375
2381
  if (language === "en") {
2376
2382
  return [
2377
- `Read ${manifestPath2} and tdd-instructions.md; implement code until fet test passes for this change.`,
2383
+ `Read ${manifestPath3} and tdd-instructions.md; implement code until fet test passes for this change.`,
2378
2384
  `Run fet test --change ${changeId} before fet verify.`
2379
2385
  ];
2380
2386
  }
2381
2387
  return [
2382
- `\u9605\u8BFB ${manifestPath2} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
2388
+ `\u9605\u8BFB ${manifestPath3} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
2383
2389
  `\u5728 fet verify \u4E4B\u524D\u5148\u6267\u884C fet test --change ${changeId}\u3002`
2384
2390
  ];
2385
2391
  }
2386
2392
 
2393
+ // src/visual/paths.ts
2394
+ function visualFetDirRelative(changeId) {
2395
+ return `openspec/changes/${changeId}/.fet`;
2396
+ }
2397
+ function visualManifestRelativePath(changeId) {
2398
+ return `${visualFetDirRelative(changeId)}/visual-manifest.yaml`;
2399
+ }
2400
+ function visualSpecRelativePath(changeId) {
2401
+ return `${visualFetDirRelative(changeId)}/visual-spec.md`;
2402
+ }
2403
+ function visualInstructionsRelativePath(changeId) {
2404
+ return `${visualFetDirRelative(changeId)}/visual-instructions.md`;
2405
+ }
2406
+ function visualCaptureRelativePath(changeId) {
2407
+ return `${visualFetDirRelative(changeId)}/visual-capture.json`;
2408
+ }
2409
+ function visualResultsRelativePath(changeId) {
2410
+ return `${visualFetDirRelative(changeId)}/visual-results.json`;
2411
+ }
2412
+ function visualBaselinesDirRelative(changeId) {
2413
+ return `${visualFetDirRelative(changeId)}/visual-baselines`;
2414
+ }
2415
+ function visualPageScreenshotRelative(changeId, pageId) {
2416
+ return `${visualBaselinesDirRelative(changeId)}/${pageId}/implementation.png`;
2417
+ }
2418
+
2419
+ // src/templates/visual.ts
2420
+ function renderVisualInstructions(changeId, manifest, language) {
2421
+ const pages = manifest.pages.map(
2422
+ (page) => `- \`${page.id}\`: ${page.title} \u2192 \`${page.route}\` (ignore: ${page.ignoreSelectors.join(", ") || "none"})`
2423
+ ).join("\n");
2424
+ if (language === "en") {
2425
+ return `---
2426
+ schemaVersion: 1
2427
+ fetVersion: ${FET_VERSION}
2428
+ changeId: ${changeId}
2429
+ purpose: visual-instructions
2430
+ generatedAt: ${manifest.generatedAt}
2431
+ compareMode: layout-only
2432
+ ---
2433
+
2434
+ # Visual verification (layout-only)
2435
+
2436
+ Compare **layout / spacing / shell regions** only. Do **not** pixel-match dynamic API text, list rows, or images.
2437
+
2438
+ ## Figma references
2439
+ ${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- (none detected)"}
2440
+
2441
+ ## Pages
2442
+ ${pages}
2443
+
2444
+ ## Default command
2445
+
2446
+ \`\`\`sh
2447
+ fet visual --change ${changeId}
2448
+ \`\`\`
2449
+
2450
+ Runs manifest refresh, capture (Playwright + \`--base-url\`), and layout checks in one step.
2451
+
2452
+ See \`${visualSpecRelativePath(changeId)}\` for the human-readable matrix.
2453
+ `;
2454
+ }
2455
+ return `---
2456
+ schemaVersion: 1
2457
+ fetVersion: ${FET_VERSION}
2458
+ changeId: ${changeId}
2459
+ purpose: visual-instructions
2460
+ generatedAt: ${manifest.generatedAt}
2461
+ compareMode: layout-only
2462
+ ---
2463
+
2464
+ # \u89C6\u89C9\u9A8C\u6536\uFF08\u4EC5 layout-only\uFF09
2465
+
2466
+ \u53EA\u9A8C\u6536 **\u6392\u7248 / \u95F4\u8DDD / \u58F3\u5C42\u533A\u57DF**\uFF0C**\u4E0D\u8981**\u5BF9\u52A8\u6001\u63A5\u53E3\u6587\u6848\u3001\u5217\u8868\u884C\u3001\u56FE\u7247\u505A\u50CF\u7D20\u7EA7\u5BF9\u6BD4\u3002
2467
+
2468
+ ## Figma \u5F15\u7528
2469
+ ${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- \uFF08\u672A\u68C0\u6D4B\u5230\uFF09"}
2470
+
2471
+ ## \u9875\u9762
2472
+ ${pages}
2473
+
2474
+ ## \u9ED8\u8BA4\u547D\u4EE4
2475
+
2476
+ \`\`\`sh
2477
+ fet visual --change ${changeId}
2478
+ \`\`\`
2479
+
2480
+ \u4E00\u6761\u547D\u4EE4\u5B8C\u6210\u6E05\u5355\u66F4\u65B0\u3001\u622A\u56FE\uFF08Playwright + \`--base-url\`\uFF09\u4E0E\u5E03\u5C40\u68C0\u67E5\u3002
2481
+
2482
+ \u8BE6\u89C1 \`${visualSpecRelativePath(changeId)}\`\u3002
2483
+ `;
2484
+ }
2485
+ function renderVisualSpec(changeId, manifest, language) {
2486
+ const rows = manifest.pages.flatMap(
2487
+ (page) => page.checkRegions.map(
2488
+ (region) => `| ${page.id} | ${page.route} | \`${region.selector}\` | ${region.checks.join(", ")} | ${page.ignoreSelectors.join(", ") || "-"} |`
2489
+ )
2490
+ ).join("\n");
2491
+ if (language === "en") {
2492
+ return `---
2493
+ changeId: ${changeId}
2494
+ compareMode: layout-only
2495
+ planningFingerprint: ${manifest.planningFingerprint}
2496
+ ---
2497
+
2498
+ # Visual case matrix (layout-only)
2499
+
2500
+ | Page | Route | Region | Checks | Ignored dynamic |
2501
+ |------|-------|--------|--------|-----------------|
2502
+ ${rows}
2503
+ `;
2504
+ }
2505
+ return `---
2506
+ changeId: ${changeId}
2507
+ compareMode: layout-only
2508
+ planningFingerprint: ${manifest.planningFingerprint}
2509
+ ---
2510
+
2511
+ # \u89C6\u89C9\u7528\u4F8B\u77E9\u9635\uFF08\u4EC5 layout-only\uFF09
2512
+
2513
+ | \u9875\u9762 | \u8DEF\u7531 | \u533A\u57DF | \u68C0\u67E5\u9879 | \u5FFD\u7565\u7684\u52A8\u6001\u533A |
2514
+ |------|------|------|--------|--------------|
2515
+ ${rows}
2516
+ `;
2517
+ }
2518
+ function renderVisualVerifyNextSteps(changeId, language) {
2519
+ if (language === "en") {
2520
+ return [`Run fet visual --change ${changeId} before fet verify when this change references Figma.`];
2521
+ }
2522
+ return [`\u82E5\u672C change \u5F15\u7528 Figma\uFF0C\u8BF7\u5728 fet verify \u4E4B\u524D\u6267\u884C fet visual --change ${changeId}\u3002`];
2523
+ }
2524
+
2387
2525
  // src/templates/figma-guard.ts
2388
2526
  var FIGMA_URL_PATTERN = /https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\/[^\s)\]"'<>]+/gi;
2389
2527
  function figmaStopHandoffRelativePath(changeId) {
@@ -2862,6 +3000,390 @@ When the artifact is \`specs/<capability>/spec.md\` (or you edit spec files in t
2862
3000
  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}`;
2863
3001
  }
2864
3002
 
3003
+ // src/templates/write-boundary.ts
3004
+ var WRITE_BOUNDARY_ALLOW_PREFIXES = ["src/", "openspec/"];
3005
+ var WRITE_BOUNDARY_ROOT_CONFIG_EXACT = [
3006
+ ".gitignore",
3007
+ ".gitattributes",
3008
+ ".stylelintignore",
3009
+ ".eslintignore",
3010
+ ".prettierignore",
3011
+ ".editorconfig",
3012
+ ".npmrc",
3013
+ ".nvmrc",
3014
+ ".node-version",
3015
+ ".checkrc.js",
3016
+ "package.json",
3017
+ "package-lock.json",
3018
+ "pnpm-lock.yaml",
3019
+ "yarn.lock",
3020
+ "bun.lockb",
3021
+ "npm-shrinkwrap.json"
3022
+ ];
3023
+ var WRITE_BOUNDARY_ROOT_CONFIG_BASENAME_PREFIXES = [
3024
+ ".eslintrc",
3025
+ ".stylelintrc",
3026
+ ".prettierrc",
3027
+ ".stylelint",
3028
+ "eslint.config",
3029
+ "stylelint.config",
3030
+ "prettier.config",
3031
+ "tsconfig",
3032
+ "vitest.config",
3033
+ "vite.config",
3034
+ "tsup.config",
3035
+ "jest.config",
3036
+ "rollup.config",
3037
+ "webpack.config",
3038
+ "babel.config",
3039
+ "biome.json",
3040
+ "deno.json",
3041
+ "deno.jsonc"
3042
+ ];
3043
+ function renderRootConfigPathList(language) {
3044
+ 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";
3045
+ return samples;
3046
+ }
3047
+ function renderWriteBoundaryGuardrail(language) {
3048
+ const rootList = renderRootConfigPathList(language);
3049
+ if (language === "en") {
3050
+ return `- Default write scope: \`src/**\`, \`openspec/**\`, and any \`**/.fet/**\`. All other paths need explicit user approval first.
3051
+ - **Repo-root config is high risk**: ${rootList} \u2014 list each file and why before editing; never change these silently.`;
3052
+ }
3053
+ 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
3054
+ - **\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`;
3055
+ }
3056
+ function renderWriteBoundaryPolicyBody(language) {
3057
+ if (language === "en") {
3058
+ return `## Default allowed write scope
3059
+
3060
+ - \`src/**\` \u2014 application/library source
3061
+ - \`openspec/**\` \u2014 OpenSpec specs and change artifacts
3062
+ - \`**/.fet/**\` \u2014 per-change FET handoff files (including \`openspec/changes/<id>/.fet/\`)
3063
+
3064
+ ## Ask the user first
3065
+
3066
+ Before creating, editing, or deleting files **outside** the allowed scope:
3067
+
3068
+ 1. List every path you need to touch and why.
3069
+ 2. Wait for explicit user approval (do not assume silence means yes).
3070
+ 3. Prefer minimal diffs.
3071
+
3072
+ Common paths that need approval: \`tests/**\`, \`.github/**\`, \`.workflow/**\`, \`.cursor/**\`, \`.codex/**\`, \`AGENTS.md\`, \`README*\`, \`.env*\`.
3073
+
3074
+ ## Repo-root config (always ask \u2014 never silent edit)
3075
+
3076
+ These live at the **repository root** (no subdirectory). Treat every change as infrastructure impact:
3077
+
3078
+ ${renderRootConfigPathList(language)}
3079
+
3080
+ Workflow:
3081
+
3082
+ 1. State **exact filenames** (e.g. \`package.json\`, \`.eslintrc.js\`, \`.gitignore\`).
3083
+ 2. Explain **why** each file must change.
3084
+ 3. Wait for **explicit user approval** before writing.
3085
+ 4. Do not \u201Cfix lint/format\u201D by editing root configs unless the user requested that scope.
3086
+
3087
+ ## Forbidden without approval
3088
+
3089
+ - Dependency or lockfile changes (\`package.json\`, \`package-lock.json\`, etc.) unless the user asked
3090
+ - Lint/format/tooling config at repo root (\`.eslintrc*\`, \`.stylelint*\`, \`.checkrc.js\`, \`.gitignore\`, \u2026) unless the user asked
3091
+ - Secrets or credential files
3092
+ - Using shell redirects or destructive git commands to bypass this policy
3093
+
3094
+ ## Cursor enforcement
3095
+
3096
+ 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\`.`;
3097
+ }
3098
+ return `## \u9ED8\u8BA4\u5141\u8BB8\u4FEE\u6539\u7684\u8303\u56F4
3099
+
3100
+ - \`src/**\` \u2014 \u4E1A\u52A1/\u5E93\u6E90\u7801
3101
+ - \`openspec/**\` \u2014 OpenSpec \u89C4\u8303\u4E0E change \u4EA7\u7269
3102
+ - \`**/.fet/**\` \u2014 \u5404 change \u7684 FET \u4EA4\u63A5\u6587\u4EF6\uFF08\u542B \`openspec/changes/<id>/.fet/\`\uFF09
3103
+
3104
+ ## \u987B\u5148\u5F81\u5F97\u7528\u6237\u540C\u610F
3105
+
3106
+ \u5728**\u5141\u8BB8\u8303\u56F4\u5916**\u521B\u5EFA\u3001\u4FEE\u6539\u6216\u5220\u9664\u6587\u4EF6\u4E4B\u524D\uFF1A
3107
+
3108
+ 1. \u5217\u51FA\u5C06\u8981\u4FEE\u6539\u7684\u8DEF\u5F84\u53CA\u539F\u56E0\u3002
3109
+ 2. \u7B49\u5F85\u7528\u6237\u660E\u786E\u540C\u610F\uFF08\u4E0D\u8981\u9ED8\u8BA4\u6C89\u9ED8\u5373\u540C\u610F\uFF09\u3002
3110
+ 3. \u4FDD\u6301\u6700\u5C0F\u6539\u52A8\u3002
3111
+
3112
+ \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
3113
+
3114
+ ## \u4ED3\u5E93\u6839\u76EE\u5F55\u914D\u7F6E\uFF08\u59CB\u7EC8\u987B\u8BE2\u95EE\uFF0C\u7981\u6B62\u9759\u9ED8\u4FEE\u6539\uFF09
3115
+
3116
+ \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
3117
+
3118
+ ${renderRootConfigPathList(language)}
3119
+
3120
+ \u6D41\u7A0B\uFF1A
3121
+
3122
+ 1. \u660E\u786E\u5217\u51FA**\u5B8C\u6574\u6587\u4EF6\u540D**\uFF08\u5982 \`package.json\`\u3001\`.eslintrc.js\`\u3001\`.gitignore\`\uFF09\u3002
3123
+ 2. \u8BF4\u660E**\u6BCF\u9879\u4FEE\u6539\u7684\u539F\u56E0**\u3002
3124
+ 3. \u83B7\u5F97\u7528\u6237**\u660E\u786E\u540C\u610F**\u540E\u518D\u5199\u5165\u3002
3125
+ 4. \u4E0D\u8981\u4EE5\u300C\u987A\u4FBF\u4FEE lint/\u683C\u5F0F\u300D\u4E3A\u7531\u64C5\u81EA\u6539\u6839\u76EE\u5F55\u914D\u7F6E\u3002
3126
+
3127
+ ## \u672A\u7ECF\u540C\u610F\u7981\u6B62
3128
+
3129
+ - \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
3130
+ - \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
3131
+ - \u4FEE\u6539\u5BC6\u94A5\u6216\u51ED\u8BC1\u6587\u4EF6
3132
+ - \u7528 shell \u91CD\u5B9A\u5411\u6216\u7834\u574F\u6027 git \u547D\u4EE4\u7ED5\u8FC7\u672C\u7B56\u7565
3133
+
3134
+ ## Cursor \u786C\u95E8\u7981
3135
+
3136
+ \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`;
3137
+ }
3138
+ function renderCursorWriteBoundaryRule(language) {
3139
+ 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/";
3140
+ return `<!-- FET:MANAGED
3141
+ schemaVersion: 1
3142
+ fetVersion: ${FET_VERSION}
3143
+ generator: cursor-adapter
3144
+ adapterVersion: 1
3145
+ FET:END -->
3146
+
3147
+ ---
3148
+ description: ${description}
3149
+ alwaysApply: true
3150
+ ---
3151
+
3152
+ ${renderWriteBoundaryPolicyBody(language)}
3153
+
3154
+ ${renderWriteBoundaryGuardrail(language)}
3155
+ `;
3156
+ }
3157
+ function renderCodexWriteBoundaryGuide(language) {
3158
+ return `<!-- FET:MANAGED
3159
+ schemaVersion: 1
3160
+ fetVersion: ${FET_VERSION}
3161
+ generator: codex-adapter
3162
+ adapterVersion: 1
3163
+ FET:END -->
3164
+
3165
+ # ${language === "en" ? "Write path boundary (Codex)" : "\u5199\u8DEF\u5F84\u8FB9\u754C\uFF08Codex\uFF09"}
3166
+
3167
+ ${renderWriteBoundaryPolicyBody(language)}
3168
+
3169
+ ${renderWriteBoundaryGuardrail(language)}
3170
+ `;
3171
+ }
3172
+ function renderCursorWritePathsHookMjs() {
3173
+ const allowPrefixes = [...WRITE_BOUNDARY_ALLOW_PREFIXES];
3174
+ const rootExact = [...WRITE_BOUNDARY_ROOT_CONFIG_EXACT];
3175
+ const rootPrefixes = [...WRITE_BOUNDARY_ROOT_CONFIG_BASENAME_PREFIXES];
3176
+ return `#!/usr/bin/env node
3177
+ /**
3178
+ * FET:MANAGED
3179
+ * adapterVersion: 1
3180
+ * write-path guard for Cursor preToolUse (Write/StrReplace/EditNotebook/Delete).
3181
+ */
3182
+ import { readFileSync } from "node:fs";
3183
+
3184
+ const ALLOW_PREFIXES = ${JSON.stringify(allowPrefixes)};
3185
+ const ROOT_CONFIG_EXACT = ${JSON.stringify(rootExact)};
3186
+ const ROOT_CONFIG_PREFIXES = ${JSON.stringify(rootPrefixes)};
3187
+
3188
+ function normalizePath(path) {
3189
+ return String(path ?? "")
3190
+ .replaceAll("\\\\", "/")
3191
+ .replace(/^\\.\\/+/, "")
3192
+ .replace(/^\\/+/u, "");
3193
+ }
3194
+
3195
+ function isRootConfigPath(path) {
3196
+ const normalized = normalizePath(path);
3197
+ if (!normalized || normalized.includes("/")) return false;
3198
+ const base = normalized.toLowerCase();
3199
+ if (ROOT_CONFIG_EXACT.includes(base)) return true;
3200
+ return ROOT_CONFIG_PREFIXES.some((prefix) => base === prefix || base.startsWith(prefix));
3201
+ }
3202
+
3203
+ function classifyPath(path) {
3204
+ const normalized = normalizePath(path);
3205
+ if (!normalized) return "ask";
3206
+ for (const prefix of ALLOW_PREFIXES) {
3207
+ const bare = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
3208
+ if (normalized === bare || normalized.startsWith(prefix)) return "allow";
3209
+ }
3210
+ if (normalized.startsWith(".fet/") || normalized.includes("/.fet/")) return "allow";
3211
+ if (isRootConfigPath(normalized)) return "root_config";
3212
+ return "ask";
3213
+ }
3214
+
3215
+ function extractPaths(payload) {
3216
+ const paths = [];
3217
+ const tool = payload?.tool_name ?? payload?.toolName ?? "";
3218
+ const input = payload?.tool_input ?? payload?.toolInput ?? payload?.input ?? {};
3219
+ if (tool === "Write" || tool === "StrReplace" || tool === "Delete") {
3220
+ if (input.path) paths.push(input.path);
3221
+ }
3222
+ if (tool === "EditNotebook" && input.target_notebook) {
3223
+ paths.push(input.target_notebook);
3224
+ }
3225
+ return paths;
3226
+ }
3227
+
3228
+ function respond(decision, paths) {
3229
+ if (decision === "allow") {
3230
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
3231
+ return;
3232
+ }
3233
+ const list = paths.map((p) => normalizePath(p)).filter(Boolean).join(", ") || "(unknown path)";
3234
+ const hasRootConfig = paths.some((p) => classifyPath(p) === "root_config");
3235
+ const msg = hasRootConfig
3236
+ ? "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."
3237
+ : "FET write boundary: this edit is outside src/, openspec/, or **/.fet/. Approve only if you intend to modify protected paths.";
3238
+ process.stdout.write(
3239
+ JSON.stringify({
3240
+ permission: "ask",
3241
+ user_message: msg + " Paths: " + list,
3242
+ agent_message: hasRootConfig
3243
+ ? "Repo-root config edit blocked. Explain why each root config file must change and wait for user approval."
3244
+ : "Out-of-scope write blocked pending user approval. List why each path is needed, then retry after approval."
3245
+ })
3246
+ );
3247
+ }
3248
+
3249
+ const raw = readFileSync(0, "utf8");
3250
+ let payload = {};
3251
+ try {
3252
+ payload = JSON.parse(raw || "{}");
3253
+ } catch {
3254
+ process.stdout.write(JSON.stringify({ permission: "ask", user_message: "FET write guard: invalid hook payload." }));
3255
+ process.exit(0);
3256
+ }
3257
+
3258
+ const paths = extractPaths(payload);
3259
+ if (paths.length === 0) {
3260
+ respond("allow", paths);
3261
+ process.exit(0);
3262
+ }
3263
+
3264
+ const decisions = paths.map((p) => classifyPath(p));
3265
+ if (decisions.every((d) => d === "allow")) {
3266
+ respond("allow", paths);
3267
+ } else {
3268
+ respond("ask", paths);
3269
+ }
3270
+ process.exit(0);
3271
+ `;
3272
+ }
3273
+ function renderCursorShellWriteHookMjs() {
3274
+ return `#!/usr/bin/env node
3275
+ /**
3276
+ * FET:MANAGED
3277
+ * adapterVersion: 1
3278
+ * shell guard for commands that may write outside the FET write boundary.
3279
+ */
3280
+ import { readFileSync } from "node:fs";
3281
+
3282
+ const REDIRECT = />\\s*[^\\s|&;]+/;
3283
+ const GIT_WRITE = /\\bgit\\s+(checkout|restore|reset|clean|apply)\\b/i;
3284
+ const PKG_WRITE = /\\b(npm|pnpm|yarn|bun)\\s+(install|ci|add|remove|update)\\b/i;
3285
+ const STREAM_EDIT = /\\b(sed|perl)\\s+[^\\n]*-i\\b/i;
3286
+ const TEE = /\\btee\\s+[^|;&\\n]+/i;
3287
+
3288
+ function respondAsk(command, reason) {
3289
+ process.stdout.write(
3290
+ JSON.stringify({
3291
+ permission: "ask",
3292
+ user_message: "FET shell guard: " + reason,
3293
+ agent_message: "Shell command may modify files outside src/openspec/.fet. Command: " + command
3294
+ })
3295
+ );
3296
+ }
3297
+
3298
+ const raw = readFileSync(0, "utf8");
3299
+ let payload = {};
3300
+ try {
3301
+ payload = JSON.parse(raw || "{}");
3302
+ } catch {
3303
+ respondAsk("", "invalid hook payload");
3304
+ process.exit(0);
3305
+ }
3306
+
3307
+ const command = String(payload?.command ?? "");
3308
+ if (!command.trim()) {
3309
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
3310
+ process.exit(0);
3311
+ }
3312
+
3313
+ if (
3314
+ REDIRECT.test(command) ||
3315
+ GIT_WRITE.test(command) ||
3316
+ PKG_WRITE.test(command) ||
3317
+ STREAM_EDIT.test(command) ||
3318
+ TEE.test(command)
3319
+ ) {
3320
+ respondAsk(command, "command may write or replace project files outside the default FET scope");
3321
+ process.exit(0);
3322
+ }
3323
+
3324
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
3325
+ process.exit(0);
3326
+ `;
3327
+ }
3328
+ function renderCursorHooksJson() {
3329
+ return JSON.stringify(
3330
+ {
3331
+ version: 1,
3332
+ _fet: {
3333
+ writeBoundary: true,
3334
+ fetVersion: FET_VERSION
3335
+ },
3336
+ hooks: {
3337
+ preToolUse: [
3338
+ {
3339
+ command: ".cursor/hooks/fet-guard-write-paths.mjs",
3340
+ matcher: "Write|StrReplace|EditNotebook|Delete",
3341
+ failClosed: true
3342
+ }
3343
+ ],
3344
+ beforeShellExecution: [
3345
+ {
3346
+ command: ".cursor/hooks/fet-guard-shell-writes.mjs",
3347
+ failClosed: true
3348
+ }
3349
+ ]
3350
+ }
3351
+ },
3352
+ null,
3353
+ 2
3354
+ );
3355
+ }
3356
+ function mergeCursorHooksJson(existingContent, fetContent) {
3357
+ const fet = JSON.parse(fetContent);
3358
+ if (!existingContent?.trim()) {
3359
+ return fetContent;
3360
+ }
3361
+ let existing;
3362
+ try {
3363
+ existing = JSON.parse(existingContent);
3364
+ } catch {
3365
+ return fetContent;
3366
+ }
3367
+ const merged = {
3368
+ version: existing.version ?? fet.version ?? 1,
3369
+ _fet: { ...existing._fet, ...fet._fet, writeBoundary: true },
3370
+ hooks: { ...existing.hooks }
3371
+ };
3372
+ for (const [event, fetEntries] of Object.entries(fet.hooks ?? {})) {
3373
+ const current = [...merged.hooks?.[event] ?? []];
3374
+ for (const entry of fetEntries) {
3375
+ const duplicate = current.some((item) => item.command === entry.command);
3376
+ if (!duplicate) {
3377
+ current.push(entry);
3378
+ }
3379
+ }
3380
+ merged.hooks = merged.hooks ?? {};
3381
+ merged.hooks[event] = current;
3382
+ }
3383
+ return `${JSON.stringify(merged, null, 2)}
3384
+ `;
3385
+ }
3386
+
2865
3387
  // src/commands/update-context.ts
2866
3388
  async function updateContextCommand(ctx) {
2867
3389
  let contextResult = { warnings: [] };
@@ -3818,6 +4340,8 @@ function createChangeState(fetVersion, changeId, phase) {
3818
4340
  manualVerify: null,
3819
4341
  tdd: null,
3820
4342
  testRun: null,
4343
+ visual: null,
4344
+ visualRun: null,
3821
4345
  lastOpenSpecCommand: null,
3822
4346
  warnings: []
3823
4347
  };
@@ -4104,6 +4628,7 @@ async function applyWorkflowCommand(ctx, args) {
4104
4628
  }
4105
4629
  if (figmaGuard) {
4106
4630
  applyNextSteps.unshift(...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode));
4631
+ applyNextSteps.splice(applyNextSteps.length - 1, 0, ...renderVisualVerifyNextSteps(changeId, ctx.language));
4107
4632
  }
4108
4633
  ctx.output.result({
4109
4634
  ok: true,
@@ -5043,7 +5568,7 @@ async function tddCommand(ctx) {
5043
5568
  cases,
5044
5569
  testCommand: testCommand2
5045
5570
  });
5046
- const manifestPath2 = await writeTddManifest(ctx.projectRoot, manifest);
5571
+ const manifestPath3 = await writeTddManifest(ctx.projectRoot, manifest);
5047
5572
  const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5048
5573
  await mkdir8(fetDir, { recursive: true });
5049
5574
  const instructionsPath = tddInstructionsRelativePath(changeId);
@@ -5055,7 +5580,7 @@ async function tddCommand(ctx) {
5055
5580
  status: "ready",
5056
5581
  generatedAt: manifest.generatedAt,
5057
5582
  planningFingerprint,
5058
- manifestPath: manifestPath2
5583
+ manifestPath: manifestPath3
5059
5584
  };
5060
5585
  invalidateTestRun(changeState);
5061
5586
  changeState.currentPhase = "implement";
@@ -5086,7 +5611,7 @@ async function tddCommand(ctx) {
5086
5611
  ],
5087
5612
  data: {
5088
5613
  changeId,
5089
- manifestPath: manifestPath2,
5614
+ manifestPath: manifestPath3,
5090
5615
  specPath,
5091
5616
  instructionsPath,
5092
5617
  caseCount: cases.length,
@@ -5291,81 +5816,746 @@ function truncate(value, max = 2e3) {
5291
5816
  return value.length > max ? `${value.slice(0, max)}\u2026` : value;
5292
5817
  }
5293
5818
 
5294
- // src/commands/verify.ts
5295
- import { createHash as createHash2 } from "crypto";
5296
- import { mkdir as mkdir9, readFile as readFile19, stat as stat11 } from "fs/promises";
5819
+ // src/commands/visual.ts
5820
+ import { mkdir as mkdir10 } from "fs/promises";
5821
+ import { join as join28 } from "path";
5822
+
5823
+ // src/visual/config.ts
5824
+ import { readFile as readFile19 } from "fs/promises";
5297
5825
  import { join as join25 } from "path";
5298
- async function verifyCommand(ctx, options) {
5299
- if (options.auto) {
5300
- const scan = await ctx.scanner.scan(ctx.projectRoot, {});
5301
- const plan = {
5302
- schemaVersion: 1,
5303
- packageManager: scan.project.packageManager,
5304
- workspaces: [
5305
- {
5306
- name: "root",
5307
- cwd: ".",
5308
- commands: Object.entries(scan.commands).filter(([name]) => ["lint", "typecheck", "test"].includes(name)).map(([dimension, command]) => ({
5309
- dimension,
5310
- command: command.command,
5311
- source: command.source,
5312
- required: command.required,
5313
- statusIfMissing: command.required ? "fail" : "warn"
5314
- }))
5315
- }
5316
- ],
5317
- missing: ["lint", "typecheck", "test"].filter((name) => !scan.commands[name])
5318
- };
5319
- if (ctx.yes) {
5320
- const global = await ctx.stateStore.getOrCreateGlobal();
5321
- global.verifyAuthorization = {
5322
- schemaVersion: 1,
5323
- approvedAt: (/* @__PURE__ */ new Date()).toISOString(),
5324
- commandFingerprint: fingerprint(plan),
5325
- packageManager: plan.packageManager,
5326
- plan: plan.workspaces.flatMap(
5327
- (workspace) => workspace.commands.map((command) => ({
5328
- cwd: workspace.cwd,
5329
- dimension: command.dimension,
5330
- command: command.command,
5331
- source: command.source,
5332
- required: command.required
5333
- }))
5334
- )
5335
- };
5336
- await ctx.stateStore.writeGlobal(global);
5826
+ import { parseDocument as parseDocument5 } from "yaml";
5827
+ var DEFAULT_CONFIG4 = {
5828
+ enabled: true,
5829
+ compareMode: "layout-only",
5830
+ requireBeforeVerify: "when_figma",
5831
+ whenNoCapture: "warn"
5832
+ };
5833
+ async function loadVisualConfig(projectRoot) {
5834
+ try {
5835
+ const raw = await readFile19(join25(projectRoot, "openspec", "config.yaml"), "utf8");
5836
+ const doc = parseDocument5(raw);
5837
+ const fetNode = doc.get("fet", true);
5838
+ const node = fetNode?.get?.("visual");
5839
+ if (!node || typeof node.get !== "function") {
5840
+ return DEFAULT_CONFIG4;
5337
5841
  }
5338
- ctx.output.result({
5339
- ok: true,
5340
- command: "verify",
5341
- summary: ctx.yes ? "\u5DF2\u786E\u8BA4 verify --auto \u6267\u884C\u8BA1\u5212\uFF1BMVP \u6682\u4E0D\u6267\u884C\u4ED3\u5E93\u811A\u672C\u3002" : "\u5DF2\u751F\u6210 verify --auto \u6267\u884C\u8BA1\u5212\uFF1BMVP \u6682\u4E0D\u6267\u884C\u4ED3\u5E93\u811A\u672C\u3002",
5342
- warnings: plan.missing.map((name) => `\u672A\u53D1\u73B0 ${name} \u811A\u672C\uFF0C\u5C06\u5728\u81EA\u52A8\u6267\u884C\u7248\u672C\u4E2D\u6309\u914D\u7F6E\u5904\u7406\u3002`),
5343
- data: plan,
5344
- nextSteps: ctx.yes ? ["\u5F53\u524D\u7248\u672C\u8BF7\u8FD0\u884C fet verify \u8FDB\u5165\u624B\u52A8\u9A8C\u8BC1\u6A21\u5F0F"] : ["\u5BA1\u6838\u6267\u884C\u8BA1\u5212", "\u786E\u8BA4\u8BA1\u5212\u540E\u53EF\u8FD0\u884C fet verify --auto --yes", "\u5F53\u524D\u7248\u672C\u8BF7\u8FD0\u884C fet verify \u8FDB\u5165\u624B\u52A8\u9A8C\u8BC1\u6A21\u5F0F"]
5345
- });
5346
- return;
5842
+ const enabled = node.get("enabled");
5843
+ const requireBeforeVerify = node.get("requireBeforeVerify");
5844
+ const whenNoCapture = node.get("whenNoCapture");
5845
+ return {
5846
+ enabled: enabled === void 0 ? true : Boolean(enabled),
5847
+ compareMode: "layout-only",
5848
+ requireBeforeVerify: parseRequireBeforeVerify(requireBeforeVerify),
5849
+ whenNoCapture: parseWhenNoCapture(whenNoCapture)
5850
+ };
5851
+ } catch {
5852
+ return DEFAULT_CONFIG4;
5347
5853
  }
5348
- await withProjectLock(
5349
- ctx.projectRoot,
5350
- { command: "verify", cwd: ctx.cwd, fetVersion: ctx.fetVersion },
5351
- async () => {
5352
- const changeId = await resolveChangeId(ctx);
5353
- if (options.done) {
5354
- await markDone(ctx, changeId);
5355
- } else {
5356
- await writeInstructions(ctx, changeId);
5357
- }
5358
- }
5359
- );
5360
5854
  }
5361
- async function writeInstructions(ctx, changeId) {
5362
- await assertChangeExists(ctx, changeId);
5363
- await assertTestPassed(ctx, changeId);
5364
- const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
5365
- const dir = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5366
- const instructionsPath = join25(dir, "verify-instructions.md");
5367
- await mkdir9(dir, { recursive: true });
5368
- await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
5855
+ function parseRequireBeforeVerify(value) {
5856
+ if (value === "off" || value === "always" || value === "when_figma") {
5857
+ return value;
5858
+ }
5859
+ return DEFAULT_CONFIG4.requireBeforeVerify;
5860
+ }
5861
+ function parseWhenNoCapture(value) {
5862
+ if (value === "block" || value === "warn" || value === "skip") {
5863
+ return value;
5864
+ }
5865
+ return DEFAULT_CONFIG4.whenNoCapture;
5866
+ }
5867
+ function isVisualRequiredForVerify(config, hasFigma) {
5868
+ if (!config.enabled) {
5869
+ return false;
5870
+ }
5871
+ if (config.requireBeforeVerify === "off") {
5872
+ return false;
5873
+ }
5874
+ if (config.requireBeforeVerify === "always") {
5875
+ return true;
5876
+ }
5877
+ return hasFigma;
5878
+ }
5879
+
5880
+ // src/visual/playwright.ts
5881
+ import { createRequire } from "module";
5882
+ import { dirname as dirname9, join as join26 } from "path";
5883
+ import { pathToFileURL } from "url";
5884
+ async function resolvePlaywright(projectRoot) {
5885
+ const require2 = createRequire(join26(projectRoot, "package.json"));
5886
+ const candidates = ["playwright", "@playwright/test"];
5887
+ for (const name of candidates) {
5888
+ try {
5889
+ const resolved = require2.resolve(name);
5890
+ const mod = await import(pathToFileURL(resolved).href);
5891
+ if (mod?.chromium) {
5892
+ return mod;
5893
+ }
5894
+ } catch {
5895
+ continue;
5896
+ }
5897
+ }
5898
+ return null;
5899
+ }
5900
+ async function capturePagesWithPlaywright(projectRoot, manifest, baseUrl) {
5901
+ const playwright = await resolvePlaywright(projectRoot);
5902
+ if (!playwright) {
5903
+ return [];
5904
+ }
5905
+ const browser = await playwright.chromium.launch({ headless: true });
5906
+ const results = [];
5907
+ try {
5908
+ for (const page of manifest.pages) {
5909
+ results.push(await captureSinglePage(projectRoot, browser, manifest.changeId, page, baseUrl));
5910
+ }
5911
+ } finally {
5912
+ await browser.close();
5913
+ }
5914
+ return results;
5915
+ }
5916
+ async function captureSinglePage(projectRoot, browser, changeId, pageDef, baseUrl) {
5917
+ const { mkdir: mkdir15 } = await import("fs/promises");
5918
+ const { join: joinPath } = await import("path");
5919
+ const screenshotRelative = visualPageScreenshotRelative(changeId, pageDef.id);
5920
+ const screenshotAbsolute = joinPath(projectRoot, screenshotRelative);
5921
+ await mkdir15(dirname9(screenshotAbsolute), { recursive: true });
5922
+ const url = new URL(pageDef.route, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
5923
+ const pwPage = await browser.newPage();
5924
+ try {
5925
+ await pwPage.setViewportSize(pageDef.viewport);
5926
+ await pwPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
5927
+ await pwPage.screenshot({ path: screenshotAbsolute, fullPage: true });
5928
+ const regions = await extractRegions(pwPage, pageDef);
5929
+ return {
5930
+ pageId: pageDef.id,
5931
+ route: pageDef.route,
5932
+ screenshotPath: screenshotRelative,
5933
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5934
+ regions
5935
+ };
5936
+ } catch {
5937
+ return {
5938
+ pageId: pageDef.id,
5939
+ route: pageDef.route,
5940
+ screenshotPath: null,
5941
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5942
+ regions: []
5943
+ };
5944
+ } finally {
5945
+ await pwPage.close();
5946
+ }
5947
+ }
5948
+ var EXTRACT_REGIONS_SCRIPT = `
5949
+ (inputSelectors) => {
5950
+ const layouts = [];
5951
+ for (const selector of inputSelectors) {
5952
+ const element = document.querySelector(selector);
5953
+ if (!element) {
5954
+ layouts.push({
5955
+ selector,
5956
+ exists: false,
5957
+ visible: false,
5958
+ width: 0,
5959
+ height: 0,
5960
+ display: "none",
5961
+ position: "static"
5962
+ });
5963
+ continue;
5964
+ }
5965
+ const style = window.getComputedStyle(element);
5966
+ const rect = element.getBoundingClientRect();
5967
+ layouts.push({
5968
+ selector,
5969
+ exists: true,
5970
+ visible: style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0,
5971
+ width: rect.width,
5972
+ height: rect.height,
5973
+ display: style.display,
5974
+ position: style.position
5975
+ });
5976
+ }
5977
+ return layouts;
5978
+ }
5979
+ `;
5980
+ async function extractRegions(page, pageDef) {
5981
+ const selectors = pageDef.checkRegions.map((region) => region.selector);
5982
+ return page.evaluate(EXTRACT_REGIONS_SCRIPT, selectors);
5983
+ }
5984
+
5985
+ // src/visual/manifest.ts
5986
+ import { mkdir as mkdir9, readFile as readFile20, stat as stat11 } from "fs/promises";
5987
+ import { dirname as dirname10, join as join27 } from "path";
5988
+ import { parse as parse5, stringify as stringify4 } from "yaml";
5989
+ function visualManifestPath(projectRoot, changeId) {
5990
+ return join27(projectRoot, visualManifestRelativePath(changeId));
5991
+ }
5992
+ async function readVisualManifest(projectRoot, changeId) {
5993
+ const path = visualManifestPath(projectRoot, changeId);
5994
+ try {
5995
+ await stat11(path);
5996
+ } catch {
5997
+ return null;
5998
+ }
5999
+ const doc = parse5(await readFile20(path, "utf8"));
6000
+ if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
6001
+ return null;
6002
+ }
6003
+ return doc;
6004
+ }
6005
+ async function writeVisualManifest(projectRoot, manifest) {
6006
+ const relative6 = visualManifestRelativePath(manifest.changeId);
6007
+ const path = join27(projectRoot, relative6);
6008
+ await mkdir9(dirname10(path), { recursive: true });
6009
+ await atomicWrite(path, stringify4(manifest));
6010
+ return relative6;
6011
+ }
6012
+ async function readVisualCapture(projectRoot, changeId) {
6013
+ const path = join27(projectRoot, visualCaptureRelativePath(changeId));
6014
+ try {
6015
+ const doc = JSON.parse(await readFile20(path, "utf8"));
6016
+ if (doc?.schemaVersion === 1 && doc.changeId === changeId) {
6017
+ return doc;
6018
+ }
6019
+ } catch {
6020
+ return null;
6021
+ }
6022
+ return null;
6023
+ }
6024
+ async function writeVisualCapture(projectRoot, capture) {
6025
+ const relative6 = visualCaptureRelativePath(capture.changeId);
6026
+ const path = join27(projectRoot, relative6);
6027
+ await mkdir9(dirname10(path), { recursive: true });
6028
+ await atomicWrite(path, `${JSON.stringify(capture, null, 2)}
6029
+ `);
6030
+ return relative6;
6031
+ }
6032
+ async function writeVisualResults(projectRoot, results) {
6033
+ const relative6 = visualResultsRelativePath(results.changeId);
6034
+ const path = join27(projectRoot, relative6);
6035
+ await mkdir9(dirname10(path), { recursive: true });
6036
+ await atomicWrite(path, `${JSON.stringify(results, null, 2)}
6037
+ `);
6038
+ return relative6;
6039
+ }
6040
+ function createVisualManifest(input) {
6041
+ return {
6042
+ schemaVersion: 1,
6043
+ changeId: input.changeId,
6044
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6045
+ fetVersion: FET_VERSION,
6046
+ planningFingerprint: input.planningFingerprint,
6047
+ compareMode: "layout-only",
6048
+ figmaUrls: input.figmaUrls,
6049
+ figmaSources: input.figmaSources,
6050
+ run: { baseUrl: input.baseUrl },
6051
+ pages: input.pages
6052
+ };
6053
+ }
6054
+
6055
+ // src/visual/capture.ts
6056
+ async function runVisualCapture(options) {
6057
+ const warnings = [];
6058
+ const baseUrl = options.baseUrl ?? options.manifest.run.baseUrl;
6059
+ if (!baseUrl) {
6060
+ return handleMissingBaseUrl(options.config, options.language, warnings);
6061
+ }
6062
+ const playwright = await resolvePlaywright(options.projectRoot);
6063
+ if (!playwright) {
6064
+ return handleMissingPlaywright(options.config, options.language, warnings);
6065
+ }
6066
+ const pages = await capturePagesWithPlaywright(options.projectRoot, options.manifest, baseUrl);
6067
+ const failedPages = pages.filter((page) => !page.screenshotPath);
6068
+ const capture = {
6069
+ schemaVersion: 1,
6070
+ changeId: options.changeId,
6071
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
6072
+ baseUrl,
6073
+ pages
6074
+ };
6075
+ await writeVisualCapture(options.projectRoot, capture);
6076
+ if (failedPages.length) {
6077
+ return {
6078
+ status: "failed",
6079
+ capture,
6080
+ warnings,
6081
+ message: options.language === "en" ? `Capture failed for ${failedPages.length} page(s). Check dev server and routes.` : `${failedPages.length} \u4E2A\u9875\u9762\u622A\u56FE\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5 dev \u670D\u52A1\u4E0E\u8DEF\u7531\u3002`
6082
+ };
6083
+ }
6084
+ return { status: "passed", capture, warnings };
6085
+ }
6086
+ function handleMissingBaseUrl(config, language, warnings) {
6087
+ const message = language === "en" ? "No base URL for capture. Pass --base-url or set manifest.run.baseUrl." : "\u672A\u914D\u7F6E base URL\uFF0C\u65E0\u6CD5\u622A\u56FE\u3002\u8BF7\u4F7F\u7528 --base-url \u6216\u5728 manifest \u4E2D\u8BBE\u7F6E run.baseUrl\u3002";
6088
+ warnings.push(message);
6089
+ if (config.whenNoCapture === "block") {
6090
+ return { status: "failed", capture: null, warnings, message };
6091
+ }
6092
+ return { status: "skipped", capture: null, warnings, message };
6093
+ }
6094
+ function handleMissingPlaywright(config, language, warnings) {
6095
+ const message = language === "en" ? "Playwright not found in project. Install playwright or use --check-layout-only after manual capture." : "\u9879\u76EE\u672A\u5B89\u88C5 Playwright\u3002\u8BF7\u5B89\u88C5 playwright\uFF0C\u6216\u5728\u624B\u52A8\u622A\u56FE\u540E\u4F7F\u7528 --check-layout-only\u3002";
6096
+ warnings.push(message);
6097
+ if (config.whenNoCapture === "block") {
6098
+ return { status: "failed", capture: null, warnings, message };
6099
+ }
6100
+ return { status: "skipped", capture: null, warnings, message };
6101
+ }
6102
+
6103
+ // src/visual/check-layout.ts
6104
+ function runLayoutCheck(manifest, capture, language, options) {
6105
+ const regions = [];
6106
+ if (!capture && options?.allowManifestOnly) {
6107
+ return runManifestOnlyValidation(manifest, language);
6108
+ }
6109
+ if (capture?.pages.length) {
6110
+ for (const page of manifest.pages) {
6111
+ const captured = capture.pages.find((item) => item.pageId === page.id);
6112
+ for (const region of page.checkRegions) {
6113
+ const layout = captured?.regions.find((item) => item.selector === region.selector);
6114
+ const result = evaluateRegion(page.id, region.selector, region.checks, layout, language);
6115
+ regions.push(result);
6116
+ }
6117
+ }
6118
+ } else {
6119
+ for (const page of manifest.pages) {
6120
+ if (!page.checkRegions.length) {
6121
+ regions.push({
6122
+ pageId: page.id,
6123
+ selector: "(page)",
6124
+ status: "failed",
6125
+ message: language === "en" ? "No checkRegions defined" : "\u672A\u5B9A\u4E49 checkRegions"
6126
+ });
6127
+ continue;
6128
+ }
6129
+ for (const region of page.checkRegions) {
6130
+ regions.push({
6131
+ pageId: page.id,
6132
+ selector: region.selector,
6133
+ status: capture ? "failed" : "skipped",
6134
+ message: language === "en" ? "No capture data; run capture with --base-url or install Playwright" : "\u65E0\u622A\u56FE\u6570\u636E\uFF1B\u8BF7\u5E26 --base-url \u8FD0\u884C capture \u6216\u5B89\u88C5 Playwright"
6135
+ });
6136
+ }
6137
+ }
6138
+ }
6139
+ const failed = regions.filter((item) => item.status === "failed");
6140
+ if (failed.length) {
6141
+ return {
6142
+ status: "failed",
6143
+ regions,
6144
+ message: language === "en" ? `Layout check failed for ${failed.length} region(s).` : `${failed.length} \u4E2A\u533A\u57DF\u7684\u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002`
6145
+ };
6146
+ }
6147
+ const skipped = regions.filter((item) => item.status === "skipped");
6148
+ if (skipped.length && !capture) {
6149
+ return {
6150
+ status: "failed",
6151
+ regions,
6152
+ message: language === "en" ? "Layout check requires capture data when regions are defined." : "\u5DF2\u5B9A\u4E49\u68C0\u67E5\u533A\u57DF\u65F6\u9700\u8981 capture \u6570\u636E\u624D\u80FD\u5B8C\u6210\u5E03\u5C40\u9A8C\u6536\u3002"
6153
+ };
6154
+ }
6155
+ return { status: "passed", regions };
6156
+ }
6157
+ function runManifestOnlyValidation(manifest, language) {
6158
+ const regions = [];
6159
+ if (!manifest.pages.length) {
6160
+ return {
6161
+ status: "failed",
6162
+ regions,
6163
+ message: language === "en" ? "Visual manifest has no pages." : "\u89C6\u89C9\u6E05\u5355\u4E2D\u6CA1\u6709\u4EFB\u4F55\u9875\u9762\u3002"
6164
+ };
6165
+ }
6166
+ for (const page of manifest.pages) {
6167
+ for (const region of page.checkRegions) {
6168
+ regions.push({
6169
+ pageId: page.id,
6170
+ selector: region.selector,
6171
+ status: "skipped",
6172
+ message: language === "en" ? "Manifest-only mode (layout-only, no pixel/content compare)" : "\u4EC5 manifest \u6821\u9A8C\uFF08layout-only\uFF0C\u4E0D\u5BF9\u6BD4\u50CF\u7D20/\u52A8\u6001\u5185\u5BB9\uFF09"
6173
+ });
6174
+ }
6175
+ }
6176
+ return {
6177
+ status: "passed",
6178
+ regions,
6179
+ message: language === "en" ? "Manifest validated; capture skipped \u2014 layout-only checklist recorded." : "\u6E05\u5355\u6821\u9A8C\u901A\u8FC7\uFF1B\u5DF2\u8DF3\u8FC7\u622A\u56FE \u2014 \u5DF2\u8BB0\u5F55 layout-only \u68C0\u67E5\u9879\u3002"
6180
+ };
6181
+ }
6182
+ function evaluateRegion(pageId, selector, checks, layout, language) {
6183
+ if (!layout) {
6184
+ return {
6185
+ pageId,
6186
+ selector,
6187
+ status: "failed",
6188
+ message: language === "en" ? "Region not found in capture" : "capture \u4E2D\u672A\u627E\u5230\u8BE5\u533A\u57DF"
6189
+ };
6190
+ }
6191
+ for (const check of checks) {
6192
+ if (check === "exists" && !layout.exists) {
6193
+ return { pageId, selector, status: "failed", message: language === "en" ? "Missing element" : "\u5143\u7D20\u4E0D\u5B58\u5728" };
6194
+ }
6195
+ if (check === "visible" && !layout.visible) {
6196
+ return { pageId, selector, status: "failed", message: language === "en" ? "Not visible" : "\u4E0D\u53EF\u89C1" };
6197
+ }
6198
+ if (check === "has-box" && (layout.width <= 0 || layout.height <= 0)) {
6199
+ return { pageId, selector, status: "failed", message: language === "en" ? "Zero layout box" : "\u5E03\u5C40\u5C3A\u5BF8\u4E3A 0" };
6200
+ }
6201
+ }
6202
+ return { pageId, selector, status: "passed" };
6203
+ }
6204
+ function buildVisualResults(input) {
6205
+ return {
6206
+ schemaVersion: 1,
6207
+ changeId: input.changeId,
6208
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
6209
+ compareMode: "layout-only",
6210
+ planningFingerprint: input.planningFingerprint,
6211
+ steps: input.steps,
6212
+ captureStatus: input.captureStatus,
6213
+ layoutStatus: input.layoutStatus,
6214
+ regions: input.regions
6215
+ };
6216
+ }
6217
+
6218
+ // src/visual/generate.ts
6219
+ var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
6220
+ async function buildVisualManifestPages(projectRoot, changeId, figmaUrls) {
6221
+ if (figmaUrls.length) {
6222
+ return [
6223
+ {
6224
+ id: `${changeId}-main`,
6225
+ title: "Main UI screen",
6226
+ route: "/",
6227
+ viewport: DEFAULT_VIEWPORT,
6228
+ dataMode: "mock",
6229
+ mockFixture: null,
6230
+ ignoreSelectors: [".dynamic-list", "[data-dynamic]", "[data-testid='dynamic-content']"],
6231
+ checkRegions: [
6232
+ { selector: "header, [role='banner'], .app-header", checks: ["exists", "visible", "has-box"] },
6233
+ { selector: "main, [role='main'], .app-main", checks: ["exists", "visible", "has-box"] },
6234
+ { selector: "nav, [role='navigation'], .app-nav", checks: ["exists", "has-box"] }
6235
+ ]
6236
+ }
6237
+ ];
6238
+ }
6239
+ return [
6240
+ {
6241
+ id: `${changeId}-shell`,
6242
+ title: "Application shell",
6243
+ route: "/",
6244
+ viewport: DEFAULT_VIEWPORT,
6245
+ dataMode: "mock",
6246
+ mockFixture: null,
6247
+ ignoreSelectors: [".dynamic-list", "[data-dynamic]"],
6248
+ checkRegions: [{ selector: "body", checks: ["exists", "visible", "has-box"] }]
6249
+ }
6250
+ ];
6251
+ }
6252
+ async function generateVisualManifestInput(projectRoot, changeId, baseUrl) {
6253
+ const { urls, sources } = await collectFigmaUrlsFromChange(projectRoot, changeId);
6254
+ const planningFingerprint = await computePlanningFingerprint(projectRoot, changeId);
6255
+ const pages = await buildVisualManifestPages(projectRoot, changeId, urls);
6256
+ return {
6257
+ planningFingerprint,
6258
+ figmaUrls: urls,
6259
+ figmaSources: sources,
6260
+ pages
6261
+ };
6262
+ }
6263
+ function mergeBaseUrl(manifest, baseUrl) {
6264
+ if (!baseUrl) {
6265
+ return manifest;
6266
+ }
6267
+ return {
6268
+ ...manifest,
6269
+ run: { baseUrl }
6270
+ };
6271
+ }
6272
+
6273
+ // src/visual/gates.ts
6274
+ async function assertVisualPassed(ctx, changeId) {
6275
+ const config = await loadVisualConfig(ctx.projectRoot);
6276
+ const { urls } = await collectFigmaUrlsFromChange(ctx.projectRoot, changeId);
6277
+ if (!isVisualRequiredForVerify(config, urls.length > 0)) {
6278
+ return;
6279
+ }
6280
+ const change = await ctx.stateStore.readChange(changeId);
6281
+ const visualRun = change?.visualRun;
6282
+ if (visualRun?.status === "skipped") {
6283
+ return;
6284
+ }
6285
+ if (visualRun?.status === "passed" && await fingerprintMatches2(ctx, changeId, visualRun)) {
6286
+ return;
6287
+ }
6288
+ throw new FetError({
6289
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
6290
+ message: msg(ctx.language, "\u672C change \u5C1A\u672A\u901A\u8FC7 fet visual\u3002", "This change has not passed fet visual yet."),
6291
+ details: { changeId, visualRun: visualRun ?? null, figmaUrlCount: urls.length },
6292
+ suggestedCommand: `fet visual --change ${changeId}`,
6293
+ recoverable: true
6294
+ });
6295
+ }
6296
+ async function fingerprintMatches2(ctx, changeId, visualRun) {
6297
+ const current = await computePlanningFingerprint(ctx.projectRoot, changeId);
6298
+ return visualRun.planningFingerprint === current;
6299
+ }
6300
+ function invalidateVisualRun(state) {
6301
+ state.visualRun = null;
6302
+ }
6303
+ function manifestPath2(changeId) {
6304
+ return visualManifestRelativePath(changeId);
6305
+ }
6306
+
6307
+ // src/commands/visual.ts
6308
+ async function visualCommand(ctx, options) {
6309
+ await withProjectLock(ctx.projectRoot, { command: "visual", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
6310
+ const changeId = await resolveChangeId(ctx);
6311
+ await assertChangeExists(ctx, changeId);
6312
+ const config = await loadVisualConfig(ctx.projectRoot);
6313
+ const steps = [];
6314
+ const warnings = [];
6315
+ const manifest = await ensureManifest(ctx, changeId, options.baseUrl ?? null);
6316
+ steps.push("manifest");
6317
+ if (options.plan) {
6318
+ ctx.output.result({
6319
+ ok: true,
6320
+ command: "visual",
6321
+ summary: msg(ctx.language, "\u5DF2\u751F\u6210 fet visual \u6267\u884C\u8BA1\u5212\u3002", "Generated fet visual execution plan."),
6322
+ data: {
6323
+ changeId,
6324
+ compareMode: manifest.compareMode,
6325
+ steps: options.checkLayoutOnly ? ["manifest", "check-layout"] : options.captureOnly ? ["manifest", "capture"] : ["manifest", "capture", "check-layout"],
6326
+ baseUrl: options.baseUrl ?? manifest.run.baseUrl,
6327
+ pages: manifest.pages.map((page) => ({ id: page.id, route: page.route }))
6328
+ },
6329
+ nextSteps: [`fet visual --change ${changeId}${options.baseUrl ? ` --base-url ${options.baseUrl}` : ""}`]
6330
+ });
6331
+ return;
6332
+ }
6333
+ let captureStatus = "skipped";
6334
+ let captureDoc = await readVisualCapture(ctx.projectRoot, changeId);
6335
+ if (!options.checkLayoutOnly) {
6336
+ const captureResult = await runVisualCapture({
6337
+ projectRoot: ctx.projectRoot,
6338
+ changeId,
6339
+ manifest,
6340
+ baseUrl: options.baseUrl ?? null,
6341
+ config,
6342
+ language: ctx.language
6343
+ });
6344
+ warnings.push(...captureResult.warnings);
6345
+ captureStatus = captureResult.status;
6346
+ captureDoc = captureResult.capture;
6347
+ steps.push("capture");
6348
+ if (captureResult.status === "failed") {
6349
+ await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, "failed", [], captureResult.message);
6350
+ throw new FetError({
6351
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
6352
+ message: captureResult.message ?? msg(ctx.language, "fet visual \u622A\u56FE\u5931\u8D25\u3002", "fet visual capture failed."),
6353
+ details: { changeId },
6354
+ suggestedCommand: `fet visual --change ${changeId} --base-url http://localhost:3000`,
6355
+ recoverable: true
6356
+ });
6357
+ }
6358
+ } else {
6359
+ steps.push("capture");
6360
+ captureStatus = captureDoc ? "passed" : "skipped";
6361
+ }
6362
+ if (!options.captureOnly) {
6363
+ const layout = runLayoutCheck(manifest, captureDoc, ctx.language, {
6364
+ allowManifestOnly: captureStatus === "skipped" && config.whenNoCapture !== "block"
6365
+ });
6366
+ steps.push("check-layout");
6367
+ const results = buildVisualResults({
6368
+ changeId,
6369
+ planningFingerprint: manifest.planningFingerprint,
6370
+ steps,
6371
+ captureStatus,
6372
+ layoutStatus: layout.status,
6373
+ regions: layout.regions
6374
+ });
6375
+ const resultsPath = await writeVisualResults(ctx.projectRoot, results);
6376
+ if (layout.status === "failed") {
6377
+ await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, layout.status, layout.regions, layout.message);
6378
+ throw new FetError({
6379
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
6380
+ message: layout.message ?? msg(ctx.language, "fet visual \u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002", "fet visual layout check failed."),
6381
+ details: { changeId, resultsPath: visualResultsRelativePath(changeId) },
6382
+ suggestedCommand: `fet visual --change ${changeId}`,
6383
+ recoverable: true
6384
+ });
6385
+ }
6386
+ await recordVisualSuccess(ctx, changeId, manifest.planningFingerprint, resultsPath, captureStatus);
6387
+ ctx.output.result({
6388
+ ok: true,
6389
+ command: "visual",
6390
+ summary: msg(
6391
+ ctx.language,
6392
+ `change "${changeId}" \u5DF2\u901A\u8FC7 layout-only \u89C6\u89C9\u9A8C\u6536\u3002`,
6393
+ `Layout-only visual verification passed for change "${changeId}".`
6394
+ ),
6395
+ warnings: warnings.length ? warnings : void 0,
6396
+ nextSteps: [`fet verify --change ${changeId}`],
6397
+ data: { changeId, results, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
6398
+ });
6399
+ return;
6400
+ }
6401
+ ctx.output.result({
6402
+ ok: true,
6403
+ command: "visual",
6404
+ summary: msg(ctx.language, "fet visual \u622A\u56FE\u6B65\u9AA4\u5DF2\u5B8C\u6210\u3002", "fet visual capture step completed."),
6405
+ warnings: warnings.length ? warnings : void 0,
6406
+ data: { changeId, captureStatus, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
6407
+ });
6408
+ });
6409
+ }
6410
+ async function ensureManifest(ctx, changeId, baseUrl) {
6411
+ const fingerprint2 = await computePlanningFingerprint(ctx.projectRoot, changeId);
6412
+ const existing = await readVisualManifest(ctx.projectRoot, changeId);
6413
+ if (existing && existing.planningFingerprint === fingerprint2 && (!baseUrl || existing.run.baseUrl === baseUrl)) {
6414
+ return existing;
6415
+ }
6416
+ const input = await generateVisualManifestInput(ctx.projectRoot, changeId, baseUrl);
6417
+ const manifest = mergeBaseUrl(
6418
+ createVisualManifest({
6419
+ changeId,
6420
+ planningFingerprint: input.planningFingerprint,
6421
+ figmaUrls: input.figmaUrls,
6422
+ figmaSources: input.figmaSources,
6423
+ baseUrl,
6424
+ pages: input.pages
6425
+ }),
6426
+ baseUrl
6427
+ );
6428
+ await writeVisualManifest(ctx.projectRoot, manifest);
6429
+ const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6430
+ await mkdir10(fetDir, { recursive: true });
6431
+ await atomicWrite(join28(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
6432
+ await atomicWrite(join28(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
6433
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6434
+ changeState.visual = {
6435
+ status: "ready",
6436
+ generatedAt: manifest.generatedAt,
6437
+ planningFingerprint: fingerprint2,
6438
+ manifestPath: manifestPath2(changeId)
6439
+ };
6440
+ invalidateVisualRun(changeState);
6441
+ await ctx.stateStore.writeChange(changeState);
6442
+ return manifest;
6443
+ }
6444
+ async function recordVisualSuccess(ctx, changeId, planningFingerprint, resultsPath, captureStatus) {
6445
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6446
+ changeState.visualRun = {
6447
+ status: "passed",
6448
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
6449
+ compareMode: "layout-only",
6450
+ planningFingerprint,
6451
+ manifestPath: manifestPath2(changeId),
6452
+ resultsPath,
6453
+ captureStatus
6454
+ };
6455
+ await ctx.stateStore.writeChange(changeState);
6456
+ }
6457
+ async function recordVisualFailure(ctx, changeId, planningFingerprint, steps, captureStatus, layoutStatus, regions, message) {
6458
+ const results = buildVisualResults({
6459
+ changeId,
6460
+ planningFingerprint,
6461
+ steps,
6462
+ captureStatus,
6463
+ layoutStatus,
6464
+ regions
6465
+ });
6466
+ await writeVisualResults(ctx.projectRoot, results);
6467
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6468
+ changeState.visualRun = {
6469
+ status: "failed",
6470
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
6471
+ compareMode: "layout-only",
6472
+ planningFingerprint,
6473
+ manifestPath: manifestPath2(changeId),
6474
+ resultsPath: visualResultsRelativePath(changeId),
6475
+ captureStatus
6476
+ };
6477
+ await ctx.stateStore.writeChange(changeState);
6478
+ if (message) {
6479
+ void message;
6480
+ }
6481
+ }
6482
+
6483
+ // src/commands/verify.ts
6484
+ import { createHash as createHash2 } from "crypto";
6485
+ import { mkdir as mkdir11, readFile as readFile21, stat as stat12 } from "fs/promises";
6486
+ import { join as join29 } from "path";
6487
+ async function verifyCommand(ctx, options) {
6488
+ if (options.auto) {
6489
+ const scan = await ctx.scanner.scan(ctx.projectRoot, {});
6490
+ const plan = {
6491
+ schemaVersion: 1,
6492
+ packageManager: scan.project.packageManager,
6493
+ workspaces: [
6494
+ {
6495
+ name: "root",
6496
+ cwd: ".",
6497
+ commands: Object.entries(scan.commands).filter(([name]) => ["lint", "typecheck", "test"].includes(name)).map(([dimension, command]) => ({
6498
+ dimension,
6499
+ command: command.command,
6500
+ source: command.source,
6501
+ required: command.required,
6502
+ statusIfMissing: command.required ? "fail" : "warn"
6503
+ }))
6504
+ }
6505
+ ],
6506
+ missing: ["lint", "typecheck", "test"].filter((name) => !scan.commands[name])
6507
+ };
6508
+ if (ctx.yes) {
6509
+ const global = await ctx.stateStore.getOrCreateGlobal();
6510
+ global.verifyAuthorization = {
6511
+ schemaVersion: 1,
6512
+ approvedAt: (/* @__PURE__ */ new Date()).toISOString(),
6513
+ commandFingerprint: fingerprint(plan),
6514
+ packageManager: plan.packageManager,
6515
+ plan: plan.workspaces.flatMap(
6516
+ (workspace) => workspace.commands.map((command) => ({
6517
+ cwd: workspace.cwd,
6518
+ dimension: command.dimension,
6519
+ command: command.command,
6520
+ source: command.source,
6521
+ required: command.required
6522
+ }))
6523
+ )
6524
+ };
6525
+ await ctx.stateStore.writeGlobal(global);
6526
+ }
6527
+ ctx.output.result({
6528
+ ok: true,
6529
+ command: "verify",
6530
+ summary: ctx.yes ? "\u5DF2\u786E\u8BA4 verify --auto \u6267\u884C\u8BA1\u5212\uFF1BMVP \u6682\u4E0D\u6267\u884C\u4ED3\u5E93\u811A\u672C\u3002" : "\u5DF2\u751F\u6210 verify --auto \u6267\u884C\u8BA1\u5212\uFF1BMVP \u6682\u4E0D\u6267\u884C\u4ED3\u5E93\u811A\u672C\u3002",
6531
+ warnings: plan.missing.map((name) => `\u672A\u53D1\u73B0 ${name} \u811A\u672C\uFF0C\u5C06\u5728\u81EA\u52A8\u6267\u884C\u7248\u672C\u4E2D\u6309\u914D\u7F6E\u5904\u7406\u3002`),
6532
+ data: plan,
6533
+ nextSteps: ctx.yes ? ["\u5F53\u524D\u7248\u672C\u8BF7\u8FD0\u884C fet verify \u8FDB\u5165\u624B\u52A8\u9A8C\u8BC1\u6A21\u5F0F"] : ["\u5BA1\u6838\u6267\u884C\u8BA1\u5212", "\u786E\u8BA4\u8BA1\u5212\u540E\u53EF\u8FD0\u884C fet verify --auto --yes", "\u5F53\u524D\u7248\u672C\u8BF7\u8FD0\u884C fet verify \u8FDB\u5165\u624B\u52A8\u9A8C\u8BC1\u6A21\u5F0F"]
6534
+ });
6535
+ return;
6536
+ }
6537
+ await withProjectLock(
6538
+ ctx.projectRoot,
6539
+ { command: "verify", cwd: ctx.cwd, fetVersion: ctx.fetVersion },
6540
+ async () => {
6541
+ const changeId = await resolveChangeId(ctx);
6542
+ if (options.done) {
6543
+ await markDone(ctx, changeId);
6544
+ } else {
6545
+ await writeInstructions(ctx, changeId);
6546
+ }
6547
+ }
6548
+ );
6549
+ }
6550
+ async function writeInstructions(ctx, changeId) {
6551
+ await assertChangeExists(ctx, changeId);
6552
+ await assertTestPassed(ctx, changeId);
6553
+ await assertVisualPassed(ctx, changeId);
6554
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
6555
+ const dir = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6556
+ const instructionsPath = join29(dir, "verify-instructions.md");
6557
+ await mkdir11(dir, { recursive: true });
6558
+ await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
5369
6559
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
5370
6560
  state.currentPhase = "verify";
5371
6561
  state.phases.verify = { status: "in_progress", updatedAt: generatedAt };
@@ -5380,8 +6570,9 @@ async function writeInstructions(ctx, changeId) {
5380
6570
  async function markDone(ctx, changeId) {
5381
6571
  await assertChangeExists(ctx, changeId);
5382
6572
  await assertTestPassed(ctx, changeId);
6573
+ await assertVisualPassed(ctx, changeId);
5383
6574
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
5384
- const instructionsPath = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
6575
+ const instructionsPath = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
5385
6576
  const instructions = await readInstructions(ctx, instructionsPath, changeId);
5386
6577
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
5387
6578
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -5405,8 +6596,8 @@ async function markDone(ctx, changeId) {
5405
6596
  }
5406
6597
  async function readInstructions(ctx, path, changeId) {
5407
6598
  try {
5408
- await stat11(path);
5409
- const content = await readFile19(path, "utf8");
6599
+ await stat12(path);
6600
+ const content = await readFile21(path, "utf8");
5410
6601
  const fileChangeId = readFrontMatterValue(content, "changeId");
5411
6602
  if (fileChangeId !== changeId) {
5412
6603
  throw new FetError({
@@ -5529,12 +6720,12 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
5529
6720
  import { resolve } from "path";
5530
6721
 
5531
6722
  // src/adapters/codex/index.ts
5532
- import { mkdir as mkdir10, readFile as readFile20, stat as stat12 } from "fs/promises";
6723
+ import { mkdir as mkdir12, readFile as readFile22, stat as stat13 } from "fs/promises";
5533
6724
  import { homedir } from "os";
5534
- import { dirname as dirname9, join as join26 } from "path";
6725
+ import { dirname as dirname11, join as join30 } from "path";
5535
6726
 
5536
6727
  // src/adapters/commands.ts
5537
- var FET_STANDALONE_COMMANDS = ["tdd", "test"];
6728
+ var FET_STANDALONE_COMMANDS = ["tdd", "test", "visual"];
5538
6729
  var FET_WORKFLOW_COMMANDS = [
5539
6730
  "explore",
5540
6731
  "propose",
@@ -5578,6 +6769,7 @@ Before doing FET or OpenSpec work in Codex, read:
5578
6769
  - .codex/fet/spec-language.md when writing or updating OpenSpec specs
5579
6770
  - openspec/changes/<change-id>/.fet/figma-apply-instructions.md before UI work when FET apply reports Figma links
5580
6771
  - .codex/fet/ui-display-contract.md when UI binds API data; openspec/changes/<change-id>/.fet/ui-display-contract.yaml when present
6772
+ - .codex/fet/write-boundary.md for default edit scope (src/, openspec/, .fet/ only; ask before other paths)
5581
6773
  - the active change files under openspec/changes/<change-id>/, when a change is selected
5582
6774
 
5583
6775
  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.
@@ -5599,6 +6791,7 @@ ${languageInstruction(language)}
5599
6791
  - \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
5600
6792
  - \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
5601
6793
  - \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
6794
+ - \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
5602
6795
  - \u5982\u679C\u5DF2\u9009\u62E9 change\uFF0C\u9605\u8BFB openspec/changes/<change-id>/ \u4E0B\u7684\u5F53\u524D\u4EA7\u7269
5603
6796
 
5604
6797
  \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
@@ -5637,12 +6830,19 @@ function codexUiDisplayContractFile(language = DEFAULT_LANGUAGE) {
5637
6830
  content: renderCodexUiDisplayContractGuide(language)
5638
6831
  };
5639
6832
  }
6833
+ function codexWriteBoundaryFile(language = DEFAULT_LANGUAGE) {
6834
+ return {
6835
+ path: ".codex/fet/write-boundary.md",
6836
+ content: renderCodexWriteBoundaryGuide(language)
6837
+ };
6838
+ }
5640
6839
  function codexCommandFiles(language = DEFAULT_LANGUAGE) {
5641
6840
  return [
5642
6841
  codexKarpathyGuidelinesFile(language),
5643
6842
  codexFigmaStopFile(language),
5644
6843
  codexUiDisplayContractFile(language),
5645
6844
  codexSpecLanguageFile(language),
6845
+ codexWriteBoundaryFile(language),
5646
6846
  ...FET_ADAPTER_COMMANDS.map((command) => ({
5647
6847
  path: `.codex/fet/commands/${command}.md`,
5648
6848
  content: renderCommand(command, language)
@@ -5684,6 +6884,9 @@ function renderCommand(command, language) {
5684
6884
  if (command.startsWith("graph-")) {
5685
6885
  return renderGraphCommand(command, language);
5686
6886
  }
6887
+ if (command === "tdd" || command === "test" || command === "visual") {
6888
+ return renderStandaloneWorkflowCommand(command, language);
6889
+ }
5687
6890
  const usage = renderFetAdapterUsage(command, "");
5688
6891
  return `<!-- FET:MANAGED
5689
6892
  schemaVersion: 1
@@ -5714,11 +6917,16 @@ ${usage}
5714
6917
  If the command needs a change id, pass it with \`--change <change-id>\` or use the active OpenSpec change from the user's request.
5715
6918
 
5716
6919
  After the command completes, report the important next steps from the FET output and keep any generated OpenSpec artifacts in the normal project workflow.
6920
+
6921
+ ${renderWriteBoundaryGuardrail(language)}
5717
6922
  `;
5718
6923
  }
5719
6924
  function renderCommandZh(command) {
5720
6925
  const usage = renderFetAdapterUsage(command, command === "fill-context" ? "" : command === "passthrough" ? "<openspec-command> [...args]" : "");
5721
6926
  const title = commandTitleZh(command);
6927
+ if (command === "tdd" || command === "test" || command === "visual") {
6928
+ return renderStandaloneWorkflowCommand(command, "zh-CN");
6929
+ }
5722
6930
  if (command === "graph-setup") {
5723
6931
  return `<!-- FET:MANAGED
5724
6932
  schemaVersion: 1
@@ -5776,6 +6984,8 @@ ${usage}
5776
6984
  \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
5777
6985
 
5778
6986
  \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
6987
+
6988
+ ${renderWriteBoundaryGuardrail("zh-CN")}
5779
6989
  `;
5780
6990
  }
5781
6991
  function renderPassthroughCommand(language) {
@@ -5892,6 +7102,9 @@ function renderSlashPrompt(command, language) {
5892
7102
  if (command === "apply") {
5893
7103
  return renderApplySlashPrompt(language);
5894
7104
  }
7105
+ if (command === "tdd" || command === "test" || command === "visual") {
7106
+ return renderStandaloneWorkflowSlashPrompt(command, language);
7107
+ }
5895
7108
  if (command === "verify") {
5896
7109
  return renderVerifySlashPrompt(language);
5897
7110
  }
@@ -5953,6 +7166,9 @@ function renderSlashPromptZh(command) {
5953
7166
  if (command === "graph-setup") {
5954
7167
  return renderGraphSetupSlashPrompt("zh-CN");
5955
7168
  }
7169
+ if (command === "tdd" || command === "test" || command === "visual") {
7170
+ return renderStandaloneWorkflowSlashPrompt(command, "zh-CN");
7171
+ }
5956
7172
  const usage = renderFetAdapterUsage(command, command === "fill-context" ? "" : command === "passthrough" ? "<openspec-command> [...args]" : "[...args]");
5957
7173
  const argumentHint = command === "passthrough" ? "openspec-command [...args]" : void 0;
5958
7174
  const argumentHintLine = argumentHint ? `argument-hint: ${argumentHint}
@@ -5993,9 +7209,10 @@ ${commandGoalZh(command)}
5993
7209
  - \u9ED8\u8BA4\u4F7F\u7528\u4E2D\u6587\u4EA7\u51FA\u3002
5994
7210
  - \u4E0D\u8981\u7ED5\u8FC7 FET \u76F4\u63A5\u8C03\u7528 openspec\uFF0C\u9664\u975E FET \u547D\u4EE4\u672C\u8EAB\u4E0D\u53EF\u7528\u3002
5995
7211
  - change \u4E0D\u660E\u786E\u65F6\u5148\u8BE2\u95EE\u7528\u6237\u3002
5996
- ${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")}
7212
+ ${renderWriteBoundaryGuardrail("zh-CN")}
7213
+ ${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")}
5997
7214
  ` : ""}${command === "propose" || command === "continue" || command === "ff" ? `${renderUiDisplayContractGuardrail("zh-CN")}
5998
- ` : ""}${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" : ""}`;
7215
+ ` : ""}${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" : ""}`;
5999
7216
  }
6000
7217
  function commandTitleZh(command) {
6001
7218
  const titles = {
@@ -6069,7 +7286,9 @@ First run:
6069
7286
  fet fill-context
6070
7287
  \`\`\`
6071
7288
 
6072
- 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.
7289
+ 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.
7290
+
7291
+ ${renderWriteBoundaryGuardrail(language)}
6073
7292
  `;
6074
7293
  }
6075
7294
  function renderFillContextSlashPrompt(language) {
@@ -6105,7 +7324,9 @@ Steps:
6105
7324
  Guardrails:
6106
7325
  - Do not invent facts that cannot be inferred from the repo.
6107
7326
  - Use [UNKNOWN] only when the repository does not contain enough evidence.
6108
- - Keep generated context stable and useful for future AI coding sessions.`,
7327
+ - Keep generated context stable and useful for future AI coding sessions.
7328
+ - Editing AGENTS.md is outside the default write scope; confirm user approval first (see .codex/fet/write-boundary.md).
7329
+ ${renderWriteBoundaryGuardrail(language)}`,
6109
7330
  void 0,
6110
7331
  language
6111
7332
  );
@@ -6174,14 +7395,16 @@ Steps:
6174
7395
  - Follow proposal, specs, design, and tasks.
6175
7396
  - Mark each completed task checkbox in tasks.md from \`- [ ]\` to \`- [x]\`.
6176
7397
  - Pause and ask if a task is ambiguous or reveals a design conflict.
6177
- 8. After completing or pausing, summarize completed tasks, remaining tasks, and blockers.
7398
+ 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\`.
7399
+ 9. After completing or pausing, summarize completed tasks, remaining tasks, and blockers.
6178
7400
 
6179
7401
  Guardrails:
6180
7402
  - Never skip reading OpenSpec artifacts before implementation.
6181
7403
  - When Figma links exist for this change, never implement or restyle UI without reading Figma first.
6182
7404
  - When ui-display-contract.yaml exists, API schemas are not UI checklists\u2014only displayFields may render.
6183
7405
  - Do not mark a task complete until the code change is actually done.
6184
- - Do not run sync or archive from apply.`,
7406
+ - Do not run sync or archive from apply.
7407
+ ${renderWriteBoundaryGuardrail(language)}`,
6185
7408
  void 0,
6186
7409
  language
6187
7410
  );
@@ -6608,8 +7831,97 @@ ${languageInstruction(language)}
6608
7831
  ${graphContextInstruction}
6609
7832
 
6610
7833
  ${body}
7834
+
7835
+ ${renderWriteBoundaryGuardrail(language)}
6611
7836
  `;
6612
7837
  }
7838
+ function renderStandaloneWorkflowCommand(command, language) {
7839
+ const usage = renderFetAdapterUsage(command, "");
7840
+ const { title, body } = standaloneWorkflowCopy(command, language);
7841
+ return `<!-- FET:MANAGED
7842
+ schemaVersion: 1
7843
+ fetVersion: ${FET_VERSION}
7844
+ generator: codex-adapter
7845
+ adapterVersion: 1
7846
+ command: ${usage}
7847
+ FET:END -->
7848
+
7849
+ # ${usage}
7850
+
7851
+ ${renderIdeModelPolicy(command, language)}
7852
+
7853
+ ${languageInstruction(language)}
7854
+
7855
+ ## ${language === "en" ? "Purpose" : "\u7528\u9014"}
7856
+
7857
+ ${title}
7858
+
7859
+ ## ${language === "en" ? "Workflow" : "\u5DE5\u4F5C\u6D41"}
7860
+
7861
+ ${body}
7862
+
7863
+ ${renderWriteBoundaryGuardrail(language)}
7864
+ `;
7865
+ }
7866
+ function renderStandaloneWorkflowSlashPrompt(command, language) {
7867
+ const usage = renderFetAdapterUsage(command, "[...args]");
7868
+ const { title, body, description } = standaloneWorkflowCopy(command, language);
7869
+ return renderManagedSlashPrompt(
7870
+ usage,
7871
+ description,
7872
+ `${title}
7873
+
7874
+ Steps:
7875
+
7876
+ 1. Read AGENTS.md, openspec/config.yaml, .codex/fet/karpathy-guidelines.md, and .codex/fet/write-boundary.md.
7877
+ 2. Resolve the change id when needed (\`--change <change-id>\` or active change).
7878
+ 3. Run:
7879
+ \`\`\`sh
7880
+ ${renderFetAdapterUsage(command, "")}
7881
+ \`\`\`
7882
+ 4. ${body}
7883
+ 5. Summarize outputs, paths written under openspec/changes/<change-id>/.fet/, and next steps.
7884
+
7885
+ Guardrails:
7886
+ - ${body.split("\n")[0]}
7887
+ ${renderWriteBoundaryGuardrail(language)}`,
7888
+ void 0,
7889
+ language
7890
+ );
7891
+ }
7892
+ function standaloneWorkflowCopy(command, language) {
7893
+ if (command === "tdd") {
7894
+ return language === "en" ? {
7895
+ description: "Generate per-change TDD manifest and test instructions",
7896
+ title: "Generate the TDD manifest before implementation.",
7897
+ 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)."
7898
+ } : {
7899
+ description: "\u751F\u6210\u672C change \u7684 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15",
7900
+ title: "\u5728\u5B9E\u65BD\u524D\u751F\u6210 TDD \u6E05\u5355\u3002",
7901
+ 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"
7902
+ };
7903
+ }
7904
+ if (command === "test") {
7905
+ return language === "en" ? {
7906
+ description: "Run unit tests scoped to the change TDD manifest",
7907
+ title: "Run change-scoped tests after implementation.",
7908
+ body: "Requires `tdd-manifest.yaml`. Records pass/fail in FET state; `fet verify` is blocked until this passes (unless configured to skip)."
7909
+ } : {
7910
+ description: "\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B",
7911
+ title: "\u5B9E\u73B0\u5B8C\u6210\u540E\u6309 change \u8FD0\u884C\u6D4B\u8BD5\u3002",
7912
+ 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"
7913
+ };
7914
+ }
7915
+ return language === "en" ? {
7916
+ description: "Layout-only visual verification for a change",
7917
+ title: "Run layout-only visual checks for the change.",
7918
+ 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."
7919
+ } : {
7920
+ description: "change \u7EA7 layout-only \u89C6\u89C9\u9A8C\u6536",
7921
+ title: "\u5BF9 change \u505A layout-only \u89C6\u89C9\u9A8C\u6536\u3002",
7922
+ 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"
7923
+ };
7924
+ }
6613
7925
 
6614
7926
  // src/adapters/codex/index.ts
6615
7927
  var CodexAdapter = class {
@@ -6617,7 +7929,7 @@ var CodexAdapter = class {
6617
7929
  adapterVersion = 1;
6618
7930
  async detect(projectRoot) {
6619
7931
  return {
6620
- detected: await exists6(join26(projectRoot, ".codex")) || await exists6(join26(projectRoot, "AGENTS.md")),
7932
+ detected: await exists6(join30(projectRoot, ".codex")) || await exists6(join30(projectRoot, "AGENTS.md")),
6621
7933
  reason: "Codex adapter is available for projects that use AGENTS.md"
6622
7934
  };
6623
7935
  }
@@ -6656,7 +7968,7 @@ var CodexAdapter = class {
6656
7968
  if (existing && !existing.includes("FET:MANAGED") && force) {
6657
7969
  await createBackup(target);
6658
7970
  }
6659
- await mkdir10(dirname9(target), { recursive: true });
7971
+ await mkdir12(dirname11(target), { recursive: true });
6660
7972
  await atomicWrite(target, file.content);
6661
7973
  written.push(displayPath);
6662
7974
  }
@@ -6683,9 +7995,9 @@ var CodexAdapter = class {
6683
7995
  };
6684
7996
  function resolveTarget(projectRoot, file) {
6685
7997
  if (file.root === "codex-home") {
6686
- return join26(resolveCodexHome(), file.path);
7998
+ return join30(resolveCodexHome(), file.path);
6687
7999
  }
6688
- return join26(projectRoot, file.path);
8000
+ return join30(projectRoot, file.path);
6689
8001
  }
6690
8002
  function displayPathFor(file) {
6691
8003
  if (file.root === "codex-home") {
@@ -6694,18 +8006,18 @@ function displayPathFor(file) {
6694
8006
  return file.path;
6695
8007
  }
6696
8008
  function resolveCodexHome() {
6697
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join26(homedir(), ".codex");
8009
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join30(homedir(), ".codex");
6698
8010
  }
6699
8011
  async function readExisting(path) {
6700
8012
  try {
6701
- return await readFile20(path, "utf8");
8013
+ return await readFile22(path, "utf8");
6702
8014
  } catch {
6703
8015
  return null;
6704
8016
  }
6705
8017
  }
6706
8018
  async function exists6(path) {
6707
8019
  try {
6708
- await stat12(path);
8020
+ await stat13(path);
6709
8021
  return true;
6710
8022
  } catch {
6711
8023
  return false;
@@ -6713,8 +8025,8 @@ async function exists6(path) {
6713
8025
  }
6714
8026
 
6715
8027
  // src/adapters/cursor/index.ts
6716
- import { mkdir as mkdir11, readFile as readFile21, stat as stat13 } from "fs/promises";
6717
- import { dirname as dirname10, join as join27 } from "path";
8028
+ import { mkdir as mkdir13, readFile as readFile23, stat as stat14 } from "fs/promises";
8029
+ import { dirname as dirname12, join as join31 } from "path";
6718
8030
 
6719
8031
  // src/adapters/cursor/templates.ts
6720
8032
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -6729,6 +8041,28 @@ function cursorSpecLanguageRuleFile(language = DEFAULT_LANGUAGE) {
6729
8041
  content: renderCursorSpecLanguageRule(language)
6730
8042
  };
6731
8043
  }
8044
+ function cursorWriteBoundaryRuleFile(language = DEFAULT_LANGUAGE) {
8045
+ return {
8046
+ path: ".cursor/rules/fet-write-boundary.mdc",
8047
+ content: renderCursorWriteBoundaryRule(language)
8048
+ };
8049
+ }
8050
+ function cursorHookFiles() {
8051
+ return [
8052
+ {
8053
+ path: ".cursor/hooks/fet-guard-write-paths.mjs",
8054
+ content: renderCursorWritePathsHookMjs()
8055
+ },
8056
+ {
8057
+ path: ".cursor/hooks/fet-guard-shell-writes.mjs",
8058
+ content: renderCursorShellWriteHookMjs()
8059
+ },
8060
+ {
8061
+ path: ".cursor/hooks.json",
8062
+ content: renderCursorHooksJson()
8063
+ }
8064
+ ];
8065
+ }
6732
8066
  function cursorUiDisplayContractRuleFile(language = DEFAULT_LANGUAGE) {
6733
8067
  return {
6734
8068
  path: ".cursor/rules/fet-ui-display-contract.mdc",
@@ -6738,6 +8072,7 @@ function cursorUiDisplayContractRuleFile(language = DEFAULT_LANGUAGE) {
6738
8072
  function cursorRuleFiles(language = DEFAULT_LANGUAGE) {
6739
8073
  return [
6740
8074
  cursorRuleFile(language),
8075
+ cursorWriteBoundaryRuleFile(language),
6741
8076
  cursorFigmaStopRuleFile(language),
6742
8077
  cursorUiDisplayContractRuleFile(language),
6743
8078
  cursorSpecLanguageRuleFile(language)
@@ -6775,6 +8110,7 @@ ${languageInstruction(language)}
6775
8110
  - \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
6776
8111
  - \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
6777
8112
  - \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
8113
+ - \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
6778
8114
 
6779
8115
  \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
6780
8116
  `
@@ -6810,7 +8146,9 @@ ${languageInstruction(language)}
6810
8146
 
6811
8147
  \`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
6812
8148
 
6813
- \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
8149
+ \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
8150
+
8151
+ ${renderWriteBoundaryGuardrail(language)}
6814
8152
  `;
6815
8153
  }
6816
8154
  if (command === "graph-setup") {
@@ -6849,6 +8187,9 @@ After installation, verify with \`gitnexus --version\`, then run \`fet graph ini
6849
8187
  if (command === "tdd" || command === "test") {
6850
8188
  return renderTddTestSkill(command, usage, language);
6851
8189
  }
8190
+ if (command === "visual") {
8191
+ return renderVisualSkill(usage, language);
8192
+ }
6852
8193
  if (command === "propose" || command === "continue" || command === "ff") {
6853
8194
  return renderPlanningSkill(command, usage, language);
6854
8195
  }
@@ -6913,6 +8254,8 @@ ${renderSpecArtifactGuardrail(language)}
6913
8254
 
6914
8255
  ${renderUiDisplayContractGuardrail(language)}
6915
8256
 
8257
+ ${renderWriteBoundaryGuardrail(language)}
8258
+
6916
8259
  \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
6917
8260
  `;
6918
8261
  }
@@ -6968,6 +8311,37 @@ ${uiContractBlock}
6968
8311
  \u6267\u884C\u524D\u8BF7\u9605\u8BFB AGENTS.md\u3001openspec/config.yaml \u4E0E\u5F53\u524D change \u4E0B\u7684 OpenSpec \u4EA7\u7269\u3002
6969
8312
 
6970
8313
  \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
8314
+
8315
+ ${renderWriteBoundaryGuardrail(language)}
8316
+ `;
8317
+ }
8318
+ function renderVisualSkill(usage, language) {
8319
+ const body = language === "en" ? `Default \`fet visual\` runs manifest refresh, Playwright capture (needs \`--base-url\`), and **layout-only** checks (no pixel match on dynamic API content). Use \`--plan\`, \`--capture-only\`, or \`--check-layout-only\` only when debugging.` : `\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`;
8320
+ return `<!-- FET:MANAGED
8321
+ schemaVersion: 1
8322
+ fetVersion: ${FET_VERSION}
8323
+ generator: cursor-adapter
8324
+ adapterVersion: 1
8325
+ command: ${usage}
8326
+ FET:END -->
8327
+
8328
+ ---
8329
+ name: fet-visual
8330
+ description: ${language === "en" ? "Layout-only visual verification for a change" : "change \u7EA7 layout-only \u89C6\u89C9\u9A8C\u6536"}
8331
+ disable-model-invocation: true
8332
+ ---
8333
+
8334
+ ${renderIdeModelPolicy("visual", language)}
8335
+
8336
+ ${languageInstruction(language)}
8337
+
8338
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
8339
+
8340
+ \`\`\`sh
8341
+ ${usage}
8342
+ \`\`\`
8343
+
8344
+ ${body}
6971
8345
  `;
6972
8346
  }
6973
8347
  function renderTddTestSkill(command, usage, language) {
@@ -7007,14 +8381,14 @@ var CursorAdapter = class {
7007
8381
  adapterVersion = 1;
7008
8382
  async detect(projectRoot) {
7009
8383
  return {
7010
- detected: await exists7(join27(projectRoot, ".cursor")),
8384
+ detected: await exists7(join31(projectRoot, ".cursor")),
7011
8385
  reason: "Cursor adapter is available for any project"
7012
8386
  };
7013
8387
  }
7014
8388
  async planInstall(_projectRoot, language) {
7015
8389
  return {
7016
8390
  tool: this.tool,
7017
- files: [...cursorSkillFiles(language), ...cursorRuleFiles(language)].map((file) => ({
8391
+ files: [...cursorSkillFiles(language), ...cursorRuleFiles(language), ...cursorHookFiles()].map((file) => ({
7018
8392
  ...file,
7019
8393
  managed: true
7020
8394
  }))
@@ -7024,8 +8398,14 @@ var CursorAdapter = class {
7024
8398
  const written = [];
7025
8399
  const skipped = [];
7026
8400
  for (const file of plan.files) {
7027
- const target = join27(projectRoot, file.path);
8401
+ const target = join31(projectRoot, file.path);
7028
8402
  const existing = await readExisting2(target);
8403
+ if (file.path === ".cursor/hooks.json") {
8404
+ await mkdir13(dirname12(target), { recursive: true });
8405
+ await atomicWrite(target, mergeCursorHooksJson(existing, file.content));
8406
+ written.push(file.path);
8407
+ continue;
8408
+ }
7029
8409
  if (existing && !existing.includes("FET:MANAGED") && !force) {
7030
8410
  throw new FetError({
7031
8411
  code: "TOOL_ADAPTER_CONFLICT" /* ToolAdapterConflict */,
@@ -7037,7 +8417,7 @@ var CursorAdapter = class {
7037
8417
  if (existing && !existing.includes("FET:MANAGED") && force) {
7038
8418
  await createBackup(target);
7039
8419
  }
7040
- await mkdir11(dirname10(target), { recursive: true });
8420
+ await mkdir13(dirname12(target), { recursive: true });
7041
8421
  await atomicWrite(target, file.content);
7042
8422
  written.push(file.path);
7043
8423
  }
@@ -7047,14 +8427,16 @@ var CursorAdapter = class {
7047
8427
  const plan = await this.planInstall(projectRoot);
7048
8428
  const checks = [];
7049
8429
  for (const file of plan.files) {
7050
- const target = join27(projectRoot, file.path);
8430
+ const target = join31(projectRoot, file.path);
7051
8431
  const content = await readExisting2(target);
7052
- const managed = Boolean(content?.includes("FET:MANAGED"));
7053
- const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
8432
+ const hooksManaged = file.path === ".cursor/hooks.json" && Boolean(content?.includes('"writeBoundary"'));
8433
+ const hookScript = file.path.startsWith(".cursor/hooks/fet-guard-") && file.path.endsWith(".mjs");
8434
+ const managed = hooksManaged || hookScript || Boolean(content?.includes("FET:MANAGED"));
8435
+ const versionMatches = file.path === ".cursor/hooks.json" ? Boolean(content?.includes('"writeBoundary"')) : hookScript ? Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`)) : Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
7054
8436
  checks.push({
7055
8437
  id: `cursor:${file.path}`,
7056
8438
  status: !content ? "warn" : managed && versionMatches ? "pass" : "warn",
7057
- 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`,
8439
+ 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`,
7058
8440
  suggestedCommand: !content || !managed || !versionMatches ? "fet init" : void 0
7059
8441
  });
7060
8442
  }
@@ -7063,14 +8445,14 @@ var CursorAdapter = class {
7063
8445
  };
7064
8446
  async function readExisting2(path) {
7065
8447
  try {
7066
- return await readFile21(path, "utf8");
8448
+ return await readFile23(path, "utf8");
7067
8449
  } catch {
7068
8450
  return null;
7069
8451
  }
7070
8452
  }
7071
8453
  async function exists7(path) {
7072
8454
  try {
7073
- await stat13(path);
8455
+ await stat14(path);
7074
8456
  return true;
7075
8457
  } catch {
7076
8458
  return false;
@@ -7082,13 +8464,13 @@ import { execFile as execFile4 } from "child_process";
7082
8464
  import { promisify as promisify4 } from "util";
7083
8465
 
7084
8466
  // src/openspec/inspector.ts
7085
- import { readdir as readdir6, stat as stat14 } from "fs/promises";
7086
- import { join as join28 } from "path";
8467
+ import { readdir as readdir6, stat as stat15 } from "fs/promises";
8468
+ import { join as join32 } from "path";
7087
8469
  async function inspectOpenSpecProject(projectRoot) {
7088
- const openspecPath = join28(projectRoot, "openspec");
7089
- const changesPath = join28(openspecPath, "changes");
7090
- const legacyArchivePath = join28(openspecPath, "archive");
7091
- const changesArchivePath = join28(changesPath, "archive");
8470
+ const openspecPath = join32(projectRoot, "openspec");
8471
+ const changesPath = join32(openspecPath, "changes");
8472
+ const legacyArchivePath = join32(openspecPath, "archive");
8473
+ const changesArchivePath = join32(changesPath, "archive");
7092
8474
  return {
7093
8475
  exists: await exists8(openspecPath),
7094
8476
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
@@ -7096,13 +8478,13 @@ async function inspectOpenSpecProject(projectRoot) {
7096
8478
  };
7097
8479
  }
7098
8480
  async function inspectOpenSpecChange(projectRoot, changeId) {
7099
- const changePath = join28(projectRoot, "openspec", "changes", changeId);
7100
- const tasksPath = join28(changePath, "tasks.md");
7101
- const specsPath = join28(changePath, "specs");
8481
+ const changePath = join32(projectRoot, "openspec", "changes", changeId);
8482
+ const tasksPath = join32(changePath, "tasks.md");
8483
+ const specsPath = join32(changePath, "specs");
7102
8484
  return {
7103
8485
  changeId,
7104
8486
  exists: await exists8(changePath),
7105
- hasProposal: await exists8(join28(changePath, "proposal.md")),
8487
+ hasProposal: await exists8(join32(changePath, "proposal.md")),
7106
8488
  hasTasks: await exists8(tasksPath),
7107
8489
  hasSpecs: await exists8(specsPath),
7108
8490
  tasksPath,
@@ -7120,7 +8502,7 @@ async function listDirectories(path, options = {}) {
7120
8502
  }
7121
8503
  async function exists8(path) {
7122
8504
  try {
7123
- await stat14(path);
8505
+ await stat15(path);
7124
8506
  return true;
7125
8507
  } catch {
7126
8508
  return false;
@@ -7303,13 +8685,13 @@ function escapeRegExp(value) {
7303
8685
  }
7304
8686
 
7305
8687
  // src/scanner/routes.ts
7306
- import { readdir as readdir7, stat as stat15 } from "fs/promises";
7307
- import { join as join29, relative as relative5, sep } from "path";
8688
+ import { readdir as readdir7, stat as stat16 } from "fs/promises";
8689
+ import { join as join33, relative as relative5, sep } from "path";
7308
8690
  async function scanRoutes(projectRoot) {
7309
8691
  const candidates = ["src/routes", "src/pages", "app", "pages"];
7310
8692
  const routes = [];
7311
8693
  for (const candidate of candidates) {
7312
- const root = join29(projectRoot, candidate);
8694
+ const root = join33(projectRoot, candidate);
7313
8695
  if (!await exists9(root)) {
7314
8696
  continue;
7315
8697
  }
@@ -7337,7 +8719,7 @@ async function listFiles(root) {
7337
8719
  const entries = await readdir7(root, { withFileTypes: true });
7338
8720
  const files = [];
7339
8721
  for (const entry of entries) {
7340
- const path = join29(root, entry.name);
8722
+ const path = join33(root, entry.name);
7341
8723
  if (entry.isDirectory()) {
7342
8724
  files.push(...await listFiles(path));
7343
8725
  } else {
@@ -7348,7 +8730,7 @@ async function listFiles(root) {
7348
8730
  }
7349
8731
  async function exists9(path) {
7350
8732
  try {
7351
- await stat15(path);
8733
+ await stat16(path);
7352
8734
  return true;
7353
8735
  } catch {
7354
8736
  return false;
@@ -7505,9 +8887,9 @@ async function createCommandContext(command, options) {
7505
8887
  import { createInterface as createInterface2 } from "readline/promises";
7506
8888
 
7507
8889
  // src/update/check.ts
7508
- import { mkdir as mkdir12, readFile as readFile22, writeFile } from "fs/promises";
8890
+ import { mkdir as mkdir14, readFile as readFile24, writeFile } from "fs/promises";
7509
8891
  import { homedir as homedir2 } from "os";
7510
- import { dirname as dirname11, join as join30 } from "path";
8892
+ import { dirname as dirname13, join as join34 } from "path";
7511
8893
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
7512
8894
  function getFetUpdateCheckMode(env = process.env) {
7513
8895
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -7580,11 +8962,11 @@ function formatFetUpdateWarning(availability, language) {
7580
8962
  }
7581
8963
  function cachePath() {
7582
8964
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
7583
- return join30(home, ".fet", "update-check-cache.json");
8965
+ return join34(home, ".fet", "update-check-cache.json");
7584
8966
  }
7585
8967
  async function readUpdateCheckCache() {
7586
8968
  try {
7587
- const raw = await readFile22(cachePath(), "utf8");
8969
+ const raw = await readFile24(cachePath(), "utf8");
7588
8970
  const parsed = JSON.parse(raw);
7589
8971
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
7590
8972
  return null;
@@ -7600,7 +8982,7 @@ async function readUpdateCheckCache() {
7600
8982
  }
7601
8983
  async function writeUpdateCheckCache(cache) {
7602
8984
  const path = cachePath();
7603
- await mkdir12(dirname11(path), { recursive: true });
8985
+ await mkdir14(dirname13(path), { recursive: true });
7604
8986
  await writeFile(path, `${JSON.stringify(cache, null, 2)}
7605
8987
  `, "utf8");
7606
8988
  }
@@ -7691,6 +9073,19 @@ addGlobalOptions(program.command("tdd").description("\u6839\u636E\u89C4\u5212\u4
7691
9073
  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(
7692
9074
  wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
7693
9075
  );
9076
+ addGlobalOptions(
9077
+ program.command("visual").description("layout-only \u89C6\u89C9\u9A8C\u6536\uFF08\u9ED8\u8BA4\uFF1A\u66F4\u65B0\u6E05\u5355 + \u622A\u56FE + \u5E03\u5C40\u68C0\u67E5\uFF09").option("--plan", "\u4EC5\u8F93\u51FA\u6267\u884C\u8BA1\u5212").option("--capture-only", "\u4EC5\u6267\u884C\u622A\u56FE").option("--check-layout-only", "\u4EC5\u6267\u884C\u5E03\u5C40\u68C0\u67E5\uFF08\u4F9D\u8D56\u5DF2\u6709 capture\uFF09").option("--base-url <url>", "\u5E94\u7528 base URL\uFF0C\u7528\u4E8E Playwright \u622A\u56FE")
9078
+ ).action(
9079
+ wrap(
9080
+ "visual",
9081
+ (ctx, options) => visualCommand(ctx, {
9082
+ plan: Boolean(options.plan),
9083
+ captureOnly: Boolean(options.captureOnly),
9084
+ checkLayoutOnly: Boolean(options.checkLayoutOnly),
9085
+ baseUrl: options.baseUrl
9086
+ })
9087
+ )
9088
+ );
7694
9089
  addGlobalOptions(program.command("verify").description("\u6700\u7EC8\u8D28\u91CF\u9A8C\u8BC1").option("--done", "\u58F0\u660E\u624B\u52A8\u9A8C\u8BC1\u5DF2\u5B8C\u6210").option("--auto", "\u751F\u6210\u6216\u6267\u884C\u81EA\u52A8\u9A8C\u8BC1\u8BA1\u5212")).action(
7695
9090
  wrap("verify", verifyCommand)
7696
9091
  );