@cloud411716/fancy-webnovel 1.0.46 → 3.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 (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. 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 +427 -0
  238. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  239. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  240. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  241. 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 +168 -0
  242. 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 +184 -0
  243. 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
  244. 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 +131 -0
  245. 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 +85 -0
  246. 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 +103 -0
  247. 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 +68 -0
  248. 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 +142 -0
  249. 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 +292 -0
  250. 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 +266 -0
  251. 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 +336 -0
  252. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  253. 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 +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
@@ -0,0 +1,1057 @@
1
+ /**
2
+ * fancy-topic plugin entry
3
+ *
4
+ * 两条路径:
5
+ * A 探索模式 /fancy-topic [参数] trim 后为空 → 选平台/选文件/LLM 评分
6
+ * B 敲定模式 /fancy-topic setup 逐项弹框 → 原子写盘 → phase=topic_done
7
+ *
8
+ * 不论哪条路径,都先检查 .fancy-deployed 存在且 phase ∈ {scanned, topic_done}。
9
+ */
10
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, mkdirSync } from 'fs';
11
+ import { join, resolve } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
14
+ import { readDeployedState, deployedStateProblemText, DEPLOYED_STATUS } from '#deployed-state';
15
+ import { repairTopicDecision, selectNonRedundantBlocks, inspectBlockBodies } from '#topic-decision';
16
+ import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
17
+ import { normalizeTopicData } from '#topic-schema';
18
+ import { resolveWorkspace } from '#workspace';
19
+ import { topic } from './templates.js';
20
+ import { deployTopicTools } from './tool-deploy.js';
21
+
22
+ export const inject = ['commands', 'userQuestions'];
23
+
24
+ /**
25
+ * select-topic.js 的绝对路径。
26
+ *
27
+ * 必须用 fileURLToPath:URL.pathname 会把中文/空格百分号编码
28
+ * (/tmp/普通 目录 → /tmp/%E6%99%AE%E9%80%9A%20%E7%9B%AE%E5%BD%95),
29
+ * 拼出来的路径不存在,spawnScript 必然报 MODULE_NOT_FOUND,
30
+ * 选题根本写不进去。run-scan.js 里同一个坑已经修过。
31
+ */
32
+ const SELECT_TOPIC_SCRIPT = join(
33
+ fileURLToPath(new URL('.', import.meta.url)),
34
+ 'scripts',
35
+ 'select-topic.js'
36
+ );
37
+
38
+ /**
39
+ * 多选字段的 id 集合。
40
+ *
41
+ * 这两个字段必须保持**数组**语义:多选回传 selected 是数组,
42
+ * 写盘时也是 JSON 数组。首次收集与必填重试轮必须用同一套取值逻辑 ——
43
+ * 曾经重试轮写成 `ans.selected?.[0]`,把多选塌成字符串且只保留第一项。
44
+ */
45
+ const MULTISELECT_IDS = new Set(['hard_constraints', 'anti_tropes']);
46
+
47
+ /**
48
+ * 从 DSH 的一条 answer 里取值。
49
+ *
50
+ * ## custom 与 selected 谁优先(2026-10-29 审计)
51
+ *
52
+ * DSH 的契约里两者可以并存:
53
+ *
54
+ * selected: string[] 选中的选项标签
55
+ * custom?: string 自由输入的"其他"
56
+ *
57
+ * web 端在**单选**时会把 selected 清空(只留 custom),
58
+ * 但 TUI 端可能两个都传 —— `selected: ['其他']` + `custom: '玄幻'`。
59
+ * 这时取 `selected[0]` 会拿到"其他"这个标签,
60
+ * 用户真正输入的题材**被丢掉**。
61
+ *
62
+ * 所以:**custom 是用户亲手敲的,比 selected 的标签更具体,优先用它**。
63
+ * 多选时两者都要保留(custom 是用户在已选项之外额外加的一条)。
64
+ *
65
+ * @param {object} ans - DSH 的 AskUserQuestionAnswerItem
66
+ * @returns {string|string[]|undefined} undefined 表示这条 answer 没有对应字段
67
+ */
68
+ function collectAnswerValue(ans) {
69
+ if (!ans || !ans.id) return undefined;
70
+ const custom = typeof ans.custom === 'string' ? ans.custom.trim() : '';
71
+ if (MULTISELECT_IDS.has(ans.id)) {
72
+ const out = Array.isArray(ans.selected) ? ans.selected.slice() : [];
73
+ if (custom && !out.includes(custom)) out.push(custom);
74
+ return out;
75
+ }
76
+ // 单选或文本:custom(用户亲手输入的)优先,否则取选中的标签
77
+ if (custom) return custom;
78
+ return ans.selected?.[0] ?? '';
79
+ }
80
+
81
+ function getProjectRoot(session) {
82
+ // Scan/topic/analyze are workspace-level tools. The workspace may be found
83
+ // from cwd, FANCY_HOME, or the global pointer written by bootstrap.
84
+ const cwd = session.header?.cwd;
85
+ if (!cwd) return null;
86
+ try {
87
+ return resolveWorkspace({ cwd, required: true });
88
+ } catch {
89
+ // Fall back to the session cwd so the caller can report "not initialized"
90
+ // with the right path instead of "no project root".
91
+ return cwd;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 读取项目 phase。
97
+ *
98
+ * 必须区分「读不到」和「phase 值不对」:
99
+ * 之前一律返回 null,调用方只能报"请先运行 /fancy-scan 扫榜" ——
100
+ * 但 .fancy-deployed 内容损坏时,跑 scan 也没用,错误信息会把用户带偏。
101
+ *
102
+ * @returns {{phase: string|null, error: 'missing'|'corrupt'|'no_phase'|null, message?: string}}
103
+ */
104
+ /**
105
+ * 平台目录(中文名 + 英文 value),用于判定某行标题归属哪个平台。
106
+ */
107
+ const PLATFORM_DIR = topic.PLATFORM_OPTIONS.map(p => ({
108
+ value: p.value,
109
+ label: p.label,
110
+ lowerValue: String(p.value).toLowerCase(),
111
+ }));
112
+
113
+ /**
114
+ * 判断一行标题归属哪个平台。
115
+ *
116
+ * 规则:去掉 start/end 后缀后,标题**以平台名开头**才算该平台的段标题。
117
+ * "番茄-男频阅读榜-20261021" → 番茄
118
+ * "番茄男频阅读榜分析" → 番茄(LLM 漏写标记时也认)
119
+ * "参考:起点同类作品" → 不以平台名开头 → 不归属任何平台
120
+ * 第二个规则很重要:正文里的 markdown 小标题常带平台名,若一律当段标题,
121
+ * 就会把别的平台的内容从段落中间切开。
122
+ */
123
+ function platformOfHeader(text) {
124
+ const core = String(text || '')
125
+ .trim()
126
+ .replace(/[-—–\s]*(start|end)\s*$/i, '')
127
+ .trim();
128
+ if (!core) return null;
129
+ for (const p of PLATFORM_DIR) {
130
+ if (core.startsWith(p.label)) return p.value;
131
+ if (core.toLowerCase().startsWith(p.lowerValue)) return p.value;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * 把 topic_decision_<date>.md 按**平台归属**切成块。
138
+ *
139
+ * 块边界规则(按优先级):
140
+ * 1. **-start 标记行** → 开一个新块(**哪怕前一个块是同平台**)
141
+ * 2. **-end 标记行** → 关闭当前块(同平台)
142
+ * 3. 其它以平台名开头的标题行 → 若当前有开着的**同平台**块,
143
+ * 它只是该块的另一个标题(LLM 常在 -start 后面再写一个报告大标题),
144
+ * 追加进该块;否则自己开一个新块
145
+ *
146
+ * 规则 1 是关键:同一个平台在一次报告里可能有**多个 -start 块**
147
+ * (例:"# 起点-全选-20261021-start" 与 "# 起点-签约榜-20261021-start"),
148
+ * 它们是两次独立分析,不能合并成一块 —— 合并后用户看到的
149
+ * "起点只有 1 块" 就是错的。
150
+ *
151
+ * 为什么不用 -start/-end 配对切块:LLM 偶尔漏写标记,靠配对会让漏掉 -end
152
+ * 的那一块一路吞到下一个 -end,把夹在中间的**别的平台**的分析一起带走
153
+ * —— 实测过:选番茄时起点的分析也一起发给了 LLM,而且全程没有任何报错。
154
+ * 现在靠"标题行"和"-start 标记"两个信号切块,漏写 -end 也不会串台。
155
+ *
156
+ * 返回 [{ platform, headers, body, hasStart, hasEnd }]
157
+ */
158
+ function splitDecisionSections(content) {
159
+ const lines = String(content || '').split('\n');
160
+ const raw = [];
161
+ let cur = null;
162
+ const preamble = [];
163
+
164
+ for (const line of lines) {
165
+ // 独占一行的代码围栏(``` / ~~~)是 markdown 装饰,不是正文内容。
166
+ // 扫榜提示词把 start/end 模板放在代码块里展示,LLM 照抄时会把围栏
167
+ // 一起写进 topic_decision 文件;这里只丢掉**围栏行本身**,
168
+ // 围栏里面的正文照旧保留(不"自愈"文件,只在解析时忽略装饰)。
169
+ if (/^[ \t]*(?:`{3,}|~{3,}).*$/.test(line)) continue;
170
+
171
+ const m = line.match(/^[ \t]*#{1,6}[ \t]*(.+?)[ \t]*$/);
172
+ const title = m ? m[1].trim() : '';
173
+ const platform = title ? platformOfHeader(title) : null;
174
+
175
+ if (platform) {
176
+ // 分隔符是必需的:`# Restart` 不能被当成 `-start` 标记。
177
+ const isStartMark = /(?:^|[-—–\s])start\s*$/i.test(title);
178
+ const isEndMark = /(?:^|[-—–\s])end\s*$/i.test(title);
179
+ const titleKey = title.replace(/(?:^|[-—–\s])(?:start|end)\s*$/i, '').trim();
180
+
181
+ // -end 行:关闭当前块(同平台且同 key),**不开新块**。
182
+ // 不能只看平台:`# 起点-签约榜-end` 会把 `# 起点-全选-start`
183
+ // 开的块错误闭合,两块内容随后可能被合并/错切。
184
+ if (isEndMark && cur && cur.platform === platform && (!cur.key || cur.key === titleKey)) {
185
+ cur.hasEnd = true;
186
+ cur.headers.push(`# ${title}`);
187
+ raw.push(cur);
188
+ cur = null;
189
+ continue;
190
+ }
191
+ // key 对不上的 end 不关闭当前块;当普通标题保留在块内,
192
+ // 由 inspectTopicDecision / parseMarkerBlocks 去报告“标记不匹配”。
193
+ if (isEndMark && cur && cur.platform === platform) {
194
+ cur.headers.push(`# ${title}`);
195
+ continue;
196
+ }
197
+ // 孤立 end / 别的平台的 end:不要凭空开一个空块。
198
+ if (isEndMark) {
199
+ if (cur) cur.bodyLines.push(`# ${title}`);
200
+ else preamble.push(`# ${title}`);
201
+ continue;
202
+ }
203
+
204
+ // 块内的另一个标题:同平台、当前块开着、且它不是 -start/-end 标记。
205
+ // 追加进当前块,不新开块 —— LLM 常在 -start 后面紧跟一个报告大标题。
206
+ if (cur && cur.platform === platform && !isStartMark && !isEndMark) {
207
+ cur.headers.push(`# ${title}`);
208
+ continue;
209
+ }
210
+
211
+ // 其余情况开新块(含 -start 标记行:同平台也必须开新块)
212
+ if (cur) raw.push(cur);
213
+ cur = {
214
+ platform,
215
+ key: titleKey,
216
+ headers: [`# ${title}`],
217
+ bodyLines: [],
218
+ hasStart: isStartMark,
219
+ hasEnd: false,
220
+ };
221
+ continue;
222
+ }
223
+
224
+ if (cur) cur.bodyLines.push(line);
225
+ else preamble.push(line);
226
+ }
227
+ if (cur) raw.push(cur);
228
+
229
+ // 文件前言并入第一块(LLM 有时把本次分析写在任何标题之前)
230
+ if (preamble.length && raw.length) {
231
+ raw[0].bodyLines = [...preamble, ...raw[0].bodyLines];
232
+ }
233
+
234
+ return raw.map((s) => ({
235
+ platform: s.platform,
236
+ headers: s.headers,
237
+ body: s.bodyLines.join('\n').trim(),
238
+ hasStart: s.hasStart,
239
+ hasEnd: s.hasEnd,
240
+ }));
241
+ }
242
+
243
+ /**
244
+ * 从块里挑出属于指定平台的块。
245
+ *
246
+ * 平台名(番茄/起点/晋江/七猫)出现在标题中即认为匹配;
247
+ * 同时也认英文 value(fanqie/qidian/...)—— LLM 偶尔会写成英文,
248
+ * 只认中文会让用户看到"没有找到该平台的内容块"。
249
+ */
250
+ function filterSectionsByPlatform(sections, platformValue) {
251
+ const val = String(platformValue || '').toLowerCase();
252
+ if (!val) return [];
253
+ return sections.filter(s => s.platform === val);
254
+ }
255
+
256
+ /**
257
+ * 从 扫榜结果/ 的产物文件名推断扫过哪些平台。
258
+ *
259
+ * 产物文件名形如 "番茄男频阅读榜_20261021.md" —— 以平台中文名开头。
260
+ * 用途:LLM 一次平台标记都没写时,若只扫过一个平台,
261
+ * 就能确定整份文件都属于那个平台(降级处理,不至于直接失败)。
262
+ */
263
+ function scannedPlatforms(scanDir) {
264
+ const found = [];
265
+ if (!existsSync(scanDir)) return found;
266
+ let files = [];
267
+ try { files = readdirSync(scanDir); } catch (_) { return found; }
268
+ for (const f of files) {
269
+ if (!f.endsWith('.md')) continue;
270
+ if (/^topic_decision_\d{8}\.md$/.test(f)) continue;
271
+ for (const p of PLATFORM_DIR) {
272
+ if (f.startsWith(p.label) && !found.includes(p.value)) found.push(p.value);
273
+ }
274
+ }
275
+ return found;
276
+ }
277
+
278
+ function platformCn(value) {
279
+ const m = topic.PLATFORM_OPTIONS.find(p => p.value === value);
280
+ return m ? m.label : value;
281
+ }
282
+
283
+ // 把任意形式的 platform(label 或 value)归一化成 value 用于比较
284
+ function platformToValue(p) {
285
+ if (!p) return '';
286
+ const direct = topic.PLATFORM_OPTIONS.find(opt => opt.value === p);
287
+ if (direct) return p;
288
+ const byLabel = topic.PLATFORM_OPTIONS.find(opt => opt.label === p);
289
+ return byLabel ? byLabel.value : p;
290
+ }
291
+
292
+ // 比较两个 platform 值(不论是 label 还是 value,都视为相等)
293
+ function platformEqual(a, b) {
294
+ if (!a || !b) return false;
295
+ if (a === b) return true;
296
+ return platformToValue(a) === platformToValue(b);
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // 路径 A:探索模式
301
+ // ---------------------------------------------------------------------------
302
+
303
+ 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 文件
325
+ const scanDir = join(projectRoot, '扫榜结果');
326
+ let decisionFiles = [];
327
+ if (existsSync(scanDir)) {
328
+ try {
329
+ const st = lstatSync(scanDir);
330
+ if (st.isSymbolicLink() || !st.isDirectory()) {
331
+ return notify(session, 'error', topic.notify({
332
+ type: 'read_failed',
333
+ err: '扫榜结果/ 不是真实目录,已拒绝读取。请将其替换为真实目录后重试。',
334
+ }));
335
+ }
336
+ } catch (e) {
337
+ return notify(session, 'error', topic.notify({
338
+ type: 'read_failed',
339
+ err: `检查扫榜结果目录失败:${e.message}`,
340
+ }));
341
+ }
342
+ try {
343
+ decisionFiles = readdirSync(scanDir)
344
+ .filter(f => /^topic_decision_\d{8}\.md$/.test(f))
345
+ // 不跟随符号链接:决策文件必须在工作区目录内,防止读到项目外的内容。
346
+ .filter(f => {
347
+ try {
348
+ const st = lstatSync(join(scanDir, f));
349
+ return st.isFile() && !st.isSymbolicLink();
350
+ } catch { return false; }
351
+ })
352
+ .sort()
353
+ .reverse(); // 最新的在前
354
+ } catch (e) {
355
+ return notify(session, 'error', topic.notify({
356
+ type: 'read_failed',
357
+ err: `读取扫榜结果目录失败:${e.message}`,
358
+ }));
359
+ }
360
+ }
361
+
362
+ if (decisionFiles.length === 0) {
363
+ return notify(session, 'error', topic.notify({ type: 'no_decision_file' }));
364
+ }
365
+
366
+ // 弹框 2:选 topic_decision 文件
367
+ if (signal?.aborted) {
368
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
369
+ }
370
+ const askedfileChoice = await askUserQuestion(ctx, invocation, [topic.askDecisionFile(decisionFiles)]);
371
+ if (!askedfileChoice.ok) {
372
+ return notify(session, 'error', topic.notify({
373
+ type: askedfileChoice.failure.type,
374
+ err: askedfileChoice.failure.message
375
+ }));
376
+ }
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__' 判断导致该分支永远不可达。
385
+ if (selectedFile === topic.RESCAN_LABEL) {
386
+ return notify(session, 'success', topic.notify({ type: 'path_a_rescan' }));
387
+ }
388
+ if (!selectedFile || !decisionFiles.includes(selectedFile)) {
389
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
390
+ }
391
+
392
+ // 读取 topic_decision_<date>.md
393
+ const decisionPath = join(projectRoot, '扫榜结果', selectedFile);
394
+ 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
+ try {
414
+ content = readFileSync(decisionPath, 'utf-8');
415
+ } catch (e) {
416
+ return notify(session, 'error', topic.notify({
417
+ type: 'read_failed',
418
+ err: `读取 ${selectedFile} 失败:${e.message}`,
419
+ }));
420
+ }
421
+
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) {
432
+ return notify(session, 'error', topic.notify({
433
+ type: 'empty_decision_file',
434
+ decisionFile: selectedFile,
435
+ }));
436
+ }
437
+
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
+ }
506
+ }
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
+ }
527
+ }
528
+
529
+ // 被覆盖过的段落已从备份补回 —— 必须告诉用户"这份文件刚才丢过东西"
530
+ if (repairedKeys.length) {
531
+ notes.push(
532
+ `检测到该文件曾被覆盖(LLM 写文件时用了覆盖语义),已从备份补回 ${repairedKeys.length} 个内容块:` +
533
+ repairedKeys.join('、') + '。'
534
+ );
535
+ }
536
+
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) {
558
+ 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]))],
563
+ }));
564
+ }
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 });
606
+ }
607
+ const sectionSummary = [...summaryByPlatform.entries()].map(([name, blocks]) => ({
608
+ platform: name,
609
+ blocks,
610
+ }));
611
+
612
+ // LLM 只执行工作区内的只读脚本:先把 save-suggestion.js 及其 import 闭包
613
+ // 部署到 `<工作区目录>/.fancy-tools/fancy-topic/`,再把部署后的路径写进提示词。
614
+ // 不能把插件目录(node_modules)暴露给模型。
615
+ const deployed = deployTopicTools(projectRoot);
616
+ if (!deployed.ok) {
617
+ return notify(session, 'error', topic.notify({
618
+ type: 'tool_deploy_failed',
619
+ err: deployed.problems.join(';'),
620
+ }));
621
+ }
622
+
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 分析
640
+ 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),但保险起见
652
+ return notify(session, 'error', topic.notify({
653
+ type: 'llm_delivery_failed',
654
+ err: err.message,
655
+ }));
656
+ }
657
+
658
+ return notify(session, 'success', topic.notify({
659
+ type: 'path_a_done',
660
+ platform: platformCnName,
661
+ decisionFile: selectedFile,
662
+ blockCount: matchedSections.length,
663
+ notes,
664
+ }));
665
+ }
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // 路径 B:敲定模式
669
+ // ---------------------------------------------------------------------------
670
+
671
+ 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' }));
692
+ }
693
+
694
+ // 弹框:选平台(路径 B 也独立选,因为路径 A 不写文件)
695
+ if (signal?.aborted) {
696
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
697
+ }
698
+ const askedplatformChoice = await askUserQuestion(ctx, invocation, [topic.askPlatform()]);
699
+ if (!askedplatformChoice.ok) {
700
+ return notify(session, 'error', topic.notify({
701
+ type: askedplatformChoice.failure.type,
702
+ err: askedplatformChoice.failure.message
703
+ }));
704
+ }
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(符号链接,已忽略)',
728
+ }));
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
+ }
743
+
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) {
785
+ return notify(session, 'error', topic.notify({
786
+ type: askedfieldAnswers.failure.type,
787
+ err: askedfieldAnswers.failure.message
788
+ }));
789
+ }
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) {
864
+ return notify(session, 'error', topic.notify({
865
+ type: 'fields_missing',
866
+ missing: stillMissing,
867
+ }));
868
+ }
869
+
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;
899
+ try {
900
+ const parsed = JSON.parse(writeResult.output || '{}');
901
+ phaseWarning = parsed.phase_warning || null;
902
+ } 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
+ }
947
+
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'));
957
+ }
958
+
959
+ export async function apply(ctx) {
960
+ ctx.commands.register({
961
+ name: 'fancy-topic',
962
+ description: '📝 选题与创意约束 ( usage: /fancy-topic [setup] | help )',
963
+ input: {
964
+ hint: '.',
965
+ },
966
+ handler: async (invocation) => {
967
+ const session = invocation.agent.session;
968
+ const { signal } = invocation;
969
+
970
+ // 1. 解析路径:A 探索 / B 敲定
971
+ // 只允许两种形式:
972
+ // /fancy-topic → 探索
973
+ // /fancy-topic setup → 敲定
974
+ // 其他任何参数都必须报错,不能悄悄当成探索/敲定。
975
+ const rawInput = (invocation.rawInput || '').trim();
976
+ const normalizedInput = rawInput.toLowerCase();
977
+ if (normalizedInput === 'help') {
978
+ return notify(session, 'success', topic.notify({ type: 'help' }));
979
+ }
980
+ if (normalizedInput && normalizedInput !== 'setup') {
981
+ return notify(session, 'error', topic.notify({
982
+ type: 'invalid_argument',
983
+ arg: rawInput,
984
+ }));
985
+ }
986
+ const isSetupPath = normalizedInput === 'setup';
987
+
988
+ // 2. 检查项目初始化
989
+ const projectRoot = getProjectRoot(session);
990
+ let projectRootAbs = projectRoot ? resolve(projectRoot) : null;
991
+ if (!projectRootAbs) {
992
+ return notify(session, 'error', topic.notify({
993
+ type: 'no_project_root',
994
+ err: '会话未提供当前工作目录'
995
+ }));
996
+ }
997
+ if (isSensitiveProjectPath(projectRootAbs) || !isValidProjectPath(projectRootAbs)) {
998
+ return notify(session, 'error', topic.notify({
999
+ type: 'no_project_root',
1000
+ err: '工作区目录路径不合法或属于受保护目录'
1001
+ }));
1002
+ }
1003
+ try {
1004
+ projectRootAbs = realpathSync(projectRootAbs);
1005
+ } catch (e) {
1006
+ return notify(session, 'error', topic.notify({
1007
+ type: 'no_project_root',
1008
+ err: `无法读取工作区目录:${e.message}`
1009
+ }));
1010
+ }
1011
+ if (isSensitiveProjectPath(projectRootAbs) || !isValidProjectPath(projectRootAbs)) {
1012
+ return notify(session, 'error', topic.notify({
1013
+ type: 'no_project_root',
1014
+ err: '工作区目录的真实路径属于受保护目录'
1015
+ }));
1016
+ }
1017
+ if (!existsSync(join(projectRootAbs, '.fancy-deployed'))) {
1018
+ return notify(session, 'error', topic.notify({ type: 'not_initialized' }));
1019
+ }
1020
+
1021
+ // 3. 检查 phase
1022
+ // 区分"状态文件读不了"和"phase 值不对"——前者跑 scan 也没用,
1023
+ // 报成"请先扫榜"会把用户带偏。
1024
+ const deployedState = readDeployedState(projectRootAbs);
1025
+ if (deployedState.status === DEPLOYED_STATUS.CORRUPT
1026
+ || deployedState.status === DEPLOYED_STATUS.NO_PHASE
1027
+ || deployedState.status === DEPLOYED_STATUS.UNKNOWN_PHASE) {
1028
+ // 用共享的修复指引,而不是只说"重新运行 /fancy-bootstrap"——
1029
+ // 文件**存在**时 bootstrap 会拒绝执行(它只在文件不存在时才初始化),
1030
+ // 让用户去跑 bootstrap 等于把人送进死胡同。
1031
+ return notify(session, 'error',
1032
+ deployedStateProblemText(deployedState, { projectRoot: projectRootAbs })
1033
+ || topic.notify({
1034
+ type: 'phase_unreadable',
1035
+ err: deployedState.status === DEPLOYED_STATUS.CORRUPT
1036
+ ? '.fancy-deployed 不是合法 JSON'
1037
+ : '.fancy-deployed 缺少 phase 字段',
1038
+ }));
1039
+ }
1040
+ const phase = deployedState.phase;
1041
+ if (phase !== 'scanned' && phase !== 'topic_done') {
1042
+ return notify(session, 'error', topic.notify({ type: 'phase_wrong', phase }));
1043
+ }
1044
+
1045
+ if (signal?.aborted) {
1046
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
1047
+ }
1048
+
1049
+ // 4. 路径分发
1050
+ if (isSetupPath) {
1051
+ return pathSetup(ctx, session, invocation, signal, projectRootAbs);
1052
+ } else {
1053
+ return pathExplore(ctx, session, invocation, signal, projectRootAbs);
1054
+ }
1055
+ },
1056
+ });
1057
+ }