@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
@@ -3,16 +3,21 @@
3
3
  *
4
4
  * 两条路径:
5
5
  * A 探索模式 /fancy-topic [参数] trim 后为空 → 选平台/选文件/LLM 评分
6
- * B 敲定模式 /fancy-topic setup 8 弹框收集 → 原子写盘 → phase=topic_done
6
+ * B 敲定模式 /fancy-topic setup 逐项弹框 → 原子写盘 → phase=topic_done
7
7
  *
8
8
  * 不论哪条路径,都先检查 .fancy-deployed 存在且 phase ∈ {scanned, topic_done}。
9
9
  */
10
- import { existsSync, readdirSync, readFileSync } from 'fs';
11
- import { join } from 'path';
10
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, mkdirSync } from 'fs';
11
+ import { join, resolve } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
14
14
  import { readDeployedState, deployedStateProblemText, DEPLOYED_STATUS } from '#deployed-state';
15
+ 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';
15
19
  import { topic } from './templates.js';
20
+ import { deployTopicTools } from './tool-deploy.js';
16
21
 
17
22
  export const inject = ['commands', 'userQuestions'];
18
23
 
@@ -42,26 +47,49 @@ const MULTISELECT_IDS = new Set(['hard_constraints', 'anti_tropes']);
42
47
  /**
43
48
  * 从 DSH 的一条 answer 里取值。
44
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
+ *
45
65
  * @param {object} ans - DSH 的 AskUserQuestionAnswerItem
46
66
  * @returns {string|string[]|undefined} undefined 表示这条 answer 没有对应字段
47
67
  */
48
68
  function collectAnswerValue(ans) {
49
69
  if (!ans || !ans.id) return undefined;
70
+ const custom = typeof ans.custom === 'string' ? ans.custom.trim() : '';
50
71
  if (MULTISELECT_IDS.has(ans.id)) {
51
- if (Array.isArray(ans.selected) && ans.selected.length > 0) return ans.selected.slice();
52
- if (typeof ans.custom === 'string' && ans.custom.trim() !== '') return [ans.custom.trim()];
53
- return [];
72
+ const out = Array.isArray(ans.selected) ? ans.selected.slice() : [];
73
+ if (custom && !out.includes(custom)) out.push(custom);
74
+ return out;
54
75
  }
55
- // 单选或文本:取 selected[0] 或 custom
56
- return ans.selected?.[0] ?? ans.custom ?? '';
76
+ // 单选或文本:custom(用户亲手输入的)优先,否则取选中的标签
77
+ if (custom) return custom;
78
+ return ans.selected?.[0] ?? '';
57
79
  }
58
80
 
59
81
  function getProjectRoot(session) {
60
- // 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
61
- // 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
62
- // 此时返回 null,由调用方给出明确报错。
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.
63
84
  const cwd = session.header?.cwd;
64
- return cwd || null;
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
+ }
65
93
  }
66
94
 
67
95
  /**
@@ -145,18 +173,33 @@ function splitDecisionSections(content) {
145
173
  const platform = title ? platformOfHeader(title) : null;
146
174
 
147
175
  if (platform) {
148
- const isStartMark = /[-—–\s]*start\s*$/i.test(title);
149
- const isEndMark = /[-—–\s]*end\s*$/i.test(title);
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();
150
180
 
151
- // -end 行:关闭当前块(同平台),**不开新块**
152
- // —— 少了这一步,"-end" 自己会变成一个空的新块。
153
- if (isEndMark && cur && cur.platform === platform) {
181
+ // -end 行:关闭当前块(同平台且同 key),**不开新块**。
182
+ // 不能只看平台:`# 起点-签约榜-end` 会把 `# 起点-全选-start`
183
+ // 开的块错误闭合,两块内容随后可能被合并/错切。
184
+ if (isEndMark && cur && cur.platform === platform && (!cur.key || cur.key === titleKey)) {
154
185
  cur.hasEnd = true;
155
186
  cur.headers.push(`# ${title}`);
156
187
  raw.push(cur);
157
188
  cur = null;
158
189
  continue;
159
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
+ }
160
203
 
161
204
  // 块内的另一个标题:同平台、当前块开着、且它不是 -start/-end 标记。
162
205
  // 追加进当前块,不新开块 —— LLM 常在 -start 后面紧跟一个报告大标题。
@@ -169,6 +212,7 @@ function splitDecisionSections(content) {
169
212
  if (cur) raw.push(cur);
170
213
  cur = {
171
214
  platform,
215
+ key: titleKey,
172
216
  headers: [`# ${title}`],
173
217
  bodyLines: [],
174
218
  hasStart: isStartMark,
@@ -279,12 +323,41 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
279
323
 
280
324
  // 扫描 扫榜结果/ 目录下的 topic_decision_<date>.md 文件
281
325
  const scanDir = join(projectRoot, '扫榜结果');
282
- const decisionFiles = existsSync(scanDir)
283
- ? readdirSync(scanDir)
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)
284
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
+ })
285
352
  .sort()
286
- .reverse() // 最新的在前
287
- : [];
353
+ .reverse(); // 最新的在前
354
+ } catch (e) {
355
+ return notify(session, 'error', topic.notify({
356
+ type: 'read_failed',
357
+ err: `读取扫榜结果目录失败:${e.message}`,
358
+ }));
359
+ }
360
+ }
288
361
 
289
362
  if (decisionFiles.length === 0) {
290
363
  return notify(session, 'error', topic.notify({ type: 'no_decision_file' }));
@@ -319,12 +392,46 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
319
392
  // 读取 topic_decision_<date>.md
320
393
  const decisionPath = join(projectRoot, '扫榜结果', selectedFile);
321
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
+
322
413
  try {
323
414
  content = readFileSync(decisionPath, 'utf-8');
324
415
  } catch (e) {
325
416
  return notify(session, 'error', topic.notify({
326
- type: 'write_failed',
327
- err: `读取 ${selectedFile} 失败: ${e.message}`,
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,
328
435
  }));
329
436
  }
330
437
 
@@ -337,6 +444,96 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
337
444
  let matchedSections = filterSectionsByPlatform(sections, platformValue);
338
445
  const notes = [];
339
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
+
340
537
  // 一次平台标记都没写:若只扫过一个平台,整份文件都属于它(降级,不直接失败)
341
538
  if (sections.length === 0) {
342
539
  const scanned = scannedPlatforms(scanDir);
@@ -366,6 +563,7 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
366
563
  }));
367
564
  }
368
565
 
566
+
369
567
  // 诊断:哪些块缺 start/end 标记(LLM 格式不规范,但已按标题隔离,不影响正确性)
370
568
  // 分开统计 -start / -end:只缺 -end 的块被说成"标记缺失"是错的 ——
371
569
  // 那种块明明写着 -start,却出现在告警名单里,用户会以为切错了。
@@ -382,14 +580,24 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
382
580
  notes.push(`其中 ${parts.join(';')},已按标题行切分隔离,不影响平台归属。`);
383
581
  }
384
582
 
385
- // 拼装发给 LLM 的提示
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”这个普通文本行,正文从 `## 一、...` 开始。
386
594
  const blocksText = matchedSections.map((s, i) =>
387
- `## 块 ${i + 1}(${s.headers.join(' / ')})\n${s.body}`
595
+ `【数据块 ${i + 1}】\n${s.body}`
388
596
  ).join('\n\n');
389
597
 
390
598
  // 汇总按平台分组(保持文件里的出现顺序),并给出每个平台的块数。
391
599
  // 之前是"每块一行",同一个平台会出现多行、总数也和块数对不上;
392
- // 术语统一用"块"(与下面的 "## 块 N" 标记一致)。
600
+ // 术语统一用"块"(与下面的 "【数据块 N】" 标记一致)。
393
601
  const summaryByPlatform = new Map();
394
602
  for (const s of sections) {
395
603
  const name = platformCn(s.platform) || s.platform;
@@ -401,13 +609,31 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
401
609
  blocks,
402
610
  }));
403
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
+
404
625
  const prompt = topic.explorePrompt({
405
626
  platform: platformCnName,
406
627
  decisionFile: selectedFile,
628
+ // 绝对路径:提示词里"参考文件"和 source_decision_file 都用它,
629
+ // 免得模型拿着裸文件名拼不出它在 扫榜结果/ 下
630
+ decisionPath: decisionPath,
407
631
  blocks: blocksText,
408
632
  blockCount: matchedSections.length,
409
633
  sectionSummary,
410
634
  notes,
635
+ projectRoot,
636
+ saveScriptPath: deployed.saveScript,
411
637
  });
412
638
 
413
639
  // 触发 LLM 分析
@@ -417,15 +643,15 @@ async function pathExplore(ctx, session, invocation, signal, projectRoot) {
417
643
  // 否则投递失败也会报"分析已完成"
418
644
  if (!follow || follow.ok !== true) {
419
645
  return notify(session, 'error', topic.notify({
420
- type: 'write_failed',
421
- err: `触发 LLM 失败: ${(follow && follow.error) || '提示词投递失败'}`,
646
+ type: 'llm_delivery_failed',
647
+ err: (follow && follow.error) || '提示词投递失败',
422
648
  }));
423
649
  }
424
650
  } catch (err) {
425
651
  // llmFollowup 通常不抛错(它自己 try/catch),但保险起见
426
652
  return notify(session, 'error', topic.notify({
427
- type: 'write_failed',
428
- err: `触发 LLM 失败: ${err.message}`,
653
+ type: 'llm_delivery_failed',
654
+ err: err.message,
429
655
  }));
430
656
  }
431
657
 
@@ -495,10 +721,24 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
495
721
  // 那段代码对用户完全不可见,属于死代码,已删除。
496
722
  const suggestionPath = join(projectRoot, '_novel_topic_suggestion.json');
497
723
  let suggestion = null;
498
- if (existsSync(suggestionPath)) {
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)) {
499
730
  try {
500
- suggestion = JSON.parse(readFileSync(suggestionPath, 'utf-8'));
501
- } catch (_) { /* 损坏就忽略 */ }
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
+ }
502
742
  }
503
743
 
504
744
  // 比较 suggestion 与 selectedPlatform,决定是否应用建议值
@@ -508,11 +748,27 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
508
748
  // 读 _novel_topic.json,仅当其 platform 与 selectedPlatform 一致时作为"当前值"展示
509
749
  const currentValues = (() => {
510
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
+ }
511
758
  try {
512
- const data = JSON.parse(readFileSync(novelTopicPath, 'utf-8'));
759
+ const data = normalizeTopicData(JSON.parse(readFileSync(novelTopicPath, 'utf-8')));
513
760
  if (!data.platform) return data; // 没有 platform 字段就当作一致(兼容旧数据)
514
761
  return platformEqual(data.platform, selectedPlatform) ? data : {};
515
- } catch (_) { return {}; }
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
+ }
516
772
  })();
517
773
 
518
774
  // 只有 suggestion 平台匹配时,askFields 才显示"推荐值"
@@ -537,7 +793,7 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
537
793
  return notify(session, 'error', topic.notify({ type: 'cancelled' }));
538
794
  }
539
795
 
540
- // 收集 8 字段的值。
796
+ // 收集所有选题字段的值。
541
797
  // 首次收集与必填重试轮必须走同一套取值逻辑(collectAnswerValue)——
542
798
  // 否则多选字段会在重试轮塌成字符串、丢掉第 2 项以后的选择。
543
799
  const fieldValues = { platform: selectedPlatform };
@@ -615,37 +871,95 @@ async function pathSetup(ctx, session, invocation, signal, projectRoot) {
615
871
  if (signal?.aborted) {
616
872
  return notify(session, 'error', topic.notify({ type: 'cancelled' }));
617
873
  }
618
- const writeResult = await spawnScript('node', [
874
+ const writeArgs = [
619
875
  SELECT_TOPIC_SCRIPT,
620
876
  '--project-root', projectRoot,
621
- '--book-name', fieldValues.book_name,
622
- '--genre', fieldValues.genre,
623
- '--target-words', String(fieldValues.target_words),
624
- '--target-chapters', String(fieldValues.target_chapters),
625
- '--core-summary', fieldValues.core_summary,
626
- '--core-conflict', fieldValues.core_conflict,
627
- '--platform', fieldValues.platform,
628
- '--hard-constraints', JSON.stringify(fieldValues.hard_constraints || []),
629
- '--anti-tropes', JSON.stringify(fieldValues.anti_tropes || []),
630
- 'write',
631
- ], { timeout: 30000 });
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 });
632
889
 
633
890
  if (!writeResult.ok) {
634
891
  const errMsg = writeResult.error || '未知错误';
635
892
  return notify(session, 'error', topic.notify({ type: 'write_failed', err: errMsg }));
636
893
  }
637
894
 
638
- return notify(session, 'success', topic.notify({
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({
639
907
  type: 'path_b_done',
640
- fieldCount: Object.keys(fieldValues).length,
641
- extra: extraNotes.join('\n\n'),
642
- }));
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'));
643
957
  }
644
958
 
645
959
  export async function apply(ctx) {
646
960
  ctx.commands.register({
647
961
  name: 'fancy-topic',
648
- description: '📝 选题与创意约束 ( usage: /fancy-topic [setup] )',
962
+ description: '📝 选题与创意约束 ( usage: /fancy-topic [setup] | help )',
649
963
  input: {
650
964
  hint: '.',
651
965
  },
@@ -654,25 +968,60 @@ export async function apply(ctx) {
654
968
  const { signal } = invocation;
655
969
 
656
970
  // 1. 解析路径:A 探索 / B 敲定
971
+ // 只允许两种形式:
972
+ // /fancy-topic → 探索
973
+ // /fancy-topic setup → 敲定
974
+ // 其他任何参数都必须报错,不能悄悄当成探索/敲定。
657
975
  const rawInput = (invocation.rawInput || '').trim();
658
- const isSetupPath = rawInput.toLowerCase() === 'setup';
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';
659
987
 
660
988
  // 2. 检查项目初始化
661
989
  const projectRoot = getProjectRoot(session);
662
- if (!projectRoot) {
990
+ let projectRootAbs = projectRoot ? resolve(projectRoot) : null;
991
+ if (!projectRootAbs) {
663
992
  return notify(session, 'error', topic.notify({
664
993
  type: 'no_project_root',
665
- err: '会话未提供 cwd'
994
+ err: '会话未提供当前工作目录'
666
995
  }));
667
996
  }
668
- if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
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'))) {
669
1018
  return notify(session, 'error', topic.notify({ type: 'not_initialized' }));
670
1019
  }
671
1020
 
672
1021
  // 3. 检查 phase
673
1022
  // 区分"状态文件读不了"和"phase 值不对"——前者跑 scan 也没用,
674
1023
  // 报成"请先扫榜"会把用户带偏。
675
- const deployedState = readDeployedState(projectRoot);
1024
+ const deployedState = readDeployedState(projectRootAbs);
676
1025
  if (deployedState.status === DEPLOYED_STATUS.CORRUPT
677
1026
  || deployedState.status === DEPLOYED_STATUS.NO_PHASE
678
1027
  || deployedState.status === DEPLOYED_STATUS.UNKNOWN_PHASE) {
@@ -680,7 +1029,7 @@ export async function apply(ctx) {
680
1029
  // 文件**存在**时 bootstrap 会拒绝执行(它只在文件不存在时才初始化),
681
1030
  // 让用户去跑 bootstrap 等于把人送进死胡同。
682
1031
  return notify(session, 'error',
683
- deployedStateProblemText(deployedState, { projectRoot })
1032
+ deployedStateProblemText(deployedState, { projectRoot: projectRootAbs })
684
1033
  || topic.notify({
685
1034
  type: 'phase_unreadable',
686
1035
  err: deployedState.status === DEPLOYED_STATUS.CORRUPT
@@ -693,11 +1042,15 @@ export async function apply(ctx) {
693
1042
  return notify(session, 'error', topic.notify({ type: 'phase_wrong', phase }));
694
1043
  }
695
1044
 
1045
+ if (signal?.aborted) {
1046
+ return notify(session, 'error', topic.notify({ type: 'cancelled' }));
1047
+ }
1048
+
696
1049
  // 4. 路径分发
697
1050
  if (isSetupPath) {
698
- return pathSetup(ctx, session, invocation, signal, projectRoot);
1051
+ return pathSetup(ctx, session, invocation, signal, projectRootAbs);
699
1052
  } else {
700
- return pathExplore(ctx, session, invocation, signal, projectRoot);
1053
+ return pathExplore(ctx, session, invocation, signal, projectRootAbs);
701
1054
  }
702
1055
  },
703
1056
  });