@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
@@ -0,0 +1,538 @@
1
+ /**
2
+ * v2/manifest.js —— 项目级依赖图与实体索引。
3
+ *
4
+ * 设计原则:
5
+ * - 业务 JSON 只声明 _meta.upstream(我来自哪里);
6
+ * - 下游关系全部由 manifest 计算(谁引用了我);
7
+ * - patch 只按图遍历,不靠 LLM 猜依赖。
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import {
12
+ SCHEMA_VERSION,
13
+ V2_STAGES,
14
+ V2_STAGE_BY_ID,
15
+ STAGE_ORDER,
16
+ asArray,
17
+ bytesHash,
18
+ contentHash,
19
+ hashFile,
20
+ nowIso,
21
+ readJson,
22
+ readStageStatus,
23
+ relProjectPath,
24
+ stageDir,
25
+ stageHash,
26
+ writeJsonAtomic,
27
+ } from './core.js';
28
+
29
+ export const MANIFEST_PATH = ['设定', 'manifest.json'];
30
+
31
+ const STAGE_UPSTREAM = {
32
+ '00-baseline': [],
33
+ '10-scale': ['00-baseline'],
34
+ '20-macro': ['10-scale'],
35
+ '30-cast': ['10-scale', '20-macro'],
36
+ '40-rhythm': ['10-scale', '20-macro', '30-cast'],
37
+ '50-writing': ['40-rhythm'],
38
+ '60-deslop': ['50-writing'],
39
+ };
40
+
41
+ const ENTITY_TYPE_BY_KEY = {
42
+ characters: '人物',
43
+ factions: '势力',
44
+ locations: '地点',
45
+ items: '道具',
46
+ conflicts: '冲突',
47
+ foreshadows: '伏笔',
48
+ climaxes: '高潮',
49
+ payoffs: '爽点',
50
+ endings: '结局',
51
+ world_rules: '世界观',
52
+ main_line: '主线',
53
+ branches: '支线',
54
+ roles: '角色功能位',
55
+ character_relations: '关系',
56
+ faction_relations: '势力关系',
57
+ memberships: '势力成员',
58
+ information_gaps: '信息差',
59
+ chapters: '章',
60
+ chapters_map: '章',
61
+ };
62
+
63
+ const ENTITY_PREFIX = {
64
+ C: '人物',
65
+ F: '势力',
66
+ L: '地点',
67
+ P: '道具',
68
+ X: '冲突',
69
+ FS: '伏笔',
70
+ CL: '高潮',
71
+ PL: '爽点',
72
+ E: '结局',
73
+ R: '世界观',
74
+ M: '主线',
75
+ B: '支线',
76
+ REL: '关系',
77
+ FREL: '势力关系',
78
+ MEM: '势力成员',
79
+ IG: '信息差',
80
+ CH: '章',
81
+ ROLE: '角色功能位',
82
+ };
83
+
84
+ function stageFiles(projectRoot, stageId) {
85
+ const dir = stageDir(projectRoot, stageId);
86
+ if (!fs.existsSync(dir)) return [];
87
+ const out = [];
88
+ const walk = (current) => {
89
+ let names = [];
90
+ try {
91
+ names = fs.readdirSync(current, { withFileTypes: true });
92
+ } catch {
93
+ return;
94
+ }
95
+ for (const entry of names) {
96
+ const name = entry.name;
97
+ if (name.startsWith('.')) continue;
98
+ if (name === '_context' || name === 'bundle.json' || name === 'README.md') continue;
99
+ const full = path.join(current, name);
100
+ if (entry.isDirectory()) {
101
+ if (name === 'outlines' || name === 'chapters' || name === 'timelines') walk(full);
102
+ continue;
103
+ }
104
+ if (name.endsWith('.json') || name.endsWith('.md')) out.push(full);
105
+ }
106
+ };
107
+ walk(dir);
108
+ return out.sort();
109
+ }
110
+
111
+ function fileNodeId(projectRoot, stageId, file) {
112
+ const rel = path.relative(stageDir(projectRoot, stageId), file).split(path.sep).join('/');
113
+ if (stageId === '40-rhythm' && rel.startsWith('outlines/')) {
114
+ return '40-rhythm.outline.' + rel.replace(/^outlines\//, '').replace(/\.json$/, '');
115
+ }
116
+ if (stageId === '50-writing' && rel.startsWith('chapters/')) {
117
+ return '50-writing.chapter.' + rel.replace(/^chapters\//, '').replace(/\.md$/, '');
118
+ }
119
+ return stageId + '.' + rel.replace(/\.(json|md)$/, '').replace(/\//g, '.');
120
+ }
121
+
122
+ function expectedUpstream(stageId, rel) {
123
+ if (stageId === '00-baseline') return [];
124
+ if (stageId === '10-scale') return ['00-baseline'];
125
+ if (stageId === '20-macro') return ['10-scale'];
126
+ if (stageId === '30-cast') return ['10-scale', '20-macro'];
127
+ if (stageId === '40-rhythm') {
128
+ if (rel.startsWith('outlines/')) return ['40-rhythm.chapter_map', '10-scale', '20-macro', '30-cast'];
129
+ if (rel.startsWith('timelines/') || rel === 'index.json') return ['40-rhythm.chapter_map'];
130
+ return ['10-scale', '20-macro', '30-cast'];
131
+ }
132
+ if (stageId === '50-writing') return ['40-rhythm'];
133
+ if (stageId === '60-deslop') return ['50-writing'];
134
+ return [];
135
+ }
136
+
137
+ function metaUpstream(data) {
138
+ const raw = data && data._meta && data._meta.upstream;
139
+ if (!Array.isArray(raw)) return null;
140
+ return raw.map((x) => (typeof x === 'string' ? x : x && x.node_id)).filter(Boolean);
141
+ }
142
+
143
+ function entityTypeOf(key, id) {
144
+ if (key && ENTITY_TYPE_BY_KEY[key]) return ENTITY_TYPE_BY_KEY[key];
145
+ const prefix = String(id || '').match(/^(FREL|REL|MEM|ROLE|FS|CL|PL|IG|CH|[CFLPXEMRB])/);
146
+ if (prefix) return ENTITY_PREFIX[prefix[1]] || '';
147
+ return '';
148
+ }
149
+
150
+ function scanEntities(value, out, keyHint) {
151
+ if (Array.isArray(value)) {
152
+ for (const item of value) scanEntities(item, out, keyHint);
153
+ return;
154
+ }
155
+ if (!value || typeof value !== 'object') return;
156
+ const entityId = typeof value.id === 'string' && value.id
157
+ ? value.id
158
+ : (typeof value.chapter_id === 'string' && value.chapter_id ? value.chapter_id : null);
159
+ if (entityId) {
160
+ const type = entityTypeOf(keyHint, entityId);
161
+ if (type) {
162
+ out.push({
163
+ id: entityId,
164
+ type,
165
+ name: value.name || value.title || '',
166
+ key: keyHint || '',
167
+ display_no: value.display_no,
168
+ });
169
+ }
170
+ }
171
+ for (const [key, child] of Object.entries(value)) {
172
+ if (key === '_meta') continue;
173
+ scanEntities(child, out, key);
174
+ }
175
+ }
176
+
177
+ function tokensIn(text) {
178
+ const set = new Set();
179
+ const re = /\b(?:ROLE-[A-Z0-9-]+|IG-[A-Za-z0-9-]+|[A-Z]{1,3}-?\d{3,})\b/g;
180
+ let m;
181
+ while ((m = re.exec(text)) !== null) set.add(m[0]);
182
+ return set;
183
+ }
184
+
185
+ function stageNode(projectRoot, stageId) {
186
+ const spec = V2_STAGE_BY_ID[stageId];
187
+ const status = readStageStatus(projectRoot, stageId, { allowLegacy: false });
188
+ return {
189
+ kind: 'stage',
190
+ stage: stageId,
191
+ label: spec.label,
192
+ path: path.join('设定', spec.dir).split(path.sep).join('/'),
193
+ status: status.status,
194
+ hash: stageHash(projectRoot, stageId),
195
+ upstream: STAGE_UPSTREAM[stageId] || [],
196
+ files: [],
197
+ updated_at: nowIso(),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * 构建 manifest。write=true 时写回 设定/manifest.json。
203
+ */
204
+ export function buildManifest(projectRoot, opts = {}) {
205
+ const nodes = {};
206
+ const edges = [];
207
+ const entities = {};
208
+ const allFiles = [];
209
+ const textCache = new Map();
210
+
211
+ for (const stage of V2_STAGES) {
212
+ if (!fs.existsSync(stageDir(projectRoot, stage.id))) continue;
213
+ nodes[stage.id] = stageNode(projectRoot, stage.id);
214
+ }
215
+
216
+ // 阶段边
217
+ for (const [to, ups] of Object.entries(STAGE_UPSTREAM)) {
218
+ if (!nodes[to]) continue;
219
+ for (const from of ups) {
220
+ if (!nodes[from]) continue;
221
+ edges.push({ from, to, kind: from === '10-scale' ? 'quantity' : (from === '40-rhythm' ? 'outline' : 'content') });
222
+ }
223
+ }
224
+
225
+ // 文件节点
226
+ const fileNodeByPath = new Map();
227
+ for (const stage of V2_STAGES) {
228
+ if (!nodes[stage.id]) continue;
229
+ for (const file of stageFiles(projectRoot, stage.id)) {
230
+ const rel = path.relative(stageDir(projectRoot, stage.id), file).split(path.sep).join('/');
231
+ const nodeId = fileNodeId(projectRoot, stage.id, file);
232
+ const data = file.endsWith('.json') ? readJson(file, null) : null;
233
+ const upstream = data ? (metaUpstream(data) || expectedUpstream(stage.id, rel)) : [];
234
+ const node = {
235
+ kind: 'file',
236
+ stage: stage.id,
237
+ path: relProjectPath(projectRoot, file),
238
+ status: (() => {
239
+ const s = readStageStatus(projectRoot, stage.id);
240
+ return s.status;
241
+ })(),
242
+ hash: file.endsWith('.json') ? hashFile(file) : bytesHash(fs.readFileSync(file)),
243
+ upstream,
244
+ downstream: [],
245
+ file: rel,
246
+ updated_at: nowIso(),
247
+ };
248
+ nodes[nodeId] = node;
249
+ nodes[stage.id].files.push(nodeId);
250
+ fileNodeByPath.set(file, nodeId);
251
+ allFiles.push({ file, nodeId, stage: stage.id, rel, data });
252
+ for (const up of upstream) {
253
+ edges.push({ from: up, to: nodeId, kind: 'file' });
254
+ }
255
+ }
256
+ }
257
+
258
+ // 文件节点的 upstream 若指向阶段节点,补阶段节点文件列表(manifest 自身可追)
259
+ // 反向边:所有节点
260
+ const downstream = new Map();
261
+ for (const [id, node] of Object.entries(nodes)) downstream.set(id, new Set());
262
+ for (const e of edges) {
263
+ if (!downstream.has(e.from)) downstream.set(e.from, new Set());
264
+ downstream.get(e.from).add(e.to);
265
+ }
266
+ for (const [id, set] of downstream.entries()) {
267
+ if (nodes[id]) nodes[id].downstream = [...set].sort();
268
+ }
269
+
270
+ // 实体索引
271
+ const entityRows = [];
272
+ for (const entry of allFiles) {
273
+ if (!entry.data) continue;
274
+ const rows = [];
275
+ scanEntities(entry.data, rows, '');
276
+ for (const row of rows) {
277
+ if (!entities[row.id]) {
278
+ entities[row.id] = {
279
+ type: row.type,
280
+ name: row.name || '',
281
+ display_no: row.display_no,
282
+ source: entry.nodeId,
283
+ source_path: nodes[entry.nodeId].path,
284
+ downstream: [],
285
+ };
286
+ entityRows.push(row.id);
287
+ }
288
+ }
289
+ }
290
+
291
+ // 文本引用索引:一次扫描每个文件,用 token 正则匹配实体 ID。
292
+ for (const entry of allFiles) {
293
+ let text = textCache.get(entry.file);
294
+ if (text === undefined) {
295
+ try {
296
+ text = fs.readFileSync(entry.file, 'utf-8');
297
+ } catch {
298
+ text = '';
299
+ }
300
+ textCache.set(entry.file, text);
301
+ }
302
+ const tokens = tokensIn(text);
303
+ for (const token of tokens) {
304
+ const row = entities[token];
305
+ if (!row) continue;
306
+ if (row.source === entry.nodeId) continue;
307
+ if (!row.downstream.includes(entry.nodeId)) row.downstream.push(entry.nodeId);
308
+ }
309
+ }
310
+ // 章实体额外关联已写正文:正文文件标题只有“第N章”,文本里没有 CH001 字样,
311
+ // 单靠 ID 扫描会漏掉它。
312
+ for (const [id, row] of Object.entries(entities)) {
313
+ if (row.type !== '章' || !row.display_no) continue;
314
+ const manuscriptNode = '50-writing.chapter.第' + String(row.display_no).padStart(3, '0') + '章';
315
+ if (nodes[manuscriptNode] && manuscriptNode !== row.source && !row.downstream.includes(manuscriptNode)) {
316
+ row.downstream.push(manuscriptNode);
317
+ }
318
+ }
319
+ for (const row of Object.values(entities)) row.downstream.sort();
320
+
321
+ const stageStatus = {};
322
+ for (const stage of V2_STAGES) {
323
+ if (nodes[stage.id]) stageStatus[stage.id] = nodes[stage.id].status;
324
+ }
325
+ const project = readJson(path.join(stageDir(projectRoot, '00-baseline'), 'input.json'), {}) || {};
326
+ const manifest = {
327
+ schema_version: SCHEMA_VERSION,
328
+ generated_at: nowIso(),
329
+ project: {
330
+ book_name: project.book_name || project.name || '',
331
+ target_chapters: Number(project.target_chapters || 0) || null,
332
+ target_words: Number(project.target_words || 0) || null,
333
+ },
334
+ stages: stageStatus,
335
+ nodes,
336
+ edges,
337
+ entities,
338
+ };
339
+
340
+ if (opts.write !== false) {
341
+ writeJsonAtomic(path.join(projectRoot, ...MANIFEST_PATH), manifest);
342
+ }
343
+ return manifest;
344
+ }
345
+
346
+ export function readManifest(projectRoot) {
347
+ return readJson(path.join(projectRoot, ...MANIFEST_PATH), null);
348
+ }
349
+
350
+ /** 规划/校验用:相对路径 → 文件节点。 */
351
+ export function fileNodeByProjectPath(manifest, rel) {
352
+ for (const [id, node] of Object.entries(manifest.nodes || {})) {
353
+ if (node.kind === 'file' && node.path === rel) return { id, ...node };
354
+ }
355
+ return null;
356
+ }
357
+
358
+ /** 反向依赖闭包:传入节点 ID,返回所有下游节点(含自身)。 */
359
+ export function reverseClosure(manifest, nodeIds) {
360
+ const nodes = manifest.nodes || {};
361
+ const result = new Set();
362
+ const queue = [...nodeIds];
363
+ while (queue.length) {
364
+ const id = queue.shift();
365
+ if (!id || result.has(id)) continue;
366
+ result.add(id);
367
+ const node = nodes[id];
368
+ if (!node) continue;
369
+ for (const next of asArray(node.downstream)) queue.push(next);
370
+ }
371
+ return result;
372
+ }
373
+
374
+ /** 找一条请求里点名到的实体(按 ID 或名称)。 */
375
+ export function findEntities(manifest, text) {
376
+ const request = String(text || '');
377
+ const found = [];
378
+ for (const [id, entity] of Object.entries(manifest.entities || {})) {
379
+ if (request.includes(id) || (entity.name && entity.name.length >= 2 && request.includes(entity.name))) {
380
+ found.push({ id, ...entity });
381
+ }
382
+ }
383
+ return found;
384
+ }
385
+
386
+ /**
387
+ * 生成 patch 影响计划。
388
+ * 优先级:请求中明确点名的实体 → 字段/阶段关键词 → 全下游。
389
+ */
390
+ export function buildPatchPlan(projectRoot, userRequest, manifest) {
391
+ const m = manifest || readManifest(projectRoot) || buildManifest(projectRoot, { write: false });
392
+ const requested = findEntities(m, userRequest);
393
+ const affectedNodes = new Set();
394
+ const reasons = [];
395
+
396
+ for (const entity of requested) {
397
+ affectedNodes.add(entity.source);
398
+ affectedNodes.add(entity.id);
399
+ for (const id of asArray(entity.downstream)) affectedNodes.add(id);
400
+ // 关系/成员关系不是章节地图里直接引用的 ID,但它们的语义会进入每章的关系呈现。
401
+ // 依赖图无法从文本反查“这条关系被哪些章节写到”,所以按保守规则把节奏与正文纳入。
402
+ if (['关系', '势力关系', '势力成员'].includes(entity.type)) {
403
+ affectedNodes.add('40-rhythm');
404
+ affectedNodes.add('50-writing');
405
+ }
406
+ reasons.push('实体 ' + entity.id + (entity.name ? '(' + entity.name + ')' : ''));
407
+ }
408
+
409
+ // 字段/阶段关键词兜底:不点名实体时,至少把相关阶段及其下游拉进来。
410
+ const stageHints = [];
411
+ if (/数量|配额|章节数|卷数|幕数|字数|规模|quota|scale/i.test(userRequest)) stageHints.push('10-scale');
412
+ if (/世界观|主题|主线|支线|结局|卷纲|幕纲|情绪|角色功能|macro/i.test(userRequest)) stageHints.push('20-macro');
413
+ if (/人物|角色|势力|地点|道具|冲突|伏笔|高潮|爽点|关系|信息差|cast/i.test(userRequest)) stageHints.push('30-cast');
414
+ if (/关系|成员|立场|态度/i.test(userRequest)) stageHints.push('40-rhythm', '50-writing');
415
+ if (/章节|细纲|节奏|钩子|时间线|rhythm/i.test(userRequest)) stageHints.push('40-rhythm');
416
+ if (/正文|章节内容|writing/i.test(userRequest)) stageHints.push('50-writing');
417
+ for (const stageId of stageHints) {
418
+ if (m.nodes && m.nodes[stageId]) {
419
+ affectedNodes.add(stageId);
420
+ for (const id of asArray(m.nodes[stageId].downstream)) affectedNodes.add(id);
421
+ reasons.push('阶段 ' + stageId);
422
+ }
423
+ }
424
+
425
+ // 没识别出任何范围:全部已生成阶段(保守处理,要求确认)。
426
+ if (!affectedNodes.size) {
427
+ for (const [id, node] of Object.entries(m.nodes || {})) {
428
+ if (node.kind === 'stage') affectedNodes.add(id);
429
+ }
430
+ reasons.push('未识别到具体实体,按全量下游处理');
431
+ }
432
+
433
+ const closure = reverseClosure(m, [...affectedNodes]);
434
+ const files = [];
435
+ const stages = new Set();
436
+ for (const id of closure) {
437
+ const node = m.nodes && m.nodes[id];
438
+ if (!node) continue;
439
+ if (node.kind === 'stage') {
440
+ stages.add(node.stage || id);
441
+ for (const fileId of asArray(node.files)) {
442
+ const f = m.nodes[fileId];
443
+ if (f && f.kind === 'file') files.push(f.path);
444
+ }
445
+ } else if (node.kind === 'file') {
446
+ stages.add(node.stage);
447
+ files.push(node.path);
448
+ }
449
+ }
450
+ const uniqFiles = [...new Set(files)].sort();
451
+ const stageList = STAGE_ORDER.filter((s) => stages.has(s));
452
+ const writingAffected = stageList.includes('50-writing') || uniqFiles.some((f) => f.includes('50-writing'));
453
+
454
+ return {
455
+ request: userRequest,
456
+ reasons: [...new Set(reasons)],
457
+ entities: requested.map((e) => ({ id: e.id, type: e.type, name: e.name, source: e.source_path || e.source })),
458
+ stages: stageList,
459
+ files: uniqFiles,
460
+ requires_confirmation: writingAffected || requested.length === 0,
461
+ writing_affected: writingAffected,
462
+ manifest_generated_at: m.generated_at || null,
463
+ };
464
+ }
465
+
466
+ /** 校验 manifest 自身与磁盘是否一致。 */
467
+ export function verifyManifest(projectRoot, manifest) {
468
+ const m = manifest || readManifest(projectRoot);
469
+ const errors = [];
470
+ const warnings = [];
471
+ if (!m) {
472
+ return { ok: false, errors: [{ code: 'MANIFEST_MISSING', message: '设定/manifest.json 不存在' }], warnings };
473
+ }
474
+ const current = buildManifest(projectRoot, { write: false });
475
+ const nodeIds = new Set([...Object.keys(m.nodes || {}), ...Object.keys(current.nodes || {})]);
476
+ for (const id of nodeIds) {
477
+ const oldNode = (m.nodes || {})[id];
478
+ const newNode = (current.nodes || {})[id];
479
+ if (!oldNode && newNode) {
480
+ errors.push({ code: 'MANIFEST_NODE_ADDED', path: newNode.path, message: '新增节点未写入 manifest:' + id });
481
+ continue;
482
+ }
483
+ if (oldNode && !newNode) {
484
+ errors.push({ code: 'MANIFEST_NODE_REMOVED', path: oldNode.path, message: '节点已从磁盘删除但 manifest 未更新:' + id });
485
+ continue;
486
+ }
487
+ if (oldNode && newNode && oldNode.hash !== newNode.hash) {
488
+ errors.push({ code: 'MANIFEST_HASH_MISMATCH', path: newNode.path, message: '文件内容变化但 manifest 未更新:' + id });
489
+ }
490
+ }
491
+ // 上游哈希是否与 _meta 声明一致(stale 检测)
492
+ for (const [id, node] of Object.entries(current.nodes || {})) {
493
+ if (node.kind !== 'file') continue;
494
+ const entry = current.nodes[id];
495
+ const data = readJson(path.join(projectRoot, entry.path), null);
496
+ const ups = metaUpstream(data);
497
+ if (!ups) continue;
498
+ for (const upId of ups) {
499
+ const up = current.nodes[upId];
500
+ if (!up) {
501
+ warnings.push({ code: 'MANIFEST_UPSTREAM_UNKNOWN', path: entry.path, message: '上游节点不存在:' + upId });
502
+ continue;
503
+ }
504
+ const declared = asArray(data._meta.upstream).find((x) => x && x.node_id === upId);
505
+ if (declared && declared.hash && up.hash && declared.hash !== up.hash) {
506
+ errors.push({
507
+ code: 'MANIFEST_UPSTREAM_STALE',
508
+ path: entry.path,
509
+ message: '上游已变化,本文件标记 stale:' + upId,
510
+ expected: up.hash,
511
+ actual: declared.hash,
512
+ hint: '重新运行当前阶段,或运行 patch 让依赖图逐层更新',
513
+ });
514
+ }
515
+ }
516
+ }
517
+ return { ok: errors.length === 0, errors, warnings, manifest: current };
518
+ }
519
+
520
+ /**
521
+ * 给业务 JSON 填 _meta.upstream。读取上游文件当前哈希。
522
+ * 返回 meta 对象(不含 content_hash;content_hash 由写盘方补)。
523
+ */
524
+ export function makeUpstreamMeta(projectRoot, nodeIds) {
525
+ const current = buildManifest(projectRoot, { write: false });
526
+ const out = [];
527
+ for (const nodeId of asArray(nodeIds)) {
528
+ const node = current.nodes && current.nodes[nodeId];
529
+ if (!node) continue;
530
+ out.push({
531
+ node_id: nodeId,
532
+ path: node.path,
533
+ hash: node.hash,
534
+ snapshot: node.kind === 'stage' ? '_context/' + nodeId.split('-').pop() + '.json' : undefined,
535
+ });
536
+ }
537
+ return out;
538
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * v2/migrate.js —— 旧 1.x 项目迁移。
3
+ *
4
+ * 迁移原则:
5
+ * - 旧数据不删除,复制到 设定/legacy/v1/ 只读保留;
6
+ * - 能确定性复现的数量先迁移(容量、配额、卷/幕区间);
7
+ * - 宏观/实体/节奏不伪造:缺少新契约字段的内容不硬塞,交给用户确认后重新生成或 patch。
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import {
12
+ V1_STAGE_DIR,
13
+ ensureDir,
14
+ fileExists,
15
+ readJson,
16
+ stageDir,
17
+ stageFile,
18
+ writeJsonAtomic,
19
+ writeTextAtomic,
20
+ } from './core.js';
21
+ import { buildScale, finalizeScale, renderScaleMd, validateScale } from './scale.js';
22
+
23
+ function copyDir(src, dest) {
24
+ if (!fs.existsSync(src)) return false;
25
+ ensureDir(dest);
26
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
27
+ const from = path.join(src, entry.name);
28
+ const to = path.join(dest, entry.name);
29
+ if (entry.isDirectory()) copyDir(from, to);
30
+ else if (entry.isFile()) fs.copyFileSync(from, to);
31
+ }
32
+ return true;
33
+ }
34
+
35
+ function oldFramework(projectRoot) {
36
+ return readJson(path.join(projectRoot, '设定', 'raw', 'sources', '骨架', '框架.json'), null);
37
+ }
38
+
39
+ function oldHierarchy(projectRoot) {
40
+ return readJson(path.join(projectRoot, '设定', 'raw', 'sources', '骨架', '层级.json'), null);
41
+ }
42
+
43
+ /**
44
+ * 生成 scale:优先沿用旧框架的 capacity / quota / volume_ratio。
45
+ */
46
+ export function migrateScale(projectRoot, topicData, formulasDoc, opts = {}) {
47
+ const framework = oldFramework(projectRoot) || {};
48
+ const cap = framework.capacity || {};
49
+ const topic = {
50
+ ...topicData,
51
+ target_chapters: Number(cap.target_chapters) || topicData.target_chapters,
52
+ target_words: Number(cap.target_words) || topicData.target_words,
53
+ };
54
+ const scale = buildScale(topic, formulasDoc, opts);
55
+ const oldQuota = framework.quota || {};
56
+ const q = scale.quotas;
57
+ const set = (obj, key, value) => {
58
+ if (value === undefined || value === null) return;
59
+ const n = Number(value);
60
+ if (Number.isFinite(n)) obj[key] = n;
61
+ };
62
+ set(q.characters, 'protagonist', oldQuota.character_counts && oldQuota.character_counts.protagonist);
63
+ set(q.characters, 'core_supporting', oldQuota.character_counts && oldQuota.character_counts.core_supporting);
64
+ set(q.characters, 'volume_supporting', oldQuota.character_counts && oldQuota.character_counts.volume_supporting);
65
+ set(q.characters, 'core_antagonist', oldQuota.character_counts && oldQuota.character_counts.core_antagonist);
66
+ set(q.characters, 'volume_antagonist', oldQuota.character_counts && oldQuota.character_counts.volume_antagonist);
67
+ set(q, 'factions', oldQuota.faction_count);
68
+ set(q, 'faction_relations', oldQuota.faction_relation_count);
69
+ set(q, 'locations', oldQuota.location_count);
70
+ set(q.items, 'core', oldQuota.item_counts && oldQuota.item_counts.core);
71
+ set(q.items, 'branch', oldQuota.item_counts && oldQuota.item_counts.branch);
72
+ set(q.items, 'symbolic', oldQuota.item_counts && oldQuota.item_counts.symbolic);
73
+ set(q.conflicts, 'core', oldQuota.conflict_counts && oldQuota.conflict_counts.core);
74
+ set(q.conflicts, 'phase', oldQuota.conflict_counts && oldQuota.conflict_counts.phase);
75
+ set(q.conflicts, 'branch', oldQuota.conflict_counts && oldQuota.conflict_counts.branch);
76
+ set(q.foreshadows, 'small', oldQuota.foreshadow_counts && oldQuota.foreshadow_counts.small);
77
+ set(q.foreshadows, 'medium', oldQuota.foreshadow_counts && oldQuota.foreshadow_counts.medium);
78
+ set(q.foreshadows, 'large', oldQuota.foreshadow_counts && oldQuota.foreshadow_counts.large);
79
+ set(q.climaxes, 'small', oldQuota.climax_counts && oldQuota.climax_counts.small);
80
+ set(q.climaxes, 'medium', oldQuota.climax_counts && oldQuota.climax_counts.medium);
81
+ set(q.climaxes, 'large', oldQuota.climax_counts && oldQuota.climax_counts.large);
82
+ set(q.pleasures, 'small', oldQuota.pleasure_counts && oldQuota.pleasure_counts.small);
83
+ set(q.pleasures, 'medium', oldQuota.pleasure_counts && oldQuota.pleasure_counts.medium);
84
+ set(q.pleasures, 'large', oldQuota.pleasure_counts && oldQuota.pleasure_counts.large);
85
+ set(q.world_rules, 'core', oldQuota.world_rule_counts && oldQuota.world_rule_counts.core);
86
+ set(q.world_rules, 'information', oldQuota.world_rule_counts && oldQuota.world_rule_counts.information);
87
+ set(q.world_rules, 'social', oldQuota.world_rule_counts && oldQuota.world_rule_counts.social);
88
+ set(q, 'endings', oldQuota.ending_count);
89
+ set(q, 'branches', oldQuota.branch_count);
90
+ set(q, 'main_lines', oldQuota.main_line_count);
91
+ if (oldQuota.relations && typeof oldQuota.relations === 'object') {
92
+ set(q.relations, 'character', oldQuota.relations.character);
93
+ set(q.relations, 'faction', oldQuota.relations.faction);
94
+ set(q.relations, 'membership', oldQuota.relations.membership);
95
+ }
96
+ finalizeScale(scale, formulasDoc, opts);
97
+ return scale;
98
+ }
99
+
100
+ export function migrateV1(projectRoot, topicData, formulasDoc, opts = {}) {
101
+ const legacyRoot = path.join(projectRoot, '设定', 'legacy', 'v1');
102
+ ensureDir(legacyRoot);
103
+ const copied = [];
104
+ for (const name of Object.values(V1_STAGE_DIR)) {
105
+ const src = path.join(projectRoot, '设定', 'raw', 'sources', name);
106
+ if (!fs.existsSync(src)) continue;
107
+ copyDir(src, path.join(legacyRoot, name));
108
+ copied.push(name);
109
+ }
110
+
111
+ const scale = migrateScale(projectRoot, topicData, formulasDoc, opts);
112
+ const validation = validateScale(scale, formulasDoc, opts);
113
+ if (validation.ok) {
114
+ writeJsonAtomic(stageFile(projectRoot, '10-scale', 'scale.json'), scale);
115
+ writeTextAtomic(stageFile(projectRoot, '10-scale', 'scale.md'), renderScaleMd(scale));
116
+ }
117
+
118
+ // 迁移报告:明确哪些东西没有伪造。
119
+ const hierarchy = oldHierarchy(projectRoot);
120
+ const report = {
121
+ schema_version: '2.1',
122
+ migrated_at: new Date().toISOString(),
123
+ status: validation.ok ? 'MIGRATED_PARTIAL' : 'MIGRATED_SCALE_INVALID',
124
+ legacy_dirs_copied: copied,
125
+ scale_ok: validation.ok,
126
+ scale_errors: validation.errors,
127
+ volumes_in_legacy: Array.isArray(hierarchy && hierarchy.volumes) ? hierarchy.volumes.length : 0,
128
+ notes: [
129
+ '旧 骨架/血肉/节奏 已复制到 设定/legacy/v1/,只读保留。',
130
+ '10-scale 已按旧框架的 capacity/quota 迁移。',
131
+ '20-macro / 30-cast / 40-rhythm 不自动伪造:旧数据缺少新契约字段时,硬转换会产出看似完整但不可校验的内容。',
132
+ '下一步:检查 10-scale/scale.json,然后依次运行 /fancy-story-builder macro、cast、rhythm。',
133
+ ],
134
+ };
135
+ writeJsonAtomic(path.join(projectRoot, '设定', 'legacy', 'v1', 'migration-report.json'), report);
136
+ return report;
137
+ }