speccore 5.87.2 → 5.89.0

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.
@@ -11,6 +11,56 @@ const task_utils_1 = require("../../utils/task-utils");
11
11
  const next_steps_1 = require("../../core/next-steps");
12
12
  const readline_1 = require("readline");
13
13
  const prompt_builder_1 = require("../../core/prompt-builder");
14
+ /** 将名称转为目录安全的短 slug(2-4 词) */
15
+ function slugify(name) {
16
+ return name
17
+ .replace(/[\u4e00-\u9fff]/g, '') // 去掉中文
18
+ .replace(/[^a-zA-Z0-9\s-]/g, '') // 去特殊字符
19
+ .split(/\s+/)
20
+ .filter(Boolean)
21
+ .slice(0, 3) // 最多 3 词
22
+ .join('-')
23
+ .toLowerCase() || 'task';
24
+ }
25
+ /** 粒度约束常量 */
26
+ const GRANULARITY_RULES = {
27
+ macro: { label: '粗粒度 (macro)', minHours: 20, maxHours: 80, maxApis: 15, maxTables: 5, maxPages: 5, desc: '每个任务 1-2 周,按业务方向合并' },
28
+ module: { label: '中粒度 (module)', minHours: 12, maxHours: 40, maxApis: 8, maxTables: 3, maxPages: 3, desc: '每个任务 3-5 天,按功能/端拆分' },
29
+ atomic: { label: '细粒度 (atomic)', minHours: 4, maxHours: 24, maxApis: 3, maxTables: 2, maxPages: 1, desc: '每个任务 1-3 天,按接口/表拆分' },
30
+ };
31
+ /** 校验任务工时是否在粒度范围内(按单人 max 工时计算) */
32
+ function validateGranularity(gran, hoursByPlatform, apiCount, tableCount) {
33
+ const rule = GRANULARITY_RULES[gran];
34
+ const warnings = [];
35
+ const platformEntries = Object.entries(hoursByPlatform);
36
+ const maxPerPerson = platformEntries.length > 0 ? Math.max(...platformEntries.map(([, h]) => h)) : 0;
37
+ const totalHours = platformEntries.reduce((sum, [, h]) => sum + h, 0);
38
+ const maxPlatform = platformEntries.length > 0 ? platformEntries.reduce((a, b) => (b[1] > a[1] ? b : a))[0] : '';
39
+ if (maxPerPerson > rule.maxHours) {
40
+ warnings.push(`⚠️ 单人最大工时 ${maxPerPerson}h(${maxPlatform})超出上限 ${rule.maxHours}h → 建议再拆`);
41
+ }
42
+ else if (maxPerPerson < rule.minHours) {
43
+ warnings.push(`⚠️ 单人最大工时 ${maxPerPerson}h(${maxPlatform})低于下限 ${rule.minHours}h → 建议合并到关联任务`);
44
+ }
45
+ if (apiCount > rule.maxApis)
46
+ warnings.push(`⚠️ 接口 ${apiCount} 个超出上限 ${rule.maxApis} → 建议按业务领域拆分`);
47
+ if (tableCount > rule.maxTables)
48
+ warnings.push(`⚠️ 数据表 ${tableCount} 张超出上限 ${rule.maxTables} → 建议按数据层拆分`);
49
+ // 返回额外信息供展示
50
+ if (platformEntries.length > 1) {
51
+ const breakdown = platformEntries.map(([p, h]) => `${p}:${h}h`).join(' + ');
52
+ warnings.unshift(`ℹ️ 工时分布: ${breakdown} = ${totalHours}h(max per person: ${maxPerPerson}h)`);
53
+ }
54
+ return warnings;
55
+ }
56
+ /** 根据团队规模推荐粒度 */
57
+ function recommendGranularity(teamSize) {
58
+ if (teamSize <= 3)
59
+ return 'macro';
60
+ if (teamSize <= 8)
61
+ return 'module';
62
+ return 'atomic';
63
+ }
14
64
  function promptUser(question) {
15
65
  const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stdout });
16
66
  return new Promise(resolve => {
@@ -20,7 +70,42 @@ function promptUser(question) {
20
70
  async function detectPlatforms(iterationDir, specified) {
21
71
  if (specified)
22
72
  return specified.split(',').map(p => p.trim()).filter(Boolean);
23
- // Detect from 020-specs/ subdirectories (populated by analyze)
73
+ // 1. 优先从 CONSTITUTION.md 读取「对应需求端」配置
74
+ const constitutionPath = (0, path_1.join)('.speccore', 'CONSTITUTION.md');
75
+ if (await (0, fs_extra_1.pathExists)(constitutionPath)) {
76
+ const content = await (0, fs_extra_1.readFile)(constitutionPath, 'utf-8');
77
+ const lines = content.split('\n');
78
+ let headerIdx = -1;
79
+ for (let i = 0; i < lines.length; i++) {
80
+ if (lines[i].includes('对应需求端')) {
81
+ headerIdx = i;
82
+ break;
83
+ }
84
+ }
85
+ if (headerIdx >= 0) {
86
+ const headers = lines[headerIdx].split('|').map(h => h.trim()).filter(Boolean);
87
+ const platformColIdx = headers.findIndex(h => h.includes('对应需求端'));
88
+ if (platformColIdx >= 0) {
89
+ const platforms = new Set();
90
+ for (let i = headerIdx + 1; i < lines.length; i++) {
91
+ const line = lines[i].trim();
92
+ if (!line.startsWith('|') || line.match(/^\|\s*[-:]/))
93
+ continue;
94
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
95
+ if (cells[platformColIdx]) {
96
+ cells[platformColIdx].split(',').forEach((p) => {
97
+ const trimmed = p.trim();
98
+ if (trimmed && !trimmed.startsWith('>'))
99
+ platforms.add(trimmed);
100
+ });
101
+ }
102
+ }
103
+ if (platforms.size > 0)
104
+ return [...platforms];
105
+ }
106
+ }
107
+ }
108
+ // 2. 回退:扫描 020-specs/ 子目录
24
109
  const specsDir = (0, path_1.join)(iterationDir, '020-specs');
25
110
  if (await (0, fs_extra_1.pathExists)(specsDir)) {
26
111
  const entries = await (0, fs_extra_1.readdir)(specsDir, { withFileTypes: true });
@@ -30,7 +115,7 @@ async function detectPlatforms(iterationDir, specified) {
30
115
  if (platforms.length > 0)
31
116
  return platforms;
32
117
  }
33
- return ['web']; // default
118
+ return ['web']; // 默认
34
119
  }
35
120
  async function iterationSplitCommand(options) {
36
121
  // ── Prompt 模式 ──
@@ -63,7 +148,8 @@ async function iterationSplitCommand(options) {
63
148
  const task = tasks[i];
64
149
  // 将 AI JSON 转换为 Section,复用 createTaskFromSection 创建完整目录
65
150
  const desc = task.description || task.name || '';
66
- const scope = Array.isArray(task.scope) ? task.scope.join(', ') : (task.scope || '');
151
+ const scopeArr = Array.isArray(task.scope) ? task.scope : [];
152
+ const scope = scopeArr.join(', ');
67
153
  const apis = Array.isArray(task.apis) ? task.apis.join('\n') : '';
68
154
  const acs = Array.isArray(task.acceptanceCriteria) ? task.acceptanceCriteria.join('\n') : '';
69
155
  let content = desc;
@@ -73,14 +159,22 @@ async function iterationSplitCommand(options) {
73
159
  content += `\n\n接口:\n${apis}`;
74
160
  if (acs)
75
161
  content += `\n\n验收标准:\n${acs}`;
162
+ // 从 scope 提取平台列表(后端 + 前端各端)
163
+ const taskScopePlatforms = [];
164
+ const isBackend = scopeArr.some((s) => /后端|backend/i.test(s));
165
+ const fePlatforms = scopeArr.filter((s) => !/后端|backend/i.test(s)).map((s) => s.trim()).filter(Boolean);
166
+ if (isBackend)
167
+ taskScopePlatforms.push('backend');
168
+ taskScopePlatforms.push(...fePlatforms);
76
169
  const section = {
77
170
  name: task.name || `Task ${i + 1}`,
78
171
  content,
79
172
  level: 2,
80
- platform: scope?.match(/后台|backend/) ? 'backend' : undefined,
173
+ platform: isBackend ? 'backend' : (fePlatforms[0] || undefined),
81
174
  };
82
175
  section._complexity = {
83
176
  estimatedHours: task.estimatedHours || 8,
177
+ hoursByPlatform: (task.hoursByPlatform && typeof task.hoursByPlatform === 'object') ? task.hoursByPlatform : {},
84
178
  priority: task.priority || 'medium',
85
179
  complexity: task.risk === 'high' ? 'high' : task.risk === 'low' ? 'low' : 'medium',
86
180
  apiCount: (task.apis || []).length,
@@ -89,6 +183,9 @@ async function iterationSplitCommand(options) {
89
183
  wordCount: content.length,
90
184
  };
91
185
  section._owner = task.owner || '未分配';
186
+ section._taskType = (task.type && ['feature', 'bugfix', 'refactor', 'research'].includes(task.type)) ? task.type : 'feature';
187
+ if (taskScopePlatforms.length > 0)
188
+ section._scopePlatforms = taskScopePlatforms;
92
189
  sections.push(section);
93
190
  }
94
191
  // 检测已有任务 + 冲突处理
@@ -98,17 +195,92 @@ async function iterationSplitCommand(options) {
98
195
  logger_1.logger.info(' 使用 --force 强制覆盖');
99
196
  return;
100
197
  }
101
- // 创建完整任务目录(复用本地拆分的完整流程)
102
- // 注意:始终走计数器,不使用 AI 提供的 ID,避免编号重复
198
+ // --force 清理旧任务(避免新旧叠加编号暴增)
199
+ if (options.force) {
200
+ const tasksRoot = (0, path_1.join)(iterDirFull, '030-tasks');
201
+ if (await (0, fs_extra_1.pathExists)(tasksRoot)) {
202
+ const entries = await (0, fs_extra_1.readdir)(tasksRoot, { withFileTypes: true });
203
+ for (const entry of entries) {
204
+ if (entry.isDirectory()) {
205
+ await (0, fs_extra_1.remove)((0, path_1.join)(tasksRoot, entry.name));
206
+ }
207
+ }
208
+ logger_1.logger.info(` 🗑 已清理旧任务目录`);
209
+ }
210
+ }
211
+ // 确定粒度
212
+ const staffing2 = readStaffing(iterDirFull);
213
+ const teamSize2 = staffing2 ? staffing2.length : 0;
214
+ const granularity = options.granularity || recommendGranularity(teamSize2);
215
+ const granRule = GRANULARITY_RULES[granularity];
216
+ const isInteractive = process.stdin.isTTY; // 非 TTY(管道调用)时自动确认
217
+ logger_1.logger.info(` 📏 粒度: ${granRule.label}${options.granularity ? ' (用户指定)' : ` (${teamSize2} 人团队自动推荐)`}`);
218
+ if (!isInteractive)
219
+ logger_1.logger.info(' ℹ️ 非交互终端,自动确认所有任务');
220
+ // 逐任务交互确认
221
+ const createdSections = [];
103
222
  for (let i = 0; i < sections.length; i++) {
104
- const { id: taskId } = await (0, global_counters_1.nextTaskId)(sections[i].name);
105
- sections[i]._taskId = taskId;
106
- await createTaskFromSection(iterDirFull, taskId, sections[i], allPlatforms);
107
- logger_1.logger.info(` ✅ 创建: ${taskId} - ${sections[i].name}`);
223
+ const sec = sections[i];
224
+ const complexity = sec._complexity || {};
225
+ const taskType = sec._taskType || 'feature';
226
+ const deps = (tasks[i].dependencies || []);
227
+ const acs = (tasks[i].acceptanceCriteria || []);
228
+ // 展示任务摘要
229
+ logger_1.logger.info(`\n ━━━━ 任务 ${i + 1}/${sections.length} ━━━━`);
230
+ logger_1.logger.info(` 📌 ${sec.name}`);
231
+ logger_1.logger.info(` 🏷 类型: ${taskType} | 🎯 优先级: ${complexity.priority || 'medium'}`);
232
+ // 按端展示工时分布
233
+ const hbp = complexity.hoursByPlatform || {};
234
+ const hbpEntries = Object.entries(hbp);
235
+ if (hbpEntries.length > 0) {
236
+ const breakdown = hbpEntries.map(([p, h]) => `${p}:${h}h`).join(' + ');
237
+ const maxPerPerson = Math.max(...hbpEntries.map(([, h]) => h));
238
+ logger_1.logger.info(` ⏱ 工时: ${breakdown} = ${complexity.estimatedHours}h(max per person: ${maxPerPerson}h)`);
239
+ }
240
+ else {
241
+ logger_1.logger.info(` ⏱ 预估: ${complexity.estimatedHours}h`);
242
+ }
243
+ if (complexity.apiCount)
244
+ logger_1.logger.info(` 🔌 接口: ${complexity.apiCount} 个 | 🗄 数据表: ${complexity.dbCount || 0} 张`);
245
+ if (deps.length > 0)
246
+ logger_1.logger.info(` 🔗 依赖: ${deps.join(', ')}`);
247
+ if (acs.length > 0) {
248
+ logger_1.logger.info(` ✅ 验收标准:`);
249
+ for (const ac of acs.slice(0, 5))
250
+ logger_1.logger.info(` ${ac}`);
251
+ }
252
+ // 粒度校验(按单人 max 工时)
253
+ const warnings = validateGranularity(granularity, hbp, complexity.apiCount || 0, complexity.dbCount || 0);
254
+ if (warnings.length > 0) {
255
+ for (const w of warnings) {
256
+ if (w.startsWith('ℹ️'))
257
+ logger_1.logger.info(` ${w}`);
258
+ else
259
+ logger_1.logger.warn(` ${w}`);
260
+ }
261
+ }
262
+ // 交互确认(仅确认,调整应回到 AI 对话重新生成方案)
263
+ if (isInteractive) {
264
+ const answer = await promptUser(` 确认创建?(y/回车确认,n 调整方案):`);
265
+ if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
266
+ logger_1.logger.info(` 💡 如需调整,请告诉 AI:`);
267
+ logger_1.logger.info(` "把 XX 和 YY 合为一个任务" / "ZZ 任务太大,拆成两个" / "修改工时为 Xh"`);
268
+ logger_1.logger.info(` AI 会参考 .speccore/prompts/split-suggestion-${iter}.md 中的规则重新生成`);
269
+ logger_1.logger.info(` 调整后再次执行本命令即可`);
270
+ return;
271
+ }
272
+ }
273
+ const { id: taskId } = await (0, global_counters_1.nextTaskId)(sec.name, tasks[i].topic);
274
+ sec._taskId = taskId;
275
+ await createTaskFromSection(iterDirFull, taskId, sec, allPlatforms, taskType);
276
+ createdSections.push(sec);
277
+ logger_1.logger.info(` ✅ 创建: ${taskId} - [${taskType}] ${sec.name}`);
278
+ }
279
+ if (createdSections.length > 0) {
280
+ await generateImpactGraph(iterDirFull, createdSections, allPlatforms);
281
+ await updateProjectGraph(iterDirFull, createdSections);
108
282
  }
109
- await generateImpactGraph(iterDirFull, sections, allPlatforms);
110
- await updateProjectGraph(iterDirFull, sections);
111
- logger_1.logger.success(`✅ 创建了 ${sections.length} 个任务(完整目录结构)`);
283
+ logger_1.logger.success(`✅ 创建了 ${createdSections.length}/${sections.length} 个任务(${sections.length - createdSections.length} 个跳过)`);
112
284
  }
113
285
  else {
114
286
  logger_1.logger.warn('AI 返回格式非数组,将作为 Markdown 写入 REQUIREMENT.md');
@@ -204,12 +376,9 @@ async function iterationSplitCommand(options) {
204
376
  // 粒度推荐(基于 STAFFING 人数)
205
377
  const staffing = readStaffing(iterationDir);
206
378
  const teamSize = staffing ? staffing.length : 0;
207
- const recommendedGranularity = options.granularity ||
208
- (teamSize <= 3 ? 'macro' : teamSize <= 8 ? 'module' : 'atomic');
209
- const granularityLabel = recommendedGranularity === 'macro' ? '\u7c97\u7c92\u5ea6 (macro)' :
210
- recommendedGranularity === 'module' ? '\u4e2d\u7c92\u5ea6 (module)' : '\u7ec6\u7c92\u5ea6 (atomic)';
211
- const granularityHint = recommendedGranularity === 'macro' ? '\u6bcf\u4e2a\u4efb\u52a1 1-2 \u5468\uff0c\u6309\u4e1a\u52a1\u65b9\u5411\u5408\u5e76' :
212
- recommendedGranularity === 'module' ? '\u6bcf\u4e2a\u4efb\u52a1 3-5 \u5929\uff0c\u6309\u529f\u80fd/\u7aef\u62c6\u5206' : '\u6bcf\u4e2a\u4efb\u52a1 1-3 \u5929\uff0c\u6309\u63a5\u53e3/\u8868\u62c6\u5206';
379
+ const recommendedGranularity = options.granularity || recommendGranularity(teamSize);
380
+ const granularityLabel = GRANULARITY_RULES[recommendedGranularity].label;
381
+ const granularityHint = GRANULARITY_RULES[recommendedGranularity].desc;
213
382
  // 构建完整 prompt
214
383
  let splitPrompt = buildSplitPrompt(iteration, constitutionContent, reqContent2, specContents, staffing, teamSize, granularityLabel, granularityHint);
215
384
  await (0, fs_extra_1.writeFile)((0, path_1.join)(promptsDir, `split-suggestion-${iteration}.md`), splitPrompt);
@@ -275,7 +444,7 @@ async function iterationSplitCommand(options) {
275
444
  }
276
445
  for (const section of approved) {
277
446
  const taskId = section._taskId;
278
- await createTaskFromSection(iterationDir, taskId, section, platforms);
447
+ await createTaskFromSection(iterationDir, taskId, section, platforms, section._taskType);
279
448
  }
280
449
  spinner.stop(`✅ 创建了 ${approved.length} 个任务`);
281
450
  return;
@@ -335,7 +504,7 @@ async function iterationSplitCommand(options) {
335
504
  break;
336
505
  }
337
506
  if (resp?.toLowerCase() === 'y' || resp === '') {
338
- await createTaskFromSection(iterationDir, taskId, sections[i], platforms);
507
+ await createTaskFromSection(iterationDir, taskId, sections[i], platforms, sections[i]._taskType);
339
508
  created++;
340
509
  logger_1.logger.info(` ✅ ${taskId}`);
341
510
  }
@@ -353,7 +522,7 @@ async function iterationSplitCommand(options) {
353
522
  // Default: create all
354
523
  for (let i = 0; i < sections.length; i++) {
355
524
  const taskId = sections[i]._taskId;
356
- await createTaskFromSection(iterationDir, taskId, sections[i], platforms);
525
+ await createTaskFromSection(iterationDir, taskId, sections[i], platforms, sections[i]._taskType);
357
526
  }
358
527
  await generateImpactGraph(iterationDir, sections, platforms);
359
528
  await generateEnvExample(iterationDir, sections);
@@ -365,7 +534,7 @@ async function iterationSplitCommand(options) {
365
534
  // Create tasks(使用预分配的 ID)
366
535
  for (let i = 0; i < sections.length; i++) {
367
536
  const taskId = sections[i]._taskId;
368
- await createTaskFromSection(iterationDir, taskId, sections[i], platforms);
537
+ await createTaskFromSection(iterationDir, taskId, sections[i], platforms, sections[i]._taskType);
369
538
  }
370
539
  // ── Generate impact graph + risk scores ──
371
540
  await generateImpactGraph(iterationDir, sections, platforms);
@@ -465,15 +634,16 @@ function filterTemplateNoise(sections) {
465
634
  return true;
466
635
  });
467
636
  }
468
- async function createTaskFromSection(iterationDir, taskId, section, allPlatforms) {
469
- const taskDir = (0, path_1.join)(iterationDir, '030-tasks', taskId);
470
- const taskPlatforms = section.platform ? [section.platform] : allPlatforms;
637
+ async function createTaskFromSection(iterationDir, taskId, section, allPlatforms, taskType = 'feature') {
638
+ // taskId 已含 slug(nextTaskId 返回 Task-NNN-slug),直接用
639
+ const taskDir = (0, path_1.join)(iterationDir, '030-tasks', taskType, taskId);
640
+ const taskPlatforms = section._scopePlatforms || (section.platform ? [section.platform] : allPlatforms);
471
641
  const complexity = section._complexity || { estimatedHours: 2, priority: 'medium', complexity: 'medium', apiCount: 0, dbCount: 0, pageCount: 0, wordCount: 0 };
472
642
  const owner = section._owner || '未分配';
473
643
  const today = new Date().toISOString().split('T')[0];
474
644
  // ── 1. 元信息目录 ──
475
645
  await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '.meta'));
476
- await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '.meta', 'type'), 'feature');
646
+ await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '.meta', 'type'), taskType);
477
647
  await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '.meta', 'status'), 'todo');
478
648
  await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '.meta', 'owner'), owner);
479
649
  await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '.meta', 'created-at'), today);
@@ -605,7 +775,7 @@ ${apiDesc}
605
775
  await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '00-specs', 'TASK.md'), `# ${section.name}
606
776
 
607
777
  ## 任务信息
608
- - 类型: feature
778
+ - 类型: ${taskType}
609
779
  - 状态: 🔲 待开发
610
780
  - 优先级: ${complexity.priority}
611
781
  - 负责人: ${owner}
@@ -665,19 +835,26 @@ ${apiDesc}
665
835
  if (adr) {
666
836
  await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '99-artifacts', 'ADR.md'), adr);
667
837
  }
668
- // ── 5. 实现目录 10-backend/ 20-20-frontend/ ──
838
+ // ── 5. 实现目录 10-backend/ 20-frontend/ ──
669
839
  for (const platform of taskPlatforms) {
670
- if (platform.startsWith('后台') || platform === 'backend') {
840
+ if (platform === 'backend') {
841
+ // 纯后端任务:直接创建 10-backend/src/ 和 10-backend/tests/
842
+ await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', 'src'));
843
+ await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', 'tests'));
844
+ }
845
+ else if (platform.startsWith('后台')) {
846
+ // 后台服务任务:创建 10-backend/{service}/src/ 和 tests/
671
847
  const service = platform.replace(/^后台/, '').trim() || 'default';
672
- await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', service || platform, 'src'));
673
- await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', service || platform, 'tests'));
848
+ await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', service, 'src'));
849
+ await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', service, 'tests'));
674
850
  }
675
851
  else {
852
+ // 前端任务:创建 20-frontend/{platform}/src/ 和 tests/
676
853
  await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '20-frontend', platform, 'src'));
677
854
  await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '20-frontend', platform, 'tests'));
678
855
  }
679
856
  }
680
- if (!taskPlatforms.some(p => p.startsWith('后台'))) {
857
+ if (!taskPlatforms.some((p) => p === 'backend' || p.startsWith('后台'))) {
681
858
  await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', 'src'));
682
859
  await (0, fs_extra_1.ensureDir)((0, path_1.join)(taskDir, '10-backend', 'tests'));
683
860
  }
@@ -688,7 +865,16 @@ ${apiDesc}
688
865
  const testContent = await (0, fs_extra_1.readFile)((0, path_1.join)(taskDir, '99-artifacts', 'TEST.md'), 'utf-8');
689
866
  const reviewContent = await (0, fs_extra_1.readFile)((0, path_1.join)(taskDir, '99-artifacts', 'REVIEW.md'), 'utf-8');
690
867
  for (const platform of taskPlatforms) {
691
- if (platform.startsWith('后台') || platform === 'backend') {
868
+ if (platform === 'backend') {
869
+ // 纯后端任务:规格写入 10-backend/ 根目录
870
+ await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '10-backend', 'REQ.md'), reqContent);
871
+ await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '10-backend', 'TECH.md'), techContent);
872
+ await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '10-backend', 'TASK.md'), taskContent);
873
+ await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '10-backend', 'TEST.md'), testContent);
874
+ await (0, fs_extra_1.writeFile)((0, path_1.join)(taskDir, '10-backend', 'REVIEW.md'), reviewContent);
875
+ }
876
+ else if (platform.startsWith('后台')) {
877
+ // 后台服务任务:规格写入 10-backend/{service}/
692
878
  const service = platform.replace(/^后台/, '').trim() || platform;
693
879
  const svcDir = (0, path_1.join)(taskDir, '10-backend', service);
694
880
  await (0, fs_extra_1.ensureDir)(svcDir);
@@ -699,6 +885,7 @@ ${apiDesc}
699
885
  await (0, fs_extra_1.writeFile)((0, path_1.join)(svcDir, 'REVIEW.md'), reviewContent);
700
886
  }
701
887
  else {
888
+ // 前端任务:规格写入 20-frontend/{platform}/
702
889
  const feDir = (0, path_1.join)(taskDir, '20-frontend', platform);
703
890
  await (0, fs_extra_1.ensureDir)(feDir);
704
891
  await (0, fs_extra_1.writeFile)((0, path_1.join)(feDir, 'REQ.md'), reqContent);
@@ -727,7 +914,7 @@ async function updateProjectGraph(iterationDir, sections) {
727
914
  while (/端端/.test(taskName))
728
915
  taskName = taskName.replace('端端', '端');
729
916
  if (!content.includes(taskId)) {
730
- const taskEntry = `| ${taskId} | ${taskName} | feature | 0% | 🔲 待开发 | |\n`;
917
+ const taskEntry = `| ${taskId} | ${taskName} | ${sections[i]._taskType || 'feature'} | 0% | 🔲 待开发 | |\n`;
731
918
  content = content.replace('| 任务编号 | 任务名称 | 类型 | 进度 | 状态 | 负责人 |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n', `| 任务编号 | 任务名称 | 类型 | 进度 | 状态 | 负责人 |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n${taskEntry}`);
732
919
  }
733
920
  }
@@ -938,7 +1125,8 @@ async function generateImpactGraph(iterationDir, sections, platforms) {
938
1125
  const taskId = s._taskId || `Task-${String(i + 1).padStart(3, '0')}`;
939
1126
  const risk = await (0, risk_scorer_1.scoreRisk)(s.content + s.name, s.name, iterationDir);
940
1127
  impact += `| ${taskId}: ${s.name} | ${risk.level} | ${risk.score} | ${risk.tags.join(' ')} | ${risk.reasons.join('; ')} |\n`;
941
- const taskDir = (0, path_1.join)(iterationDir, '030-tasks', taskId);
1128
+ const taskType = s._taskType || 'feature';
1129
+ const taskDir = (0, path_1.join)(iterationDir, '030-tasks', taskType, taskId);
942
1130
  if (await (0, fs_extra_1.pathExists)(taskDir)) {
943
1131
  // 生成风险报告并嵌入 TASK.md(去重:只写一次)
944
1132
  const taskMdPath = (0, path_1.join)(taskDir, '00-specs', 'TASK.md');
@@ -1331,9 +1519,21 @@ function buildSplitPrompt(iteration, constitutionContent, reqContent, specConten
1331
1519
  }
1332
1520
  p += `\n检测到 ${teamSize} 人团队,推荐粒度: ${granularityLabel}\n\n---\n\n`;
1333
1521
  }
1334
- // 粒度说明
1522
+ // 粒度说明(含硬约束)
1335
1523
  p += `## 🎯 拆分粒度: ${granularityLabel}\n\n`;
1336
1524
  p += `${granularityHint}\n\n`;
1525
+ p += `### 当前粒度硬约束(必须严格遵守)\n`;
1526
+ p += `> ⚠️ 工时约束按 **max(各端工时)** 计算,即单个开发人员的实际工作量,不是所有端的总和\n\n`;
1527
+ if (granularityLabel.includes('粗')) {
1528
+ p += `- 每人工时: 20-80h(1-2 周)\n- 接口上限: 15 个/任务\n- 数据表上限: 5 张/任务\n- 页面上限: 5 个/任务\n`;
1529
+ }
1530
+ else if (granularityLabel.includes('中')) {
1531
+ p += `- 每人工时: 12-40h(3-5 天)\n- 接口上限: 8 个/任务\n- 数据表上限: 3 张/任务\n- 页面上限: 3 个/任务\n`;
1532
+ }
1533
+ else {
1534
+ p += `- 每人工时: 4-24h(1-3 天)\n- 接口上限: 3 个/任务\n- 数据表上限: 2 张/任务\n- 页面上限: 1 个/任务\n`;
1535
+ }
1536
+ p += `\n**超出上限必须再拆,低于下限必须合并。**\n\n`;
1337
1537
  p += `用户可通过 --granularity macro|module|atomic 调整全局粒度。\n\n`;
1338
1538
  // SpecCore 拆分原则
1339
1539
  p += `## ⚙️ SpecCore 拆分原则\n\n`;
@@ -1346,34 +1546,43 @@ function buildSplitPrompt(iteration, constitutionContent, reqContent, specConten
1346
1546
  p += `- execute 时不强依赖其他 Task 的运行时状态\n`;
1347
1547
  p += `- 有明确的验收标准(AC 可枚举)\n`;
1348
1548
  p += `- 可独立提 PR、独立 review\n\n`;
1349
- p += `### 合并规则\n`;
1549
+ p += `### 合并规则(优先合并,减少任务数)\n`;
1350
1550
  p += `- 同一数据实体的 CRUD → 共享数据模型,合并为 1 个任务\n`;
1351
1551
  p += `- 页面 + 对应后端接口 < 5 个 → 前后端强耦合,一人做效率最高\n`;
1352
1552
  p += `- 纯配置/文案/样式微调 → 不构成独立工作单元\n`;
1353
- p += `- 关联紧密的小功能(如列表页 + 详情页)→ 共享路由和状态\n\n`;
1553
+ p += `- 关联紧密的小功能(如列表页 + 详情页)→ 共享路由和状态\n`;
1554
+ p += `- **复杂度判断**:如果一个需求章节接口 ≤ 3、数据表 ≤ 1、预估工时 < 粒度下限 → 必须合并到最相关的任务,不单独拆\n`;
1555
+ p += `- **宁少勿多**:任务数越少越好,每个任务应该是真正独立的工作单元。如果两个功能共享数据模型或路由,一人就能做完,不要拆\n\n`;
1354
1556
  p += `### 拆分规则\n`;
1355
- p += `- 接口 > 8 个 → 按业务领域拆\n`;
1356
- p += `- 涉及 > 3 张新表 → 按数据层拆\n`;
1357
- p += `- 超出粒度时间上限 → 必须再拆\n`;
1557
+ p += `- 超出当前粒度接口上限 → 按业务领域拆\n`;
1558
+ p += `- 超出当前粒度数据表上限 → 按数据层拆\n`;
1559
+ p += `- 超出当前粒度工时上限 → 必须再拆\n`;
1560
+ p += `- 低于当前粒度工时下限 → 合并到关联任务\n`;
1358
1561
  p += `- 跨端功能 → 按端拆(后端 1 个 + 每个前端各 1 个)\n`;
1359
1562
  p += `- 独立第三方集成(支付/短信/OSS)→ 独立任务\n\n`;
1360
1563
  p += `### 依赖关系\n`;
1361
1564
  p += `- 基础模块(认证/数据库/配置)优先拆出,作为第一批任务\n`;
1362
1565
  p += `- 依赖链深度 ≤ 3\n`;
1363
1566
  p += `- 同层级无循环依赖\n\n`;
1567
+ p += `### 总量约束\n`;
1568
+ p += `- 单次迭代总任务数: 3-15 个(超出说明粒度不合适)\n`;
1569
+ p += `- 每个任务必须有明确的 owner(对应 STAFFING 中的成员)\n`;
1570
+ p += `- 高优先级任务排在前面\n\n`;
1364
1571
  // 输出格式
1365
1572
  p += `## 📤 输出格式\n\n`;
1366
1573
  p += `请输出 JSON 数组,每个 Task 包含:\n`;
1367
1574
  p += '```json\n';
1368
1575
  p += `[\n {\n`;
1369
1576
  p += ` "id": "Task-001",\n`;
1370
- p += ` "name": "任务名称",\n`;
1577
+ p += ` "name": "任务名称(中文)",\n`;
1578
+ p += ` "topic": "english-slug-for-directory",\n`;
1371
1579
  p += ` "type": "feature|bugfix|refactor|research",\n`;
1372
1580
  p += ` "reason": "为什么这样拆分",\n`;
1373
1581
  p += ` "scope": ["后端", "admin"],\n`;
1374
1582
  p += ` "apis": ["POST /api/auth/login"],\n`;
1375
1583
  p += ` "tables": ["users"],\n`;
1376
- p += ` "estimatedHours": 8,\n`;
1584
+ p += ` "hoursByPlatform": { "后端": 8, "admin": 8 },\n`;
1585
+ p += ` "estimatedHours": 16,\n`;
1377
1586
  p += ` "priority": "high|medium|low",\n`;
1378
1587
  p += ` "dependencies": [],\n`;
1379
1588
  p += ` "acceptanceCriteria": ["AC1: ..."],\n`;
@@ -1381,15 +1590,23 @@ function buildSplitPrompt(iteration, constitutionContent, reqContent, specConten
1381
1590
  p += ` "owner": "建议负责人"\n`;
1382
1591
  p += ` }\n]\n`;
1383
1592
  p += '```\n\n';
1593
+ p += `> **topic** 必须是英文短横线格式(如 \`user-authentication\`、\`product-crud\`),用于生成任务目录名 Task-NNN-{topic}\n\n`;
1594
+ p += `### ⚠️ 工时估算规则(重要)\n\n`;
1595
+ p += `- **hoursByPlatform**: 按端分别估算工时,key 对应 scope 中的端名称\n`;
1596
+ p += `- **estimatedHours**: 各端工时总和(仅用于展示,不参与粒度校验)\n`;
1597
+ p += `- **粒度校验用 max(各端工时)**:衡量「一个开发人员实际干多少」,不是总和\n`;
1598
+ p += `- 例:后端 8h + admin 8h = total 16h,但 per-person max = 8h,按 8h 判断粒度\n`;
1599
+ p += `- 同一功能的前后端各端工作必须在一个原子任务里,不要按端拆分任务\n\n`;
1384
1600
  // 质量自检
1385
- p += `## ✅ 质量自检\n\n`;
1386
- p += `拆分完成后自查:\n`;
1601
+ p += `## ✅ 质量自检(必须全部通过)\n\n`;
1387
1602
  p += `□ 每个任务都满足原子任务定义?\n`;
1388
- p += `□ 没有超出粒度时间上限的任务?\n`;
1603
+ p += `□ 每个任务的 estimatedHours 在当前粒度范围内?(不满足 → 合并或再拆)\n`;
1389
1604
  p += `□ 没有循环依赖?\n`;
1390
1605
  p += `□ 基础模块排在前面?\n`;
1391
- p += `□ 同领域功能没被过度拆分?\n`;
1392
- p += `□ 总任务数合理(3-15 个/迭代)?\n`;
1606
+ p += `□ 同领域功能没被过度拆分?(同一数据实体的 CRUD 必须合并)\n`;
1607
+ p += `□ 总任务数在 3-15 个范围内?\n`;
1608
+ p += `□ 每个任务都有明确的 owner 和 acceptanceCriteria?\n`;
1609
+ p += `□ 每个任务都能独立提 PR、独立 review?\n`;
1393
1610
  // 自动模式指令
1394
1611
  p += `## 🤖 自动模式指令\n\n`;
1395
1612
  p += `本拆分在自动模式下执行,请遵循以下原则:\n`;
@@ -1404,6 +1621,13 @@ function buildSplitPrompt(iteration, constitutionContent, reqContent, specConten
1404
1621
  `;
1405
1622
  p += `3. **遇阻断就跳过** — 如果某个功能模块信息不足无法拆分,跳过它并在疑问清单中记录\n`;
1406
1623
  p += `4. **输出 JSON** — 直接输出拆分结果的 JSON 数组,不要输出其他内容\n`;
1624
+ // 持久指令(用户调整时 AI 可回读此文件)
1625
+ p += `\n## 🔄 调整指令(持久有效)\n\n`;
1626
+ p += `当用户要求调整拆分方案时(如“合并”“拆分”“改工时”“改优先级”):\n`;
1627
+ p += `1. **先重读本文件** — 本文件包含完整的拆分规则、粒度约束、端配置、Spec 上下文\n`;
1628
+ p += `2. **在同一套规则下调整** — 合并/拆分/修改都必须遵守粒度硬约束\n`;
1629
+ p += `3. **重新输出完整 JSON** — 不要只输出修改的部分,输出调整后的完整数组\n`;
1630
+ p += `4. **文件路径**: \`.speccore/prompts/split-suggestion-${iteration}.md\`\n\n`;
1407
1631
  return p;
1408
1632
  }
1409
1633
  /**
@@ -1533,15 +1757,24 @@ async function detectExistingTasks(iterDir) {
1533
1757
  // 优先从 030-tasks/ 扫描,兼容旧布局(迭代根目录)
1534
1758
  const scanDir = (0, path_1.join)(iterDir, '030-tasks');
1535
1759
  const targetDir = (await (0, fs_extra_1.pathExists)(scanDir)) ? scanDir : iterDir;
1536
- try {
1537
- const entries = require('fs').readdirSync(targetDir, { withFileTypes: true });
1538
- for (const e of entries) {
1539
- if (e.isDirectory() && e.name.startsWith('Task-')) {
1540
- tasks.push(e.name);
1760
+ const scanRecursive = async (dir) => {
1761
+ try {
1762
+ const entries = await (0, fs_extra_1.readdir)(dir, { withFileTypes: true });
1763
+ for (const e of entries) {
1764
+ if (e.isDirectory()) {
1765
+ if (e.name.startsWith('Task-')) {
1766
+ tasks.push(e.name);
1767
+ }
1768
+ else if (!e.name.startsWith('.')) {
1769
+ // 递归扫描类型子目录(feature/bugfix/refactor/research)
1770
+ await scanRecursive((0, path_1.join)(dir, e.name));
1771
+ }
1772
+ }
1541
1773
  }
1542
1774
  }
1543
- }
1544
- catch { }
1775
+ catch { }
1776
+ };
1777
+ await scanRecursive(targetDir);
1545
1778
  return tasks;
1546
1779
  }
1547
1780
  //# sourceMappingURL=split.js.map