@cloud411716/fancy-webnovel 2.2.0 → 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 (211) hide show
  1. package/client.js +201 -15
  2. package/cordis.patch.yml +5 -0
  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 +11 -7
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/confirmation.js +80 -0
  18. package/lib/db-init.js +123 -0
  19. package/lib/deployed-state.js +11 -11
  20. package/lib/direction-labels.js +265 -0
  21. package/lib/error-codes.js +31 -31
  22. package/lib/infra.js +29 -4
  23. package/lib/material-db.js +149 -0
  24. package/lib/path-guard.js +150 -0
  25. package/lib/projects.js +400 -0
  26. package/lib/scraper-utils.js +140 -0
  27. package/lib/surreal-server.js +338 -0
  28. package/lib/topic-decision.js +860 -0
  29. package/lib/topic-fields.js +251 -0
  30. package/lib/topic-schema.js +225 -0
  31. package/lib/workspace.js +501 -0
  32. package/package.json +29 -4
  33. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  34. package/plugins/fancy-analyze/index.js +1128 -77
  35. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  36. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  37. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  38. package/plugins/fancy-analyze/summary-progress.js +71 -7
  39. package/plugins/fancy-analyze/templates.js +437 -139
  40. package/plugins/fancy-bootstrap/index.js +276 -131
  41. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  42. package/plugins/fancy-bootstrap/templates.js +35 -18
  43. package/plugins/fancy-db/index.js +172 -0
  44. package/plugins/fancy-douyin/deploy.js +134 -0
  45. package/plugins/fancy-douyin/index.js +551 -0
  46. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  47. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  48. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  49. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  50. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  51. package/plugins/fancy-douyin/state.js +197 -0
  52. package/plugins/fancy-douyin/templates.js +127 -0
  53. package/plugins/fancy-export/exporter.js +805 -0
  54. package/plugins/fancy-export/index.js +63 -0
  55. package/plugins/fancy-import/import-driver.js +29 -6
  56. package/plugins/fancy-import/index.js +13 -5
  57. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  58. package/plugins/fancy-import/templates.js +17 -8
  59. package/plugins/fancy-material/exporter.js +153 -0
  60. package/plugins/fancy-material/genres.js +256 -0
  61. package/plugins/fancy-material/index.js +835 -0
  62. package/plugins/fancy-material/inventory.js +143 -0
  63. package/plugins/fancy-material/kinds.js +313 -0
  64. package/plugins/fancy-material/queue.js +55 -0
  65. package/plugins/fancy-material/scripts/query.js +254 -0
  66. package/plugins/fancy-material/templates.js +258 -0
  67. package/plugins/fancy-material/tool-deploy.js +138 -0
  68. package/plugins/fancy-material/validate.js +149 -0
  69. package/plugins/fancy-project/index.js +133 -0
  70. package/plugins/fancy-scan/existing-block.js +100 -0
  71. package/plugins/fancy-scan/index.js +410 -21
  72. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  73. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  74. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  75. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  76. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  77. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  78. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  79. package/plugins/fancy-scan/templates/blocks.js +155 -0
  80. package/plugins/fancy-scan/templates/platforms.js +372 -0
  81. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  82. package/plugins/fancy-scan/templates.js +545 -146
  83. package/plugins/fancy-scan/tool-deploy.js +335 -0
  84. package/plugins/fancy-scan/wait-turn.js +46 -0
  85. package/plugins/fancy-story-builder/db/README.md +106 -0
  86. package/plugins/fancy-story-builder/db/client.js +228 -0
  87. package/plugins/fancy-story-builder/db/commands.js +145 -0
  88. package/plugins/fancy-story-builder/db/ids.js +39 -0
  89. package/plugins/fancy-story-builder/db/import.js +798 -0
  90. package/plugins/fancy-story-builder/db/project.js +299 -0
  91. package/plugins/fancy-story-builder/db/query.js +361 -0
  92. package/plugins/fancy-story-builder/db/schema.js +417 -0
  93. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  94. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  95. package/plugins/fancy-story-builder/index.js +4105 -396
  96. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  97. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  98. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  99. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  100. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  101. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  102. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  103. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  104. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  105. package/plugins/fancy-story-builder/references/index.md +97 -6
  106. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  108. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  111. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  115. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  116. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  117. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  118. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  119. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  120. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  121. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  122. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  123. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  124. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  125. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  126. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  127. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  128. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  129. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  130. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  131. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  132. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  133. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  134. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  135. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  136. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  137. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  138. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  139. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  140. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  141. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  142. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  143. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  144. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  145. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  146. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  147. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  148. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  149. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  150. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  151. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  152. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  153. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  154. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  155. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  156. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  157. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  158. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  159. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  160. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  161. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  162. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  163. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  164. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  165. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  166. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  167. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  168. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  185. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  187. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  188. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  189. 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
  190. 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
  191. 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
  192. 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
  193. 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
  194. 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
  195. 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
  196. 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
  197. 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
  198. 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
  199. 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
  200. 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
  201. 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
  202. package/plugins/fancy-story-builder/templates.js +272 -74
  203. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  204. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  205. package/plugins/fancy-topic/index.js +414 -61
  206. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  207. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  208. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  209. package/plugins/fancy-topic/templates.js +225 -122
  210. package/plugins/fancy-topic/tool-deploy.js +200 -0
  211. package/lib/safe-rm.js +0 -94
@@ -0,0 +1,434 @@
1
+ /**
2
+ * v2/context.js —— 阶段自包含:_context 快照、bundle.json、README、导出。
3
+ *
4
+ * 每个阶段目录都可以整体交给另一个 LLM:
5
+ * - _context/ 是上游只读快照
6
+ * - bundle.json 说明读法、数量快照和校验项
7
+ * - README.md 是本阶段契约
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import {
12
+ V2_STAGE_BY_ID,
13
+ asArray,
14
+ contentHash,
15
+ ensureDir,
16
+ fileExists,
17
+ hashFile,
18
+ nowIso,
19
+ readJson,
20
+ readText,
21
+ relProjectPath,
22
+ stageDir,
23
+ stageFile,
24
+ stageHash,
25
+ writeJsonAtomic,
26
+ writeTextAtomic,
27
+ } from './core.js';
28
+ import { contractFor, renderContractMarkdown } from './contracts.js';
29
+ import { renderOutlineMarkdown, renderStageMarkdown } from './export-markdown.js';
30
+
31
+ /** 每个阶段需要哪些上游快照。 */
32
+ export function contextSpecs(stageId) {
33
+ const base = {
34
+ file: 'baseline.json',
35
+ sources: ['00-baseline/input.json', '00-baseline/references.json'],
36
+ merge: 'baseline',
37
+ };
38
+ switch (stageId) {
39
+ case '10-scale':
40
+ return [
41
+ base,
42
+ { file: 'formulas.json', sources: ['00-baseline/formulas.json'], merge: 'single' },
43
+ ];
44
+ case '20-macro':
45
+ return [
46
+ base,
47
+ { file: 'scale.json', sources: ['10-scale/scale.json'], merge: 'single' },
48
+ ];
49
+ case '30-cast':
50
+ return [
51
+ base,
52
+ { file: 'scale.json', sources: ['10-scale/scale.json'], merge: 'single' },
53
+ {
54
+ file: 'macro.json',
55
+ sources: ['world.json', 'story.json', 'volumes.json', 'lines.json', 'endings.json', 'emotion.json', 'roles.json'].map((n) => '20-macro/' + n),
56
+ merge: 'macro',
57
+ },
58
+ ];
59
+ case '40-rhythm':
60
+ return [
61
+ base,
62
+ { file: 'scale.json', sources: ['10-scale/scale.json'], merge: 'single' },
63
+ {
64
+ file: 'macro.json',
65
+ sources: ['world.json', 'story.json', 'volumes.json', 'lines.json', 'endings.json', 'emotion.json', 'roles.json'].map((n) => '20-macro/' + n),
66
+ merge: 'macro',
67
+ },
68
+ {
69
+ file: 'cast.json',
70
+ sources: ['characters.json', 'factions.json', 'locations.json', 'items.json', 'conflicts.json', 'foreshadows.json', 'climaxes.json', 'payoffs.json', 'relations.json', 'information.json'].map((n) => '30-cast/' + n),
71
+ merge: 'cast',
72
+ },
73
+ ];
74
+ case '50-writing':
75
+ return [
76
+ base,
77
+ { file: 'scale.json', sources: ['10-scale/scale.json'], merge: 'single' },
78
+ {
79
+ file: 'macro.json',
80
+ sources: ['world.json', 'story.json', 'volumes.json', 'lines.json', 'endings.json', 'emotion.json', 'roles.json'].map((n) => '20-macro/' + n),
81
+ merge: 'macro',
82
+ },
83
+ {
84
+ file: 'cast.json',
85
+ sources: ['characters.json', 'factions.json', 'locations.json', 'items.json', 'conflicts.json', 'foreshadows.json', 'climaxes.json', 'payoffs.json', 'relations.json', 'information.json'].map((n) => '30-cast/' + n),
86
+ merge: 'cast',
87
+ },
88
+ { file: 'rhythm.json', sources: ['40-rhythm/chapter_map.json', '40-rhythm/index.json'], merge: 'rhythm' },
89
+ ];
90
+ case '60-deslop':
91
+ return [
92
+ base,
93
+ { file: 'scale.json', sources: ['10-scale/scale.json'], merge: 'single' },
94
+ ];
95
+ default:
96
+ return [];
97
+ }
98
+ }
99
+
100
+ function sourcePath(projectRoot, rel) {
101
+ return path.join(projectRoot, '设定', rel);
102
+ }
103
+
104
+ function withoutMeta(value) {
105
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
106
+ const out = {};
107
+ for (const [key, item] of Object.entries(value)) {
108
+ if (key === '_meta') continue;
109
+ out[key] = item;
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function mergeSources(projectRoot, sources, merge) {
115
+ const files = sources.map((rel) => ({ rel, data: readJson(sourcePath(projectRoot, rel), null) }));
116
+ const present = files.filter((x) => x.data !== null);
117
+ if (!present.length) return null;
118
+ if (merge === 'single') return withoutMeta(present[0].data);
119
+ if (merge === 'baseline') {
120
+ const input = present.find((x) => x.rel.endsWith('input.json'));
121
+ const references = present.find((x) => x.rel.endsWith('references.json'));
122
+ return {
123
+ input: input ? input.data : {},
124
+ references: references ? references.data : [],
125
+ genre_profiles: withoutMeta(readJson(sourcePath(projectRoot, '00-baseline/genre-profiles.json'), null)),
126
+ };
127
+ }
128
+ if (merge === 'macro') {
129
+ return {
130
+ world: withoutMeta(readJson(sourcePath(projectRoot, '20-macro/world.json'), {})),
131
+ story: withoutMeta(readJson(sourcePath(projectRoot, '20-macro/story.json'), {})),
132
+ volumes: withoutMeta(readJson(sourcePath(projectRoot, '20-macro/volumes.json'), {})),
133
+ lines: withoutMeta(readJson(sourcePath(projectRoot, '20-macro/lines.json'), {})),
134
+ endings: withoutMeta(readJson(sourcePath(projectRoot, '20-macro/endings.json'), {})),
135
+ emotion: withoutMeta(readJson(sourcePath(projectRoot, '20-macro/emotion.json'), {})),
136
+ roles: withoutMeta(readJson(sourcePath(projectRoot, '20-macro/roles.json'), {})),
137
+ };
138
+ }
139
+ if (merge === 'cast') {
140
+ return {
141
+ characters: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/characters.json'), {})),
142
+ factions: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/factions.json'), {})),
143
+ locations: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/locations.json'), {})),
144
+ items: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/items.json'), {})),
145
+ conflicts: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/conflicts.json'), {})),
146
+ foreshadows: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/foreshadows.json'), {})),
147
+ climaxes: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/climaxes.json'), {})),
148
+ payoffs: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/payoffs.json'), {})),
149
+ relations: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/relations.json'), {})),
150
+ information: withoutMeta(readJson(sourcePath(projectRoot, '30-cast/information.json'), {})),
151
+ };
152
+ }
153
+ if (merge === 'rhythm') {
154
+ return {
155
+ chapter_map: withoutMeta(readJson(sourcePath(projectRoot, '40-rhythm/chapter_map.json'), {})),
156
+ index: withoutMeta(readJson(sourcePath(projectRoot, '40-rhythm/index.json'), {})),
157
+ };
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function snapshotPayload(projectRoot, spec) {
163
+ const data = mergeSources(projectRoot, spec.sources, spec.merge);
164
+ if (data === null) return null;
165
+ return {
166
+ _meta: {
167
+ snapshot_of: spec.sources,
168
+ source_hash: contentHash(data),
169
+ snapshot_at: nowIso(),
170
+ read_only: true,
171
+ },
172
+ ...data,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * 生成/刷新阶段的上游快照。
178
+ * @returns {{ok:boolean, files:string[], errors:Array}}
179
+ */
180
+ export function ensureContext(projectRoot, stageId) {
181
+ const dir = path.join(stageDir(projectRoot, stageId), '_context');
182
+ ensureDir(dir);
183
+ const written = [];
184
+ const errors = [];
185
+ for (const spec of contextSpecs(stageId)) {
186
+ const payload = snapshotPayload(projectRoot, spec);
187
+ if (payload === null) {
188
+ errors.push({
189
+ code: 'BUNDLE_CONTEXT_SOURCE_MISSING',
190
+ path: '_context/' + spec.file,
191
+ message: '上游快照源文件不存在:' + spec.sources.join('、'),
192
+ hint: '先运行上游阶段,并确认阶段文件已落盘',
193
+ contract: stageId + '/README.md#self-contained',
194
+ });
195
+ continue;
196
+ }
197
+ writeJsonAtomic(path.join(dir, spec.file), payload);
198
+ written.push(spec.file);
199
+ }
200
+ return { ok: errors.length === 0, files: written, errors };
201
+ }
202
+
203
+ export function validateContext(projectRoot, stageId) {
204
+ const spec = V2_STAGE_BY_ID[stageId];
205
+ const errors = [];
206
+ const dir = path.join(stageDir(projectRoot, stageId), '_context');
207
+ for (const item of contextSpecs(stageId)) {
208
+ const file = path.join(dir, item.file);
209
+ if (!fileExists(file)) {
210
+ errors.push({
211
+ code: 'BUNDLE_CONTEXT_MISSING',
212
+ path: '_context/' + item.file,
213
+ message: '缺少上游快照',
214
+ hint: '运行 /fancy-story-builder ' + stageId + ',插件会刷新快照',
215
+ contract: stageId + '/README.md#self-contained',
216
+ });
217
+ continue;
218
+ }
219
+ const snapshot = readJson(file, null);
220
+ if (!snapshot || !snapshot._meta) {
221
+ errors.push({ code: 'BUNDLE_CONTEXT_INVALID', path: '_context/' + item.file, message: '快照缺少 _meta', contract: stageId + '/README.md#self-contained' });
222
+ continue;
223
+ }
224
+ const current = snapshotPayload(projectRoot, item);
225
+ if (!current) {
226
+ errors.push({ code: 'BUNDLE_CONTEXT_SOURCE_MISSING', path: '_context/' + item.file, message: '上游源文件不存在,快照无法核对', contract: stageId + '/README.md#self-contained' });
227
+ continue;
228
+ }
229
+ if (snapshot._meta.source_hash !== current._meta.source_hash) {
230
+ errors.push({
231
+ code: 'BUNDLE_CONTEXT_STALE',
232
+ path: '_context/' + item.file,
233
+ message: '上游已变化,快照过期',
234
+ expected: current._meta.source_hash,
235
+ actual: snapshot._meta.source_hash,
236
+ hint: '重新运行本阶段以刷新 _context,或运行 patch 逐层更新',
237
+ contract: stageId + '/README.md#self-contained',
238
+ });
239
+ }
240
+ }
241
+ // 阶段目录不能引用项目外文件
242
+ const scan = (value, p) => {
243
+ if (typeof value === 'string') {
244
+ if (path.isAbsolute(value) || /node_modules|fancy-story-builder\/index\.js|plugins\/fancy-story-builder/.test(value)) {
245
+ errors.push({ code: 'BUNDLE_EXTERNAL_PATH', path: p, message: '阶段文件不能引用项目外或插件源码路径', actual: value, contract: stageId + '/README.md#self-contained' });
246
+ }
247
+ return;
248
+ }
249
+ if (Array.isArray(value)) {
250
+ value.forEach((x, i) => scan(x, p + '[' + i + ']'));
251
+ return;
252
+ }
253
+ if (value && typeof value === 'object') {
254
+ for (const [k, v] of Object.entries(value)) {
255
+ if (k === '_meta') continue;
256
+ scan(v, p ? p + '.' + k : k);
257
+ }
258
+ }
259
+ };
260
+ for (const name of asArray(spec && spec.requiredFiles)) {
261
+ const file = path.join(stageDir(projectRoot, stageId), name);
262
+ if (!fileExists(file)) continue;
263
+ scan(readJson(file, null), name);
264
+ }
265
+ return { ok: errors.length === 0, errors };
266
+ }
267
+
268
+ export function quantitySnapshot(stageId, scale) {
269
+ const q = (scale && scale.quotas) || {};
270
+ const base = {
271
+ target_chapters: scale && scale.capacity && scale.capacity.target_chapters,
272
+ target_words: scale && scale.capacity && scale.capacity.target_words,
273
+ volume_count: scale && scale.structure && scale.structure.volume_count,
274
+ };
275
+ if (stageId === '30-cast' || stageId === '40-rhythm' || stageId === '50-writing') {
276
+ base.character_counts = q.characters || {};
277
+ base.conflict_counts = q.conflicts || {};
278
+ }
279
+ if (stageId === '40-rhythm' || stageId === '50-writing') {
280
+ base.foreshadow_counts = q.foreshadows || {};
281
+ base.payoff_counts = q.pleasures || {};
282
+ }
283
+ return base;
284
+ }
285
+
286
+ export function writeBundle(projectRoot, stageId, opts = {}) {
287
+ const contract = contractFor(stageId);
288
+ if (!contract) return null;
289
+ const scale = readJson(stageFile(projectRoot, '10-scale', 'scale.json'), null);
290
+ const bundle = {
291
+ schema_version: '2.1',
292
+ stage: stageId,
293
+ purpose: contract.purpose,
294
+ read_order: contract.readOrder,
295
+ upstream: (V2_STAGE_BY_ID[stageId] && V2_STAGE_BY_ID[stageId].upstream) || [],
296
+ quantity_snapshot: quantitySnapshot(stageId, scale),
297
+ checks: contract.checks,
298
+ forbidden: contract.forbidden,
299
+ self_contained: {
300
+ context_dir: '_context',
301
+ required_files: contextSpecs(stageId).map((x) => '_context/' + x.file),
302
+ },
303
+ generated_at: nowIso(),
304
+ ...opts,
305
+ };
306
+ writeJsonAtomic(path.join(stageDir(projectRoot, stageId), 'bundle.json'), bundle);
307
+ writeTextAtomic(path.join(stageDir(projectRoot, stageId), 'README.md'), renderContractMarkdown(stageId));
308
+ return bundle;
309
+ }
310
+
311
+ export function validateBundle(projectRoot, stageId) {
312
+ const errors = [];
313
+ const context = validateContext(projectRoot, stageId);
314
+ errors.push(...context.errors);
315
+ const bundleFile = path.join(stageDir(projectRoot, stageId), 'bundle.json');
316
+ const bundle = readJson(bundleFile, null);
317
+ if (!bundle) {
318
+ errors.push({ code: 'BUNDLE_MISSING', path: stageId + '/bundle.json', message: '缺少 bundle.json', hint: '运行本阶段会生成', contract: stageId + '/README.md#self-contained' });
319
+ } else {
320
+ const required = contextSpecs(stageId).map((x) => '_context/' + x.file);
321
+ for (const rel of required) {
322
+ if (!fileExists(path.join(stageDir(projectRoot, stageId), rel))) {
323
+ errors.push({ code: 'BUNDLE_CONTEXT_MISSING', path: stageId + '/bundle.json', message: 'bundle 要求的上下文文件不存在:' + rel, contract: stageId + '/README.md#self-contained' });
324
+ }
325
+ }
326
+ if (!bundle.quantity_snapshot || !bundle.quantity_snapshot.target_chapters) {
327
+ errors.push({ code: 'BUNDLE_QUANTITY_MISSING', path: stageId + '/bundle.json', message: 'bundle 缺少数量快照', contract: stageId + '/README.md#self-contained' });
328
+ }
329
+ }
330
+ const readme = path.join(stageDir(projectRoot, stageId), 'README.md');
331
+ if (!fileExists(readme)) {
332
+ errors.push({ code: 'BUNDLE_README_MISSING', path: stageId + '/README.md', message: '缺少阶段契约 README.md', contract: stageId + '/README.md#self-contained' });
333
+ }
334
+ return { ok: errors.length === 0, errors };
335
+ }
336
+
337
+ /**
338
+ * 把阶段目录导出成一个 Markdown 汇总文件。
339
+ *
340
+ * 输出是 `设定/exports/<stageId>/<stageId>.md`;不再复制源 JSON,
341
+ * 因为导出对象是另一个 LLM,需要的是可读内容,而不是重复的 JSON。
342
+ */
343
+ export function exportStage(projectRoot, stageId, opts = {}) {
344
+ const src = stageDir(projectRoot, stageId);
345
+ if (!fs.existsSync(src)) {
346
+ return { ok: false, error: '阶段目录不存在:' + stageId, path: null };
347
+ }
348
+ const bundle = writeBundle(projectRoot, stageId);
349
+ const validation = validateBundle(projectRoot, stageId);
350
+ let rendered;
351
+ try {
352
+ rendered = renderStageMarkdown(projectRoot, stageId);
353
+ } catch (err) {
354
+ return { ok: false, error: '生成 Markdown 失败:' + (err instanceof Error ? err.message : String(err)), path: null };
355
+ }
356
+ const dest = path.join(projectRoot, '设定', 'exports', stageId);
357
+ // 旧版导出会把整目录复制过来;这里清掉旧文件,避免 JSON 副本继续误导读者。
358
+ fs.rmSync(dest, { recursive: true, force: true });
359
+ ensureDir(dest);
360
+ const file = path.join(dest, stageId + '.md');
361
+ writeTextAtomic(file, rendered.markdown);
362
+ return {
363
+ ok: true,
364
+ path: relProjectPath(projectRoot, file),
365
+ markdown: file,
366
+ files: rendered.files,
367
+ title: rendered.title,
368
+ validation,
369
+ bundle,
370
+ };
371
+ }
372
+
373
+ /**
374
+ * 解析章节引用:稳定 chapter_id(大小写不敏感)、display_no 或文件名。
375
+ * 返回真实的 chapter_id;找不到返回 null。
376
+ */
377
+ export function resolveOutlineChapterId(projectRoot, input) {
378
+ const raw = String(input || '').trim();
379
+ if (!raw) return null;
380
+ const rhythmDir = stageDir(projectRoot, '40-rhythm');
381
+ const outlinesDir = path.join(rhythmDir, 'outlines');
382
+ if (fileExists(path.join(outlinesDir, raw + '.json'))) return raw;
383
+
384
+ const map = readJson(stageFile(projectRoot, '40-rhythm', 'chapter_map.json'), { chapters: [] });
385
+ const chapters = asArray(map && map.chapters);
386
+ const byId = chapters.find((c) => String(c.chapter_id).toLowerCase() === raw.toLowerCase());
387
+ if (byId && fileExists(path.join(outlinesDir, byId.chapter_id + '.json'))) return byId.chapter_id;
388
+ if (/^\d+$/.test(raw)) {
389
+ const byNo = chapters.find((c) => Number(c.display_no) === Number(raw));
390
+ if (byNo && fileExists(path.join(outlinesDir, byNo.chapter_id + '.json'))) return byNo.chapter_id;
391
+ }
392
+
393
+ try {
394
+ const names = fs.readdirSync(outlinesDir).filter((n) => n.endsWith('.json'));
395
+ const hit = names.find((n) => n.slice(0, -5).toLowerCase() === raw.toLowerCase());
396
+ if (hit) return hit.slice(0, -5);
397
+ } catch {
398
+ /* outlines 还没生成 */
399
+ }
400
+ return null;
401
+ }
402
+
403
+ /** 导出单章细纲为一个 Markdown 文件。 */
404
+ export function exportOutline(projectRoot, chapterRef) {
405
+ const chapterId = resolveOutlineChapterId(projectRoot, chapterRef);
406
+ if (!chapterId) {
407
+ return { ok: false, error: '细纲不存在:' + chapterRef, path: null, errors: [] };
408
+ }
409
+ let rendered;
410
+ try {
411
+ rendered = renderOutlineMarkdown(projectRoot, chapterId);
412
+ } catch (err) {
413
+ return { ok: false, error: '生成 Markdown 失败:' + (err instanceof Error ? err.message : String(err)), path: null, errors: [] };
414
+ }
415
+ if (!rendered) return { ok: false, error: '细纲不存在:' + chapterRef, path: null, errors: [] };
416
+ const dest = path.join(projectRoot, '设定', 'exports', '40-rhythm', chapterId);
417
+ fs.rmSync(dest, { recursive: true, force: true });
418
+ ensureDir(dest);
419
+ const file = path.join(dest, chapterId + '.md');
420
+ writeTextAtomic(file, rendered.markdown);
421
+ return {
422
+ ok: true,
423
+ path: relProjectPath(projectRoot, file),
424
+ markdown: file,
425
+ chapterId,
426
+ files: rendered.files,
427
+ outline: rendered.outline,
428
+ };
429
+ }
430
+
431
+ /** 读取导出目录里的 bundle(供测试/自包含检查)。 */
432
+ export function readExportedBundle(projectRoot, stageId) {
433
+ return readJson(path.join(projectRoot, '设定', 'exports', stageId, 'bundle.json'), null);
434
+ }