@cloud411716/fancy-webnovel 2.2.0 → 3.0.3

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 (212) hide show
  1. package/README.md +65 -0
  2. package/client.js +201 -15
  3. package/cordis.patch.yml +5 -1
  4. package/docs/douyin-plugin.md +118 -0
  5. package/docs/material-library/README.md +404 -0
  6. package/docs/material-library/import.js +437 -0
  7. package/docs/material-library/prompt.md +370 -0
  8. package/docs/material-library/queries.surql +94 -0
  9. package/docs/material-library/sample.jsonl +14 -0
  10. package/docs/material-library/schema.surql +46 -0
  11. package/docs/mcp-profile.example.yml +24 -0
  12. package/docs/story-graph-design.md +602 -0
  13. package/docs/story-graph-validation.md +81 -0
  14. package/docs/workspace-mcp.md +284 -0
  15. package/index.js +12 -7
  16. package/lib/atomic-write.js +109 -0
  17. package/lib/chapter-order.js +100 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +11 -11
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +31 -31
  23. package/lib/infra.js +29 -4
  24. package/lib/material-db.js +149 -0
  25. package/lib/path-guard.js +150 -0
  26. package/lib/projects.js +400 -0
  27. package/lib/scraper-utils.js +140 -0
  28. package/lib/surreal-server.js +338 -0
  29. package/lib/topic-decision.js +860 -0
  30. package/lib/topic-fields.js +251 -0
  31. package/lib/topic-schema.js +225 -0
  32. package/lib/workspace.js +501 -0
  33. package/package.json +31 -7
  34. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  35. package/plugins/fancy-analyze/index.js +1128 -77
  36. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  37. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  38. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  39. package/plugins/fancy-analyze/summary-progress.js +71 -7
  40. package/plugins/fancy-analyze/templates.js +437 -139
  41. package/plugins/fancy-bootstrap/index.js +276 -131
  42. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  43. package/plugins/fancy-bootstrap/templates.js +35 -18
  44. package/plugins/fancy-db/index.js +172 -0
  45. package/plugins/fancy-douyin/deploy.js +134 -0
  46. package/plugins/fancy-douyin/index.js +551 -0
  47. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  48. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  49. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  50. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  51. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  52. package/plugins/fancy-douyin/state.js +197 -0
  53. package/plugins/fancy-douyin/templates.js +127 -0
  54. package/plugins/fancy-export/exporter.js +805 -0
  55. package/plugins/fancy-export/index.js +63 -0
  56. package/plugins/fancy-import/import-driver.js +29 -6
  57. package/plugins/fancy-import/index.js +13 -5
  58. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  59. package/plugins/fancy-import/templates.js +17 -8
  60. package/plugins/fancy-material/exporter.js +153 -0
  61. package/plugins/fancy-material/genres.js +256 -0
  62. package/plugins/fancy-material/index.js +835 -0
  63. package/plugins/fancy-material/inventory.js +145 -0
  64. package/plugins/fancy-material/kinds.js +313 -0
  65. package/plugins/fancy-material/queue.js +55 -0
  66. package/plugins/fancy-material/scripts/query.js +254 -0
  67. package/plugins/fancy-material/templates.js +258 -0
  68. package/plugins/fancy-material/tool-deploy.js +138 -0
  69. package/plugins/fancy-material/validate.js +149 -0
  70. package/plugins/fancy-project/index.js +133 -0
  71. package/plugins/fancy-scan/existing-block.js +100 -0
  72. package/plugins/fancy-scan/index.js +410 -21
  73. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  74. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  75. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  76. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  77. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  78. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  79. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  80. package/plugins/fancy-scan/templates/blocks.js +155 -0
  81. package/plugins/fancy-scan/templates/platforms.js +372 -0
  82. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  83. package/plugins/fancy-scan/templates.js +545 -146
  84. package/plugins/fancy-scan/tool-deploy.js +335 -0
  85. package/plugins/fancy-scan/wait-turn.js +46 -0
  86. package/plugins/fancy-story-builder/db/README.md +106 -0
  87. package/plugins/fancy-story-builder/db/client.js +228 -0
  88. package/plugins/fancy-story-builder/db/commands.js +145 -0
  89. package/plugins/fancy-story-builder/db/ids.js +39 -0
  90. package/plugins/fancy-story-builder/db/import.js +798 -0
  91. package/plugins/fancy-story-builder/db/project.js +299 -0
  92. package/plugins/fancy-story-builder/db/query.js +361 -0
  93. package/plugins/fancy-story-builder/db/schema.js +417 -0
  94. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  95. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  96. package/plugins/fancy-story-builder/index.js +4105 -396
  97. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  98. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  99. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  100. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  101. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  102. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  103. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  104. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  105. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  106. package/plugins/fancy-story-builder/references/index.md +97 -6
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  108. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  111. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  115. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  116. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  117. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  118. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  119. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  120. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  121. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  122. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  123. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  124. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  125. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  126. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  127. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  128. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  129. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  130. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  131. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  132. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  133. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  134. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  135. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  136. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  137. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  138. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  139. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  140. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  141. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  142. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  143. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  144. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  145. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  146. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  147. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  148. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  149. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  150. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  151. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  152. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  153. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  154. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  155. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  156. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  157. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  158. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  159. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  160. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  161. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  162. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  163. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  164. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  165. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  166. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  167. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  168. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  185. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  187. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  188. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  189. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  190. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
  191. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
  192. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
  193. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  194. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
  195. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
  196. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
  197. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
  198. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
  199. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
  200. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
  201. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
  202. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
  203. package/plugins/fancy-story-builder/templates.js +272 -74
  204. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  205. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  206. package/plugins/fancy-topic/index.js +414 -61
  207. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  208. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  209. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  210. package/plugins/fancy-topic/templates.js +225 -122
  211. package/plugins/fancy-topic/tool-deploy.js +200 -0
  212. package/lib/safe-rm.js +0 -94
@@ -16,18 +16,23 @@
16
16
  * 不依赖 phase:这个 skill 可以独立于整条流水线使用
17
17
  * (用户在写正文之前、中途、之后都可以调用来分析参考爆款)
18
18
  */
19
- import { existsSync, statSync, readdirSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync, renameSync } from 'fs';
19
+ import { existsSync, statSync, lstatSync, chmodSync, readdirSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync, renameSync } from 'fs';
20
20
  import { join, dirname, basename } from 'path';
21
21
  import { fileURLToPath } from 'url';
22
+ import { atomicWriteFile } from '#atomic-write';
22
23
  import { askUserQuestion, spawnScript } from '#infra';
23
24
  import { notify, llmFollowup } from '#infra';
24
25
  import { describeFetchError, llmWikiError, readJsonSafe } from '#llm-wiki';
25
26
  import { readDeployedState, DEPLOYED_STATUS } from '#deployed-state';
27
+ import { resolveWorkspace } from '#workspace';
28
+ import { sortByChapter, chapterNumber } from '#chapter-order';
29
+ import { findMissingIngestOutputs } from './ingest-coverage.js';
26
30
  import { analyze } from './templates.js';
27
31
  import {
28
32
  scanSummaryProgress,
29
33
  dirsToDispatch,
30
- listTechDirs,
34
+ listSummaryDirs,
35
+ LEGACY_STRUCTURE_DIRS,
31
36
  } from './summary-progress.js';
32
37
  import { normalizeCommandInput, matchSubcommand } from './command-input.js';
33
38
 
@@ -35,7 +40,104 @@ export const inject = ['commands', 'userQuestions'];
35
40
 
36
41
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
42
  const SPLIT_SCRIPT = join(__dirname, 'scripts', 'split_source.js');
43
+ // 登记脚本:把"追加技法内容 + 登记章节"做成幂等原子的操作。
44
+ // 让模型手工编辑 YAML 头部很容易把已登记的清单冲掉 —— 头部是唯一事实源,
45
+ // 冲掉了下次 summary 会把老章节全部重做一遍。
46
+ //
47
+ // **脚本不在插件目录里执行** —— 插件目录在 node_modules 下(部署后位置
48
+ // 还不一样),让模型去那里跑文件既别扭又易变。派发提示词前把它复制到
49
+ // 项目里的 .fancy-tools/,提示词里给项目内的路径。
50
+ const REGISTER_SCRIPT = join(__dirname, 'scripts', 'append-summary-chapter.js');
51
+ const REGISTER_TOOL_DIR = '.fancy-tools';
52
+
53
+ /**
54
+ * 确保项目里有最新版登记脚本,返回它的绝对路径(失败返回 null)。
55
+ *
56
+ * 内容比对后才写 —— 每次 summary 都覆盖一次的话会白白改 mtime,
57
+ * 也会让"这个文件是不是被人手改过"变得看不出来。
58
+ *
59
+ * @param {string} projectRoot
60
+ * @returns {string|null}
61
+ */
62
+ export function ensureRegisterScript(projectRoot) {
63
+ try {
64
+ if (!projectRoot || typeof projectRoot !== 'string') return null;
65
+ if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) return null;
66
+ if (!existsSync(REGISTER_SCRIPT)) return null;
67
+
68
+ const destDir = join(projectRoot, REGISTER_TOOL_DIR);
69
+ let dirStat = null;
70
+ try { dirStat = lstatSync(destDir); } catch { /* 不存在 */ }
71
+ if (dirStat) {
72
+ // .fancy-tools 绝不能是符号链接 —— 否则脚本会被写到项目外。
73
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
74
+ console.error('[fancy-analyze] .fancy-tools 不是真实目录,拒绝部署登记脚本');
75
+ return null;
76
+ }
77
+ } else {
78
+ mkdirSync(destDir);
79
+ }
80
+
81
+ const dest = join(destDir, 'append-summary-chapter.js');
82
+ let destStat = null;
83
+ try { destStat = lstatSync(dest); } catch { /* 不存在 */ }
84
+ if (destStat && (destStat.isSymbolicLink() || !destStat.isFile())) {
85
+ console.error('[fancy-analyze] 登记脚本目标不是普通文件,拒绝覆盖:' + dest);
86
+ return null;
87
+ }
88
+
89
+ const srcContent = readFileSync(REGISTER_SCRIPT, 'utf-8');
90
+ if (destStat) {
91
+ const same = readFileSync(dest, 'utf-8') === srcContent;
92
+ if (!same) atomicWriteFile(dest, srcContent, { mode: 0o444 });
93
+ // 内容没变也要去掉写权限(老项目里可能是 0644)。
94
+ chmodSync(dest, 0o444);
95
+ return dest;
96
+ }
97
+
98
+ atomicWriteFile(dest, srcContent, { mode: 0o444 });
99
+ return dest;
100
+ } catch (err) {
101
+ console.error('[fancy-analyze] 准备登记脚本失败:' + (err && err.message ? err.message : String(err)));
102
+ return null;
103
+ }
104
+ }
38
105
  const SCHEMA_SOURCE = join(__dirname, 'schema-analyze.md');
106
+ const DEFINITIONS_SOURCE = join(__dirname, 'analyze-definitions.md');
107
+ const DEFINITIONS_FILENAME = '分析定义.md';
108
+
109
+ /** 按章写作技法(与 schema-analyze.md 的 13 项保持一致)。 */
110
+ const CHAPTER_WRITING_TYPES = [
111
+ '叙事视角',
112
+ '章节结构',
113
+ '节奏韵律',
114
+ '对话技巧',
115
+ '描写技法',
116
+ '情节机制',
117
+ '开头钩子',
118
+ '结尾收束',
119
+ '句式变化',
120
+ '段落布局',
121
+ '场景转换',
122
+ '情绪渲染',
123
+ '张力构建',
124
+ ];
125
+ /** 只做称呼/关系称谓、通常不该单独建角色页的名字。 */
126
+ const RELATION_ONLY_ENTITY_RE = /^(?:哥哥|姐姐|弟弟|妹妹|父亲|母亲|爸爸|妈妈|爷爷|奶奶|外公|外婆|师父|师傅|老师|老板|同学|朋友|儿子|女儿)$/u;
127
+ /** 按章建页的类型集合(13 写作技法 + 章节功能设计)。 */
128
+ const CHAPTER_PAGE_TYPES = new Set([...CHAPTER_WRITING_TYPES, '章节功能设计']);
129
+
130
+ /** 结构设计页类型(与 schema-analyze.md 的 8 项保持一致)。 */
131
+ const ENTITY_STRUCTURE_TYPES = [
132
+ '人物关系设计',
133
+ '势力结构',
134
+ '世界观设计',
135
+ '冲突设计',
136
+ '主线支线结构',
137
+ '分卷节奏',
138
+ '伏笔布局',
139
+ '角色弧光设计',
140
+ ];
39
141
 
40
142
  /** resummary 备份旧总结文件的目录(放在 对标/ 下,不在 wiki 树里,免得被当成技法目录)。 */
41
143
  const SUMMARY_BACKUP_DIRNAME = '.summary-backup';
@@ -52,11 +154,16 @@ function backupStamp(ts) {
52
154
  }
53
155
 
54
156
  function getProjectRoot(session) {
55
- // 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
56
- // 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
57
- // 此时返回 null,由调用方给出明确报错。
157
+ // Analysis is shared at the workspace level: <workspace>/对标.
58
158
  const cwd = session.header?.cwd;
59
- return cwd || null;
159
+ if (!cwd) return null;
160
+ try {
161
+ return resolveWorkspace({ cwd, required: true });
162
+ } catch {
163
+ // Fall back to the session cwd so the caller can report "not initialized"
164
+ // with the right path instead of "no project root".
165
+ return cwd;
166
+ }
60
167
  }
61
168
 
62
169
  /**
@@ -180,6 +287,68 @@ async function queryIngestStatus(baseUrl, token, projectId) {
180
287
  return callLlmWikiApi(baseUrl, token, `/projects/${projectId}/ingest/querystatus`, 'GET', null);
181
288
  }
182
289
 
290
+ /**
291
+ * summary / resummary 的前置门禁:ingest 必须真的完成。
292
+ *
293
+ * 只看本地 `对标/wiki` 存在是不够的:ingest 还在排队时,wiki 目录可能已经
294
+ * 有一半页面,summary 会把“还没落盘的章节”当成“没有输入”或永远等不到。
295
+ *
296
+ * 这里用 llm_wiki 的 /ingest/querystatus:
297
+ * - missingFiles 非空 → 还有源文件没进 wiki;
298
+ * - queue.pending / processing > 0 → 队列还在跑;
299
+ * - queue.failed > 0 → 有失败任务,需要 /fancy-analyze fix。
300
+ * 任一条成立都不让 summary 开工。
301
+ *
302
+ * @returns {{ok:boolean, text?:string}}
303
+ */
304
+ async function checkIngestReadyForSummary(projectRoot, opts = {}) {
305
+ const command = opts.command || 'summary';
306
+ const { config, errorText } = loadDeployedConfig(projectRoot);
307
+ if (errorText) return { ok: false, text: errorText };
308
+ const referenceId = config && typeof config.reference_id === 'string' ? config.reference_id.trim() : '';
309
+ if (!referenceId) {
310
+ return { ok: false, text: analyze.notify({ type: 'no_reference_id' }) };
311
+ }
312
+ const llmWikiUrl = (config.llm_wiki_url || '').replace(/\/$/, '');
313
+ const llmWikiToken = config.llm_wiki_token || '';
314
+ if (!llmWikiUrl || !llmWikiToken) {
315
+ return { ok: false, text: analyze.notify({ type: 'llm_wiki_not_configured' }) };
316
+ }
317
+ let result;
318
+ try {
319
+ result = await queryIngestStatus(llmWikiUrl, llmWikiToken, referenceId);
320
+ } catch (err) {
321
+ return { ok: false, text: analyze.notify({
322
+ type: 'status_query_failed',
323
+ err: describeFetchError(err),
324
+ }) };
325
+ }
326
+ const apiErr = llmWikiError(result);
327
+ if (apiErr) {
328
+ return { ok: false, text: analyze.notify({ type: 'status_query_failed', err: apiErr }) };
329
+ }
330
+ const data = result.data || {};
331
+ // 和 status 用同一套排序:服务端返回 missingFiles 的顺序是随机的,
332
+ // summary / summary-status / resummary 三个入口都走这里,统一按书名+章号排。
333
+ const missingFiles = sortByChapter(Array.isArray(data.missingFiles) ? data.missingFiles : []);
334
+ const queue = data.queue && typeof data.queue === 'object' ? data.queue : {};
335
+ const pending = Number(queue.pending) || 0;
336
+ const processing = Number(queue.processing) || 0;
337
+ const failed = Number(queue.failed) || 0;
338
+ if (missingFiles.length || pending || processing || failed) {
339
+ return {
340
+ ok: false,
341
+ text: analyze.notify({
342
+ type: 'summary_ingest_not_ready',
343
+ command,
344
+ missingFiles,
345
+ queue: { ...queue, pending, processing, failed },
346
+ }),
347
+ };
348
+ }
349
+ return { ok: true };
350
+ }
351
+
183
352
  /**
184
353
  * 在 参考资料/ 下查找匹配的源文件
185
354
  *
@@ -207,17 +376,24 @@ function findSourceFile(refDir, bookName) {
207
376
  const targetBase = bookName.trim().split(/[\\/]/).pop();
208
377
  const target = stripExt(targetBase);
209
378
 
210
- const stat = statSync(refDir);
211
- const entries = stat.isDirectory()
212
- ? readdirSync(refDir)
213
- : [];
379
+ const stat = (() => {
380
+ try { return statSync(refDir); } catch { return null; }
381
+ })();
382
+ if (!stat) return { error: 'dir_missing' };
383
+ let entries = [];
384
+ try {
385
+ entries = stat.isDirectory() ? readdirSync(refDir) : [];
386
+ } catch {
387
+ entries = [];
388
+ }
214
389
 
215
390
  // 情况:参考资料/<书名>.txt(文件)
216
391
  const directMatches = [];
217
392
  for (const name of entries) {
218
393
  if (stripExt(name) === target) {
219
394
  const full = join(refDir, name);
220
- const st = statSync(full);
395
+ let st = null;
396
+ try { st = statSync(full); } catch { continue; }
221
397
  if (st.isFile()) {
222
398
  directMatches.push({ path: full, kind: 'file' });
223
399
  }
@@ -238,6 +414,181 @@ function findSourceFile(refDir, bookName) {
238
414
  return { error: 'not_found', matches: allCandidates.map(c => c.name) };
239
415
  }
240
416
 
417
+ /**
418
+ * 从来源页里解析 `### 列表` / `### 结构设计分析` 的「| 名称 | 有/无 |」表格。
419
+ */
420
+ function parseHasTable(content, heading) {
421
+ const headingIndex = content.indexOf(heading);
422
+ if (headingIndex === -1) return {};
423
+ const after = content.substring(headingIndex);
424
+ const nextHeading = after.match(/\n#{1,3}\s+\S/);
425
+ const tableEnd = nextHeading ? after.indexOf(nextHeading[0]) : after.length;
426
+ const tableContent = after.substring(0, tableEnd);
427
+ const out = {};
428
+ for (const line of tableContent.split('\n')) {
429
+ const row = line.match(/^\|\s*(.+?)\s*\|\s*(有|无)\s*\|/);
430
+ if (row) out[row[1].trim()] = row[2] === '有';
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * 只保留当前 schema 定义的按章类型。
437
+ * 旧来源页的「### 列表」里还有 伏笔回收/世界观构建/角色弧光 ——
438
+ * 它们是按章页面,新版不会再生成对应页面。不过滤的话 status 会一直
439
+ * 报这三类缺失、fix 重传后又永远补不上。
440
+ */
441
+ function filterChapterTypes(table) {
442
+ return Object.fromEntries(
443
+ Object.entries(table).filter(([type]) => CHAPTER_PAGE_TYPES.has(type)));
444
+ }
445
+
446
+ /**
447
+ * 从来源页的「### 跨章节结构实体」表里解析实体行。
448
+ * 表头:结构类型 | 实体/线索 | 页面文件 | 本章动作 | 首次章节 | 本次章节
449
+ */
450
+ function parseEntityRows(content, bookName, chapterKey) {
451
+ const heading = '### 跨章节结构实体';
452
+ const headingIndex = content.indexOf(heading);
453
+ if (headingIndex === -1) return [];
454
+ const after = content.substring(headingIndex);
455
+ const nextHeading = after.match(/\n#{1,3}\s+\S/);
456
+ const tableEnd = nextHeading ? after.indexOf(nextHeading[0]) : after.length;
457
+ const lines = after.substring(0, tableEnd).split('\n');
458
+ const rows = [];
459
+ for (const line of lines) {
460
+ if (!line.trim().startsWith('|')) continue;
461
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
462
+ if (cells.length < 6) continue;
463
+ const [type, entity, rawPageFile, action, firstChapter, currentChapter] = cells;
464
+ if (!ENTITY_STRUCTURE_TYPES.includes(type)) continue;
465
+ // 模型经常漏掉 .md、加反引号、或者写成 wiki/类型/文件名 的完整路径。
466
+ // 这里统一成裸文件名;带“第X章”的仍保留,交给下面的 invalidPageFile 报错。
467
+ let pageFile = String(rawPageFile || '')
468
+ .trim()
469
+ .replace(/^`+|`+$/g, '')
470
+ .replace(/^\[\[|\]\]$/g, '')
471
+ .trim();
472
+ if (pageFile.includes('/')) pageFile = pageFile.split('/').pop();
473
+ if (pageFile && pageFile !== '无' && pageFile !== '-' && !pageFile.endsWith('.md')) {
474
+ pageFile += '.md';
475
+ }
476
+ if (!pageFile || !pageFile.endsWith('.md')) continue;
477
+ rows.push({
478
+ bookName,
479
+ chapterKey,
480
+ type,
481
+ entity,
482
+ pageFile,
483
+ action,
484
+ // 跨章节实体页的文件名必须稳定:带“第X章”说明模型又按章建页了。
485
+ invalidPageFile: /第\d+章/.test(pageFile),
486
+ firstChapter: firstChapter && firstChapter !== '-' ? firstChapter : chapterKey,
487
+ currentChapter: currentChapter && currentChapter !== '-' ? currentChapter : chapterKey,
488
+ });
489
+ }
490
+ return rows;
491
+ }
492
+
493
+ /**
494
+ * 读实体页 frontmatter 里的 tags(支持行内数组和块列表)。
495
+ * 角色弧光页常把正式姓名/别名写在 tags 里,例如老班页的 tags 含“刘安国”。
496
+ */
497
+ function parseFrontmatterTags(content) {
498
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(content || ''));
499
+ if (!fm) return [];
500
+ const lines = fm[1].split(/\r?\n/);
501
+ const tags = [];
502
+ let inTags = false;
503
+ for (const line of lines) {
504
+ const m = /^tags\s*:\s*(.*)$/.exec(line.trim());
505
+ if (m) {
506
+ const raw = m[1].trim();
507
+ if (raw.startsWith('[') && raw.endsWith(']')) {
508
+ for (const item of raw.slice(1, -1).split(',')) {
509
+ const tag = item.trim().replace(/^["']|["']$/g, '');
510
+ if (tag) tags.push(tag);
511
+ }
512
+ inTags = false;
513
+ } else if (raw) {
514
+ tags.push(raw.replace(/^["']|["']$/g, ''));
515
+ inTags = false;
516
+ } else {
517
+ inTags = true;
518
+ }
519
+ continue;
520
+ }
521
+ if (!inTags) continue;
522
+ const item = /^\s*-\s+(.+)$/.exec(line);
523
+ if (item) {
524
+ const tag = item[1].trim().replace(/^["']|["']$/g, '');
525
+ if (tag) tags.push(tag);
526
+ } else if (line.trim()) {
527
+ inTags = false;
528
+ }
529
+ }
530
+ return tags;
531
+ }
532
+
533
+ /** 从实体页正文提取“正式姓名为X / 本名X / 又名X”这类别名。 */
534
+ function parseAliasesFromContent(content) {
535
+ const out = [];
536
+ const re = /(?:正式姓名|本名|真名|原名|又名|化名|姓名)\s*[为是::]\s*([\u4e00-\u9fa5]{2,4})/g;
537
+ let match;
538
+ while ((match = re.exec(String(content || ''))) !== null) {
539
+ if (match[1]) out.push(match[1]);
540
+ }
541
+ return out;
542
+ }
543
+
544
+ function hasChapterHeading(content, chapter) {
545
+ if (!chapter) return true;
546
+ const escaped = escapeRegExp(chapter);
547
+ // 允许 `### 第5章`、`## 第5章 转折`、`### 第5章:转折` 等写法;
548
+ // 但 `第1章` 不能匹配 `第10章`(后面紧跟数字时不放行)。
549
+ return new RegExp(`^#{2,3}\\s*${escaped}(?:\\s|$|[::((—-])`, 'm').test(content);
550
+ }
551
+
552
+ /**
553
+ * fix 重新 ingest 时带给 LLM 的修复指令。
554
+ *
555
+ * 只重传文件不够:模型可能再次漏掉同样的实体页/结构表。把这次
556
+ * 修复要重点检查的项目直接写进 ingest 任务,让分析/生成两个阶段都看到。
557
+ */
558
+ function buildFixInstruction(bookName, chapter, pageInfo = null) {
559
+ const existing = Array.isArray(pageInfo?.existing) ? pageInfo.existing : [];
560
+ const missing = Array.isArray(pageInfo?.missing) ? pageInfo.missing : [];
561
+ const list = (items) => items.slice(0, 40).map((item) => `- ${item}`).join('\n');
562
+
563
+ const lines = [`这是《${bookName}》${chapter} 的修复性重新 ingest。`, ''];
564
+
565
+ if (existing.length > 0) {
566
+ lines.push('上次已经生成并保留的页面(本次不要重写):');
567
+ lines.push(list(existing));
568
+ lines.push('');
569
+ }
570
+
571
+ lines.push('本次需要补的页面/小节:');
572
+ if (missing.length > 0) {
573
+ lines.push(list(missing));
574
+ } else {
575
+ // 正常不该走到这里;如果真没有 pageInfo,也只能给出本章级的明确动作,
576
+ // 不能再让模型去“猜 status 的缺失清单”。
577
+ lines.push('- 本章没有可枚举的页面级缺失;请重新核对来源页的「### 列表」与「### 跨章节结构实体」表,并按 schema 补齐。');
578
+ }
579
+ lines.push('');
580
+
581
+ lines.push('要求:');
582
+ lines.push('1. 只输出本次缺失页面/小节对应的 FILE 块;已经存在的页面不要重新生成;');
583
+ lines.push('2. 如果缺失的是实体页的 ### 第X章 小节,输出该实体页并只追加本章小节,页面合并会保留旧内容;');
584
+ lines.push('3. 如果缺失的是来源页或整章产物,按 schema 正常生成;');
585
+ lines.push('4. 已有实体页只追加本章小节,不得按章新建,不得换名新建;');
586
+ lines.push('5. 先读 对标/分析定义.md,按其中的术语定义判断,不要自创定义;');
587
+ lines.push('6. 14 种按章类型(13 种写作技法 + 章节功能设计)逐项判断“有/无”,标“有”的必须创建对应按章页面;');
588
+ lines.push('7. 本名/正式名优先;称呼、绰号、简称、集团/公司后缀不要另建实体。');
589
+ return lines.join('\n');
590
+ }
591
+
241
592
  /**
242
593
  * 拷贝 schema 文件到目标目录
243
594
  */
@@ -245,13 +596,108 @@ function copySchemaFile(targetDir) {
245
596
  const targetPath = join(targetDir, 'schema.md');
246
597
  mkdirSync(targetDir, { recursive: true });
247
598
  copyFileSync(SCHEMA_SOURCE, targetPath);
599
+ // 术语定义是 schema 的配套权威文件,必须和 schema 一起部署。
600
+ copyFileSync(DEFINITIONS_SOURCE, join(targetDir, DEFINITIONS_FILENAME));
248
601
  return targetPath;
249
602
  }
250
603
 
604
+ /** 读项目里的 对标/schema.md;不存在或读不了返回 null。 */
605
+ function readAnalyzeSchema(projectRoot) {
606
+ const schemaPath = join(projectRoot, '对标', 'schema.md');
607
+ if (!existsSync(schemaPath)) return null;
608
+ try {
609
+ return readFileSync(schemaPath, 'utf-8');
610
+ } catch {
611
+ return null;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * 判断 对标/schema.md 是否是上一版(按章结构设计)。
617
+ * 旧版没有「### 跨章节结构实体」,且带「### 结构设计分析」。
618
+ * 旧 schema 会让 fix 继续生成按章的 伏笔回收/世界观构建/角色弧光 页面,
619
+ * 所以 fix 前必须先把它换成新版。
620
+ */
621
+ function isLegacyAnalyzeSchema(content) {
622
+ if (!content) return true;
623
+ if (content.includes('### 结构设计分析')) return true;
624
+ if (!content.includes('### 跨章节结构实体')) return true;
625
+ if (!content.includes('世界观设计') || !content.includes('角色弧光设计')) return true;
626
+ return false;
627
+ }
628
+
629
+ /**
630
+ * fix 是否要把 对标/schema.md 刷新成插件当前版本。
631
+ *
632
+ * 不能只看“是不是旧版”:schema 里新加的强制核对清单、命名规则也要
633
+ * 能同步到已经初始化的项目,否则模型看不到最新约束。
634
+ */
635
+ /**
636
+ * 对标/raw/sources 下是否有任何拆书分片(递归,忽略隐藏目录)。
637
+ *
638
+ * status / fix 的第一步:连源文件都没有,说明还没跑过 /fancy-analyze <书名>,
639
+ * 这时去检查 schema、覆盖、.retry 全是无意义的噪音。
640
+ *
641
+ * @param {string} dir
642
+ * @returns {boolean}
643
+ */
644
+ function hasAnySourceFiles(dir) {
645
+ let entries;
646
+ try {
647
+ entries = readdirSync(dir, { withFileTypes: true });
648
+ } catch {
649
+ return false;
650
+ }
651
+ for (const entry of entries) {
652
+ if (entry.name.startsWith('.')) continue;
653
+ if (entry.isFile()) return true;
654
+ if (entry.isDirectory() && hasAnySourceFiles(join(dir, entry.name))) return true;
655
+ }
656
+ return false;
657
+ }
658
+
659
+ /**
660
+ * 对标/wiki 下是否已有任何拆解产物(旧项目可能清掉了 raw 分片)。
661
+ *
662
+ * 只看子目录里的 .md:wiki 根目录的 index.md / log.md / overview.md 是
663
+ * llm_wiki 建项目时生成的元数据,不能拿来证明“已经拆解过”。
664
+ */
665
+ function hasAnyAnalyzeOutput(dir, depth = 0) {
666
+ let entries;
667
+ try {
668
+ entries = readdirSync(dir, { withFileTypes: true });
669
+ } catch {
670
+ return false;
671
+ }
672
+ for (const entry of entries) {
673
+ if (entry.name.startsWith('.')) continue;
674
+ if (entry.isDirectory() && hasAnyAnalyzeOutput(join(dir, entry.name), depth + 1)) return true;
675
+ if (depth > 0 && entry.isFile() && entry.name.endsWith('.md')) return true;
676
+ }
677
+ return false;
678
+ }
679
+
680
+ function schemaNeedsRefresh(projectRoot) {
681
+ const current = readAnalyzeSchema(projectRoot);
682
+ if (!current) return true;
683
+ try {
684
+ const latest = readFileSync(SCHEMA_SOURCE, 'utf-8');
685
+ if (current.trim() !== latest.trim()) return true;
686
+
687
+ const defPath = join(projectRoot, '对标', DEFINITIONS_FILENAME);
688
+ if (!existsSync(defPath)) return true;
689
+ const currentDef = readFileSync(defPath, 'utf-8');
690
+ const latestDef = readFileSync(DEFINITIONS_SOURCE, 'utf-8');
691
+ return currentDef.trim() !== latestDef.trim();
692
+ } catch {
693
+ return isLegacyAnalyzeSchema(current);
694
+ }
695
+ }
696
+
251
697
  export async function apply(ctx) {
252
698
  ctx.commands.register({
253
699
  name: 'fancy-analyze',
254
- description: '🔍 拆解爆款小说(参考对标) ( usage: /fancy-analyze [书名] | status | fix | summary | summary-status | resummary [技法名] )',
700
+ description: '🔍 拆解爆款小说(参考对标) ( usage: /fancy-analyze [书名] | status | fix | summary | summary-status | resummary [技法名] | help )',
255
701
  input: {
256
702
  hint: '.',
257
703
  },
@@ -259,11 +705,15 @@ export async function apply(ctx) {
259
705
  const session = invocation.agent.session;
260
706
  const { signal } = invocation;
261
707
 
708
+ if ((invocation.rawInput || '').trim().toLowerCase() === 'help') {
709
+ return notify(session, 'success', analyze.notify({ type: 'help' }));
710
+ }
711
+
262
712
  const projectRoot = getProjectRoot(session);
263
713
  if (!projectRoot) {
264
714
  return notify(session, 'error', analyze.notify({
265
715
  type: 'no_project_root',
266
- err: '会话未提供 cwd'
716
+ err: '会话未提供当前工作目录'
267
717
  }));
268
718
  }
269
719
 
@@ -321,30 +771,73 @@ export async function apply(ctx) {
321
771
  return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
322
772
  }
323
773
 
774
+ const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
775
+ const hasSources = hasAnySourceFiles(rawSourcesDir);
776
+ const hasOutputs = hasAnyAnalyzeOutput(join(projectRoot, '对标', 'wiki'));
777
+ if (!hasSources && !hasOutputs) {
778
+ return notify(session, 'error', analyze.notify({ type: 'fix_not_analyzed' }));
779
+ }
780
+
781
+ // fix 是迁移旧项目的主要入口:先把 对标/schema.md 换成新版,
782
+ // 否则重新 ingest 会继续按旧 schema 生成按章结构页。
783
+ // 这一步放在 .retry 之前:即使没有缺失章节,用户也能用 fix 完成
784
+ // schema 迁移,而不会被“没有需要重试的项目”提前挡回去。
785
+ let schemaUpdated = false;
786
+ try {
787
+ if (schemaNeedsRefresh(projectRoot)) {
788
+ copySchemaFile(join(projectRoot, '对标'));
789
+ schemaUpdated = true;
790
+ }
791
+ } catch (err) {
792
+ const msg = err instanceof Error ? err.message : String(err);
793
+ return notify(session, 'error',
794
+ `❌ 无法更新 对标/schema.md:${msg}\n\n` +
795
+ '续跑 fix 仍会按旧 schema 生成按章结构页,所以本次没有提交 ingest。\n' +
796
+ '请确认 对标/ 可写后重试。');
797
+ }
798
+ const schemaNote = schemaUpdated
799
+ ? '## 已更新对标 schema\n对标/schema.md 已更新为新版(写作技法 + 跨章节实体页)。\n\n'
800
+ : '';
801
+ const withSchemaNote = (text) => schemaNote ? schemaNote + text : text;
802
+
324
803
  // 读取 .retry 文件
325
804
  const retryFilePath = join(projectRoot, '.retry');
326
805
  if (!existsSync(retryFilePath)) {
327
- return notify(session, 'error', `没有找到 .retry 文件。
328
- 1. 如果已执行过 fancy-analyze status 子命令则代表已成功提取,可以继续执行 fancy-analyze summary
329
- 2. 如果未执行过 fancy-analyze status 子命令请先执行该命令`);
806
+ return notify(session, 'error', withSchemaNote(`未找到 .retry 文件。
807
+ 1. 如果已执行过 /fancy-analyze status,说明已成功提取,可以继续执行 /fancy-analyze summary;
808
+ 2. 如果尚未执行过 /fancy-analyze status,请先执行该命令。`));
330
809
  }
331
810
 
332
811
  let retryData;
333
812
  try {
334
813
  retryData = JSON.parse(readFileSync(retryFilePath, 'utf-8'));
335
814
  } catch {
336
- return notify(session, 'error', '.retry 文件格式错误');
815
+ return notify(session, 'error', withSchemaNote('.retry 文件格式错误,请检查文件内容后重试。'));
337
816
  }
338
817
 
339
818
  if (!retryData || !Array.isArray(retryData.items) || retryData.items.length === 0) {
340
- return notify(session, 'success', '没有需要重试的项目');
819
+ return notify(session, 'success', withSchemaNote('当前没有需要重试的项目。'));
820
+ }
821
+
822
+ // status 额外写出的“已有页面 / 缺失页面”清单。旧项目没有这个文件时,
823
+ // fix 自动退回原来的通用修复指令。
824
+ let retryPageInfo = {};
825
+ try {
826
+ const pagesPath = join(projectRoot, '.retry-pages.json');
827
+ if (existsSync(pagesPath)) {
828
+ const parsed = JSON.parse(readFileSync(pagesPath, 'utf-8'));
829
+ if (parsed && typeof parsed === 'object') retryPageInfo = parsed;
830
+ }
831
+ } catch {
832
+ retryPageInfo = {};
341
833
  }
342
834
 
343
- const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
344
835
  const results = [];
345
836
  const errors = [];
346
837
 
347
- for (const item of retryData.items) {
838
+ // .retry 是 status 写的(现在有序),但老文件可能是乱序的 ——
839
+ // 补传顺序跟着章号走,跟 ingest 的正常流程保持一致。
840
+ for (const item of sortByChapter(retryData.items, (it) => it && it.bookName)) {
348
841
  const { bookName, chapter } = item;
349
842
  // 定位文件:书名/ 目录下查找文件名包含"第X章"的文件
350
843
  const bookDir = join(rawSourcesDir, bookName);
@@ -353,7 +846,7 @@ export async function apply(ctx) {
353
846
  continue;
354
847
  }
355
848
 
356
- let matchedFile = null;
849
+ let matchedFiles = [];
357
850
  try {
358
851
  const files = readdirSync(bookDir);
359
852
  // 查找文件名中包含"第X章"的文件(如:第8章-心好痛.txt)
@@ -361,21 +854,42 @@ export async function apply(ctx) {
361
854
  // 直接拼进正则的话,一旦内容里出现正则元字符就会抛 SyntaxError,
362
855
  // 而且 "." 未转义会让"第1章"匹配上"第1章X"这种文件名。
363
856
  const pattern = new RegExp(`${escapeRegExp(chapter)}.*\\.txt$`);
364
- matchedFile = files.find(f => pattern.test(f));
365
- if (!matchedFile) {
857
+ //
858
+ // **要全部匹配到的分片,不能只取第一个** ——
859
+ // 超长章会被 split_source.js 二次切分成
860
+ // 第7章-武科五大关-1.txt / 第7章-武科五大关-2.txt
861
+ // 两个文件名都匹配同一个正则,而 files.find() 只返回其中一个,
862
+ // 另一个分片永远补不上:服务端那一章的产物始终是残缺的
863
+ // (第一次 ingest 是把整章的分片一起传的,服务端会合并成
864
+ // 一章的产物;只补一半反而会把完整产物覆盖成半章)。
865
+ //
866
+ // 排序用自然序,保证「第-10」排在「第-2」后面,
867
+ // 补传顺序与初次 ingest 一致。
868
+ matchedFiles = files
869
+ .filter(f => pattern.test(f))
870
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
871
+ if (matchedFiles.length === 0) {
366
872
  errors.push(`未找到文件:${bookName} 下包含 ${chapter} 的文件`);
367
873
  continue;
368
874
  }
369
875
 
370
876
  // 清除 ingest-cache 中对应条目的 hash
877
+ // (一次读、一次改、一次写:多个分片共用一份 cache,
878
+ // 循环里反复读写会把前一次的结果覆盖掉)
371
879
  const ingestCachePath = join(projectRoot, '对标', '.llm-wiki', 'ingest-cache.json');
372
880
  if (existsSync(ingestCachePath)) {
373
881
  try {
374
882
  const cacheContent = readFileSync(ingestCachePath, 'utf-8');
375
883
  const cacheData = JSON.parse(cacheContent);
376
- const cacheKey = `${bookName}/${matchedFile}`;
377
- if (cacheData.entries && cacheData.entries[cacheKey]) {
378
- cacheData.entries[cacheKey].hash = '';
884
+ let cacheChanged = false;
885
+ for (const f of matchedFiles) {
886
+ const cacheKey = `${bookName}/${f}`;
887
+ if (cacheData.entries && cacheData.entries[cacheKey]) {
888
+ cacheData.entries[cacheKey].hash = '';
889
+ cacheChanged = true;
890
+ }
891
+ }
892
+ if (cacheChanged) {
379
893
  writeFileSync(ingestCachePath, JSON.stringify(cacheData, null, 2), 'utf-8');
380
894
  }
381
895
  } catch {
@@ -383,7 +897,9 @@ export async function apply(ctx) {
383
897
  }
384
898
  }
385
899
 
386
- const filePath = `raw/sources/${bookName}/${matchedFile}`;
900
+ // 整章的分片一起传 —— 与初次 ingest 的语义一致
901
+ // (初次是 split_files 全部一次传给服务端)。
902
+ const filePaths = matchedFiles.map(f => `raw/sources/${bookName}/${f}`);
387
903
  // 必须检查返回:ingest 返回 4xx/5xx 时 llm_wiki 根本没收到文件,
388
904
  // 之前这里不检查,命令照报"## 重新 ingest 成功"(实测:两个 POST
389
905
  // 都返回 500,用户看到的仍是"成功")。
@@ -392,14 +908,23 @@ export async function apply(ctx) {
392
908
  llmWikiToken,
393
909
  `/projects/${referenceId}/sources/ingest`,
394
910
  'POST',
395
- { paths: [filePath] }
911
+ {
912
+ paths: filePaths,
913
+ instruction: buildFixInstruction(
914
+ bookName,
915
+ chapter,
916
+ retryPageInfo[`${bookName}::${chapter}`] || null,
917
+ ),
918
+ }
396
919
  );
397
920
  const apiErr = llmWikiError(ingestResult);
398
921
  if (apiErr) {
399
- errors.push(`${bookName}/${matchedFile} - ${apiErr}`);
922
+ errors.push(`${bookName}/${matchedFiles.join('、')} - ${apiErr}`);
400
923
  continue;
401
924
  }
402
- results.push(`${bookName}/${matchedFile}`);
925
+ for (const f of matchedFiles) {
926
+ results.push(`${bookName}/${f}`);
927
+ }
403
928
  } catch (err) {
404
929
  errors.push(`${bookName}/${chapter} - ${describeFetchError(err)}`);
405
930
  }
@@ -421,7 +946,7 @@ export async function apply(ctx) {
421
946
  const headline = (errors.length > 0 && results.length === 0)
422
947
  ? '重新 ingest 失败:\n\n'
423
948
  : '';
424
- const finalMessage = headline + message + '\n\n请稍后执行 /fancy-analyze status 查看结果,如果仍有缺失需再次执行 /fancy-analyze fix';
949
+ const finalMessage = headline + schemaNote + message + '\n\n请稍后执行 /fancy-analyze status 查看结果,如果仍有缺失需再次执行 /fancy-analyze fix';
425
950
  return notify(session, results.length > 0 ? 'success' : 'error', finalMessage);
426
951
  }
427
952
 
@@ -444,12 +969,31 @@ export async function apply(ctx) {
444
969
  return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
445
970
  }
446
971
 
972
+ const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
973
+ const hasSources = hasAnySourceFiles(rawSourcesDir);
974
+ const hasOutputs = hasAnyAnalyzeOutput(join(projectRoot, '对标', 'wiki'));
975
+ if (!hasSources && !hasOutputs) {
976
+ return notify(session, 'error', analyze.notify({ type: 'status_not_analyzed' }));
977
+ }
978
+
979
+ const schemaOutdated = isLegacyAnalyzeSchema(readAnalyzeSchema(projectRoot));
980
+
447
981
  // 检查来源目录下的文件,从"### 列表"表格中获取【有/无】信息
448
982
  // 同时读取"来源"和"来源摘要"两个目录
449
983
  // 映射格式:{ "书名": { "第X章": { "技法名": true/false, ... }, ... } }
450
984
  const bookChapterTechHas = {};
451
985
  // 记录缺少列表的章节
452
- const missingListReports = [];
986
+ // 用 let:下面会按(书名, 章号)重排 —— readdirSync 返回的是目录项
987
+ // 哈希序(ext4 上表现为第7章排在第4章前面),不是字典序。
988
+ let missingListReports = [];
989
+ let structuralTableSeen = false;
990
+ let legacyStructuralTableSeen = false;
991
+ const entityRows = [];
992
+ const legacySourceChapters = [];
993
+
994
+ // "来源"和"来源摘要"两个目录里已有的 .md 文件名。
995
+ // 用来回答:"raw/sources 里的这个分片,拆解产物落地了吗?"
996
+ const sourceFileNames = new Set();
453
997
 
454
998
  const sourceDirs = ['来源', '来源摘要'];
455
999
  for (const sourceDir of sourceDirs) {
@@ -460,6 +1004,7 @@ export async function apply(ctx) {
460
1004
  const sourceFiles = readdirSync(duiBiaoWikiDir);
461
1005
  for (const file of sourceFiles) {
462
1006
  if (!file.endsWith('.md')) continue;
1007
+ sourceFileNames.add(file);
463
1008
  // 提取书名和第X章(如:全球高武-第10章-气血检查.md → 书名:全球高武, 第X章:第10章)
464
1009
  // 格式:书名-第X章-xxx.md 或 书名-技法名-第X章.md
465
1010
  const fullMatch = file.match(/^(.+?)-(第\d+章)-/);
@@ -473,6 +1018,14 @@ export async function apply(ctx) {
473
1018
 
474
1019
  const content = readFileSync(join(duiBiaoWikiDir, file), 'utf-8');
475
1020
 
1021
+ // 来源页没有新版结构表 → 这一章还是旧 schema 的产物。
1022
+ // 只认 来源/,不认 来源摘要/:摘要页本来就可能没有技法表。
1023
+ const hasNewStructureTables = content.includes('### 跨章节结构实体')
1024
+ || content.includes('### 章节级结构设计');
1025
+ if (sourceDir === '来源' && !hasNewStructureTables) {
1026
+ legacySourceChapters.push({ bookName, chapter: chapterKey, file });
1027
+ }
1028
+
476
1029
  // 查找"### 列表"三级标题的位置
477
1030
  const listHeadingIndex = content.indexOf('### 列表');
478
1031
  if (listHeadingIndex === -1) {
@@ -481,29 +1034,19 @@ export async function apply(ctx) {
481
1034
  continue;
482
1035
  }
483
1036
 
484
- // 从"### 列表"之后提取表格内容(到下一个三级标题之前)
485
- const afterList = content.substring(listHeadingIndex);
486
- const nextHeadingMatch = afterList.match(/\n#{1,3}\s+\S/);
487
- const tableEndIndex = nextHeadingMatch
488
- ? afterList.indexOf(nextHeadingMatch[0])
489
- : afterList.length;
490
- const tableContent = afterList.substring(0, tableEndIndex);
491
-
492
- // 解析 markdown 表格
493
- const tableLines = tableContent.split('\n');
494
- const techHas = {}; // { "世界观构建": true, "伏笔回收": false, ... }
495
-
496
- for (const line of tableLines) {
497
- // 匹配表格行:| 技法名 | 有/无 |
498
- const rowMatch = line.match(/^\|\s*(.+?)\s*\|\s*(有|无)\s*\|/);
499
- if (rowMatch) {
500
- const techName = rowMatch[1].trim();
501
- const hasValue = rowMatch[2] === '有';
502
- techHas[techName] = hasValue;
503
- }
1037
+ if (content.includes('### 跨章节结构实体') || content.includes('### 章节级结构设计')) {
1038
+ structuralTableSeen = true;
504
1039
  }
505
-
1040
+ // 上一版 schema 的按章结构设计表;只用于提示迁移,不参与检查。
1041
+ if (content.includes('### 结构设计分析')) legacyStructuralTableSeen = true;
1042
+ // 按章类型:写作技法 + 章节功能设计。
1043
+ // 过滤旧类型,避免 status 追着已废弃的按章页面不放。
1044
+ const techHas = {
1045
+ ...filterChapterTypes(parseHasTable(content, '### 列表')),
1046
+ ...filterChapterTypes(parseHasTable(content, '### 章节级结构设计')),
1047
+ };
506
1048
  bookChapterTechHas[bookName][chapterKey] = techHas;
1049
+ entityRows.push(...parseEntityRows(content, bookName, chapterKey));
507
1050
  }
508
1051
  } catch {
509
1052
  // 忽略读取错误
@@ -513,6 +1056,7 @@ export async function apply(ctx) {
513
1056
  // 检查各技法目录下是否有对应的文件
514
1057
  // 映射格式:{ "书名": { "技法名": ["第X章", "第Y章", ...], ... }, ... }
515
1058
  const bookTechChapters = {};
1059
+ const legacyStructureDirs = new Map();
516
1060
 
517
1061
  const duiBiaoWikiRoot = join(projectRoot, '对标', 'wiki');
518
1062
  if (existsSync(duiBiaoWikiRoot)) {
@@ -522,9 +1066,20 @@ export async function apply(ctx) {
522
1066
  if (dir === '来源' || dir === '来源摘要') continue;
523
1067
 
524
1068
  const dirPath = join(duiBiaoWikiRoot, dir);
525
- if (!statSync(dirPath).isDirectory()) continue;
526
-
527
- const files = readdirSync(dirPath);
1069
+ let dirStat = null;
1070
+ try { dirStat = statSync(dirPath); } catch { continue; }
1071
+ if (!dirStat.isDirectory()) continue;
1072
+
1073
+ let files = [];
1074
+ try { files = readdirSync(dirPath); } catch { continue; }
1075
+
1076
+ // 旧版按章结构页:只统计并提示迁移,不当作新版技法/实体页。
1077
+ if (LEGACY_STRUCTURE_DIRS.includes(dir)) {
1078
+ const pageCount = files.filter(
1079
+ (file) => file.endsWith('.md') && !file.endsWith('-总结.md')).length;
1080
+ legacyStructureDirs.set(dir, pageCount);
1081
+ continue;
1082
+ }
528
1083
  for (const file of files) {
529
1084
  if (!file.endsWith('.md')) continue;
530
1085
  // 提取书名和第X章(如:全球高武-节奏韵律-第10章.md → 书名:全球高武, 第X章:第10章)
@@ -546,11 +1101,79 @@ export async function apply(ctx) {
546
1101
  }
547
1102
  }
548
1103
 
1104
+ // 跨章节实体页:按“类型 + 文件名”索引,并读取正文用于检查章节小节。
1105
+ const entityPageContents = new Map();
1106
+ const legacyEntityChapterPages = new Map();
1107
+ for (const type of ENTITY_STRUCTURE_TYPES) {
1108
+ const dirPath = join(duiBiaoWikiRoot, type);
1109
+ if (!existsSync(dirPath)) continue;
1110
+ let files = [];
1111
+ try { files = readdirSync(dirPath); } catch { continue; }
1112
+ for (const file of files) {
1113
+ if (!file.endsWith('.md')) continue;
1114
+ if (file.endsWith('-总结.md')) continue;
1115
+ // 实体目录里的按章文件是上一版 schema 的产物;不把它当成
1116
+ // 新版实体页,否则 status 会误以为实体页已经存在。
1117
+ if (/第\d+章/.test(file)) {
1118
+ const list = legacyEntityChapterPages.get(type) || [];
1119
+ list.push(file);
1120
+ legacyEntityChapterPages.set(type, list);
1121
+ continue;
1122
+ }
1123
+ try {
1124
+ entityPageContents.set(
1125
+ `${type}::${file}`,
1126
+ readFileSync(join(dirPath, file), 'utf-8'),
1127
+ );
1128
+ } catch {
1129
+ // 读不到就当缺失,下面会报出来
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ // 章节列表按(书名, 章号)排 —— 遍历用的是 readdirSync 的返回顺序
1135
+ missingListReports = sortByChapter(missingListReports);
1136
+
1137
+ // ---- 拆解产物缺失检查 ----
1138
+ //
1139
+ // 前两项检查都是"文件在 来源/ 里存在"的前提下做的:
1140
+ // · "列表未找到" —— 文件在,但内容里没有 ### 列表 段;
1141
+ // · "缺失检测" —— 文件在,列表说某技法"有",但技法文件没落地。
1142
+ //
1143
+ // 但还有一种更靠前的情况压根没被检查到:**分片在 raw/sources/ 里,
1144
+ // 而 来源/ 和 来源摘要/ 里连对应的章节文件都没有** —— 这一章
1145
+ // 根本没被拆解,或者拆了但没落盘。
1146
+ //
1147
+ // 服务端的 missingFiles 只反映它自己的队列状态,拆解"跑完了"
1148
+ // 但产物没落盘这种情况它不知道,所以要本地查这一份。
1149
+ const missingIngestOutputs = findMissingIngestOutputs({
1150
+ rawSourcesDir,
1151
+ sourceFileNames,
1152
+ });
1153
+ const missingIngestReports = sortByChapter(
1154
+ missingIngestOutputs.map(o => o.report));
1155
+ const missingIngestItems = missingIngestOutputs
1156
+ .filter(o => o.chapter)
1157
+ .map(o => ({ bookName: o.bookName, chapter: o.chapter }));
1158
+
549
1159
  // 比对结果:找出"标记为有、但技法目录里没有对应文件"的章节。
550
1160
  // 之前同一段逻辑算了两遍(一次出报告、一次出 .retry),
551
1161
  // 两份循环还得保持一致,改一处漏一处就会让报告和 .retry 对不上。
552
- const missingReports = [];
1162
+ let missingReports = [];
553
1163
  const retryItems = [];
1164
+ const retryReasons = new Map();
1165
+ const addRetry = (bookName, chapter, reason) => {
1166
+ if (!bookName || !chapter) return;
1167
+ const key = bookName + '::' + chapter;
1168
+ if (reason) {
1169
+ const set = retryReasons.get(key) || new Set();
1170
+ set.add(reason);
1171
+ retryReasons.set(key, set);
1172
+ }
1173
+ const exists = retryItems.some(
1174
+ (item) => item.bookName === bookName && item.chapter === chapter);
1175
+ if (!exists) retryItems.push({ bookName, chapter });
1176
+ };
554
1177
  for (const [bookName, chapterTechHas] of Object.entries(bookChapterTechHas)) {
555
1178
  for (const [chapter, techHas] of Object.entries(chapterTechHas)) {
556
1179
  const missingTechs = [];
@@ -565,10 +1188,134 @@ export async function apply(ctx) {
565
1188
  }
566
1189
  if (missingTechs.length > 0) {
567
1190
  missingReports.push(`${bookName}${chapter}的ingest有缺失,缺失的为${missingTechs.join('、')}`);
568
- retryItems.push({ bookName, chapter });
1191
+ addRetry(bookName, chapter, '按章技法页缺失:' + missingTechs.join('、'));
1192
+ }
1193
+ }
1194
+ }
1195
+ // 跨章节实体页检查:页面不存在 → 重 ingest 首次章节;
1196
+ // 页面存在但没有本次章节小节 → 重 ingest 本次章节。
1197
+ const entityMissingReports = [];
1198
+ const entitySectionGaps = new Map();
1199
+ const pushRetry = (bookName, chapter, reason) => addRetry(bookName, chapter, reason);
1200
+ // 同一实体可能在多章被引用;按“类型 + 页面文件”分组后再报,
1201
+ // 否则一个缺失页面会按章重复几十行,真正的缺失反而被淹没。
1202
+ const entityGroups = new Map();
1203
+ for (const row of entityRows) {
1204
+ const chapter = row.currentChapter || row.chapterKey;
1205
+ const key = `${row.bookName}::${row.type}::${row.pageFile}`;
1206
+ let group = entityGroups.get(key);
1207
+ if (!group) {
1208
+ group = {
1209
+ bookName: row.bookName,
1210
+ type: row.type,
1211
+ pageFile: row.pageFile,
1212
+ invalidPageFile: row.invalidPageFile,
1213
+ entities: new Set(),
1214
+ chapters: new Set(),
1215
+ };
1216
+ entityGroups.set(key, group);
1217
+ }
1218
+ group.entities.add(row.entity);
1219
+ if (chapter) group.chapters.add(chapter);
1220
+ if (row.invalidPageFile) group.invalidPageFile = true;
1221
+ }
1222
+
1223
+ for (const group of entityGroups.values()) {
1224
+ const chapters = [...group.chapters].sort(
1225
+ (a, b) => (chapterNumber(a) ?? Number.MAX_SAFE_INTEGER) -
1226
+ (chapterNumber(b) ?? Number.MAX_SAFE_INTEGER));
1227
+ const firstChapter = chapters[0] || null;
1228
+ const entityLabel = [...group.entities].join('、');
1229
+ if (group.invalidPageFile) {
1230
+ entityMissingReports.push(
1231
+ `${group.bookName}的${group.type}实体「${entityLabel}」页面文件名 ${group.pageFile} 带章节号;跨章节实体页必须用稳定实体名,不能按章建页`);
1232
+ for (const chapter of chapters) pushRetry(group.bookName, chapter, '实体页文件名带章节号:' + group.pageFile);
1233
+ continue;
1234
+ }
1235
+ const content = entityPageContents.get(`${group.type}::${group.pageFile}`);
1236
+ if (content === undefined) {
1237
+ entityMissingReports.push(
1238
+ `${group.bookName}的${group.type}实体「${entityLabel}」缺少页面 ${group.pageFile}(首次出现 ${firstChapter || '未知'};涉及 ${chapters.join('、') || '未知章节'})`);
1239
+ for (const chapter of chapters) pushRetry(group.bookName, chapter, '实体页整页缺失:' + group.pageFile);
1240
+ continue;
1241
+ }
1242
+ for (const chapter of chapters) {
1243
+ if (hasChapterHeading(content, chapter)) continue;
1244
+ entityMissingReports.push(
1245
+ `${group.bookName}的${group.type}实体「${entityLabel}」页面 ${group.pageFile} 缺少 ${chapter} 小节`);
1246
+ entitySectionGaps.set(
1247
+ group.type, (entitySectionGaps.get(group.type) || 0) + 1);
1248
+ pushRetry(group.bookName, chapter, '实体页缺少 ' + chapter + ' 小节:' + group.pageFile);
1249
+ }
1250
+ }
1251
+
1252
+ // 角色弧光覆盖的启发式检查:人物关系页的两端通常就是角色。
1253
+ // 缺页的角色所在关系章节进 .retry,fix 重传后才会按新版 schema
1254
+ // 为这些角色补角色弧光页。
1255
+ let missingArcs = [];
1256
+ const missingArcChapters = new Map();
1257
+ {
1258
+ const arcNames = new Set();
1259
+ for (const row of entityRows) {
1260
+ if (row.type !== '角色弧光设计') continue;
1261
+ const entity = String(row.entity || '').trim();
1262
+ if (entity) arcNames.add(entity);
1263
+ // 弧光页可能用“老班”建档,但正式姓名“刘安国”写在 tags 或正文里。
1264
+ // 只看 entity 会把这种别名的关系端点误报成“缺角色弧光页”。
1265
+ const content = entityPageContents.get(`${row.type}::${row.pageFile}`) || '';
1266
+ for (const tag of parseFrontmatterTags(content)) arcNames.add(tag);
1267
+ for (const alias of parseAliasesFromContent(content)) arcNames.add(alias);
1268
+ }
1269
+ const relationCharacterNames = new Set();
1270
+ const nonCharacterName = /(父母|家人|兄弟|姐妹|同学|老师|全班|众人|人群|集团|公司|大学|学院|医院|银行|协会|联盟|家族|国家|班级|政府|部门|学校|武大|一中)/u;
1271
+ for (const row of entityRows) {
1272
+ if (row.type !== '人物关系设计') continue;
1273
+ for (const part of String(row.entity || '').split(/[-—–与、,,]/u)) {
1274
+ const name = part.trim();
1275
+ if (name.length < 2 || name.length > 4) continue;
1276
+ if (nonCharacterName.test(name)) continue;
1277
+ relationCharacterNames.add(name);
1278
+ }
1279
+ }
1280
+ missingArcs = [...relationCharacterNames].filter((name) => !arcNames.has(name));
1281
+ for (const name of missingArcs) {
1282
+ const byBook = new Map();
1283
+ for (const row of entityRows) {
1284
+ if (row.type !== '人物关系设计') continue;
1285
+ const parts = String(row.entity || '')
1286
+ .split(/[-—–与、,,]/u)
1287
+ .map((part) => part.trim());
1288
+ if (!parts.includes(name)) continue;
1289
+ const chapter = row.currentChapter || row.chapterKey;
1290
+ if (!chapter) continue;
1291
+ if (!byBook.has(row.bookName)) byBook.set(row.bookName, new Set());
1292
+ byBook.get(row.bookName).add(chapter);
1293
+ }
1294
+ for (const [bookName, chapters] of byBook) {
1295
+ missingArcChapters.set(bookName + '::' + name, chapters);
1296
+ for (const chapter of chapters) {
1297
+ pushRetry(bookName, chapter, '角色弧光页整页缺失:' + name);
1298
+ }
569
1299
  }
570
1300
  }
571
1301
  }
1302
+
1303
+ // 旧 schema 的来源页:重传这一章,才能生成「### 跨章节结构实体」
1304
+ // 表和后续实体页。没有这一步,旧项目永远停留在按章结构页。
1305
+ for (const item of legacySourceChapters) {
1306
+ pushRetry(item.bookName, item.chapter, '来源页需要重生成跨章节结构实体表');
1307
+ }
1308
+
1309
+ // 实体目录里已经存在按章文件:重传对应章节,让 ingest 先把它们
1310
+ // 合并成稳定实体页,再补本章内容。
1311
+ for (const files of legacyEntityChapterPages.values()) {
1312
+ for (const file of files) {
1313
+ const match = file.match(/^(.+?)-.*?第(\d+)章/);
1314
+ if (!match) continue;
1315
+ pushRetry(match[1], `第${match[2]}章`, '按章命名的实体页需要合并:' + file);
1316
+ }
1317
+ }
1318
+
572
1319
  // 添加缺少列表的章节到 retry
573
1320
  for (const report of missingListReports) {
574
1321
  const match = report.match(/^(.+?)(第\d+章)的列表未找到/);
@@ -577,17 +1324,117 @@ export async function apply(ctx) {
577
1324
  const chapter = match[2];
578
1325
  const exists = retryItems.some(item => item.bookName === bookName && item.chapter === chapter);
579
1326
  if (!exists) {
580
- retryItems.push({ bookName, chapter });
1327
+ addRetry(bookName, chapter, '来源页缺少「### 列表」表');
1328
+ }
1329
+ }
1330
+ }
1331
+ // 拆解产物缺失的章节同样进 retry —— 补传这一章才能把产物补出来
1332
+ for (const item of missingIngestItems) {
1333
+ const exists = retryItems.some(
1334
+ r => r.bookName === item.bookName && r.chapter === item.chapter);
1335
+ if (!exists) {
1336
+ addRetry(item.bookName, item.chapter, '整章拆解产物缺失');
1337
+ }
1338
+ }
1339
+ // 章节列表按(书名, 章号)排
1340
+ missingReports = sortByChapter(missingReports);
1341
+
1342
+ // 给 fix 准备“已有哪些页面 / 本次缺哪些页面”。
1343
+ // 写在单独的 .retry-pages.json,避免改变 .retry 的既有结构。
1344
+ const buildRetryPageInfo = (bookName, chapter) => {
1345
+ const existing = new Set();
1346
+ const missing = new Set();
1347
+
1348
+ const sourcePrefix = `${bookName}-${chapter}-`;
1349
+ const sourcePages = [...sourceFileNames].filter((file) => file.startsWith(sourcePrefix));
1350
+ for (const file of sourcePages) {
1351
+ const sourceDir = existsSync(join(projectRoot, '对标', 'wiki', '来源', file))
1352
+ ? '来源'
1353
+ : existsSync(join(projectRoot, '对标', 'wiki', '来源摘要', file))
1354
+ ? '来源摘要'
1355
+ : '来源';
1356
+ existing.add(`wiki/${sourceDir}/${file}`);
1357
+ }
1358
+ if (sourcePages.length === 0) {
1359
+ missing.add(`wiki/来源/${bookName}-${chapter}-*.md(来源页)`);
1360
+ }
1361
+
1362
+ const techHas = bookChapterTechHas[bookName]?.[chapter] || {};
1363
+ for (const [tech, hasValue] of Object.entries(techHas)) {
1364
+ if (hasValue !== true) continue;
1365
+ const pagePath = `wiki/${tech}/${bookName}-${tech}-${chapter}.md`;
1366
+ if (bookTechChapters[bookName]?.[tech]?.includes(chapter)) {
1367
+ existing.add(pagePath);
1368
+ } else {
1369
+ missing.add(pagePath);
1370
+ }
1371
+ }
1372
+
1373
+ const rows = entityRows.filter(
1374
+ (row) => row.bookName === bookName &&
1375
+ (row.currentChapter || row.chapterKey) === chapter);
1376
+ for (const row of rows) {
1377
+ if (row.invalidPageFile) {
1378
+ missing.add(`wiki/${row.type}/${row.pageFile}(文件名带章节号,必须改成稳定实体名)`);
1379
+ continue;
1380
+ }
1381
+ const pagePath = `wiki/${row.type}/${row.pageFile}`;
1382
+ const content = entityPageContents.get(`${row.type}::${row.pageFile}`);
1383
+ if (content === undefined) {
1384
+ missing.add(`${pagePath}(实体页整页缺失)`);
1385
+ } else if (!hasChapterHeading(content, chapter)) {
1386
+ missing.add(`${pagePath}(缺少 ${chapter} 小节)`);
1387
+ } else {
1388
+ existing.add(pagePath);
1389
+ }
1390
+ }
1391
+
1392
+ // 角色弧光缺页:必须把具体页面写进 .retry-pages.json,
1393
+ // 否则 fix 的提示词没有可列出的页面,只能让模型去猜。
1394
+ for (const [key, chapters] of missingArcChapters) {
1395
+ if (!chapters.has(chapter)) continue;
1396
+ const sep = key.indexOf('::');
1397
+ const arcBook = key.slice(0, sep);
1398
+ const arcName = key.slice(sep + 2);
1399
+ if (arcBook !== bookName || !arcName) continue;
1400
+ missing.add(`wiki/角色弧光设计/${bookName}-角色-${arcName}.md(整页缺失)`);
1401
+ }
1402
+
1403
+ // 其他无法枚举成页面路径的缺失原因(来源页缺列表、旧 schema 等)
1404
+ // 也要明确写出来,不能让 fix 去猜。
1405
+ if (missing.size === 0) {
1406
+ const reasons = retryReasons.get(bookName + '::' + chapter);
1407
+ if (reasons) {
1408
+ for (const reason of reasons) missing.add(reason);
581
1409
  }
582
1410
  }
1411
+
1412
+ if (sourcePages.length === 0 && rows.length === 0 &&
1413
+ !Object.values(techHas).some((value) => value === true)) {
1414
+ missing.add('整章拆解产物(来源页 + 按章页面 + 实体页)');
1415
+ }
1416
+
1417
+ return { existing: [...existing], missing: [...missing] };
1418
+ };
1419
+
1420
+ const retryPageInfo = {};
1421
+ for (const item of retryItems) {
1422
+ retryPageInfo[`${item.bookName}::${item.chapter}`] =
1423
+ buildRetryPageInfo(item.bookName, item.chapter);
583
1424
  }
1425
+
1426
+ // .retry 里的 items 同样排序:它是按缺失检测的遍历顺序 push 的,
1427
+ // 乱序会让 /fancy-analyze fix 的补传顺序跟着乱。
1428
+ const sortedRetryItems = sortByChapter(retryItems, (it) => it && it.bookName);
1429
+
584
1430
  // 根据当前检查结果覆盖 .retry 文件
585
1431
  // 写失败必须说出来:写不进去的话,后面 /fancy-analyze fix 只会报
586
1432
  // "没有找到 .retry 文件",用户完全不知道真正的失败原因在这里。
587
1433
  let retryWriteError = null;
1434
+ let retryPagesWriteError = null;
588
1435
  try {
589
- if (retryItems.length > 0) {
590
- writeFileSync(join(projectRoot, '.retry'), JSON.stringify({ items: retryItems }, null, 2), 'utf-8');
1436
+ if (sortedRetryItems.length > 0) {
1437
+ writeFileSync(join(projectRoot, '.retry'), JSON.stringify({ items: sortedRetryItems }, null, 2), 'utf-8');
591
1438
  } else {
592
1439
  // 没有缺失项时删除 .retry 文件
593
1440
  const retryFilePath = join(projectRoot, '.retry');
@@ -599,6 +1446,17 @@ export async function apply(ctx) {
599
1446
  retryWriteError = err instanceof Error ? err.message : String(err);
600
1447
  }
601
1448
 
1449
+ try {
1450
+ const pagesPath = join(projectRoot, '.retry-pages.json');
1451
+ if (sortedRetryItems.length > 0) {
1452
+ writeFileSync(pagesPath, JSON.stringify(retryPageInfo, null, 2), 'utf-8');
1453
+ } else if (existsSync(pagesPath)) {
1454
+ unlinkSync(pagesPath);
1455
+ }
1456
+ } catch (err) {
1457
+ retryPagesWriteError = err instanceof Error ? err.message : String(err);
1458
+ }
1459
+
602
1460
  try {
603
1461
  const result = await queryIngestStatus(llmWikiUrl, llmWikiToken, referenceId);
604
1462
 
@@ -612,26 +1470,164 @@ export async function apply(ctx) {
612
1470
  }));
613
1471
  }
614
1472
 
615
- const missingFiles = result.data.missingFiles || [];
1473
+ const missingFiles = sortByChapter(result.data.missingFiles || []);
616
1474
  const queue = result.data.queue || {};
617
1475
 
618
- let message = '';
1476
+ const entityStats = ENTITY_STRUCTURE_TYPES.map((type) => {
1477
+ const pages = new Set(
1478
+ entityRows.filter((row) => row.type === type).map((row) => row.pageFile));
1479
+ const missing = [...pages].filter(
1480
+ (file) => !entityPageContents.has(`${type}::${file}`)).length;
1481
+ return {
1482
+ type,
1483
+ entities: pages.size,
1484
+ missing,
1485
+ sectionGaps: entitySectionGaps.get(type) || 0,
1486
+ };
1487
+ });
1488
+ const coverageLines = [];
1489
+ if (entityStats.some((s) => s.entities > 0)) {
1490
+ coverageLines.push('## 跨章节实体覆盖');
1491
+ for (const s of entityStats) {
1492
+ const arcGap = s.type === '角色弧光设计' ? missingArcs.length : 0;
1493
+ if (s.entities === 0) {
1494
+ coverageLines.push(`- ℹ️ ${s.type}:0 个(本书未引用,不算缺失)`);
1495
+ } else if (s.missing > 0 || s.sectionGaps > 0 || arcGap > 0) {
1496
+ const parts = [`${s.entities} 个实体页`];
1497
+ if (s.missing > 0) parts.push(`缺 ${s.missing} 个`);
1498
+ if (s.sectionGaps > 0) parts.push(`另有 ${s.sectionGaps} 个章节小节缺失`);
1499
+ if (arcGap > 0) parts.push(`另有 ${arcGap} 个角色缺页`);
1500
+ coverageLines.push(`- ❌ ${s.type}:${parts.join(',')}`);
1501
+ } else {
1502
+ coverageLines.push(`- ✅ ${s.type}:${s.entities} 个实体页`);
1503
+ }
1504
+ }
1505
+ const totalMissingPages = entityStats.reduce((n, s) => n + s.missing, 0);
1506
+ const hasLegacyStructureFiles = legacyStructureDirs.size > 0 || legacyEntityChapterPages.size > 0;
1507
+ coverageLines.push('');
1508
+ if (hasLegacyStructureFiles) {
1509
+ coverageLines.push('⚠️ 结构设计覆盖未完成:还有旧版/按章命名的实体文件需要迁移。');
1510
+ } else if (missingArcs.length > 0) {
1511
+ coverageLines.push(`❌ 角色弧光覆盖不完整:${missingArcs.length} 个角色缺角色弧光页。`);
1512
+ } else if (entityMissingReports.length === 0 && totalMissingPages === 0) {
1513
+ coverageLines.push('✅ 结构设计覆盖完整,未发现缺失。');
1514
+ }
1515
+ coverageLines.push('');
1516
+ }
1517
+ if (legacyStructureDirs.size > 0) {
1518
+ coverageLines.push('## 旧版结构页(不参与新实体检查)');
1519
+ for (const [name, pageCount] of legacyStructureDirs) {
1520
+ coverageLines.push(`- ⚠️ ${name}:${pageCount} 个按章页面`);
1521
+ }
1522
+ coverageLines.push(' ⚠️ 新版改用跨章节实体页:伏笔回收→伏笔布局、世界观构建/世界观规则设计→世界观设计、角色弧光→角色弧光设计。');
1523
+ coverageLines.push(' ⚠️ 重新 ingest 对应章节后由新 schema 生成实体页;旧页面不会自动迁移,可确认后手动删除。');
1524
+ coverageLines.push('');
1525
+ }
1526
+ if (legacyEntityChapterPages.size > 0) {
1527
+ coverageLines.push('## 按章命名的实体页(不符合跨章节规则)');
1528
+ for (const [type, files] of legacyEntityChapterPages) {
1529
+ coverageLines.push(`- ❌ ${type}:${files.length} 个带章节号的文件`);
1530
+ }
1531
+ coverageLines.push(' ❌ 实体页文件名不应带 `第X章`;/fancy-analyze fix 会在重新 ingest 时自动合并为稳定实体页。');
1532
+ coverageLines.push('');
1533
+ }
1534
+ // 去掉最后一个空行,同时保证各覆盖小节之间恰好一个空行。
1535
+ while (coverageLines.length > 0 && coverageLines[coverageLines.length - 1] === '') {
1536
+ coverageLines.pop();
1537
+ }
1538
+ let structuralCoverage;
1539
+ if (coverageLines.length > 0) {
1540
+ structuralCoverage = coverageLines.join('\n');
1541
+ } else if (structuralTableSeen) {
1542
+ structuralCoverage = '## 跨章节实体覆盖\nℹ️ 结构设计表存在,但还没有记录任何跨章节实体。';
1543
+ } else if (legacyStructuralTableSeen) {
1544
+ structuralCoverage = '## 跨章节实体覆盖\n⚠️ 检测到旧版「### 结构设计分析」表(按章结构页)。新版改为「### 跨章节结构实体」;重新 ingest 后才会生成跨章节实体页。';
1545
+ } else {
1546
+ structuralCoverage = '## 跨章节实体覆盖\nℹ️ 未发现结构设计分析表(可能是旧版拆解结果;重新 ingest 后才会出现跨章节实体页)。';
1547
+ }
1548
+
1549
+ const problemLines = [];
1550
+ if (schemaOutdated) {
1551
+ problemLines.push('## 对标 schema 版本');
1552
+ problemLines.push('⚠️ 检测到 对标/schema.md 是旧版(按章结构设计)。');
1553
+ problemLines.push('⚠️ /fancy-analyze fix 会先更新为新版 schema,再补传缺失章节;否则重新 ingest 仍会生成伏笔回收/世界观构建/角色弧光这些按章页面。');
1554
+ }
619
1555
  if (retryWriteError) {
620
- message += `\n\n❌ 写入 .retry 文件失败:${retryWriteError}` +
621
- '\n缺失清单没能保存,/fancy-analyze fix 将无法工作。';
1556
+ problemLines.push('## .retry 写入失败');
1557
+ problemLines.push(`❌ 写入 .retry 文件失败:${retryWriteError}`);
1558
+ problemLines.push('❌ 缺失清单没能保存,/fancy-analyze fix 将无法工作。');
1559
+ }
1560
+ if (retryPagesWriteError) {
1561
+ problemLines.push('## .retry-pages 写入失败');
1562
+ problemLines.push(`⚠️ 写入 .retry-pages.json 失败:${retryPagesWriteError}`);
1563
+ problemLines.push('⚠️ fix 将退回通用修复指令,无法列出“已有页面/缺失页面”。');
622
1564
  }
623
1565
  if (missingReports.length > 0) {
624
- message += '\n\n## 缺失检测\n' + missingReports.join('\n');
1566
+ problemLines.push('## 缺失检测');
1567
+ for (const report of missingReports) problemLines.push(`❌ ${report}`);
625
1568
  }
626
1569
  if (missingListReports.length > 0) {
627
- message += '\n\n## 列表未找到\n' + missingListReports.join('\n');
1570
+ problemLines.push('## 列表未找到');
1571
+ for (const report of missingListReports) problemLines.push(`❌ ${report}`);
1572
+ }
1573
+ if (missingIngestReports.length > 0) {
1574
+ problemLines.push('## 拆解产物缺失');
1575
+ for (const report of missingIngestReports) problemLines.push(`❌ ${report}`);
1576
+ }
1577
+ if (entityMissingReports.length > 0) {
1578
+ problemLines.push('## 结构设计实体缺失');
1579
+ for (const report of entityMissingReports) problemLines.push(`❌ ${report}`);
1580
+ }
1581
+ const aliasReports = new Map();
1582
+ for (const row of entityRows) {
1583
+ if (!RELATION_ONLY_ENTITY_RE.test(String(row.entity || '').trim())) continue;
1584
+ aliasReports.set(`${row.type}::${row.pageFile}`, row);
1585
+ }
1586
+ if (aliasReports.size > 0) {
1587
+ problemLines.push('## 疑似称呼实体');
1588
+ for (const row of aliasReports.values()) {
1589
+ problemLines.push(`⚠️ ${row.type}「${row.entity}」可能是某个角色的称呼,不应单独建页;请合并到本名页`);
1590
+ }
1591
+ }
1592
+
1593
+ if (missingArcs.length > 0) {
1594
+ problemLines.push('## 角色弧光覆盖');
1595
+ problemLines.push(`⚠️ 以下角色出现在人物关系里,但没有角色弧光页:${missingArcs.join('、')}`);
1596
+ problemLines.push('⚠️ 新版 schema 要求每个有名角色都有角色弧光页;/fancy-analyze fix 重传相关章节后会补。');
1597
+ }
1598
+
1599
+ if (legacyStructureDirs.size > 0 || legacyEntityChapterPages.size > 0) {
1600
+ problemLines.push('## 结构设计文件需要迁移');
1601
+ problemLines.push('⚠️ 检测到旧版结构页或按章命名的实体页,详见下方覆盖统计;/fancy-analyze fix 会合并/重传。');
1602
+ }
1603
+ if (legacySourceChapters.length > 0) {
1604
+ const byBook = new Map();
1605
+ for (const item of legacySourceChapters) {
1606
+ if (!byBook.has(item.bookName)) byBook.set(item.bookName, new Set());
1607
+ byBook.get(item.bookName).add(item.chapter);
1608
+ }
1609
+ const lines = [];
1610
+ let total = 0;
1611
+ for (const [book, chapters] of byBook) {
1612
+ const sorted = [...chapters].sort(
1613
+ (a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }));
1614
+ const preview = sorted.slice(0, 5).join('、') +
1615
+ (sorted.length > 5 ? ` 等 ${sorted.length} 章` : '');
1616
+ lines.push(`⚠️ ${book}:${preview}`);
1617
+ total += sorted.length;
1618
+ }
1619
+ problemLines.push('## 旧 schema 章节');
1620
+ problemLines.push(...lines);
1621
+ problemLines.push(`⚠️ 共 ${total} 章缺少「### 跨章节结构实体」表。`);
1622
+ problemLines.push('⚠️ /fancy-analyze fix 会先更新 schema,再用新版重传这些章节。');
628
1623
  }
629
1624
 
630
1625
  return notify(session, 'success', analyze.notify({
631
1626
  type: 'status_result',
632
1627
  missingFiles,
633
1628
  queue,
634
- extraMessage: message,
1629
+ extraMessage: problemLines.join('\n').replace(/\n(?=## )/g, '\n\n'),
1630
+ coverageMessage: structuralCoverage,
635
1631
  }));
636
1632
  } catch (err) {
637
1633
  const errMsg = describeFetchError(err);
@@ -642,12 +1638,19 @@ export async function apply(ctx) {
642
1638
  }
643
1639
  }
644
1640
 
645
- // 2.3 summary-status:查技法总结进度(纯本地,零网络、零 LLM)
1641
+ // 2.3 summary-status:查技法总结进度。
646
1642
  //
647
- // 这一支刻意不碰 llm_wiki:summary 的进度是**本地文件系统事实**
648
- // (总结文件头部的「已总结章节」清单),跟 llm_wiki 的 ingest 队列
649
- // 是两回事 —— ingest 队列是"拆解"进度,summary-status 是"总结"进度。
1643
+ // 和 summary / resummary 一样先确认 ingest 完成:ingest 还没结束时,
1644
+ // wiki 里可能只有一部分来源页,此时显示"进度"会把用户引向错误结论。
1645
+ // 进度本身仍然是本地文件系统事实,但门禁必须先过。
650
1646
  if (isSummaryStatus) {
1647
+ if (signal?.aborted) {
1648
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1649
+ }
1650
+ const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'summary-status' });
1651
+ if (!ingestReady.ok) {
1652
+ return notify(session, 'error', ingestReady.text);
1653
+ }
651
1654
  const wikiRoot = join(projectRoot, '对标', 'wiki');
652
1655
  if (!existsSync(wikiRoot)) {
653
1656
  return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
@@ -669,12 +1672,19 @@ export async function apply(ctx) {
669
1672
  // 新写的和旧的混在一起,文件越滚越胖。移走是最干脆的"重来"。
670
1673
  // 备份而不是删除:旧总结可能还有参考价值。
671
1674
  if (isResummary) {
1675
+ if (signal?.aborted) {
1676
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1677
+ }
1678
+ const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'resummary' });
1679
+ if (!ingestReady.ok) {
1680
+ return notify(session, 'error', ingestReady.text);
1681
+ }
672
1682
  const wikiRoot = join(projectRoot, '对标', 'wiki');
673
1683
  if (!existsSync(wikiRoot)) {
674
1684
  return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
675
1685
  }
676
1686
 
677
- const allDirs = listTechDirs(wikiRoot);
1687
+ const allDirs = listSummaryDirs(wikiRoot);
678
1688
  const targets = resummaryTarget
679
1689
  ? allDirs.filter((name) => name === resummaryTarget)
680
1690
  : allDirs;
@@ -682,8 +1692,8 @@ export async function apply(ctx) {
682
1692
  if (resummaryTarget && targets.length === 0) {
683
1693
  const known = allDirs.join('、');
684
1694
  return notify(session, 'error',
685
- `❌ 没有名为「${resummaryTarget}」的技法目录` +
686
- (known ? `。可用:${known}` : '(对标/wiki 下还没有技法目录)'));
1695
+ `❌ 未找到名为「${resummaryTarget}」的总结目录` +
1696
+ (known ? `。可用目录:${known}` : '(对标/wiki 下尚无可用总结目录)'));
687
1697
  }
688
1698
 
689
1699
  const stamp = backupStamp(Date.now());
@@ -709,6 +1719,7 @@ export async function apply(ctx) {
709
1719
  // 提示词同样只给目录:章节名让模型自己去目录里找。
710
1720
  const tasks = dirsToDispatch(progress).map((p) => ({
711
1721
  name: p.name,
1722
+ scope: p.scope,
712
1723
  sourceCount: p.sourceCount,
713
1724
  pendingCount: p.pendingFiles.length,
714
1725
  }));
@@ -716,7 +1727,23 @@ export async function apply(ctx) {
716
1727
 
717
1728
  let dispatched = 0;
718
1729
  if (tasks.length > 0) {
719
- const prompt = analyze.generateSummarySystemPrompt({ tasks, remainingChapters });
1730
+ // 脚本复制到项目里再让模型调用 —— 不在 node_modules 里跑
1731
+ const registerTool = ensureRegisterScript(projectRoot);
1732
+ if (!registerTool) {
1733
+ return notify(session, 'error', analyze.notify({
1734
+ type: 'register_script_failed',
1735
+ projectRoot,
1736
+ }));
1737
+ }
1738
+ const prompt = analyze.generateSummarySystemPrompt({
1739
+ tasks,
1740
+ remainingChapters,
1741
+ projectRoot,
1742
+ registerScript: registerTool,
1743
+ });
1744
+ if (signal?.aborted) {
1745
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1746
+ }
720
1747
  const follow = await llmFollowup(invocation, prompt);
721
1748
  if (!follow || follow.ok !== true) {
722
1749
  return notify(session, 'error',
@@ -744,6 +1771,13 @@ export async function apply(ctx) {
744
1771
  // 模型一个字都还没写 —— 那是假成功。
745
1772
  if (isSummary) {
746
1773
  try {
1774
+ if (signal?.aborted) {
1775
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1776
+ }
1777
+ const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'summary' });
1778
+ if (!ingestReady.ok) {
1779
+ return notify(session, 'error', ingestReady.text);
1780
+ }
747
1781
  const wikiRoot = join(projectRoot, '对标', 'wiki');
748
1782
  if (!existsSync(wikiRoot)) {
749
1783
  return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
@@ -767,15 +1801,32 @@ export async function apply(ctx) {
767
1801
  // 每个目录里写清楚"怎么判断哪些章节还没总结",模型自己去目录里找。
768
1802
  const tasks = toDispatch.map((p) => ({
769
1803
  name: p.name,
1804
+ scope: p.scope,
770
1805
  sourceCount: p.sourceCount,
771
1806
  pendingCount: p.pendingFiles.length,
772
1807
  }));
773
1808
  const remainingChapters = toDispatch.reduce((n, p) => n + p.pendingFiles.length, 0);
774
1809
 
775
1810
  // 生成提示词
776
- const prompt = analyze.generateSummarySystemPrompt({ tasks, remainingChapters });
1811
+ // 脚本复制到项目里再让模型调用 —— 不在 node_modules 里跑
1812
+ const registerTool = ensureRegisterScript(projectRoot);
1813
+ if (!registerTool) {
1814
+ return notify(session, 'error', analyze.notify({
1815
+ type: 'register_script_failed',
1816
+ projectRoot,
1817
+ }));
1818
+ }
1819
+ const prompt = analyze.generateSummarySystemPrompt({
1820
+ tasks,
1821
+ remainingChapters,
1822
+ projectRoot,
1823
+ registerScript: registerTool,
1824
+ });
777
1825
 
778
1826
  // 调用 LLM 进行总结
1827
+ if (signal?.aborted) {
1828
+ return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
1829
+ }
779
1830
  const follow = await llmFollowup(invocation, prompt);
780
1831
  // llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
781
1832
  // 否则投递失败也会报"技法总结完成"