@cloud411716/fancy-webnovel 3.0.3 → 4.0.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.
Files changed (63) hide show
  1. package/docs/material-library/README.md +6 -5
  2. package/docs/material-library/prompt.md +3 -2
  3. package/docs/workspace-mcp.md +2 -2
  4. package/lib/material-topic.js +248 -0
  5. package/lib/topic-candidates.js +157 -0
  6. package/package.json +4 -2
  7. package/plugins/fancy-analyze/analyze-definitions.md +35 -35
  8. package/plugins/fancy-analyze/templates.js +3 -3
  9. package/plugins/fancy-import/prompts/01-import-prompt.md +20 -20
  10. package/plugins/fancy-material/genres.js +4 -36
  11. package/plugins/fancy-material/index.js +18 -32
  12. package/plugins/fancy-material/templates.js +11 -3
  13. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +16 -23
  14. package/plugins/fancy-scan/scripts/report-validate.js +39 -76
  15. package/plugins/fancy-scan/templates/report-schema.js +11 -8
  16. package/plugins/fancy-scan/templates.js +2 -2
  17. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +40 -28
  18. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +28 -10
  19. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +21 -9
  20. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +15 -15
  21. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +18 -18
  22. package/plugins/fancy-story-builder/purpose-template.md +1 -1
  23. package/plugins/fancy-story-builder/references/glossary.md +8 -8
  24. package/plugins/fancy-story-builder/references/index.md +2 -2
  25. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +2 -2
  26. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +2 -2
  27. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +2 -2
  28. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +2 -2
  29. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +2 -2
  30. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +2 -2
  31. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +2 -2
  32. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +2 -2
  33. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +2 -2
  34. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +7 -7
  35. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +6 -6
  36. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +2 -2
  37. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +2 -2
  38. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +2 -2
  39. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +2 -2
  40. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +2 -2
  41. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +5 -5
  42. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +2 -2
  43. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +1 -1
  44. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +2 -2
  45. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +2 -2
  46. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +5 -5
  47. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +3 -3
  48. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +3 -3
  49. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +1 -1
  50. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +2 -2
  51. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +1 -1
  52. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +3 -3
  53. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +2 -2
  54. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +2 -2
  55. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +2 -2
  56. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +1 -1
  57. package/plugins/fancy-story-builder/storydb/README.md +4 -4
  58. package/plugins/fancy-story-builder/storydb/prompts.js +1 -1
  59. package/plugins/fancy-topic/index.js +216 -546
  60. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +6 -5
  61. package/plugins/fancy-topic/prompts/02-setup-prompt.md +112 -0
  62. package/plugins/fancy-topic/scripts/save-minimal-topic.js +97 -0
  63. package/plugins/fancy-topic/templates.js +104 -6
@@ -7,19 +7,21 @@
7
7
  *
8
8
  * 不论哪条路径,都先检查 .fancy-deployed 存在且 phase ∈ {scanned, topic_done}。
9
9
  */
10
- import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, mkdirSync } from 'fs';
10
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
11
11
  import { join, resolve } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
14
14
  import { readDeployedState, deployedStateProblemText, DEPLOYED_STATUS } from '#deployed-state';
15
15
  import { repairTopicDecision, selectNonRedundantBlocks, inspectBlockBodies } from '#topic-decision';
16
16
  import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
17
- import { normalizeTopicData } from '#topic-schema';
17
+ import { parseDecisionCandidates } from '#topic-candidates';
18
+ import { NOVEL_SUGGESTION_FILE, validateSuggestion, validateMinimalTopic, readSuggestion } from '#material-topic';
19
+ import { atomicWriteJSON } from '#atomic-write';
18
20
  import { resolveWorkspace } from '#workspace';
19
21
  import { topic } from './templates.js';
20
- import { deployTopicTools } from './tool-deploy.js';
22
+ import { deployMaterialTools } from '../fancy-material/tool-deploy.js';
21
23
 
22
- export const inject = ['commands', 'userQuestions'];
24
+ export const inject = ['commands', 'userQuestions', 'tools'];
23
25
 
24
26
  /**
25
27
  * select-topic.js 的绝对路径。
@@ -29,10 +31,11 @@ export const inject = ['commands', 'userQuestions'];
29
31
  * 拼出来的路径不存在,spawnScript 必然报 MODULE_NOT_FOUND,
30
32
  * 选题根本写不进去。run-scan.js 里同一个坑已经修过。
31
33
  */
32
- const SELECT_TOPIC_SCRIPT = join(
34
+
35
+ const SAVE_MINIMAL_TOPIC_SCRIPT = join(
33
36
  fileURLToPath(new URL('.', import.meta.url)),
34
37
  'scripts',
35
- 'select-topic.js'
38
+ 'save-minimal-topic.js'
36
39
  );
37
40
 
38
41
  /**
@@ -301,27 +304,8 @@ function platformEqual(a, b) {
301
304
  // ---------------------------------------------------------------------------
302
305
 
303
306
  async function pathExplore(ctx, session, invocation, signal, projectRoot) {
304
- // 弹框 1:选平台
305
- if (signal?.aborted) {
306
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
307
- }
308
- const askedplatformChoice = await askUserQuestion(ctx, invocation, [topic.askPlatform()]);
309
- if (!askedplatformChoice.ok) {
310
- return notify(session, 'error', topic.notify({
311
- type: askedplatformChoice.failure.type,
312
- err: askedplatformChoice.failure.message
313
- }));
314
- }
315
- const platformChoice = askedplatformChoice.answer;
316
- if (!platformChoice?.answers?.[0]) {
317
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
318
- }
319
- const selectedPlatform = platformChoice.answers[0]?.selected?.[0];
320
- if (!selectedPlatform) {
321
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
322
- }
323
-
324
- // 扫描 扫榜结果/ 目录下的 topic_decision_<date>.md 文件
307
+ // 路径 A 不再调用 LLM:
308
+ // 选 topic_decision 文件 → 选该文件里已有的平台 → 选候选方向 → 写 suggestion。
325
309
  const scanDir = join(projectRoot, '扫榜结果');
326
310
  let decisionFiles = [];
327
311
  if (existsSync(scanDir)) {
@@ -342,7 +326,6 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
342
326
  try {
343
327
  decisionFiles = readdirSync(scanDir)
344
328
  .filter(f => /^topic_decision_\d{8}\.md$/.test(f))
345
- // 不跟随符号链接:决策文件必须在工作区目录内,防止读到项目外的内容。
346
329
  .filter(f => {
347
330
  try {
348
331
  const st = lstatSync(join(scanDir, f));
@@ -350,7 +333,7 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
350
333
  } catch { return false; }
351
334
  })
352
335
  .sort()
353
- .reverse(); // 最新的在前
336
+ .reverse();
354
337
  } catch (e) {
355
338
  return notify(session, 'error', topic.notify({
356
339
  type: 'read_failed',
@@ -363,25 +346,17 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
363
346
  return notify(session, 'error', topic.notify({ type: 'no_decision_file' }));
364
347
  }
365
348
 
366
- // 弹框 2:选 topic_decision 文件
367
349
  if (signal?.aborted) {
368
350
  return notify(session, 'error', topic.notify({ type: 'cancelled' }));
369
351
  }
370
- const askedfileChoice = await askUserQuestion(ctx, invocation, [topic.askDecisionFile(decisionFiles)]);
371
- if (!askedfileChoice.ok) {
352
+ const askedFile = await askUserQuestion(ctx, invocation, [topic.askDecisionFile(decisionFiles)]);
353
+ if (!askedFile.ok) {
372
354
  return notify(session, 'error', topic.notify({
373
- type: askedfileChoice.failure.type,
374
- err: askedfileChoice.failure.message
355
+ type: askedFile.failure.type,
356
+ err: askedFile.failure.message,
375
357
  }));
376
358
  }
377
- const fileChoice = askedfileChoice.answer;
378
- if (!fileChoice?.answers?.[0]) {
379
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
380
- }
381
- const selectedFile = fileChoice.answers[0]?.selected?.[0];
382
-
383
- // DSH 回传的是 label(不是 value),此处必须按 label 判断,
384
- // 此前用 value:'__rescan__' 判断导致该分支永远不可达。
359
+ const selectedFile = askedFile.answer?.answers?.[0]?.selected?.[0];
385
360
  if (selectedFile === topic.RESCAN_LABEL) {
386
361
  return notify(session, 'success', topic.notify({ type: 'path_a_rescan' }));
387
362
  }
@@ -389,27 +364,8 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
389
364
  return notify(session, 'error', topic.notify({ type: 'cancelled' }));
390
365
  }
391
366
 
392
- // 读取 topic_decision_<date>.md
393
- const decisionPath = join(projectRoot, '扫榜结果', selectedFile);
367
+ const decisionPath = join(scanDir, selectedFile);
394
368
  let content;
395
-
396
- // 2026-10-27:读之前先校验一遍有没有被覆盖。
397
- //
398
- // 写这个文件的是 LLM,write 工具是覆盖语义 —— 实测(tt5 项目)
399
- // 第二个平台分析时模型先 write("") 清空文件再 cat >> 追加,
400
- // 第一个平台的分析就此消失。fancy-scan 现在投递前会备份,
401
- // 这里拿备份比对,把被覆盖掉的段落自动补回来。
402
- let repairedKeys = [];
403
- try {
404
- const repair = repairTopicDecision(decisionPath);
405
- if (repair.repaired.length) {
406
- repairedKeys = repair.repaired;
407
- console.error(`[fancy-topic] topic_decision 缺失 ${repairedKeys.length} 个内容块,已从备份补回:${repairedKeys.join('、')}`);
408
- }
409
- } catch (e) {
410
- console.error('[fancy-topic] 校验 topic_decision 失败:' + (e && e.message ? e.message : String(e)));
411
- }
412
-
413
369
  try {
414
370
  content = readFileSync(decisionPath, 'utf-8');
415
371
  } catch (e) {
@@ -419,248 +375,80 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
419
375
  }));
420
376
  }
421
377
 
422
- // 空文件不能走“只扫过一个平台 → 整份文件都归它”的降级分支:
423
- // 那会把空字符串当成数据发给 LLM,产出建立在空白上的选题。
424
- // 只有 start/end 标记、没有正文的文件同样算空。
425
- const meaningful = String(content)
426
- .split('\n')
427
- .filter(l => !/^\s*#\s*[^\n]*-(start|end)\s*$/i.test(l))
428
- .filter(l => !/^\s*(?:`{3,}|~{3,}).*$/.test(l))
429
- .join('')
430
- .trim();
431
- if (!meaningful) {
378
+ const { platforms } = parseDecisionCandidates(content);
379
+ if (!platforms.length) {
432
380
  return notify(session, 'error', topic.notify({
433
- type: 'empty_decision_file',
381
+ type: 'no_directions',
434
382
  decisionFile: selectedFile,
435
383
  }));
436
384
  }
437
385
 
438
- // 按平台归属切段。段边界由标题行决定,不靠 -start/-end 配对 ——
439
- // 靠配对会让漏写 -end 的那段吞掉别的平台的内容(实测串台)。
440
- const sections = splitDecisionSections(content);
441
- const platformCnName = platformCn(selectedPlatform);
442
- const platformValue = platformToValue(selectedPlatform);
443
-
444
- let matchedSections = filterSectionsByPlatform(sections, platformValue);
445
- const notes = [];
446
-
447
- // ── 先验正文,再去冗余 ────────────────────────────────────────────
448
- //
449
- // 顺序反了会丢块(2026-10-29 审计):
450
- //
451
- // 1. 先 selectNonRedundantBlocks —— 后出现的"全选"块(范围宽)
452
- // 覆盖前面的"单榜"块(范围窄),单榜块被丢弃;
453
- // 2. 再 inspectBlockBodies —— 这才发现"全选"块的正文没写进去
454
- // (占位符残留 / 正文为空),把它剔除;
455
- // 3. usable 为空 → 报 block_body_invalid。
456
- //
457
- // 明明"单榜"块是好的,却因为先被去冗余丢掉而报失败。
458
- // 正确顺序:**先把没写进去的块剔掉,再在剩下的块里算冗余**。
459
- //
460
- // 实测(2026-10-28):模型把 5.2 的 heredoc 命令原样跑了一遍,
461
- // `<本次正文>` 占位符原封不动进了文件,而 start/end 两个标记都写对了。
462
- // 于是 start=1 且 end=1 被判成"写入成功",这段占位文字又被原样发给 LLM
463
- // 做选题 —— 选题结论建立在"字都没写"的块上。
464
- //
465
- // 模型看不见自己写进去的东西,所以这个判定必须由插件做(插件算得出来)。
466
- // 有问题的块**不发给 LLM**:宁可少发一块,也不能让垃圾进选题。
467
- {
468
- const bad = inspectBlockBodies(content);
469
- // 只看**本次选中平台**的块。文件里别的平台的坏块不应让本次报
470
- // “block_body_invalid”,也不应出现在本次的提示里。
471
- const relevantBad = bad.filter(b => {
472
- if (!b.key) return false;
473
- return matchedSections.some(s => String((s.headers && s.headers[0]) || '').includes(b.key));
474
- });
475
- // 只有**确凿**的失败(占位符残留 / 正文为空 / 只剩命令痕迹)才拦住派发;
476
- // "偏短""没有结构标题"只是可疑 —— 报告本来就可能短,
477
- // 把"我觉得不合格"当成"这是坏的"会误伤正常内容,所以只提示。
478
- const hardBad = relevantBad.filter(b => b.reasons && b.reasons.length);
479
- const softBad = relevantBad.filter(b => !b.reasons || !b.reasons.length);
480
-
481
- if (softBad.length) {
482
- notes.push(
483
- `有 ${softBad.length} 个内容块看起来偏薄(${softBad.map(b => b.warnings.join(';')).join(';')}),` +
484
- '已照常使用 —— 如果内容明显不对,可以手动检查一下。'
485
- );
486
- }
487
-
488
- if (hardBad.length) {
489
- const usable = matchedSections.filter(
490
- s => !hardBad.some(b => b.key && String(s.headers[0] || '').includes(b.key))
491
- );
492
- if (usable.length) {
493
- notes.push(
494
- `有 ${hardBad.length} 个内容块的正文没写进去(${hardBad.map(b => b.reasons.join(';')).join(';')}),` +
495
- '已跳过它们,只把能用的块发给 LLM;文件里的原始内容没有删除。'
496
- );
497
- matchedSections = usable;
498
- } else {
499
- // 所有块都没内容 —— 直接失败,别拿垃圾去做选题
500
- return notify(session, 'error', topic.notify({
501
- type: 'block_body_invalid',
502
- reasons: hardBad.map(b => `「${b.key}」:${b.reasons.join(';')}`),
503
- }));
504
- }
505
- }
386
+ if (signal?.aborted) {
387
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
506
388
  }
507
-
508
- // 同平台的块去冗余 ——
509
- //
510
- // 同一平台一天里可能被扫多次,每次追加一个标记块。文件里两块都留着
511
- // (数据不丢),但把两块**一起**发给 LLM 就是冗余:榜单范围窄的那块
512
- // 已经被范围宽的那块覆盖,同一批数据会被统计两遍。
513
- //
514
- // 去冗余只影响"发给 LLM 的内容",**不删文件里的任何数据**。
515
- if (matchedSections.length > 1) {
516
- const { kept, dropped } = selectNonRedundantBlocks(
517
- matchedSections,
518
- (s) => (s.headers && s.headers[0]) || ''
519
- );
520
- if (dropped.length) {
521
- matchedSections = kept;
522
- notes.push(
523
- `该平台有 ${dropped.length} 个内容块与其它块重复(${dropped.map(d => d.reason).join(';')}),` +
524
- '已只发不重复的那部分给 LLM;文件里的原始数据没有删除。'
525
- );
526
- }
389
+ const askedPlatform = await askUserQuestion(ctx, invocation, [topic.askPlatformOptions(platforms)]);
390
+ if (!askedPlatform.ok) {
391
+ return notify(session, 'error', topic.notify({
392
+ type: askedPlatform.failure.type,
393
+ err: askedPlatform.failure.message,
394
+ }));
527
395
  }
528
-
529
- // 被覆盖过的段落已从备份补回 —— 必须告诉用户"这份文件刚才丢过东西"
530
- if (repairedKeys.length) {
531
- notes.push(
532
- `检测到该文件曾被覆盖(LLM 写文件时用了覆盖语义),已从备份补回 ${repairedKeys.length} 个内容块:` +
533
- repairedKeys.join('、') + '。'
534
- );
396
+ const selectedPlatform = askedPlatform.answer?.answers?.[0]?.selected?.[0];
397
+ const platform = platforms.find(p => p.label === selectedPlatform || p.value === selectedPlatform);
398
+ if (!platform) {
399
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
535
400
  }
536
401
 
537
- // 一次平台标记都没写:若只扫过一个平台,整份文件都属于它(降级,不直接失败)
538
- if (sections.length === 0) {
539
- const scanned = scannedPlatforms(scanDir);
540
- if (scanned.length === 1 && scanned[0] === platformValue) {
541
- matchedSections = [{
542
- platform: platformValue,
543
- headers: [`(整份文件,LLM 未写平台标记)`],
544
- body: content.trim(),
545
- hasStart: false,
546
- hasEnd: false,
547
- }];
548
- notes.push('LLM 未在报告里写平台标记;本次只扫过一个平台,已按整份文件处理。');
549
- } else {
550
- // "文件里一个段都没有" 与 "有段但没有这个平台" 是两回事,
551
- // 必须分开报错:前者是 LLM 没按格式写,后者是扫榜没扫这个平台。
552
- return notify(session, 'error', topic.notify({
553
- type: 'no_decision_block',
554
- decisionFile: selectedFile,
555
- }));
556
- }
557
- } else if (matchedSections.length === 0) {
402
+ if (signal?.aborted) {
403
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
404
+ }
405
+ const askedDirection = await askUserQuestion(ctx, invocation, [topic.askDirection(platform.directions)]);
406
+ if (!askedDirection.ok) {
558
407
  return notify(session, 'error', topic.notify({
559
- type: 'platform_not_in_decision',
560
- platform: platformCnName,
561
- decisionFile: selectedFile,
562
- available: [...new Set(sections.map(s => s.headers[0]))],
408
+ type: askedDirection.failure.type,
409
+ err: askedDirection.failure.message,
563
410
  }));
564
411
  }
565
-
566
-
567
- // 诊断:哪些块缺 start/end 标记(LLM 格式不规范,但已按标题隔离,不影响正确性)
568
- // 分开统计 -start / -end:只缺 -end 的块被说成"标记缺失"是错的 ——
569
- // 那种块明明写着 -start,却出现在告警名单里,用户会以为切错了。
570
- const missingStart = matchedSections.filter(s => !s.hasStart);
571
- const missingEnd = matchedSections.filter(s => !s.hasEnd);
572
- if (missingStart.length || missingEnd.length) {
573
- const parts = [];
574
- if (missingStart.length) {
575
- parts.push(`${missingStart.length} 块缺少 -start 标记(${missingStart.map(s => s.headers[0]).join(';')})`);
576
- }
577
- if (missingEnd.length) {
578
- parts.push(`${missingEnd.length} 块缺少 -end 标记(${missingEnd.map(s => s.headers[0]).join(';')})`);
579
- }
580
- notes.push(`其中 ${parts.join(';')},已按标题行切分隔离,不影响平台归属。`);
581
- }
582
-
583
- // 拼装发给 LLM 的提示。
584
- //
585
- // 块包装**不用标题层级**(2026-10-29 提示词评审 F1):
586
- // 原来用 `## 块 N`,而块正文里有 `# 平台-榜单-日期-start` 这种 H1 ——
587
- // H2 里套 H1 是层级倒置,任务指令 `### 2.1` 与数据里的 `### 3.1`
588
- // 也混在同一层,模型只能靠"请执行以下分析"这一句判断边界。
589
- //
590
- // 现在数据整体被 `<<<FANCY_SCAN_DATA>>>` 哨兵包住(见 explorePrompt),
591
- // 块包装改成普通文本行 `【数据块 N】`,不引入任何标题层级。
592
- // 块标题已经在 source_summary_block 里逐条列出,这里不再重复 start/end 标记;
593
- // 只保留“数据块 N”这个普通文本行,正文从 `## 一、...` 开始。
594
- const blocksText = matchedSections.map((s, i) =>
595
- `【数据块 ${i + 1}】\n${s.body}`
596
- ).join('\n\n');
597
-
598
- // 汇总按平台分组(保持文件里的出现顺序),并给出每个平台的块数。
599
- // 之前是"每块一行",同一个平台会出现多行、总数也和块数对不上;
600
- // 术语统一用"块"(与下面的 "【数据块 N】" 标记一致)。
601
- const summaryByPlatform = new Map();
602
- for (const s of sections) {
603
- const name = platformCn(s.platform) || s.platform;
604
- if (!summaryByPlatform.has(name)) summaryByPlatform.set(name, []);
605
- summaryByPlatform.get(name).push({ headers: s.headers });
412
+ const selectedTitle = askedDirection.answer?.answers?.[0]?.selected?.[0];
413
+ const direction = platform.directions.find(d => d.title === selectedTitle);
414
+ if (!direction) {
415
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
606
416
  }
607
- const sectionSummary = [...summaryByPlatform.entries()].map(([name, blocks]) => ({
608
- platform: name,
609
- blocks,
610
- }));
611
417
 
612
- // LLM 只执行工作区内的只读脚本:先把 save-suggestion.js 及其 import 闭包
613
- // 部署到 `<工作区目录>/.fancy-tools/fancy-topic/`,再把部署后的路径写进提示词。
614
- // 不能把插件目录(node_modules)暴露给模型。
615
- const deployed = deployTopicTools(projectRoot);
616
- if (!deployed.ok) {
418
+ const suggestion = {
419
+ platform: platform.label,
420
+ source_decision_file: resolve(decisionPath),
421
+ direction_title: direction.title,
422
+ genre: direction.genre,
423
+ positioning: direction.positioning,
424
+ market_score: direction.market_score,
425
+ recommendation: direction.recommendation,
426
+ };
427
+ const validated = validateSuggestion(suggestion);
428
+ if (!validated.ok) {
617
429
  return notify(session, 'error', topic.notify({
618
- type: 'tool_deploy_failed',
619
- err: deployed.problems.join(';'),
430
+ type: 'suggestion_write_failed',
431
+ err: validated.errors.join(';'),
620
432
  }));
621
433
  }
622
434
 
623
- mkdirSync(join(projectRoot, '.tmp'), { recursive: true });
624
-
625
- const prompt = topic.explorePrompt({
626
- platform: platformCnName,
627
- decisionFile: selectedFile,
628
- // 绝对路径:提示词里"参考文件"和 source_decision_file 都用它,
629
- // 免得模型拿着裸文件名拼不出它在 扫榜结果/ 下
630
- decisionPath: decisionPath,
631
- blocks: blocksText,
632
- blockCount: matchedSections.length,
633
- sectionSummary,
634
- notes,
635
- projectRoot,
636
- saveScriptPath: deployed.saveScript,
637
- });
638
-
639
- // 触发 LLM 分析
435
+ const outPath = join(projectRoot, NOVEL_SUGGESTION_FILE);
640
436
  try {
641
- const follow = await llmFollowup(invocation, prompt);
642
- // llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
643
- // 否则投递失败也会报"分析已完成"
644
- if (!follow || follow.ok !== true) {
645
- return notify(session, 'error', topic.notify({
646
- type: 'llm_delivery_failed',
647
- err: (follow && follow.error) || '提示词投递失败',
648
- }));
649
- }
650
- } catch (err) {
651
- // llmFollowup 通常不抛错(它自己 try/catch),但保险起见
437
+ atomicWriteJSON(outPath, validated.normalized);
438
+ } catch (e) {
652
439
  return notify(session, 'error', topic.notify({
653
- type: 'llm_delivery_failed',
654
- err: err.message,
440
+ type: 'suggestion_write_failed',
441
+ err: e.message,
655
442
  }));
656
443
  }
657
444
 
658
445
  return notify(session, 'success', topic.notify({
659
- type: 'path_a_done',
660
- platform: platformCnName,
446
+ type: 'path_a_suggestion_done',
447
+ platform: platform.label,
661
448
  decisionFile: selectedFile,
662
- blockCount: matchedSections.length,
663
- notes,
449
+ direction: direction.title,
450
+ genre: direction.genre,
451
+ suggestionPath: outPath,
664
452
  }));
665
453
  }
666
454
 
@@ -669,294 +457,176 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
669
457
  // ---------------------------------------------------------------------------
670
458
 
671
459
  async function pathSetup(ctx, session, invocation, signal, projectRoot) {
672
- // 中间过程提示的收集处(DSH 只有 handler 的返回值会被用户看到)
673
- const extraNotes = [];
674
- const novelTopicPath = join(projectRoot, '_novel_topic.json');
675
-
676
- // 如果 _novel_topic.json 不存在,创建骨架
677
- if (!existsSync(novelTopicPath)) {
678
- const skeletonResult = await spawnScript('node', [
679
- SELECT_TOPIC_SCRIPT,
680
- '--project-root', projectRoot,
681
- 'skeleton',
682
- ], { timeout: 30000 });
683
-
684
- if (!skeletonResult.ok) {
685
- const errMsg = skeletonResult.error || '未知错误';
686
- return notify(session, 'error', topic.notify({ type: 'write_failed', err: errMsg }));
687
- }
688
- // 这条信息不能只 notify 出去:DSH 只有 handler 的返回值会经
689
- // settleThrown → command/done → channel.notify(),中间调用丢弃 = 用户看不到。
690
- // 并入最终返回消息。
691
- extraNotes.push(topic.notify({ type: 'create_skeleton' }));
460
+ const suggestion = readSuggestion(projectRoot);
461
+ if (!suggestion) {
462
+ return notify(session, 'error', topic.notify({ type: 'no_suggestion' }));
692
463
  }
693
-
694
- // 弹框:选平台(路径 B 也独立选,因为路径 A 不写文件)
695
- if (signal?.aborted) {
696
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
464
+ if (!existsSync(join(projectRoot, '.fancy', 'workspace.json'))) {
465
+ return notify(session, 'error', topic.notify({ type: 'material_not_initialized' }));
697
466
  }
698
- const askedplatformChoice = await askUserQuestion(ctx, invocation, [topic.askPlatform()]);
699
- if (!askedplatformChoice.ok) {
467
+
468
+ const tools = deployMaterialTools(projectRoot);
469
+ if (!tools.ok) {
700
470
  return notify(session, 'error', topic.notify({
701
- type: askedplatformChoice.failure.type,
702
- err: askedplatformChoice.failure.message
471
+ type: 'tool_deploy_failed',
472
+ err: tools.problems.join(';') || '未知错误',
703
473
  }));
704
474
  }
705
- const platformChoice = askedplatformChoice.answer;
706
- if (!platformChoice?.answers?.[0]) {
707
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
708
- }
709
- const selectedPlatform = platformChoice.answers[0]?.selected?.[0];
710
- if (!selectedPlatform) {
711
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
712
- }
713
-
714
- // 弹框里展示的两组值,优先级:
715
- // 1. "📌 当前值" —— _novel_topic.json 的值(仅当平台一致)
716
- // 2. "💡 推荐值" —— _novel_topic_suggestion.json 的值(仅当平台一致)
717
- //
718
- // 两者都直接写进 question 文本展示。之前还有一段"用推荐值填补上次空字段"
719
- // 的逻辑,但它唯一的落点是问题对象上的 default 字段 ——
720
- // DSH 的 AskUserQuestionItem 根本没有 default 字段(会被静默忽略),
721
- // 那段代码对用户完全不可见,属于死代码,已删除。
722
- const suggestionPath = join(projectRoot, '_novel_topic_suggestion.json');
723
- let suggestion = null;
724
- if (existsSync(suggestionPath) && lstatSync(suggestionPath).isSymbolicLink()) {
725
- extraNotes.push(topic.notify({
726
- type: 'suggestion_corrupt',
727
- file: '_novel_topic_suggestion.json(符号链接,已忽略)',
475
+ try {
476
+ const { ensureMaterialDatabase } = await import('#material-db');
477
+ await ensureMaterialDatabase(projectRoot);
478
+ } catch (e) {
479
+ return notify(session, 'error', topic.notify({
480
+ type: 'material_not_initialized',
481
+ err: e.message,
728
482
  }));
729
- } else if (existsSync(suggestionPath)) {
730
- try {
731
- suggestion = normalizeTopicData(JSON.parse(readFileSync(suggestionPath, 'utf-8')));
732
- } catch (e) {
733
- // 以前是静默忽略 —— 用户看到的是"没有推荐值",却不知道推荐值其实存在、
734
- // 只是坏了。这跟"文件被写坏"这个事实一起消失,问题无从查起。
735
- // 现在明确告诉用户,并给出补救动作。
736
- console.error('[fancy-topic] _novel_topic_suggestion.json 解析失败:' + (e && e.message ? e.message : String(e)));
737
- extraNotes.push(topic.notify({
738
- type: 'suggestion_corrupt',
739
- file: '_novel_topic_suggestion.json',
740
- }));
741
- }
742
483
  }
743
484
 
744
- // 比较 suggestion 与 selectedPlatform,决定是否应用建议值
745
- // 比较 _novel_topic.json 与 selectedPlatform,决定是否显示"当前值"
746
- const suggestionPlatformMatches = suggestion ? platformEqual(suggestion.platform, selectedPlatform) : false;
747
-
748
- // 读 _novel_topic.json,仅当其 platform 与 selectedPlatform 一致时作为"当前值"展示
749
- const currentValues = (() => {
750
- if (!existsSync(novelTopicPath)) return {};
751
- if (lstatSync(novelTopicPath).isSymbolicLink()) {
752
- extraNotes.push(topic.notify({
753
- type: 'current_values_corrupt',
754
- file: '_novel_topic.json(符号链接,已忽略)',
755
- }));
756
- return {};
757
- }
758
- try {
759
- const data = normalizeTopicData(JSON.parse(readFileSync(novelTopicPath, 'utf-8')));
760
- if (!data.platform) return data; // 没有 platform 字段就当作一致(兼容旧数据)
761
- return platformEqual(data.platform, selectedPlatform) ? data : {};
762
- } catch (e) {
763
- // 同上:损坏不能静默。_novel_topic.json 存的是**已敲定的设定**,
764
- // 它坏了会让后面的阶段读不到已定下来的东西。
765
- console.error('[fancy-topic] _novel_topic.json 解析失败:' + (e && e.message ? e.message : String(e)));
766
- extraNotes.push(topic.notify({
767
- type: 'current_values_corrupt',
768
- file: '_novel_topic.json',
769
- }));
770
- return {};
771
- }
772
- })();
773
-
774
- // 只有 suggestion 平台匹配时,askFields 才显示"推荐值"
775
- const suggestionValuesForDisplay = suggestionPlatformMatches ? suggestion : null;
776
-
777
- if (signal?.aborted) {
778
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
779
- }
780
- const askedfieldAnswers = await askUserQuestion(ctx, invocation, topic.askFields({
781
- currentValues,
782
- suggestionValues: suggestionValuesForDisplay,
783
- }));
784
- if (!askedfieldAnswers.ok) {
485
+ const prompt = topic.setupPrompt({
486
+ suggestion,
487
+ queryScript: tools.queryScript,
488
+ workspace: projectRoot,
489
+ });
490
+ let follow;
491
+ try {
492
+ follow = await llmFollowup(invocation, prompt, { appendTempRule: false });
493
+ } catch (e) {
785
494
  return notify(session, 'error', topic.notify({
786
- type: askedfieldAnswers.failure.type,
787
- err: askedfieldAnswers.failure.message
495
+ type: 'setup_prompt_failed',
496
+ err: e.message,
788
497
  }));
789
498
  }
790
- const fieldAnswers = askedfieldAnswers.answer;
791
-
792
- if (!fieldAnswers?.answers) {
793
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
794
- }
795
-
796
- // 收集所有选题字段的值。
797
- // 首次收集与必填重试轮必须走同一套取值逻辑(collectAnswerValue)——
798
- // 否则多选字段会在重试轮塌成字符串、丢掉第 2 项以后的选择。
799
- const fieldValues = { platform: selectedPlatform };
800
- for (const ans of fieldAnswers.answers) {
801
- const v = collectAnswerValue(ans);
802
- if (v !== undefined) fieldValues[ans.id] = v;
803
- }
804
-
805
- // 判断一个值是否"为空"(用于必填检查)
806
- // 空数组 []、空字符串 ''、null、undefined、数字 0 都视为空
807
- // (数字 0 是骨架模板里 target_words / target_chapters 的默认值,无意义)
808
- function isEmpty(v) {
809
- if (v === undefined || v === null) return true;
810
- if (typeof v === 'string') return v.trim() === '';
811
- if (Array.isArray(v)) return v.length === 0;
812
- if (typeof v === 'number') return v === 0;
813
- return false;
814
- }
815
-
816
- // 必填字段为空 → 重新弹窗(仅弹缺失字段,最多 3 轮)
817
- const MAX_RETRY = 3;
818
- for (let retry = 0; retry < MAX_RETRY; retry++) {
819
- const missing = topic.TOPIC_FIELDS
820
- .filter(f => f.required && isEmpty(fieldValues[f.id]))
821
- .map(f => f.id);
822
- if (missing.length === 0) break;
823
-
824
- // 这里**不能**往 extraNotes 里塞"必填字段未填写"的告警:
825
- // DSH 只有 handler 的返回值会送达用户(中间 notify 会被丢弃),
826
- // 而这条告警是**每一轮重试前**无条件塞的 —— 用户在重试轮补全之后,
827
- // 那条历史告警仍然留在最终消息里,用户会以为没填成功、流程其实已经成功。
828
- // 缺失情况改由下面的最终校验统一报告。
829
-
830
- // 只重新弹缺失字段;已有值保留在 fieldValues 里,
831
- // 重试轮只覆盖这次被问到的字段。
832
- if (signal?.aborted) {
833
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
834
- }
835
- const askedreAnswers = await askUserQuestion(ctx, invocation, topic.askFields({
836
- currentValues,
837
- suggestionValues: suggestionValuesForDisplay,
838
- }).filter(q => missing.includes(q.id)));
839
- if (!askedreAnswers.ok) {
840
- return notify(session, 'error', topic.notify({
841
- type: askedreAnswers.failure.type,
842
- err: askedreAnswers.failure.message
843
- }));
844
- }
845
- const reAnswers = askedreAnswers.answer;
846
- if (!reAnswers?.answers) {
847
- return notify(session, 'error', topic.notify({
848
- type: 'fields_missing',
849
- missing,
850
- }));
851
- }
852
- for (const ans of reAnswers.answers) {
853
- const v = collectAnswerValue(ans);
854
- if (v !== undefined) fieldValues[ans.id] = v;
855
- }
856
- }
857
-
858
- // 最终校验:只报**最终仍然缺失**的字段。
859
- // 用户补全过就一条告警都不该留。
860
- const stillMissing = topic.TOPIC_FIELDS
861
- .filter(f => f.required && isEmpty(fieldValues[f.id]))
862
- .map(f => f.id);
863
- if (stillMissing.length > 0) {
499
+ if (!follow || follow.ok !== true) {
864
500
  return notify(session, 'error', topic.notify({
865
- type: 'fields_missing',
866
- missing: stillMissing,
501
+ type: 'setup_prompt_failed',
502
+ err: (follow && follow.error) || '提示词投递失败',
867
503
  }));
868
504
  }
505
+ return notify(session, 'success', topic.notify({
506
+ type: 'setup_started',
507
+ genre: suggestion.genre,
508
+ }));
509
+ }
869
510
 
870
- // 调用 select-topic.js 原子写盘 + phase 推进
871
- if (signal?.aborted) {
872
- return notify(session, 'error', topic.notify({ type: 'cancelled' }));
873
- }
874
- const writeArgs = [
875
- SELECT_TOPIC_SCRIPT,
876
- '--project-root', projectRoot,
877
- ];
878
- // 字段清单来自 topic.TOPIC_FIELDS(与提示词/弹框/schema 同源),
879
- // 新增字段不需要在这里再手写一遍 --xxx 参数。
880
- for (const f of topic.TOPIC_FIELDS) {
881
- const v = fieldValues[f.id];
882
- writeArgs.push(
883
- '--' + f.id.replace(/_/g, '-'),
884
- Array.isArray(v) ? JSON.stringify(v) : String(v ?? '')
885
- );
886
- }
887
- writeArgs.push('--platform', fieldValues.platform, 'write');
888
- const writeResult = await spawnScript('node', writeArgs, { timeout: 30000 });
889
-
890
- if (!writeResult.ok) {
891
- const errMsg = writeResult.error || '未知错误';
892
- return notify(session, 'error', topic.notify({ type: 'write_failed', err: errMsg }));
893
- }
894
-
895
- // phase 没推进成 topic_done 时,选题内容是写进去了的(所以 ok:true),
896
- // 但项目状态没跟上 —— 下次运行可能还会提示"需要先选题"。
897
- // 这条要说给用户听,不能闷在 JSON 里。
898
- let phaseWarning = null;
511
+ /**
512
+ * 注册 fancy_topic_finalize 工具。
513
+ *
514
+ * LLM 在用户确认 5 个候选方案之一后调用:
515
+ * - 校验最小 topic(genre + ability 6 kinds + plot 7 kinds)
516
+ * - 原子写入 _novel_topic.json
517
+ * - 推进 phase
518
+ * - 创建/更新项目库
519
+ */
520
+ async function registerFinalizeTool(ctx) {
521
+ if (!ctx.tools || typeof ctx.tools.register !== 'function') return;
522
+ let defineTool;
899
523
  try {
900
- const parsed = JSON.parse(writeResult.output || '{}');
901
- phaseWarning = parsed.phase_warning || null;
524
+ ({ defineTool } = await import('@deepseek-ai/dsh-tools'));
902
525
  } catch {
903
- // stdout 不是 JSON —— 没有 phase_warning 可读,忽略
904
- }
905
-
906
- const baseDone = topic.notify({
907
- type: 'path_b_done',
908
- fieldCount: topic.TOPIC_FIELDS.length,
909
- extra: [extraNotes.join('\n\n'), phaseWarning].filter(Boolean).join('\n\n'),
910
- });
911
-
912
- // Lightweight roots (a single directory with .fancy-deployed, used by the
913
- // shared scan/topic tests and older standalone setups) only need the topic
914
- // file. Full workspaces additionally create the project directory, database
915
- // and per-project user.
916
- if (!existsSync(join(projectRoot, '.fancy', 'workspace.json'))) {
917
- return notify(session, 'success', baseDone);
918
- }
919
-
920
- let projectInfo = null;
921
- let projectError = null;
922
- let projectAction = 'created';
923
- try {
924
- const { createProject, getProject, updateProjectTopic } = await import('#projects');
925
- const { initProjectDatabase } = await import('#db-init');
926
- const finalized = normalizeTopicData(JSON.parse(readFileSync(novelTopicPath, 'utf-8')));
927
- const title = finalized.book_name || finalized.title || '';
928
- if (!title) throw new Error('选题缺少 book_name,无法创建项目');
929
- const found = await getProject(projectRoot, title);
930
- projectAction = found ? 'updated' : 'created';
931
- const project = found
932
- ? await updateProjectTopic(projectRoot, found.project_id, finalized)
933
- : await createProject(projectRoot, { title, topic: finalized });
934
- await initProjectDatabase(projectRoot, project.project_id);
935
- projectInfo = project;
936
- } catch (err) {
937
- projectError = err && err.message ? err.message : String(err);
938
- }
939
-
940
- if (projectError) {
941
- return notify(session, 'error', [
942
- '⚠️ 选题已写入,但项目创建或更新失败:' + projectError,
943
- '修复后运行 /fancy-topic setup 重试,或运行 /fancy-db init 检查数据库。',
944
- extraNotes.join('\n\n'),
945
- ].filter(Boolean).join('\n\n'));
946
- }
526
+ return;
527
+ }
528
+ const json = { type: 'json' };
529
+ const str = (description) => ({ type: 'string', description });
530
+ ctx.tools.register(defineTool({
531
+ name: 'fancy_topic_finalize',
532
+ description: '用户明确确认某个选题方案后,将 genre + 能力组 6 个 kind + 剧情与脑洞组 7 个 kind 写入 _novel_topic.json,并创建/更新项目库。不要在讨论阶段调用。',
533
+ parameters: {
534
+ title: str('选中方案的标题,用作项目名'),
535
+ genre: str('题材'),
536
+ ability: json,
537
+ plot: json,
538
+ workspace: str('工作区路径;省略时用当前会话目录'),
539
+ },
540
+ output: {
541
+ schema: json,
542
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
543
+ },
544
+ async execute(args, exec) {
545
+ const session = exec?.agent?.session;
546
+ let root;
547
+ try {
548
+ root = resolveWorkspace({ cwd: args.workspace || session?.cwd || session?.header?.cwd || process.cwd() });
549
+ } catch (e) {
550
+ return { ok: false, error: { code: 'WORKSPACE_NOT_FOUND', message: e.message } };
551
+ }
552
+ const title = typeof args.title === 'string' ? args.title.trim() : '';
553
+ if (!title) {
554
+ return { ok: false, error: { code: 'TITLE_REQUIRED', message: 'title 必须是非空字符串' } };
555
+ }
556
+ const validated = validateMinimalTopic({
557
+ genre: args.genre,
558
+ ability: args.ability,
559
+ plot: args.plot,
560
+ });
561
+ if (!validated.ok) {
562
+ return {
563
+ ok: false,
564
+ error: { code: 'TOPIC_INVALID', message: validated.errors.join(';') },
565
+ };
566
+ }
947
567
 
948
- return notify(session, 'success', [
949
- baseDone,
950
- '',
951
- projectAction === 'updated' ? '📚 项目已更新' : '📚 项目已创建',
952
- '书名:' + projectInfo.name,
953
- 'UUID:' + projectInfo.project_id,
954
- '目录:' + projectInfo.dir,
955
- '数据库:' + projectInfo.db_name,
956
- ].join('\n'));
568
+ const tmpDir = join(root, '.tmp');
569
+ mkdirSync(tmpDir, { recursive: true });
570
+ const inputPath = join(tmpDir, 'fancy_topic_finalize_' + Date.now() + '.json');
571
+ try {
572
+ writeFileSync(inputPath, JSON.stringify({ title, ...validated.normalized }, null, 2), 'utf8');
573
+ const written = await spawnScript('node', [
574
+ SAVE_MINIMAL_TOPIC_SCRIPT,
575
+ '--project-root', root,
576
+ '--input', inputPath,
577
+ ], { timeout: 30000 });
578
+ if (!written.ok) {
579
+ return { ok: false, error: { code: 'TOPIC_WRITE_FAILED', message: written.error || '写入失败' } };
580
+ }
581
+ let parsed = {};
582
+ try { parsed = JSON.parse(written.output || '{}'); } catch { /* ignore */ }
583
+ if (parsed.ok !== true) {
584
+ return { ok: false, error: { code: 'TOPIC_WRITE_FAILED', message: (parsed.error && parsed.error.message) || '写入失败' } };
585
+ }
586
+
587
+ // 轻量目录(只有 .fancy-deployed)不创建项目库
588
+ if (!existsSync(join(root, '.fancy', 'workspace.json'))) {
589
+ return { ok: true, title, topic: parsed.topic, phase: parsed.phase, project: null };
590
+ }
591
+
592
+ try {
593
+ const { createProject, getProject, updateProjectTopic } = await import('#projects');
594
+ const { initProjectDatabase } = await import('#db-init');
595
+ const found = await getProject(root, title);
596
+ const project = found
597
+ ? await updateProjectTopic(root, found.project_id, parsed.topic)
598
+ : await createProject(root, { title, topic: parsed.topic });
599
+ await initProjectDatabase(root, project.project_id);
600
+ return {
601
+ ok: true,
602
+ title,
603
+ topic: parsed.topic,
604
+ phase: parsed.phase,
605
+ project: {
606
+ project_id: project.project_id,
607
+ name: project.name,
608
+ dir: project.dir,
609
+ db_name: project.db_name,
610
+ },
611
+ };
612
+ } catch (e) {
613
+ return {
614
+ ok: true,
615
+ title,
616
+ topic: parsed.topic,
617
+ phase: parsed.phase,
618
+ warning: '选题已写入,但项目库创建/更新失败:' + (e && e.message ? e.message : String(e)),
619
+ };
620
+ }
621
+ } finally {
622
+ try { unlinkSync(inputPath); } catch { /* ignore */ }
623
+ }
624
+ },
625
+ }));
957
626
  }
958
627
 
959
628
  export async function apply(ctx) {
629
+ await registerFinalizeTool(ctx);
960
630
  ctx.commands.register({
961
631
  name: 'fancy-topic',
962
632
  description: '📝 选题与创意约束 ( usage: /fancy-topic [setup] | help )',