@nick848/fet 1.1.8 → 1.1.10

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
@@ -9,7 +9,7 @@ import { createInterface as createInterface3 } from "readline/promises";
9
9
  import { Command } from "commander";
10
10
 
11
11
  // src/commands/doctor.ts
12
- import { readFile as readFile4, stat as stat3 } from "fs/promises";
12
+ import { stat as stat3 } from "fs/promises";
13
13
  import { join as join6 } from "path";
14
14
 
15
15
  // src/context-placeholders.ts
@@ -188,18 +188,18 @@ function toGitNexusState(detection, previous) {
188
188
  };
189
189
  }
190
190
  async function inspectGitNexusGraph(projectRoot, env = process.env) {
191
- const relative5 = env.FET_GITNEXUS_GRAPH_PATH?.trim() || DEFAULT_GRAPH_PATH;
192
- const graphPath = join5(projectRoot, relative5);
191
+ const relative6 = env.FET_GITNEXUS_GRAPH_PATH?.trim() || DEFAULT_GRAPH_PATH;
192
+ const graphPath = join5(projectRoot, relative6);
193
193
  try {
194
194
  const info = await stat2(graphPath);
195
195
  return {
196
- graphPath: relative5,
196
+ graphPath: relative6,
197
197
  graphExists: true,
198
198
  lastIndexedAt: info.mtime.toISOString()
199
199
  };
200
200
  } catch {
201
201
  return {
202
- graphPath: relative5,
202
+ graphPath: relative6,
203
203
  graphExists: false,
204
204
  lastIndexedAt: null
205
205
  };
@@ -268,7 +268,10 @@ async function doctorCommand(ctx, options = {}) {
268
268
  checks.push(await checkState(ctx));
269
269
  checks.push(await checkFile("agents", join6(ctx.projectRoot, "AGENTS.md"), "AGENTS.md \u7F3A\u5931", "fet update-context"));
270
270
  checks.push(await checkFile("config", join6(ctx.projectRoot, "openspec", "config.yaml"), "openspec/config.yaml \u7F3A\u5931", "fet init"));
271
- checks.push(await checkPlaceholders(ctx));
271
+ const placeholders = await checkPlaceholders(ctx);
272
+ if (placeholders) {
273
+ checks.push(placeholders);
274
+ }
272
275
  checks.push(await checkGitNexus(ctx));
273
276
  for (const adapter of ctx.toolAdapters) {
274
277
  checks.push(...await adapter.doctor(ctx.projectRoot));
@@ -333,27 +336,21 @@ async function checkFile(id, path, missing, suggestedCommand) {
333
336
  return await exists(path) ? { id, status: "pass", message: `${id} \u5B58\u5728` } : { id, status: "warn", message: missing, suggestedCommand };
334
337
  }
335
338
  async function checkPlaceholders(ctx) {
336
- try {
337
- await readFile4(join6(ctx.projectRoot, "AGENTS.md"), "utf8");
338
- const count2 = await countAgentsLlmPlaceholders(ctx.projectRoot);
339
- return count2 ? {
340
- id: "context-placeholders",
341
- status: "warn",
342
- message: ctx.language === "en" ? `AGENTS.md has ${count2} LLM placeholder(s)` : `AGENTS.md \u4ECD\u6709 ${count2} \u4E2A LLM \u5360\u4F4D\u7B26`,
343
- suggestedCommand: "fet fill-context"
344
- } : {
345
- id: "context-placeholders",
346
- status: "pass",
347
- message: ctx.language === "en" ? "AGENTS.md placeholders resolved" : "AGENTS.md \u5360\u4F4D\u7B26\u5DF2\u5904\u7406"
348
- };
349
- } catch {
350
- return {
351
- id: "context-placeholders",
352
- status: "warn",
353
- message: ctx.language === "en" ? "AGENTS.md missing" : "AGENTS.md \u7F3A\u5931",
354
- suggestedCommand: "fet update-context"
355
- };
339
+ const agentsPath = join6(ctx.projectRoot, "AGENTS.md");
340
+ if (!await exists(agentsPath)) {
341
+ return null;
356
342
  }
343
+ const count2 = await countAgentsLlmPlaceholders(ctx.projectRoot);
344
+ return count2 ? {
345
+ id: "context-placeholders",
346
+ status: "warn",
347
+ message: ctx.language === "en" ? `AGENTS.md has ${count2} LLM placeholder(s)` : `AGENTS.md \u4ECD\u6709 ${count2} \u4E2A LLM \u5360\u4F4D\u7B26`,
348
+ suggestedCommand: "fet fill-context"
349
+ } : {
350
+ id: "context-placeholders",
351
+ status: "pass",
352
+ message: ctx.language === "en" ? "AGENTS.md placeholders resolved" : "AGENTS.md \u5360\u4F4D\u7B26\u5DF2\u5904\u7406"
353
+ };
357
354
  }
358
355
  async function exists(path) {
359
356
  try {
@@ -369,20 +366,20 @@ import { mkdir as mkdir3 } from "fs/promises";
369
366
  import { dirname as dirname4, join as join10 } from "path";
370
367
 
371
368
  // src/agents-miniprogram.ts
372
- import { readFile as readFile7 } from "fs/promises";
369
+ import { readFile as readFile6 } from "fs/promises";
373
370
  import { join as join9 } from "path";
374
371
 
375
372
  // src/scanner/miniprogram.ts
376
- import { readdir, readFile as readFile6, stat as stat5 } from "fs/promises";
373
+ import { readdir, readFile as readFile5, stat as stat5 } from "fs/promises";
377
374
  import { join as join8, relative } from "path";
378
375
 
379
376
  // src/scanner/package.ts
380
- import { readFile as readFile5, stat as stat4 } from "fs/promises";
377
+ import { readFile as readFile4, stat as stat4 } from "fs/promises";
381
378
  import { join as join7 } from "path";
382
379
  import { parse } from "yaml";
383
380
  async function readPackageJson(projectRoot) {
384
381
  try {
385
- return JSON.parse(await readFile5(join7(projectRoot, "package.json"), "utf8"));
382
+ return JSON.parse(await readFile4(join7(projectRoot, "package.json"), "utf8"));
386
383
  } catch {
387
384
  return null;
388
385
  }
@@ -463,7 +460,7 @@ async function detectWorkspaces(projectRoot, pkg) {
463
460
  return packageWorkspaces;
464
461
  }
465
462
  try {
466
- const workspace = parse(await readFile5(join7(projectRoot, "pnpm-workspace.yaml"), "utf8"));
463
+ const workspace = parse(await readFile4(join7(projectRoot, "pnpm-workspace.yaml"), "utf8"));
467
464
  return (workspace?.packages ?? []).map((path) => ({
468
465
  name: path,
469
466
  path,
@@ -604,7 +601,7 @@ async function resolveAppJsonPath(projectRoot, platform) {
604
601
  const candidates = [];
605
602
  if (platform.id === "wechat") {
606
603
  try {
607
- const config = JSON.parse(await readFile6(join8(projectRoot, "project.config.json"), "utf8"));
604
+ const config = JSON.parse(await readFile5(join8(projectRoot, "project.config.json"), "utf8"));
608
605
  if (config.miniprogramRoot) {
609
606
  candidates.push(join8(projectRoot, config.miniprogramRoot, "app.json"));
610
607
  }
@@ -628,7 +625,7 @@ async function resolveAppJsonPath(projectRoot, platform) {
628
625
  }
629
626
  async function readAppJson(path) {
630
627
  try {
631
- return JSON.parse(await readFile6(path, "utf8"));
628
+ return JSON.parse(await readFile5(path, "utf8"));
632
629
  } catch {
633
630
  return null;
634
631
  }
@@ -991,7 +988,7 @@ async function applyMiniprogramAgentsContext(projectRoot, language) {
991
988
  const detection = await detectMiniprogramProject(projectRoot);
992
989
  let existing;
993
990
  try {
994
- existing = await readFile7(agentsPath, "utf8");
991
+ existing = await readFile6(agentsPath, "utf8");
995
992
  } catch {
996
993
  return {
997
994
  applied: false,
@@ -1116,7 +1113,7 @@ import { mkdir as mkdir5 } from "fs/promises";
1116
1113
  import { dirname as dirname6, join as join12 } from "path";
1117
1114
 
1118
1115
  // src/graph-context.ts
1119
- import { mkdir as mkdir4, readdir as readdir2, readFile as readFile8 } from "fs/promises";
1116
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile7 } from "fs/promises";
1120
1117
  import { dirname as dirname5, join as join11 } from "path";
1121
1118
  var MAX_SOURCE_CONTEXT = 8e3;
1122
1119
  var MAX_GRAPH_OUTPUT = 2e4;
@@ -1362,7 +1359,7 @@ async function listSpecFiles(specsRoot) {
1362
1359
  }
1363
1360
  async function readOptional(path) {
1364
1361
  try {
1365
- return await readFile8(path, "utf8");
1362
+ return await readFile7(path, "utf8");
1366
1363
  } catch {
1367
1364
  return null;
1368
1365
  }
@@ -1768,19 +1765,19 @@ import { stat as stat6 } from "fs/promises";
1768
1765
  import { join as join15 } from "path";
1769
1766
 
1770
1767
  // src/commands/update-context.ts
1771
- import { readFile as readFile10 } from "fs/promises";
1768
+ import { readFile as readFile9 } from "fs/promises";
1772
1769
  import { createInterface } from "readline/promises";
1773
1770
  import { join as join14 } from "path";
1774
1771
 
1775
1772
  // src/config/yaml.ts
1776
- import { readFile as readFile9 } from "fs/promises";
1773
+ import { readFile as readFile8 } from "fs/promises";
1777
1774
  import { parseDocument } from "yaml";
1778
1775
  async function mergeFetConfig(configPath, renderedFetYaml) {
1779
1776
  const fetDoc = parseDocument(renderedFetYaml);
1780
1777
  const nextFet = fetDoc.get("fet", true);
1781
1778
  let existing = "";
1782
1779
  try {
1783
- existing = await readFile9(configPath, "utf8");
1780
+ existing = await readFile8(configPath, "utf8");
1784
1781
  } catch {
1785
1782
  return renderedFetYaml;
1786
1783
  }
@@ -2088,6 +2085,11 @@ function renderFetConfig(scan, language = "zh-CN") {
2088
2085
  uiDisplayContract: {
2089
2086
  enabled: true
2090
2087
  },
2088
+ tdd: {
2089
+ enabled: true,
2090
+ mode: "require_before_apply",
2091
+ whenNoTestScript: "block"
2092
+ },
2091
2093
  specLanguage: {
2092
2094
  style: "layered_bilingual",
2093
2095
  canonical: "en",
@@ -2253,6 +2255,135 @@ fet verify --done --change ${changeId}
2253
2255
  `;
2254
2256
  }
2255
2257
 
2258
+ // src/tdd/paths.ts
2259
+ function tddFetDirRelative(changeId) {
2260
+ return `openspec/changes/${changeId}/.fet`;
2261
+ }
2262
+ function tddManifestRelativePath(changeId) {
2263
+ return `${tddFetDirRelative(changeId)}/tdd-manifest.yaml`;
2264
+ }
2265
+ function tddSpecRelativePath(changeId) {
2266
+ return `${tddFetDirRelative(changeId)}/tdd-spec.md`;
2267
+ }
2268
+ function tddInstructionsRelativePath(changeId) {
2269
+ return `${tddFetDirRelative(changeId)}/tdd-instructions.md`;
2270
+ }
2271
+ function tddResultsRelativePath(changeId) {
2272
+ return `${tddFetDirRelative(changeId)}/tdd-results.json`;
2273
+ }
2274
+
2275
+ // src/templates/tdd.ts
2276
+ function renderTddInstructions(changeId, manifest, language) {
2277
+ const manifestPath2 = tddManifestRelativePath(changeId);
2278
+ const specPath = tddSpecRelativePath(changeId);
2279
+ const caseList = manifest.cases.map((item) => `- \`${item.id}\`: ${item.title} \u2192 \`${item.testFile}\` (${item.testIds.join(", ")})`).join("\n");
2280
+ if (language === "en") {
2281
+ return `---
2282
+ schemaVersion: 1
2283
+ fetVersion: ${FET_VERSION}
2284
+ changeId: ${changeId}
2285
+ purpose: tdd-instructions
2286
+ generatedAt: ${manifest.generatedAt}
2287
+ ---
2288
+
2289
+ # TDD instructions (this change)
2290
+
2291
+ Create or update unit tests **before** marking implementation tasks done in \`tasks.md\`.
2292
+
2293
+ ## Sources
2294
+ ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2295
+
2296
+ ## Cases (from ${manifestPath2})
2297
+ ${caseList}
2298
+
2299
+ ## Rules
2300
+ 1. Each case must map to a real test file under the repo test tree.
2301
+ 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.
2303
+ 4. When tests exist, run \`fet test --change ${changeId}\` before \`fet verify\`.
2304
+
2305
+ Human-readable matrix: \`${specPath}\`
2306
+ `;
2307
+ }
2308
+ return `---
2309
+ schemaVersion: 1
2310
+ fetVersion: ${FET_VERSION}
2311
+ changeId: ${changeId}
2312
+ purpose: tdd-instructions
2313
+ generatedAt: ${manifest.generatedAt}
2314
+ ---
2315
+
2316
+ # TDD \u6307\u4EE4\uFF08\u672C change\uFF09
2317
+
2318
+ \u5728\u5C06 \`tasks.md\` \u4E2D\u7684\u4EFB\u52A1\u6807\u4E3A\u5B8C\u6210\u4E4B\u524D\uFF0C\u5148\u521B\u5EFA\u6216\u66F4\u65B0\u5355\u5143\u6D4B\u8BD5\u3002
2319
+
2320
+ ## \u6765\u6E90
2321
+ ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2322
+
2323
+ ## \u7528\u4F8B\uFF08\u89C1 ${manifestPath2}\uFF09
2324
+ ${caseList}
2325
+
2326
+ ## \u89C4\u5219
2327
+ 1. \u6BCF\u4E2A\u7528\u4F8B\u5FC5\u987B\u5BF9\u5E94\u4ED3\u5E93\u5185\u771F\u5B9E\u6D4B\u8BD5\u6587\u4EF6\u3002
2328
+ 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
2330
+ 4. \u6D4B\u8BD5\u5C31\u7EEA\u540E\u5148 \`fet test --change ${changeId}\`\uFF0C\u518D \`fet verify\`\u3002
2331
+
2332
+ \u53EF\u8BFB\u77E9\u9635\u89C1 \`${specPath}\`
2333
+ `;
2334
+ }
2335
+ function renderTddSpec(changeId, manifest, language) {
2336
+ const rows = manifest.cases.map((item) => renderSpecRow(item, language)).join("\n");
2337
+ if (language === "en") {
2338
+ return `---
2339
+ schemaVersion: 1
2340
+ changeId: ${changeId}
2341
+ generatedAt: ${manifest.generatedAt}
2342
+ planningFingerprint: ${manifest.planningFingerprint}
2343
+ ---
2344
+
2345
+ # TDD case matrix
2346
+
2347
+ | ID | Scenario | Spec reference | Test file | Required |
2348
+ |----|----------|----------------|-----------|----------|
2349
+ ${rows}
2350
+ `;
2351
+ }
2352
+ return `---
2353
+ schemaVersion: 1
2354
+ changeId: ${changeId}
2355
+ generatedAt: ${manifest.generatedAt}
2356
+ planningFingerprint: ${manifest.planningFingerprint}
2357
+ ---
2358
+
2359
+ # TDD \u7528\u4F8B\u77E9\u9635
2360
+
2361
+ | ID | \u573A\u666F | Spec \u5F15\u7528 | \u6D4B\u8BD5\u6587\u4EF6 | \u5FC5\u9700 |
2362
+ |----|------|-----------|----------|------|
2363
+ ${rows}
2364
+ `;
2365
+ }
2366
+ function renderSpecRow(item, language) {
2367
+ const required = language === "en" ? item.required ? "yes" : "no" : item.required ? "\u662F" : "\u5426";
2368
+ return `| ${item.id} | ${escapeTable(item.title)} | ${escapeTable(item.specRef)} | \`${item.testFile}\` | ${required} |`;
2369
+ }
2370
+ function escapeTable(value) {
2371
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
2372
+ }
2373
+ function renderTddApplyNextSteps(changeId, language) {
2374
+ const manifestPath2 = tddManifestRelativePath(changeId);
2375
+ if (language === "en") {
2376
+ return [
2377
+ `Read ${manifestPath2} and tdd-instructions.md; implement code until fet test passes for this change.`,
2378
+ `Run fet test --change ${changeId} before fet verify.`
2379
+ ];
2380
+ }
2381
+ return [
2382
+ `\u9605\u8BFB ${manifestPath2} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
2383
+ `\u5728 fet verify \u4E4B\u524D\u5148\u6267\u884C fet test --change ${changeId}\u3002`
2384
+ ];
2385
+ }
2386
+
2256
2387
  // src/templates/figma-guard.ts
2257
2388
  var FIGMA_URL_PATTERN = /https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\/[^\s)\]"'<>]+/gi;
2258
2389
  function figmaStopHandoffRelativePath(changeId) {
@@ -2824,7 +2955,7 @@ async function confirmInitCanReplaceUnmanagedAgents(ctx) {
2824
2955
  }
2825
2956
  async function readOptional2(path) {
2826
2957
  try {
2827
- return await readFile10(path, "utf8");
2958
+ return await readFile9(path, "utf8");
2828
2959
  } catch {
2829
2960
  return null;
2830
2961
  }
@@ -2892,11 +3023,11 @@ async function exists4(path) {
2892
3023
  }
2893
3024
 
2894
3025
  // src/commands/proxy.ts
2895
- import { readFile as readFile15 } from "fs/promises";
2896
- import { join as join19 } from "path";
3026
+ import { readFile as readFile17 } from "fs/promises";
3027
+ import { join as join22 } from "path";
2897
3028
 
2898
3029
  // src/figma-guard.ts
2899
- import { readdir as readdir3, readFile as readFile11, stat as stat7 } from "fs/promises";
3030
+ import { readdir as readdir3, readFile as readFile10, stat as stat7 } from "fs/promises";
2900
3031
  import { join as join16, relative as relative2 } from "path";
2901
3032
  import { parseDocument as parseDocument2 } from "yaml";
2902
3033
  var DEFAULT_CONFIG = {
@@ -2906,7 +3037,7 @@ var DEFAULT_CONFIG = {
2906
3037
  };
2907
3038
  async function loadFigmaGuardConfig(projectRoot) {
2908
3039
  try {
2909
- const raw = await readFile11(join16(projectRoot, "openspec", "config.yaml"), "utf8");
3040
+ const raw = await readFile10(join16(projectRoot, "openspec", "config.yaml"), "utf8");
2910
3041
  const doc = parseDocument2(raw);
2911
3042
  const fetNode = doc.get("fet", true);
2912
3043
  const node = fetNode?.get?.("figmaGuard");
@@ -3042,7 +3173,7 @@ async function walk(dir, files) {
3042
3173
  async function readOptional3(path) {
3043
3174
  try {
3044
3175
  await stat7(path);
3045
- return await readFile11(path, "utf8");
3176
+ return await readFile10(path, "utf8");
3046
3177
  } catch {
3047
3178
  return null;
3048
3179
  }
@@ -3050,7 +3181,7 @@ async function readOptional3(path) {
3050
3181
 
3051
3182
  // src/ui-display-contract.ts
3052
3183
  import { existsSync as existsSync2 } from "fs";
3053
- import { readdir as readdir4, readFile as readFile12, stat as stat8 } from "fs/promises";
3184
+ import { readdir as readdir4, readFile as readFile11, stat as stat8 } from "fs/promises";
3054
3185
  import { join as join17, relative as relative3 } from "path";
3055
3186
  import { parse as parse3, parseDocument as parseDocument3 } from "yaml";
3056
3187
  var DEFAULT_CONFIG2 = {
@@ -3062,7 +3193,7 @@ var BACKTICK_PATH_PATTERN = /`([^`]+\.(?:ya?ml|json))`/gi;
3062
3193
  var OPENAPI_BARE_PATTERN = /\b(openapi\.ya?ml|swagger\.ya?ml|swagger\.json)\b/gi;
3063
3194
  async function loadUiDisplayContractConfig(projectRoot) {
3064
3195
  try {
3065
- const raw = await readFile12(join17(projectRoot, "openspec", "config.yaml"), "utf8");
3196
+ const raw = await readFile11(join17(projectRoot, "openspec", "config.yaml"), "utf8");
3066
3197
  const doc = parseDocument3(raw);
3067
3198
  const fetNode = doc.get("fet", true);
3068
3199
  const node = fetNode?.get?.("uiDisplayContract");
@@ -3336,10 +3467,263 @@ async function walk2(dir, files) {
3336
3467
  async function readOptional4(path) {
3337
3468
  try {
3338
3469
  await stat8(path);
3339
- return await readFile12(path, "utf8");
3470
+ return await readFile11(path, "utf8");
3471
+ } catch {
3472
+ return null;
3473
+ }
3474
+ }
3475
+
3476
+ // src/commands/change-id.ts
3477
+ function toKebabId(value) {
3478
+ return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3479
+ }
3480
+ function msg(language, zh, en) {
3481
+ return language === "en" ? en : zh;
3482
+ }
3483
+ async function resolveChangeId(ctx) {
3484
+ if (ctx.changeId) {
3485
+ return ctx.changeId;
3486
+ }
3487
+ const global = await ctx.stateStore.getOrCreateGlobal();
3488
+ if (global.activeChangeId) {
3489
+ return global.activeChangeId;
3490
+ }
3491
+ const inspection = await ctx.openSpec.inspectProject(ctx.projectRoot);
3492
+ if (inspection.changes.length === 1 && inspection.changes[0]) {
3493
+ return inspection.changes[0];
3494
+ }
3495
+ throw new FetError({
3496
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
3497
+ message: msg(ctx.language, "\u65E0\u6CD5\u786E\u5B9A\u76EE\u6807 change", "Cannot determine which change to use"),
3498
+ details: { openChangeIds: inspection.changes },
3499
+ suggestedCommand: "fet <command> --change <change-id>"
3500
+ });
3501
+ }
3502
+ async function assertChangeExists(ctx, changeId) {
3503
+ const inspection = await ctx.openSpec.inspectChange(ctx.projectRoot, changeId);
3504
+ if (!inspection.exists) {
3505
+ throw new FetError({
3506
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
3507
+ message: msg(ctx.language, "\u6307\u5B9A\u7684 change \u4E0D\u5B58\u5728", "The specified change does not exist"),
3508
+ details: { changeId },
3509
+ suggestedCommand: `fet doctor`
3510
+ });
3511
+ }
3512
+ }
3513
+
3514
+ // src/tdd/config.ts
3515
+ import { readFile as readFile12 } from "fs/promises";
3516
+ import { join as join18 } from "path";
3517
+ import { parseDocument as parseDocument4 } from "yaml";
3518
+ var DEFAULT_CONFIG3 = {
3519
+ enabled: true,
3520
+ mode: "require_before_apply",
3521
+ whenNoTestScript: "block"
3522
+ };
3523
+ async function loadTddConfig(projectRoot) {
3524
+ try {
3525
+ const raw = await readFile12(join18(projectRoot, "openspec", "config.yaml"), "utf8");
3526
+ const doc = parseDocument4(raw);
3527
+ const fetNode = doc.get("fet", true);
3528
+ const node = fetNode?.get?.("tdd");
3529
+ if (!node || typeof node.get !== "function") {
3530
+ return DEFAULT_CONFIG3;
3531
+ }
3532
+ const enabled = node.get("enabled");
3533
+ const modeRaw = node.get("mode");
3534
+ const whenNoTestScriptRaw = node.get("whenNoTestScript");
3535
+ return {
3536
+ enabled: enabled === void 0 ? true : Boolean(enabled),
3537
+ mode: parseGateMode(modeRaw),
3538
+ whenNoTestScript: parseWhenNoTestScript(whenNoTestScriptRaw)
3539
+ };
3540
+ } catch {
3541
+ return DEFAULT_CONFIG3;
3542
+ }
3543
+ }
3544
+ function parseGateMode(value) {
3545
+ if (value === "off" || value === "optional" || value === "require_before_apply") {
3546
+ return value;
3547
+ }
3548
+ return DEFAULT_CONFIG3.mode;
3549
+ }
3550
+ function parseWhenNoTestScript(value) {
3551
+ if (value === "warn" || value === "skip") {
3552
+ return value;
3553
+ }
3554
+ return DEFAULT_CONFIG3.whenNoTestScript;
3555
+ }
3556
+ function isTddRequired(config) {
3557
+ return config.enabled && config.mode === "require_before_apply";
3558
+ }
3559
+
3560
+ // src/tdd/fingerprint.ts
3561
+ import { createHash } from "crypto";
3562
+ import { readdir as readdir5, readFile as readFile13, stat as stat9 } from "fs/promises";
3563
+ import { join as join19, relative as relative4 } from "path";
3564
+ async function collectPlanningSources(projectRoot, changeId) {
3565
+ const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
3566
+ const sources = [];
3567
+ const rootFiles = ["proposal.md", "tasks.md", "design.md"];
3568
+ for (const name of rootFiles) {
3569
+ const path = join19(changeRoot, name);
3570
+ if (await exists5(path)) {
3571
+ sources.push(relative4(projectRoot, path).replace(/\\/g, "/"));
3572
+ }
3573
+ }
3574
+ const specsDir = join19(changeRoot, "specs");
3575
+ if (await exists5(specsDir)) {
3576
+ for (const file of await walkFiles(specsDir)) {
3577
+ if (file.endsWith(".md")) {
3578
+ sources.push(relative4(projectRoot, file).replace(/\\/g, "/"));
3579
+ }
3580
+ }
3581
+ }
3582
+ return sources.sort();
3583
+ }
3584
+ async function computePlanningFingerprint(projectRoot, changeId) {
3585
+ const sources = await collectPlanningSources(projectRoot, changeId);
3586
+ const hash = createHash("sha256");
3587
+ for (const source of sources) {
3588
+ const content = await readFile13(join19(projectRoot, source), "utf8");
3589
+ hash.update(source);
3590
+ hash.update("\0");
3591
+ hash.update(content);
3592
+ hash.update("\0");
3593
+ }
3594
+ return `sha256:${hash.digest("hex")}`;
3595
+ }
3596
+ async function walkFiles(dir) {
3597
+ const entries = await readdir5(dir, { withFileTypes: true });
3598
+ const files = [];
3599
+ for (const entry of entries) {
3600
+ const path = join19(dir, entry.name);
3601
+ if (entry.isDirectory()) {
3602
+ files.push(...await walkFiles(path));
3603
+ } else if (entry.isFile()) {
3604
+ files.push(path);
3605
+ }
3606
+ }
3607
+ return files;
3608
+ }
3609
+ async function exists5(path) {
3610
+ try {
3611
+ await stat9(path);
3612
+ return true;
3613
+ } catch {
3614
+ return false;
3615
+ }
3616
+ }
3617
+
3618
+ // src/tdd/manifest.ts
3619
+ import { mkdir as mkdir6, readFile as readFile14, stat as stat10 } from "fs/promises";
3620
+ import { dirname as dirname8, join as join20 } from "path";
3621
+ import { parse as parse4, stringify as stringify3 } from "yaml";
3622
+ function tddManifestPath(projectRoot, changeId) {
3623
+ return join20(projectRoot, tddManifestRelativePath(changeId));
3624
+ }
3625
+ async function readTddManifest(projectRoot, changeId) {
3626
+ const path = tddManifestPath(projectRoot, changeId);
3627
+ try {
3628
+ await stat10(path);
3340
3629
  } catch {
3341
3630
  return null;
3342
3631
  }
3632
+ const doc = parse4(await readFile14(path, "utf8"));
3633
+ if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
3634
+ return null;
3635
+ }
3636
+ return doc;
3637
+ }
3638
+ async function writeTddManifest(projectRoot, manifest) {
3639
+ const relative6 = tddManifestRelativePath(manifest.changeId);
3640
+ const path = join20(projectRoot, relative6);
3641
+ await mkdir6(dirname8(path), { recursive: true });
3642
+ await atomicWrite(path, stringify3(manifest));
3643
+ return relative6;
3644
+ }
3645
+ async function writeTddResults(projectRoot, results) {
3646
+ const relative6 = tddResultsRelativePath(results.changeId);
3647
+ const path = join20(projectRoot, relative6);
3648
+ await mkdir6(dirname8(path), { recursive: true });
3649
+ await atomicWrite(path, `${JSON.stringify(results, null, 2)}
3650
+ `);
3651
+ return relative6;
3652
+ }
3653
+ function createTddManifest(input) {
3654
+ return {
3655
+ schemaVersion: 1,
3656
+ changeId: input.changeId,
3657
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3658
+ fetVersion: FET_VERSION,
3659
+ planningFingerprint: input.planningFingerprint,
3660
+ sources: input.sources,
3661
+ cases: input.cases,
3662
+ run: {
3663
+ mode: input.cases.length ? "manifest" : "workspace",
3664
+ fallbackCommand: input.testCommand
3665
+ }
3666
+ };
3667
+ }
3668
+
3669
+ // src/tdd/gates.ts
3670
+ async function assertTddReady(ctx, changeId) {
3671
+ const config = await loadTddConfig(ctx.projectRoot);
3672
+ if (!isTddRequired(config)) {
3673
+ return;
3674
+ }
3675
+ const manifest = await readTddManifest(ctx.projectRoot, changeId);
3676
+ if (!manifest) {
3677
+ throw new FetError({
3678
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3679
+ message: msg(ctx.language, "\u7F3A\u5C11 TDD \u4EA7\u7269\uFF0C\u65E0\u6CD5\u8FDB\u5165 apply\u3002", "TDD artifacts are missing; cannot run apply."),
3680
+ details: { changeId, expected: tddManifestRelativePath(changeId) },
3681
+ suggestedCommand: `fet tdd --change ${changeId}`,
3682
+ recoverable: true
3683
+ });
3684
+ }
3685
+ const fingerprint2 = await computePlanningFingerprint(ctx.projectRoot, changeId);
3686
+ if (manifest.planningFingerprint !== fingerprint2) {
3687
+ throw new FetError({
3688
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3689
+ message: msg(
3690
+ ctx.language,
3691
+ "\u89C4\u5212\u4EA7\u7269\u5DF2\u53D8\u66F4\uFF0CTDD \u6E05\u5355\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002",
3692
+ "Planning artifacts changed; TDD manifest is stale. Regenerate it."
3693
+ ),
3694
+ details: { changeId, manifestFingerprint: manifest.planningFingerprint, currentFingerprint: fingerprint2 },
3695
+ suggestedCommand: `fet tdd --change ${changeId}`,
3696
+ recoverable: true
3697
+ });
3698
+ }
3699
+ }
3700
+ async function assertTestPassed(ctx, changeId) {
3701
+ const config = await loadTddConfig(ctx.projectRoot);
3702
+ if (!config.enabled || config.mode === "off") {
3703
+ return;
3704
+ }
3705
+ const change = await ctx.stateStore.readChange(changeId);
3706
+ const testRun = change?.testRun;
3707
+ if (testRun?.status === "skipped") {
3708
+ return;
3709
+ }
3710
+ if (testRun?.status === "passed" && await fingerprintMatches(ctx, changeId, testRun)) {
3711
+ return;
3712
+ }
3713
+ throw new FetError({
3714
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3715
+ message: msg(ctx.language, "\u672C change \u5C1A\u672A\u901A\u8FC7 fet test\u3002", "This change has not passed fet test yet."),
3716
+ details: { changeId, testRun: testRun ?? null },
3717
+ suggestedCommand: `fet test --change ${changeId}`,
3718
+ recoverable: true
3719
+ });
3720
+ }
3721
+ async function fingerprintMatches(ctx, changeId, testRun) {
3722
+ const current = await computePlanningFingerprint(ctx.projectRoot, changeId);
3723
+ return testRun.planningFingerprint === current;
3724
+ }
3725
+ function invalidateTestRun(state) {
3726
+ state.testRun = null;
3343
3727
  }
3344
3728
 
3345
3729
  // src/state/project.ts
@@ -3369,8 +3753,8 @@ async function git(cwd, args) {
3369
3753
  }
3370
3754
 
3371
3755
  // src/state/store.ts
3372
- import { mkdir as mkdir6, readFile as readFile13 } from "fs/promises";
3373
- import { join as join18 } from "path";
3756
+ import { mkdir as mkdir7, readFile as readFile15 } from "fs/promises";
3757
+ import { join as join21 } from "path";
3374
3758
 
3375
3759
  // src/language.ts
3376
3760
  var DEFAULT_LANGUAGE = "zh-CN";
@@ -3432,6 +3816,8 @@ function createChangeState(fetVersion, changeId, phase) {
3432
3816
  lastSyncedAt: null
3433
3817
  },
3434
3818
  manualVerify: null,
3819
+ tdd: null,
3820
+ testRun: null,
3435
3821
  lastOpenSpecCommand: null,
3436
3822
  warnings: []
3437
3823
  };
@@ -3488,7 +3874,7 @@ var StateStore = class {
3488
3874
  project;
3489
3875
  async readGlobal() {
3490
3876
  try {
3491
- const value = JSON.parse(await readFile13(this.globalPath(), "utf8"));
3877
+ const value = JSON.parse(await readFile15(this.globalPath(), "utf8"));
3492
3878
  assertGlobalState(value);
3493
3879
  return value;
3494
3880
  } catch (error) {
@@ -3503,13 +3889,13 @@ var StateStore = class {
3503
3889
  }
3504
3890
  async writeGlobal(state) {
3505
3891
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3506
- await mkdir6(join18(this.projectRoot, "openspec"), { recursive: true });
3892
+ await mkdir7(join21(this.projectRoot, "openspec"), { recursive: true });
3507
3893
  await atomicWrite(this.globalPath(), `${JSON.stringify(state, null, 2)}
3508
3894
  `);
3509
3895
  }
3510
3896
  async readChange(changeId) {
3511
3897
  try {
3512
- const value = JSON.parse(await readFile13(this.changePath(changeId), "utf8"));
3898
+ const value = JSON.parse(await readFile15(this.changePath(changeId), "utf8"));
3513
3899
  assertChangeState(value);
3514
3900
  return value;
3515
3901
  } catch (error) {
@@ -3524,15 +3910,15 @@ var StateStore = class {
3524
3910
  }
3525
3911
  async writeChange(state) {
3526
3912
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3527
- await mkdir6(join18(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
3913
+ await mkdir7(join21(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
3528
3914
  await atomicWrite(this.changePath(state.changeId), `${JSON.stringify(state, null, 2)}
3529
3915
  `);
3530
3916
  }
3531
3917
  globalPath() {
3532
- return join18(this.projectRoot, "openspec", "fet-state.json");
3918
+ return join21(this.projectRoot, "openspec", "fet-state.json");
3533
3919
  }
3534
3920
  changePath(changeId) {
3535
- return join18(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
3921
+ return join21(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
3536
3922
  }
3537
3923
  };
3538
3924
  function isNotFound(error) {
@@ -3540,11 +3926,11 @@ function isNotFound(error) {
3540
3926
  }
3541
3927
 
3542
3928
  // src/state/tasks.ts
3543
- import { readFile as readFile14 } from "fs/promises";
3929
+ import { readFile as readFile16 } from "fs/promises";
3544
3930
  async function readCompletedTaskIds(tasksPath) {
3545
3931
  let content;
3546
3932
  try {
3547
- content = await readFile14(tasksPath, "utf8");
3933
+ content = await readFile16(tasksPath, "utf8");
3548
3934
  } catch {
3549
3935
  return [];
3550
3936
  }
@@ -3678,6 +4064,7 @@ async function applyWorkflowCommand(ctx, args) {
3678
4064
  await withProjectLock(ctx.projectRoot, { command: "apply", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
3679
4065
  await assertOpenSpecCommandSupported(ctx, "status", "apply");
3680
4066
  await assertOpenSpecCommandSupported(ctx, "instructions", "apply");
4067
+ await assertTddReady(ctx, changeId);
3681
4068
  runState.graphContext = await buildWorkflowGraphContext(ctx, {
3682
4069
  command: "apply",
3683
4070
  args: ["tasks", "--change", changeId],
@@ -3709,6 +4096,7 @@ async function applyWorkflowCommand(ctx, args) {
3709
4096
  const applyNextSteps = [
3710
4097
  `Read openspec/changes/${changeId}/tasks.md and the instructions output.`,
3711
4098
  "Implement pending tasks and update task checkboxes only after the work is done.",
4099
+ ...renderTddApplyNextSteps(changeId, ctx.language),
3712
4100
  `Run fet verify --change ${changeId}`
3713
4101
  ];
3714
4102
  if (uiContract) {
@@ -3853,7 +4241,7 @@ async function onboardWorkflowCommand(ctx) {
3853
4241
  summary: "fet onboard loaded local FET/OpenSpec workflow context.",
3854
4242
  nextSteps: [
3855
4243
  inspection.changes.length ? `Open changes: ${inspection.changes.join(", ")}` : "No open changes found. Run fet propose <change-id-or-description> to start one.",
3856
- "Use fet continue to prepare planning artifacts, fet apply for implementation instructions, fet verify before archive."
4244
+ "Use fet continue to prepare planning artifacts, fet tdd then fet apply for implementation, fet test then fet verify before archive."
3857
4245
  ],
3858
4246
  data: { activeChangeId: state.activeChangeId, openChangeIds: inspection.changes, archivedChangeIds: inspection.archived }
3859
4247
  });
@@ -3898,7 +4286,7 @@ async function artifactWorkflowCommand(ctx, command, args) {
3898
4286
  `Create or update openspec/changes/${changeId}/${resolveOutputPath(status, artifactId)}`,
3899
4287
  "Review the artifact with the user before generating the next planning file.",
3900
4288
  `Run fet passthrough status --change ${changeId}`,
3901
- status.isComplete ? `Run fet apply --change ${changeId}` : `Run fet continue --change ${changeId} when ready for the next artifact`
4289
+ status.isComplete ? `Run fet tdd --change ${changeId}, then fet apply --change ${changeId}` : `Run fet continue --change ${changeId} when ready for the next artifact`
3902
4290
  ];
3903
4291
  if (uiContract) {
3904
4292
  planningNextSteps.unshift(
@@ -3940,7 +4328,7 @@ async function ensureProposedChange(ctx, args) {
3940
4328
  });
3941
4329
  }
3942
4330
  const input = args.join(" ").trim();
3943
- const changeId = isKebabId(input) ? input : toKebabId(input);
4331
+ const changeId = isKebabId(input) ? input : toKebabId2(input);
3944
4332
  if (!changeId) {
3945
4333
  throw new FetError({
3946
4334
  code: "INVALID_ARGUMENTS" /* InvalidArguments */,
@@ -4090,7 +4478,7 @@ function resolveOutputPath(status, artifactId) {
4090
4478
  function isKebabId(value) {
4091
4479
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
4092
4480
  }
4093
- function toKebabId(value) {
4481
+ function toKebabId2(value) {
4094
4482
  return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4095
4483
  }
4096
4484
  function parseOpenSpecJson(stdout) {
@@ -4127,7 +4515,7 @@ async function createChangelogEntry(projectRoot, changeId) {
4127
4515
  };
4128
4516
  }
4129
4517
  async function appendChangelog(projectRoot, entry) {
4130
- const changelogPath = join19(projectRoot, "CHANGELOG.md");
4518
+ const changelogPath = join22(projectRoot, "CHANGELOG.md");
4131
4519
  const existing = await readOptional5(changelogPath);
4132
4520
  const legacyContentLabel = "\u66F4\u65B0\u5185\u5BB9";
4133
4521
  const block = `updateTime: ${entry.updateTime}
@@ -4140,12 +4528,12 @@ ${block}` : block;
4140
4528
  await atomicWrite(changelogPath, next);
4141
4529
  }
4142
4530
  async function readChangeRequirement(projectRoot, changeId) {
4143
- const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
4144
- const proposal = await readOptional5(join19(changeRoot, "proposal.md"));
4531
+ const changeRoot = join22(projectRoot, "openspec", "changes", changeId);
4532
+ const proposal = await readOptional5(join22(changeRoot, "proposal.md"));
4145
4533
  if (proposal) {
4146
4534
  return summarizeMarkdown(proposal);
4147
4535
  }
4148
- const readme = await readOptional5(join19(changeRoot, "README.md"));
4536
+ const readme = await readOptional5(join22(changeRoot, "README.md"));
4149
4537
  if (readme) {
4150
4538
  return summarizeMarkdown(readme);
4151
4539
  }
@@ -4157,7 +4545,7 @@ function summarizeMarkdown(content) {
4157
4545
  }
4158
4546
  async function readOptional5(path) {
4159
4547
  try {
4160
- return await readFile15(path, "utf8");
4548
+ return await readFile17(path, "utf8");
4161
4549
  } catch {
4162
4550
  return null;
4163
4551
  }
@@ -4511,10 +4899,402 @@ async function updateCommand(ctx) {
4511
4899
  });
4512
4900
  }
4513
4901
 
4902
+ // src/commands/tdd.ts
4903
+ import { mkdir as mkdir8 } from "fs/promises";
4904
+ import { join as join24 } from "path";
4905
+
4906
+ // src/tdd/extract-cases.ts
4907
+ import { readFile as readFile18 } from "fs/promises";
4908
+ import { join as join23 } from "path";
4909
+ async function extractCasesFromChange(projectRoot, changeId, sources) {
4910
+ const cases = [];
4911
+ const seen = /* @__PURE__ */ new Set();
4912
+ for (const source of sources) {
4913
+ const content = await readFile18(join23(projectRoot, source), "utf8");
4914
+ if (source.endsWith("tasks.md")) {
4915
+ for (const item of extractTaskCases(content, changeId)) {
4916
+ if (!seen.has(item.id)) {
4917
+ seen.add(item.id);
4918
+ cases.push({ ...item, specRef: `${source} \u2014 ${item.specRef}` });
4919
+ }
4920
+ }
4921
+ }
4922
+ if (source.includes("/specs/") && source.endsWith(".md")) {
4923
+ for (const item of extractScenarioCases(content, changeId, source)) {
4924
+ if (!seen.has(item.id)) {
4925
+ seen.add(item.id);
4926
+ cases.push(item);
4927
+ }
4928
+ }
4929
+ }
4930
+ }
4931
+ if (!cases.length) {
4932
+ cases.push({
4933
+ id: `${changeId}-smoke`,
4934
+ title: "Change smoke test",
4935
+ specRef: sources[0] ?? `openspec/changes/${changeId}/`,
4936
+ testFile: `tests/changes/${changeId}.test.ts`,
4937
+ testIds: [`${changeId}-smoke`],
4938
+ required: true
4939
+ });
4940
+ }
4941
+ return cases;
4942
+ }
4943
+ function extractTaskCases(content, changeId) {
4944
+ const cases = [];
4945
+ const lines = content.split(/\r?\n/);
4946
+ for (const line of lines) {
4947
+ const numbered = line.match(/^\s*[-*]\s+\[[ xX]?\]\s+((?:\d+(?:\.\d+)*\.?)?)\s*(.+)$/);
4948
+ if (numbered) {
4949
+ const taskId = numbered[1] || String(cases.length + 1);
4950
+ const title = numbered[2]?.trim() ?? taskId;
4951
+ const id = toKebabId(`${changeId}-task-${taskId}`) || `${changeId}-task-${cases.length + 1}`;
4952
+ cases.push({
4953
+ id,
4954
+ title,
4955
+ specRef: title,
4956
+ testFile: suggestTestFile(changeId, id),
4957
+ testIds: [id],
4958
+ required: true
4959
+ });
4960
+ continue;
4961
+ }
4962
+ const plain = line.match(/^\s*[-*]\s+\[[ xX]?\]\s+(.+)$/);
4963
+ if (plain?.[1]) {
4964
+ const title = plain[1].trim();
4965
+ const id = toKebabId(`${changeId}-${title}`) || `${changeId}-task-${cases.length + 1}`;
4966
+ cases.push({
4967
+ id,
4968
+ title,
4969
+ specRef: title,
4970
+ testFile: suggestTestFile(changeId, id),
4971
+ testIds: [id],
4972
+ required: true
4973
+ });
4974
+ }
4975
+ }
4976
+ return cases;
4977
+ }
4978
+ function extractScenarioCases(content, changeId, source) {
4979
+ const cases = [];
4980
+ const lines = content.split(/\r?\n/);
4981
+ let currentRequirement = "";
4982
+ for (const line of lines) {
4983
+ const req = line.match(/^#{2,4}\s+Requirement:\s*(.+)$/i);
4984
+ if (req?.[1]) {
4985
+ currentRequirement = req[1].trim();
4986
+ }
4987
+ const scenario = line.match(/^#{2,4}\s+Scenario:\s*(.+)$/i);
4988
+ if (scenario?.[1]) {
4989
+ const title = scenario[1].trim();
4990
+ const id = toKebabId(`${changeId}-${title}`) || `${changeId}-scenario-${cases.length + 1}`;
4991
+ cases.push({
4992
+ id,
4993
+ title,
4994
+ specRef: currentRequirement ? `Requirement: ${currentRequirement} \u2014 Scenario: ${title}` : `Scenario: ${title}`,
4995
+ testFile: suggestTestFile(changeId, id),
4996
+ testIds: [id],
4997
+ required: true
4998
+ });
4999
+ }
5000
+ }
5001
+ if (!cases.length && content.trim()) {
5002
+ const id = `${changeId}-spec-smoke`;
5003
+ cases.push({
5004
+ id,
5005
+ title: `Spec coverage for ${source}`,
5006
+ specRef: source,
5007
+ testFile: suggestTestFile(changeId, id),
5008
+ testIds: [id],
5009
+ required: true
5010
+ });
5011
+ }
5012
+ return cases;
5013
+ }
5014
+ function suggestTestFile(changeId, caseId) {
5015
+ const segment = caseId.replace(new RegExp(`^${changeId}-?`), "") || "suite";
5016
+ return `tests/changes/${changeId}/${segment}.test.ts`;
5017
+ }
5018
+
5019
+ // src/commands/tdd.ts
5020
+ async function tddCommand(ctx) {
5021
+ await withProjectLock(ctx.projectRoot, { command: "tdd", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5022
+ const changeId = await resolveChangeId(ctx);
5023
+ await assertChangeExists(ctx, changeId);
5024
+ const config = await loadTddConfig(ctx.projectRoot);
5025
+ const sources = await collectPlanningSources(ctx.projectRoot, changeId);
5026
+ if (!sources.length) {
5027
+ throw new FetError({
5028
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
5029
+ message: msg(ctx.language, "\u672A\u627E\u5230\u53EF\u7528\u4E8E\u751F\u6210 TDD \u7684\u89C4\u5212\u4EA7\u7269\u3002", "No planning artifacts found for TDD generation."),
5030
+ details: { changeId },
5031
+ suggestedCommand: `fet continue --change ${changeId}`,
5032
+ recoverable: true
5033
+ });
5034
+ }
5035
+ const planningFingerprint = await computePlanningFingerprint(ctx.projectRoot, changeId);
5036
+ const cases = await extractCasesFromChange(ctx.projectRoot, changeId, sources);
5037
+ const scan = await ctx.scanner.scan(ctx.projectRoot, {});
5038
+ const testCommand2 = scan.commands.test?.command ?? scan.commands["test:unit"]?.command ?? null;
5039
+ const manifest = createTddManifest({
5040
+ changeId,
5041
+ planningFingerprint,
5042
+ sources,
5043
+ cases,
5044
+ testCommand: testCommand2
5045
+ });
5046
+ const manifestPath2 = await writeTddManifest(ctx.projectRoot, manifest);
5047
+ const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5048
+ await mkdir8(fetDir, { recursive: true });
5049
+ const instructionsPath = tddInstructionsRelativePath(changeId);
5050
+ const specPath = tddSpecRelativePath(changeId);
5051
+ await atomicWrite(join24(ctx.projectRoot, instructionsPath), renderTddInstructions(changeId, manifest, ctx.language));
5052
+ await atomicWrite(join24(ctx.projectRoot, specPath), renderTddSpec(changeId, manifest, ctx.language));
5053
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5054
+ changeState.tdd = {
5055
+ status: "ready",
5056
+ generatedAt: manifest.generatedAt,
5057
+ planningFingerprint,
5058
+ manifestPath: manifestPath2
5059
+ };
5060
+ invalidateTestRun(changeState);
5061
+ changeState.currentPhase = "implement";
5062
+ changeState.phases.implement = { status: "in_progress", updatedAt: manifest.generatedAt };
5063
+ await ctx.stateStore.writeChange(changeState);
5064
+ const global = await ctx.stateStore.getOrCreateGlobal();
5065
+ global.activeChangeId = changeId;
5066
+ await ctx.stateStore.writeGlobal(global);
5067
+ ctx.output.result({
5068
+ ok: true,
5069
+ command: "tdd",
5070
+ summary: msg(
5071
+ ctx.language,
5072
+ `\u5DF2\u4E3A change "${changeId}" \u751F\u6210 TDD \u4EA7\u7269\uFF08${cases.length} \u4E2A\u7528\u4F8B\uFF09\u3002`,
5073
+ `Generated TDD artifacts for change "${changeId}" (${cases.length} case(s)).`
5074
+ ),
5075
+ warnings: !testCommand2 && config.whenNoTestScript !== "skip" ? [
5076
+ msg(
5077
+ ctx.language,
5078
+ "\u9879\u76EE\u672A\u914D\u7F6E test \u811A\u672C\uFF1B\u5B9E\u73B0\u540E\u53EF\u80FD\u65E0\u6CD5\u8FD0\u884C fet test\u3002",
5079
+ "No test script found in the project; fet test may fail after implementation."
5080
+ )
5081
+ ] : void 0,
5082
+ nextSteps: [
5083
+ msg(ctx.language, `\u9605\u8BFB ${instructionsPath}\uFF0C\u7531 IDE/AI \u521B\u5EFA/\u66F4\u65B0\u6E05\u5355\u4E2D\u7684\u6D4B\u8BD5\u6587\u4EF6\u3002`, `Read ${instructionsPath} and create/update test files listed in the manifest.`),
5084
+ msg(ctx.language, `\u5B8C\u6210\u540E\u8FD0\u884C fet apply --change ${changeId}`, `Then run fet apply --change ${changeId}`),
5085
+ msg(ctx.language, `\u5B9E\u73B0\u540E\u8FD0\u884C fet test --change ${changeId}`, `After implementation, run fet test --change ${changeId}`)
5086
+ ],
5087
+ data: {
5088
+ changeId,
5089
+ manifestPath: manifestPath2,
5090
+ specPath,
5091
+ instructionsPath,
5092
+ caseCount: cases.length,
5093
+ planningFingerprint,
5094
+ testCommand: testCommand2
5095
+ }
5096
+ });
5097
+ });
5098
+ }
5099
+
5100
+ // src/commands/run-script.ts
5101
+ import { spawn as spawn2 } from "child_process";
5102
+ async function runShellCommand(commandLine, cwd, extraArgs = []) {
5103
+ const parts = splitCommandLine(commandLine);
5104
+ const executable = parts[0];
5105
+ if (!executable) {
5106
+ throw new Error("Empty command");
5107
+ }
5108
+ const args = [...parts.slice(1), ...extraArgs];
5109
+ return runProcess(executable, args, cwd, commandLine);
5110
+ }
5111
+ async function runProcess(executable, args, cwd, label = executable) {
5112
+ return new Promise((resolve2) => {
5113
+ const stdout = [];
5114
+ const stderr = [];
5115
+ const child = spawn2(executable, args, {
5116
+ cwd,
5117
+ shell: process.platform === "win32",
5118
+ env: process.env
5119
+ });
5120
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
5121
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
5122
+ child.on("close", (code, signal) => {
5123
+ resolve2({
5124
+ command: label,
5125
+ args,
5126
+ exitCode: code ?? 1,
5127
+ signal,
5128
+ stdout: Buffer.concat(stdout).toString("utf8"),
5129
+ stderr: Buffer.concat(stderr).toString("utf8")
5130
+ });
5131
+ });
5132
+ child.on("error", () => {
5133
+ resolve2({
5134
+ command: label,
5135
+ args,
5136
+ exitCode: 1,
5137
+ signal: null,
5138
+ stdout: Buffer.concat(stdout).toString("utf8"),
5139
+ stderr: Buffer.concat(stderr).toString("utf8")
5140
+ });
5141
+ });
5142
+ });
5143
+ }
5144
+ function splitCommandLine(commandLine) {
5145
+ return commandLine.trim().split(/\s+/);
5146
+ }
5147
+
5148
+ // src/commands/test.ts
5149
+ async function testCommand(ctx, options) {
5150
+ await withProjectLock(ctx.projectRoot, { command: "test", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5151
+ const changeId = await resolveChangeId(ctx);
5152
+ await assertChangeExists(ctx, changeId);
5153
+ const config = await loadTddConfig(ctx.projectRoot);
5154
+ const manifest = await readTddManifest(ctx.projectRoot, changeId);
5155
+ if (!manifest) {
5156
+ throw new FetError({
5157
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5158
+ message: msg(ctx.language, "\u7F3A\u5C11 TDD \u6E05\u5355\uFF0C\u65E0\u6CD5\u8FD0\u884C fet test\u3002", "TDD manifest is missing; cannot run fet test."),
5159
+ details: { changeId },
5160
+ suggestedCommand: `fet tdd --change ${changeId}`,
5161
+ recoverable: true
5162
+ });
5163
+ }
5164
+ const planningFingerprint = await computePlanningFingerprint(ctx.projectRoot, changeId);
5165
+ if (manifest.planningFingerprint !== planningFingerprint) {
5166
+ throw new FetError({
5167
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5168
+ message: msg(ctx.language, "TDD \u6E05\u5355\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u8FD0\u884C fet tdd\u3002", "TDD manifest is stale; rerun fet tdd."),
5169
+ details: { changeId },
5170
+ suggestedCommand: `fet tdd --change ${changeId}`,
5171
+ recoverable: true
5172
+ });
5173
+ }
5174
+ const scan = await ctx.scanner.scan(ctx.projectRoot, {});
5175
+ const testCommand2 = manifest.run.fallbackCommand ?? scan.commands.test?.command ?? scan.commands["test:unit"]?.command ?? null;
5176
+ if (!testCommand2) {
5177
+ if (config.whenNoTestScript === "skip") {
5178
+ await recordSkippedTestRun(ctx, changeId, planningFingerprint, manifestPath(changeId));
5179
+ ctx.output.result({
5180
+ ok: true,
5181
+ command: "test",
5182
+ summary: msg(ctx.language, "\u672A\u914D\u7F6E test \u811A\u672C\uFF0C\u5DF2\u8DF3\u8FC7 fet test\u3002", "No test script configured; fet test skipped."),
5183
+ warnings: [msg(ctx.language, "\u8DF3\u8FC7\u540E\u53EF\u76F4\u63A5 fet verify\uFF08\u82E5\u9879\u76EE\u7B56\u7565\u5141\u8BB8\uFF09\u3002", "You may proceed to fet verify if your policy allows.")],
5184
+ data: { changeId, skipped: true }
5185
+ });
5186
+ return;
5187
+ }
5188
+ throw new FetError({
5189
+ code: "CONFIG_INVALID" /* ConfigInvalid */,
5190
+ message: msg(ctx.language, "\u672A\u627E\u5230 test \u811A\u672C\uFF0C\u65E0\u6CD5\u6267\u884C fet test\u3002", "No test script found; cannot run fet test."),
5191
+ suggestedCommand: "\u5728 package.json \u4E2D\u6DFB\u52A0 scripts.test \u6216\u914D\u7F6E openspec/config.yaml fet.tdd.whenNoTestScript: skip"
5192
+ });
5193
+ }
5194
+ const extraArgs = buildTestArgs(manifest);
5195
+ const planLabel = `${testCommand2}${extraArgs.length ? ` ${extraArgs.join(" ")}` : ""}`;
5196
+ if (options.plan) {
5197
+ ctx.output.result({
5198
+ ok: true,
5199
+ command: "test",
5200
+ summary: msg(ctx.language, "\u5DF2\u751F\u6210 fet test \u6267\u884C\u8BA1\u5212\u3002", "Generated fet test execution plan."),
5201
+ data: {
5202
+ changeId,
5203
+ command: planLabel,
5204
+ cases: manifest.cases.map((item) => ({ id: item.id, testFile: item.testFile }))
5205
+ },
5206
+ nextSteps: [`fet test --change ${changeId}`]
5207
+ });
5208
+ return;
5209
+ }
5210
+ const result = await runShellCommand(testCommand2, ctx.projectRoot, extraArgs);
5211
+ const caseResults = manifest.cases.map((item) => ({
5212
+ id: item.id,
5213
+ status: result.exitCode === 0 ? "passed" : "failed",
5214
+ exitCode: result.exitCode,
5215
+ message: result.exitCode === 0 ? void 0 : msg(ctx.language, "\u6D4B\u8BD5\u547D\u4EE4\u672A\u901A\u8FC7", "Test command failed")
5216
+ }));
5217
+ const resultsDoc = {
5218
+ schemaVersion: 1,
5219
+ changeId,
5220
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5221
+ command: planLabel,
5222
+ exitCode: result.exitCode,
5223
+ planningFingerprint,
5224
+ cases: caseResults
5225
+ };
5226
+ const resultsPath = await writeTddResults(ctx.projectRoot, resultsDoc);
5227
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5228
+ changeState.testRun = {
5229
+ status: result.exitCode === 0 ? "passed" : "failed",
5230
+ ranAt: resultsDoc.ranAt,
5231
+ command: planLabel,
5232
+ exitCode: result.exitCode,
5233
+ planningFingerprint,
5234
+ manifestPath: manifestPath(changeId),
5235
+ resultsPath
5236
+ };
5237
+ await ctx.stateStore.writeChange(changeState);
5238
+ if (result.exitCode !== 0) {
5239
+ throw new FetError({
5240
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
5241
+ message: msg(ctx.language, "fet test \u672A\u901A\u8FC7\u3002", "fet test failed."),
5242
+ details: {
5243
+ changeId,
5244
+ exitCode: result.exitCode,
5245
+ command: planLabel,
5246
+ resultsPath: tddResultsRelativePath(changeId),
5247
+ stderr: truncate(result.stderr)
5248
+ },
5249
+ suggestedCommand: `fet test --change ${changeId}`,
5250
+ recoverable: true
5251
+ });
5252
+ }
5253
+ ctx.output.result({
5254
+ ok: true,
5255
+ command: "test",
5256
+ summary: msg(ctx.language, `change "${changeId}" \u7684\u5355\u6D4B\u5DF2\u901A\u8FC7\u3002`, `Unit tests passed for change "${changeId}".`),
5257
+ nextSteps: [`fet verify --change ${changeId}`],
5258
+ data: {
5259
+ changeId,
5260
+ command: planLabel,
5261
+ resultsPath: tddResultsRelativePath(changeId),
5262
+ cases: caseResults
5263
+ }
5264
+ });
5265
+ });
5266
+ }
5267
+ function manifestPath(changeId) {
5268
+ return tddManifestRelativePath(changeId);
5269
+ }
5270
+ function buildTestArgs(manifest) {
5271
+ if (!manifest || manifest.run.mode === "workspace") {
5272
+ return [];
5273
+ }
5274
+ const files = [...new Set(manifest.cases.map((item) => item.testFile).filter(Boolean))];
5275
+ return files.length ? ["--", ...files] : [];
5276
+ }
5277
+ async function recordSkippedTestRun(ctx, changeId, planningFingerprint, manifestPathValue) {
5278
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5279
+ changeState.testRun = {
5280
+ status: "skipped",
5281
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5282
+ command: "(skipped)",
5283
+ exitCode: 0,
5284
+ planningFingerprint,
5285
+ manifestPath: manifestPathValue,
5286
+ resultsPath: null
5287
+ };
5288
+ await ctx.stateStore.writeChange(changeState);
5289
+ }
5290
+ function truncate(value, max = 2e3) {
5291
+ return value.length > max ? `${value.slice(0, max)}\u2026` : value;
5292
+ }
5293
+
4514
5294
  // src/commands/verify.ts
4515
- import { createHash } from "crypto";
4516
- import { mkdir as mkdir7, readFile as readFile16, stat as stat9 } from "fs/promises";
4517
- import { join as join20 } from "path";
5295
+ import { createHash as createHash2 } from "crypto";
5296
+ import { mkdir as mkdir9, readFile as readFile19, stat as stat11 } from "fs/promises";
5297
+ import { join as join25 } from "path";
4518
5298
  async function verifyCommand(ctx, options) {
4519
5299
  if (options.auto) {
4520
5300
  const scan = await ctx.scanner.scan(ctx.projectRoot, {});
@@ -4580,10 +5360,11 @@ async function verifyCommand(ctx, options) {
4580
5360
  }
4581
5361
  async function writeInstructions(ctx, changeId) {
4582
5362
  await assertChangeExists(ctx, changeId);
5363
+ await assertTestPassed(ctx, changeId);
4583
5364
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
4584
- const dir = join20(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
4585
- const instructionsPath = join20(dir, "verify-instructions.md");
4586
- await mkdir7(dir, { recursive: true });
5365
+ const dir = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5366
+ const instructionsPath = join25(dir, "verify-instructions.md");
5367
+ await mkdir9(dir, { recursive: true });
4587
5368
  await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
4588
5369
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
4589
5370
  state.currentPhase = "verify";
@@ -4598,9 +5379,10 @@ async function writeInstructions(ctx, changeId) {
4598
5379
  }
4599
5380
  async function markDone(ctx, changeId) {
4600
5381
  await assertChangeExists(ctx, changeId);
5382
+ await assertTestPassed(ctx, changeId);
4601
5383
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
4602
- const instructionsPath = join20(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
4603
- const instructions = await readInstructions(instructionsPath, changeId);
5384
+ const instructionsPath = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
5385
+ const instructions = await readInstructions(ctx, instructionsPath, changeId);
4604
5386
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
4605
5387
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
4606
5388
  state.currentPhase = "verify";
@@ -4621,26 +5403,19 @@ async function markDone(ctx, changeId) {
4621
5403
  nextSteps: [`fet sync --change ${changeId}`, `fet archive --change ${changeId}`]
4622
5404
  });
4623
5405
  }
4624
- async function assertChangeExists(ctx, changeId) {
4625
- const inspection = await ctx.openSpec.inspectChange(ctx.projectRoot, changeId);
4626
- if (!inspection.exists) {
4627
- throw new FetError({
4628
- code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4629
- message: "\u6307\u5B9A\u7684 change \u4E0D\u5B58\u5728",
4630
- details: { changeId },
4631
- suggestedCommand: "fet verify --change <change-id>"
4632
- });
4633
- }
4634
- }
4635
- async function readInstructions(path, changeId) {
5406
+ async function readInstructions(ctx, path, changeId) {
4636
5407
  try {
4637
- await stat9(path);
4638
- const content = await readFile16(path, "utf8");
5408
+ await stat11(path);
5409
+ const content = await readFile19(path, "utf8");
4639
5410
  const fileChangeId = readFrontMatterValue(content, "changeId");
4640
5411
  if (fileChangeId !== changeId) {
4641
5412
  throw new FetError({
4642
5413
  code: "STATE_CORRUPTED" /* StateCorrupted */,
4643
- message: "\u9A8C\u8BC1\u6307\u4EE4\u6587\u4EF6\u4E0E\u5F53\u524D change \u4E0D\u5339\u914D",
5414
+ message: msg(
5415
+ ctx.language,
5416
+ "\u9A8C\u8BC1\u6307\u4EE4\u6587\u4EF6\u4E0E\u5F53\u524D change \u4E0D\u5339\u914D",
5417
+ "Verify instructions do not match the current change"
5418
+ ),
4644
5419
  details: { expected: changeId, actual: fileChangeId },
4645
5420
  suggestedCommand: `fet verify --change ${changeId}`
4646
5421
  });
@@ -4652,7 +5427,7 @@ async function readInstructions(path, changeId) {
4652
5427
  }
4653
5428
  throw new FetError({
4654
5429
  code: "STATE_CORRUPTED" /* StateCorrupted */,
4655
- message: "\u9A8C\u8BC1\u6307\u4EE4\u6587\u4EF6\u4E0D\u5B58\u5728\u6216\u65E0\u6CD5\u8BFB\u53D6",
5430
+ message: msg(ctx.language, "\u9A8C\u8BC1\u6307\u4EE4\u6587\u4EF6\u4E0D\u5B58\u5728\u6216\u65E0\u6CD5\u8BFB\u53D6", "Verify instructions file is missing or unreadable"),
4656
5431
  details: { path },
4657
5432
  suggestedCommand: `fet verify --change ${changeId}`
4658
5433
  });
@@ -4663,26 +5438,7 @@ function readFrontMatterValue(content, key) {
4663
5438
  return match?.[1]?.trim() ?? null;
4664
5439
  }
4665
5440
  function fingerprint(value) {
4666
- return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
4667
- }
4668
- async function resolveChangeId(ctx) {
4669
- if (ctx.changeId) {
4670
- return ctx.changeId;
4671
- }
4672
- const global = await ctx.stateStore.getOrCreateGlobal();
4673
- if (global.activeChangeId) {
4674
- return global.activeChangeId;
4675
- }
4676
- const inspection = await ctx.openSpec.inspectProject(ctx.projectRoot);
4677
- if (inspection.changes.length === 1 && inspection.changes[0]) {
4678
- return inspection.changes[0];
4679
- }
4680
- throw new FetError({
4681
- code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4682
- message: "\u65E0\u6CD5\u786E\u5B9A\u8981\u9A8C\u8BC1\u7684 change",
4683
- details: { openChangeIds: inspection.changes },
4684
- suggestedCommand: "fet verify --change <change-id>"
4685
- });
5441
+ return `sha256:${createHash2("sha256").update(JSON.stringify(value)).digest("hex")}`;
4686
5442
  }
4687
5443
 
4688
5444
  // src/model-policy.ts
@@ -4773,11 +5529,12 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
4773
5529
  import { resolve } from "path";
4774
5530
 
4775
5531
  // src/adapters/codex/index.ts
4776
- import { mkdir as mkdir8, readFile as readFile17, stat as stat10 } from "fs/promises";
5532
+ import { mkdir as mkdir10, readFile as readFile20, stat as stat12 } from "fs/promises";
4777
5533
  import { homedir } from "os";
4778
- import { dirname as dirname8, join as join21 } from "path";
5534
+ import { dirname as dirname9, join as join26 } from "path";
4779
5535
 
4780
5536
  // src/adapters/commands.ts
5537
+ var FET_STANDALONE_COMMANDS = ["tdd", "test"];
4781
5538
  var FET_WORKFLOW_COMMANDS = [
4782
5539
  "explore",
4783
5540
  "propose",
@@ -4792,7 +5549,7 @@ var FET_WORKFLOW_COMMANDS = [
4792
5549
  "onboard"
4793
5550
  ];
4794
5551
  var FET_GRAPH_COMMANDS = ["graph-status", "graph-setup", "graph-init", "graph-refresh", "graph-doctor", "graph-handoff"];
4795
- var FET_ADAPTER_COMMANDS = [...FET_WORKFLOW_COMMANDS, "update", "fill-context", "passthrough", ...FET_GRAPH_COMMANDS];
5552
+ var FET_ADAPTER_COMMANDS = [...FET_WORKFLOW_COMMANDS, ...FET_STANDALONE_COMMANDS, "update", "fill-context", "passthrough", ...FET_GRAPH_COMMANDS];
4796
5553
  function renderFetAdapterUsage(command, args = "[...args]") {
4797
5554
  if (command.startsWith("graph-")) {
4798
5555
  const subcommand = command.slice("graph-".length);
@@ -5860,7 +6617,7 @@ var CodexAdapter = class {
5860
6617
  adapterVersion = 1;
5861
6618
  async detect(projectRoot) {
5862
6619
  return {
5863
- detected: await exists5(join21(projectRoot, ".codex")) || await exists5(join21(projectRoot, "AGENTS.md")),
6620
+ detected: await exists6(join26(projectRoot, ".codex")) || await exists6(join26(projectRoot, "AGENTS.md")),
5864
6621
  reason: "Codex adapter is available for projects that use AGENTS.md"
5865
6622
  };
5866
6623
  }
@@ -5899,7 +6656,7 @@ var CodexAdapter = class {
5899
6656
  if (existing && !existing.includes("FET:MANAGED") && force) {
5900
6657
  await createBackup(target);
5901
6658
  }
5902
- await mkdir8(dirname8(target), { recursive: true });
6659
+ await mkdir10(dirname9(target), { recursive: true });
5903
6660
  await atomicWrite(target, file.content);
5904
6661
  written.push(displayPath);
5905
6662
  }
@@ -5926,9 +6683,9 @@ var CodexAdapter = class {
5926
6683
  };
5927
6684
  function resolveTarget(projectRoot, file) {
5928
6685
  if (file.root === "codex-home") {
5929
- return join21(resolveCodexHome(), file.path);
6686
+ return join26(resolveCodexHome(), file.path);
5930
6687
  }
5931
- return join21(projectRoot, file.path);
6688
+ return join26(projectRoot, file.path);
5932
6689
  }
5933
6690
  function displayPathFor(file) {
5934
6691
  if (file.root === "codex-home") {
@@ -5937,18 +6694,18 @@ function displayPathFor(file) {
5937
6694
  return file.path;
5938
6695
  }
5939
6696
  function resolveCodexHome() {
5940
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join21(homedir(), ".codex");
6697
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join26(homedir(), ".codex");
5941
6698
  }
5942
6699
  async function readExisting(path) {
5943
6700
  try {
5944
- return await readFile17(path, "utf8");
6701
+ return await readFile20(path, "utf8");
5945
6702
  } catch {
5946
6703
  return null;
5947
6704
  }
5948
6705
  }
5949
- async function exists5(path) {
6706
+ async function exists6(path) {
5950
6707
  try {
5951
- await stat10(path);
6708
+ await stat12(path);
5952
6709
  return true;
5953
6710
  } catch {
5954
6711
  return false;
@@ -5956,8 +6713,8 @@ async function exists5(path) {
5956
6713
  }
5957
6714
 
5958
6715
  // src/adapters/cursor/index.ts
5959
- import { mkdir as mkdir9, readFile as readFile18, stat as stat11 } from "fs/promises";
5960
- import { dirname as dirname9, join as join22 } from "path";
6716
+ import { mkdir as mkdir11, readFile as readFile21, stat as stat13 } from "fs/promises";
6717
+ import { dirname as dirname10, join as join27 } from "path";
5961
6718
 
5962
6719
  // src/adapters/cursor/templates.ts
5963
6720
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -6089,6 +6846,9 @@ After installation, verify with \`gitnexus --version\`, then run \`fet graph ini
6089
6846
  if (command === "apply") {
6090
6847
  return renderApplySkill(usage, language);
6091
6848
  }
6849
+ if (command === "tdd" || command === "test") {
6850
+ return renderTddTestSkill(command, usage, language);
6851
+ }
6092
6852
  if (command === "propose" || command === "continue" || command === "ff") {
6093
6853
  return renderPlanningSkill(command, usage, language);
6094
6854
  }
@@ -6206,6 +6966,38 @@ ${figmaBlock}
6206
6966
  ${uiContractBlock}
6207
6967
 
6208
6968
  \u6267\u884C\u524D\u8BF7\u9605\u8BFB AGENTS.md\u3001openspec/config.yaml \u4E0E\u5F53\u524D change \u4E0B\u7684 OpenSpec \u4EA7\u7269\u3002
6969
+
6970
+ \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
6971
+ `;
6972
+ }
6973
+ function renderTddTestSkill(command, usage, language) {
6974
+ const description = command === "tdd" ? language === "en" ? "Generate per-change TDD manifest and test instructions" : "\u751F\u6210\u672C change \u7684 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15" : language === "en" ? "Run unit tests scoped to the change TDD manifest" : "\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B";
6975
+ const body = command === "tdd" ? language === "en" ? `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 create failing tests in the repo before implementation.` : `\u89C4\u5212\u4EA7\u7269\u5C31\u7EEA\u540E\u3001\`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\u3002` : language === "en" ? `Run after implementation. Requires a current \`tdd-manifest.yaml\`. Records pass/fail in FET state; \`fet verify\` is blocked until this passes (unless configured to skip).` : `\u5B9E\u73B0\u5B8C\u6210\u540E\u8FD0\u884C\u3002\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`;
6976
+ return `<!-- FET:MANAGED
6977
+ schemaVersion: 1
6978
+ fetVersion: ${FET_VERSION}
6979
+ generator: cursor-adapter
6980
+ adapterVersion: 1
6981
+ command: ${usage}
6982
+ FET:END -->
6983
+
6984
+ ---
6985
+ name: fet-${command}
6986
+ description: ${description}
6987
+ disable-model-invocation: true
6988
+ ---
6989
+
6990
+ ${renderIdeModelPolicy(command, language)}
6991
+
6992
+ ${languageInstruction(language)}
6993
+
6994
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
6995
+
6996
+ \`\`\`sh
6997
+ ${usage}
6998
+ \`\`\`
6999
+
7000
+ ${body}
6209
7001
  `;
6210
7002
  }
6211
7003
 
@@ -6215,7 +7007,7 @@ var CursorAdapter = class {
6215
7007
  adapterVersion = 1;
6216
7008
  async detect(projectRoot) {
6217
7009
  return {
6218
- detected: await exists6(join22(projectRoot, ".cursor")),
7010
+ detected: await exists7(join27(projectRoot, ".cursor")),
6219
7011
  reason: "Cursor adapter is available for any project"
6220
7012
  };
6221
7013
  }
@@ -6232,7 +7024,7 @@ var CursorAdapter = class {
6232
7024
  const written = [];
6233
7025
  const skipped = [];
6234
7026
  for (const file of plan.files) {
6235
- const target = join22(projectRoot, file.path);
7027
+ const target = join27(projectRoot, file.path);
6236
7028
  const existing = await readExisting2(target);
6237
7029
  if (existing && !existing.includes("FET:MANAGED") && !force) {
6238
7030
  throw new FetError({
@@ -6245,7 +7037,7 @@ var CursorAdapter = class {
6245
7037
  if (existing && !existing.includes("FET:MANAGED") && force) {
6246
7038
  await createBackup(target);
6247
7039
  }
6248
- await mkdir9(dirname9(target), { recursive: true });
7040
+ await mkdir11(dirname10(target), { recursive: true });
6249
7041
  await atomicWrite(target, file.content);
6250
7042
  written.push(file.path);
6251
7043
  }
@@ -6255,7 +7047,7 @@ var CursorAdapter = class {
6255
7047
  const plan = await this.planInstall(projectRoot);
6256
7048
  const checks = [];
6257
7049
  for (const file of plan.files) {
6258
- const target = join22(projectRoot, file.path);
7050
+ const target = join27(projectRoot, file.path);
6259
7051
  const content = await readExisting2(target);
6260
7052
  const managed = Boolean(content?.includes("FET:MANAGED"));
6261
7053
  const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
@@ -6271,14 +7063,14 @@ var CursorAdapter = class {
6271
7063
  };
6272
7064
  async function readExisting2(path) {
6273
7065
  try {
6274
- return await readFile18(path, "utf8");
7066
+ return await readFile21(path, "utf8");
6275
7067
  } catch {
6276
7068
  return null;
6277
7069
  }
6278
7070
  }
6279
- async function exists6(path) {
7071
+ async function exists7(path) {
6280
7072
  try {
6281
- await stat11(path);
7073
+ await stat13(path);
6282
7074
  return true;
6283
7075
  } catch {
6284
7076
  return false;
@@ -6290,45 +7082,45 @@ import { execFile as execFile4 } from "child_process";
6290
7082
  import { promisify as promisify4 } from "util";
6291
7083
 
6292
7084
  // src/openspec/inspector.ts
6293
- import { readdir as readdir5, stat as stat12 } from "fs/promises";
6294
- import { join as join23 } from "path";
7085
+ import { readdir as readdir6, stat as stat14 } from "fs/promises";
7086
+ import { join as join28 } from "path";
6295
7087
  async function inspectOpenSpecProject(projectRoot) {
6296
- const openspecPath = join23(projectRoot, "openspec");
6297
- const changesPath = join23(openspecPath, "changes");
6298
- const legacyArchivePath = join23(openspecPath, "archive");
6299
- const changesArchivePath = join23(changesPath, "archive");
7088
+ const openspecPath = join28(projectRoot, "openspec");
7089
+ const changesPath = join28(openspecPath, "changes");
7090
+ const legacyArchivePath = join28(openspecPath, "archive");
7091
+ const changesArchivePath = join28(changesPath, "archive");
6300
7092
  return {
6301
- exists: await exists7(openspecPath),
7093
+ exists: await exists8(openspecPath),
6302
7094
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
6303
7095
  archived: [.../* @__PURE__ */ new Set([...await listDirectories(legacyArchivePath), ...await listDirectories(changesArchivePath)])]
6304
7096
  };
6305
7097
  }
6306
7098
  async function inspectOpenSpecChange(projectRoot, changeId) {
6307
- const changePath = join23(projectRoot, "openspec", "changes", changeId);
6308
- const tasksPath = join23(changePath, "tasks.md");
6309
- const specsPath = join23(changePath, "specs");
7099
+ const changePath = join28(projectRoot, "openspec", "changes", changeId);
7100
+ const tasksPath = join28(changePath, "tasks.md");
7101
+ const specsPath = join28(changePath, "specs");
6310
7102
  return {
6311
7103
  changeId,
6312
- exists: await exists7(changePath),
6313
- hasProposal: await exists7(join23(changePath, "proposal.md")),
6314
- hasTasks: await exists7(tasksPath),
6315
- hasSpecs: await exists7(specsPath),
7104
+ exists: await exists8(changePath),
7105
+ hasProposal: await exists8(join28(changePath, "proposal.md")),
7106
+ hasTasks: await exists8(tasksPath),
7107
+ hasSpecs: await exists8(specsPath),
6316
7108
  tasksPath,
6317
7109
  changePath
6318
7110
  };
6319
7111
  }
6320
7112
  async function listDirectories(path, options = {}) {
6321
7113
  try {
6322
- const entries = await readdir5(path, { withFileTypes: true });
7114
+ const entries = await readdir6(path, { withFileTypes: true });
6323
7115
  const excluded = new Set(options.exclude ?? []);
6324
7116
  return entries.filter((entry) => entry.isDirectory() && !excluded.has(entry.name)).map((entry) => entry.name);
6325
7117
  } catch {
6326
7118
  return [];
6327
7119
  }
6328
7120
  }
6329
- async function exists7(path) {
7121
+ async function exists8(path) {
6330
7122
  try {
6331
- await stat12(path);
7123
+ await stat14(path);
6332
7124
  return true;
6333
7125
  } catch {
6334
7126
  return false;
@@ -6395,14 +7187,14 @@ function exec(command, args) {
6395
7187
  }
6396
7188
 
6397
7189
  // src/openspec/runner.ts
6398
- import { spawn as spawn2 } from "child_process";
7190
+ import { spawn as spawn3 } from "child_process";
6399
7191
  async function runOpenSpec(executablePath, command, args, options) {
6400
7192
  const spawnCommand = executablePath === "npx openspec" ? "npx" : executablePath;
6401
7193
  const spawnArgs = executablePath === "npx openspec" ? ["openspec", command, ...args] : [command, ...args];
6402
7194
  return new Promise((resolve2, reject) => {
6403
7195
  const stdout = [];
6404
7196
  const stderr = [];
6405
- const child = spawn2(spawnCommand, spawnArgs, {
7197
+ const child = spawn3(spawnCommand, spawnArgs, {
6406
7198
  cwd: options.cwd,
6407
7199
  stdio: options.stdio ?? "inherit",
6408
7200
  shell: process.platform === "win32"
@@ -6511,14 +7303,14 @@ function escapeRegExp(value) {
6511
7303
  }
6512
7304
 
6513
7305
  // src/scanner/routes.ts
6514
- import { readdir as readdir6, stat as stat13 } from "fs/promises";
6515
- import { join as join24, relative as relative4, sep } from "path";
7306
+ import { readdir as readdir7, stat as stat15 } from "fs/promises";
7307
+ import { join as join29, relative as relative5, sep } from "path";
6516
7308
  async function scanRoutes(projectRoot) {
6517
7309
  const candidates = ["src/routes", "src/pages", "app", "pages"];
6518
7310
  const routes = [];
6519
7311
  for (const candidate of candidates) {
6520
- const root = join24(projectRoot, candidate);
6521
- if (!await exists8(root)) {
7312
+ const root = join29(projectRoot, candidate);
7313
+ if (!await exists9(root)) {
6522
7314
  continue;
6523
7315
  }
6524
7316
  for (const file of await listFiles(root)) {
@@ -6526,8 +7318,8 @@ async function scanRoutes(projectRoot) {
6526
7318
  continue;
6527
7319
  }
6528
7320
  routes.push({
6529
- path: inferRoutePath(relative4(root, file)),
6530
- source: relative4(projectRoot, file).split(sep).join("/"),
7321
+ path: inferRoutePath(relative5(root, file)),
7322
+ source: relative5(projectRoot, file).split(sep).join("/"),
6531
7323
  inferred: true,
6532
7324
  confidence: "medium"
6533
7325
  });
@@ -6542,10 +7334,10 @@ function inferRoutePath(relativePath) {
6542
7334
  return `/${withoutIndex}`.replace(/\/+/g, "/");
6543
7335
  }
6544
7336
  async function listFiles(root) {
6545
- const entries = await readdir6(root, { withFileTypes: true });
7337
+ const entries = await readdir7(root, { withFileTypes: true });
6546
7338
  const files = [];
6547
7339
  for (const entry of entries) {
6548
- const path = join24(root, entry.name);
7340
+ const path = join29(root, entry.name);
6549
7341
  if (entry.isDirectory()) {
6550
7342
  files.push(...await listFiles(path));
6551
7343
  } else {
@@ -6554,9 +7346,9 @@ async function listFiles(root) {
6554
7346
  }
6555
7347
  return files;
6556
7348
  }
6557
- async function exists8(path) {
7349
+ async function exists9(path) {
6558
7350
  try {
6559
- await stat13(path);
7351
+ await stat15(path);
6560
7352
  return true;
6561
7353
  } catch {
6562
7354
  return false;
@@ -6713,9 +7505,9 @@ async function createCommandContext(command, options) {
6713
7505
  import { createInterface as createInterface2 } from "readline/promises";
6714
7506
 
6715
7507
  // src/update/check.ts
6716
- import { mkdir as mkdir10, readFile as readFile19, writeFile } from "fs/promises";
7508
+ import { mkdir as mkdir12, readFile as readFile22, writeFile } from "fs/promises";
6717
7509
  import { homedir as homedir2 } from "os";
6718
- import { dirname as dirname10, join as join25 } from "path";
7510
+ import { dirname as dirname11, join as join30 } from "path";
6719
7511
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
6720
7512
  function getFetUpdateCheckMode(env = process.env) {
6721
7513
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -6788,11 +7580,11 @@ function formatFetUpdateWarning(availability, language) {
6788
7580
  }
6789
7581
  function cachePath() {
6790
7582
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
6791
- return join25(home, ".fet", "update-check-cache.json");
7583
+ return join30(home, ".fet", "update-check-cache.json");
6792
7584
  }
6793
7585
  async function readUpdateCheckCache() {
6794
7586
  try {
6795
- const raw = await readFile19(cachePath(), "utf8");
7587
+ const raw = await readFile22(cachePath(), "utf8");
6796
7588
  const parsed = JSON.parse(raw);
6797
7589
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
6798
7590
  return null;
@@ -6808,7 +7600,7 @@ async function readUpdateCheckCache() {
6808
7600
  }
6809
7601
  async function writeUpdateCheckCache(cache) {
6810
7602
  const path = cachePath();
6811
- await mkdir10(dirname10(path), { recursive: true });
7603
+ await mkdir12(dirname11(path), { recursive: true });
6812
7604
  await writeFile(path, `${JSON.stringify(cache, null, 2)}
6813
7605
  `, "utf8");
6814
7606
  }
@@ -6895,6 +7687,10 @@ for (const action of ["init", "refresh"]) {
6895
7687
  addGlobalOptions(program.command("doctor").description("\u8BCA\u65AD\u72B6\u6001\u3001\u914D\u7F6E\u4E0E\u4E00\u81F4\u6027").option("--fix-lock", "\u6E05\u7406 FET \u9501\u6587\u4EF6")).action(
6896
7688
  wrap("doctor", (ctx, options) => doctorCommand(ctx, { fixLock: Boolean(options.fixLock) }))
6897
7689
  );
7690
+ addGlobalOptions(program.command("tdd").description("\u6839\u636E\u89C4\u5212\u4EA7\u7269\u751F\u6210 change \u7EA7 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15")).action(wrap("tdd", tddCommand));
7691
+ 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
+ wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
7693
+ );
6898
7694
  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(
6899
7695
  wrap("verify", verifyCommand)
6900
7696
  );