@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,419 @@
1
+ /**
2
+ * v2/core.js —— fancy-story-builder 2.0 的共享基础
3
+ *
4
+ * 这里只放确定性能力:
5
+ * - 阶段/目录/文件契约
6
+ * - 统一 JSON 读写与内容哈希
7
+ * - 阶段状态与 wiki 状态映射
8
+ * - 项目内的相对路径
9
+ *
10
+ * 不在这里做 LLM 调用,也不做任何"猜依赖"的判断。
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import crypto from 'node:crypto';
15
+
16
+ export const SCHEMA_VERSION = '2.1';
17
+ export const CONTRACT_VERSION = '2026-11-06';
18
+
19
+ export const V2_STAGES = [
20
+ {
21
+ id: '00-baseline',
22
+ label: '输入基线',
23
+ dir: '00-baseline',
24
+ upstream: [],
25
+ requiredFiles: ['input.json', 'references.json', 'formulas.json'],
26
+ },
27
+ {
28
+ id: '10-scale',
29
+ label: '数量',
30
+ dir: '10-scale',
31
+ upstream: ['00-baseline'],
32
+ requiredFiles: ['scale.json'],
33
+ },
34
+ {
35
+ id: '20-macro',
36
+ label: '宏观',
37
+ dir: '20-macro',
38
+ upstream: ['10-scale'],
39
+ requiredFiles: ['world.json', 'story.json', 'volumes.json', 'lines.json', 'endings.json', 'emotion.json', 'roles.json'],
40
+ },
41
+ {
42
+ id: '30-cast',
43
+ label: '实体',
44
+ dir: '30-cast',
45
+ upstream: ['10-scale', '20-macro'],
46
+ requiredFiles: [
47
+ 'characters.json', 'factions.json', 'locations.json', 'items.json',
48
+ 'conflicts.json', 'foreshadows.json', 'climaxes.json', 'payoffs.json',
49
+ 'relations.json', 'information.json',
50
+ ],
51
+ },
52
+ {
53
+ id: '40-rhythm',
54
+ label: '节奏',
55
+ dir: '40-rhythm',
56
+ upstream: ['10-scale', '20-macro', '30-cast'],
57
+ requiredFiles: ['chapter_map.json'],
58
+ },
59
+ {
60
+ id: '50-writing',
61
+ label: '正文',
62
+ dir: '50-writing',
63
+ upstream: ['40-rhythm'],
64
+ requiredFiles: ['ledger.json'],
65
+ },
66
+ {
67
+ id: '60-deslop',
68
+ label: '去味',
69
+ dir: '60-deslop',
70
+ upstream: ['50-writing'],
71
+ requiredFiles: [],
72
+ },
73
+ ];
74
+
75
+ export const V2_STAGE_BY_ID = Object.fromEntries(V2_STAGES.map((s) => [s.id, s]));
76
+ export const V2_STAGE_IDS = V2_STAGES.map((s) => s.id);
77
+
78
+ export const STAGE_ORDER = V2_STAGE_IDS.slice();
79
+
80
+ /** 旧命令/旧目录 → 新阶段。迁移与兼容都从这里取,避免两处写死。 */
81
+ export const V1_TO_V2_STAGE = {
82
+ skeleton: '10-scale',
83
+ flesh: '30-cast',
84
+ rhythm: '40-rhythm',
85
+ writing: '50-writing',
86
+ deslop: '60-deslop',
87
+ };
88
+
89
+ export const V1_STAGE_DIR = {
90
+ skeleton: '骨架',
91
+ flesh: '血肉',
92
+ rhythm: '节奏',
93
+ writing: '写作',
94
+ };
95
+
96
+ /**
97
+ * 阶段目录:设定/<stage dir>。
98
+ * 注意:不是 设定/raw/sources/<旧目录>;2.0 的阶段目录是并列的。
99
+ */
100
+ export function stageDir(projectRoot, stageId) {
101
+ const spec = V2_STAGE_BY_ID[stageId];
102
+ if (!spec) throw new Error('未知阶段:' + stageId);
103
+ return path.join(projectRoot, '设定', spec.dir);
104
+ }
105
+
106
+ export function stageFile(projectRoot, stageId, name) {
107
+ return path.join(stageDir(projectRoot, stageId), name);
108
+ }
109
+
110
+ export function legacyStageDir(projectRoot, stageId) {
111
+ const name = V1_STAGE_DIR[stageId];
112
+ if (!name) return null;
113
+ return path.join(projectRoot, '设定', 'raw', 'sources', name);
114
+ }
115
+
116
+ export function isV2Project(projectRoot) {
117
+ if (!projectRoot) return false;
118
+ return ['10-scale', '20-macro', '30-cast', '40-rhythm'].some((id) =>
119
+ fs.existsSync(stageDir(projectRoot, id))
120
+ ) || fs.existsSync(path.join(projectRoot, '设定', 'manifest.json'));
121
+ }
122
+
123
+ export function ensureDir(dir) {
124
+ fs.mkdirSync(dir, { recursive: true });
125
+ return dir;
126
+ }
127
+
128
+ export function readJson(file, fallback = null) {
129
+ try {
130
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
131
+ } catch {
132
+ return fallback;
133
+ }
134
+ }
135
+
136
+ export function readText(file, fallback = null) {
137
+ try {
138
+ return fs.readFileSync(file, 'utf-8');
139
+ } catch {
140
+ return fallback;
141
+ }
142
+ }
143
+
144
+ export function fileExists(file) {
145
+ try {
146
+ return fs.existsSync(file) && fs.statSync(file).size > 0;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /** 原子写 JSON:临时文件 → rename。 */
153
+ export function writeJsonAtomic(file, value) {
154
+ ensureDir(path.dirname(file));
155
+ const tmp = file + '.tmp-' + process.pid + '-' + Date.now();
156
+ try {
157
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
158
+ fs.renameSync(tmp, file);
159
+ } finally {
160
+ try {
161
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
162
+ } catch {
163
+ // 清理失败不掩盖主流程
164
+ }
165
+ }
166
+ return file;
167
+ }
168
+
169
+ /** 原子写文本。 */
170
+ export function writeTextAtomic(file, text) {
171
+ ensureDir(path.dirname(file));
172
+ const tmp = file + '.tmp-' + process.pid + '-' + Date.now();
173
+ try {
174
+ fs.writeFileSync(tmp, text, 'utf-8');
175
+ fs.renameSync(tmp, file);
176
+ } finally {
177
+ try {
178
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
179
+ } catch {
180
+ // ignore
181
+ }
182
+ }
183
+ return file;
184
+ }
185
+
186
+ export function nowIso() {
187
+ return new Date().toISOString();
188
+ }
189
+
190
+ /** 去掉 _meta 后,对键名递归排序,得到稳定 JSON。 */
191
+ export function canonicalize(value) {
192
+ if (Array.isArray(value)) return value.map(canonicalize);
193
+ if (value && typeof value === 'object') {
194
+ const out = {};
195
+ for (const key of Object.keys(value).sort()) {
196
+ if (key === '_meta') continue;
197
+ out[key] = canonicalize(value[key]);
198
+ }
199
+ return out;
200
+ }
201
+ return value;
202
+ }
203
+
204
+ export function contentHash(value) {
205
+ const stable = JSON.stringify(canonicalize(value));
206
+ return 'sha256:' + crypto.createHash('sha256').update(stable, 'utf-8').digest('hex');
207
+ }
208
+
209
+ export function bytesHash(bytes) {
210
+ return 'sha256:' + crypto.createHash('sha256').update(bytes).digest('hex');
211
+ }
212
+
213
+ export function hashFile(file) {
214
+ const data = readJson(file, null);
215
+ if (data !== null) return contentHash(data);
216
+ try {
217
+ return bytesHash(fs.readFileSync(file));
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /** 阶段聚合哈希:所有业务 JSON 内容哈希再哈希,顺序固定。 */
224
+ export function stageHash(projectRoot, stageId) {
225
+ const spec = V2_STAGE_BY_ID[stageId];
226
+ if (!spec) return null;
227
+ const dir = stageDir(projectRoot, stageId);
228
+ const entries = [];
229
+ const walk = (current) => {
230
+ let names = [];
231
+ try {
232
+ names = fs.readdirSync(current, { withFileTypes: true });
233
+ } catch {
234
+ return;
235
+ }
236
+ for (const entry of names) {
237
+ if (entry.name.startsWith('.') || entry.name === '_context' || entry.name === 'bundle.json' || entry.name === 'README.md') continue;
238
+ const full = path.join(current, entry.name);
239
+ if (entry.isDirectory()) {
240
+ if (['outlines', 'chapters', 'timelines'].includes(entry.name)) walk(full);
241
+ continue;
242
+ }
243
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.md')) continue;
244
+ const rel = path.relative(dir, full).split(path.sep).join('/');
245
+ entries.push(rel + ':' + hashFile(full));
246
+ }
247
+ };
248
+ walk(dir);
249
+ entries.sort();
250
+ if (!entries.length) return null;
251
+ return 'sha256:' + crypto.createHash('sha256').update(entries.join('\n'), 'utf-8').digest('hex');
252
+ }
253
+
254
+ export function relProjectPath(projectRoot, file) {
255
+ return path.relative(projectRoot, file).split(path.sep).join('/');
256
+ }
257
+
258
+ export function wikiStatus(status) {
259
+ switch (status) {
260
+ case 'passed':
261
+ return 'done';
262
+ case 'revised':
263
+ case 'locked':
264
+ return 'revised';
265
+ case 'archived':
266
+ case 'deprecated':
267
+ return 'archived';
268
+ case 'stub':
269
+ return 'stub';
270
+ default:
271
+ return 'draft';
272
+ }
273
+ }
274
+
275
+ export function isTerminalStageStatus(status) {
276
+ return status === 'passed';
277
+ }
278
+
279
+ /**
280
+ * 读 v2 阶段状态。
281
+ * 兼容旧阶段:没有 v2 .stage-status.json 时,读旧骨架/血肉/节奏的 all_check。
282
+ */
283
+ export function readStageStatus(projectRoot, stageId, opts = {}) {
284
+ const file = path.join(stageDir(projectRoot, stageId), '.stage-status.json');
285
+ const data = readJson(file, null);
286
+ if (data && typeof data === 'object') {
287
+ return {
288
+ stage: data.stage || stageId,
289
+ status: data.status || 'draft',
290
+ files: data.files || {},
291
+ checkedAt: data.checked_at || null,
292
+ errors: Array.isArray(data.errors) ? data.errors : [],
293
+ warnings: Array.isArray(data.warnings) ? data.warnings : [],
294
+ source: 'v2',
295
+ };
296
+ }
297
+ const legacy = opts.allowLegacy === false ? null : legacyStageDir(projectRoot, Object.keys(V1_STAGE_DIR).find((k) => V1_TO_V2_STAGE[k] === stageId));
298
+ if (legacy) {
299
+ const old = readJson(path.join(legacy, '.stage-status.json'), null);
300
+ const all = old && old.all_check && old.all_check.status;
301
+ if (all) {
302
+ return {
303
+ stage: stageId,
304
+ status: all === 'PASSED' ? 'passed' : (all === 'FAILED' ? 'stale' : 'draft'),
305
+ files: old.files || {},
306
+ checkedAt: old.all_check && old.all_check.last_check || null,
307
+ errors: [],
308
+ warnings: [],
309
+ source: 'v1',
310
+ };
311
+ }
312
+ }
313
+ return { stage: stageId, status: 'draft', files: {}, checkedAt: null, errors: [], warnings: [], source: null };
314
+ }
315
+
316
+ export function writeStageStatus(projectRoot, stageId, report) {
317
+ const file = path.join(stageDir(projectRoot, stageId), '.stage-status.json');
318
+ const payload = {
319
+ schema_version: SCHEMA_VERSION,
320
+ stage: stageId,
321
+ status: report && report.ok ? 'passed' : (report && report.status) || 'stale',
322
+ checked_at: nowIso(),
323
+ files: report && report.files || {},
324
+ errors: report && report.errors || [],
325
+ warnings: report && report.warnings || [],
326
+ };
327
+ writeJsonAtomic(file, payload);
328
+ return payload;
329
+ }
330
+
331
+ /**
332
+ * 统一错误对象。错误只允许指向项目内文件,不允许出现插件源码路径。
333
+ */
334
+ export function v2Error(code, opts = {}) {
335
+ const out = {
336
+ code,
337
+ severity: opts.severity || 'error',
338
+ path: opts.path || '',
339
+ message: opts.message || '',
340
+ };
341
+ if (opts.expected !== undefined) out.expected = opts.expected;
342
+ if (opts.actual !== undefined) out.actual = opts.actual;
343
+ if (opts.hint) out.hint = String(opts.hint).replace(/node_modules|plugins\/fancy-story-builder|scripts\//g, '项目契约文件');
344
+ if (opts.contract) out.contract = opts.contract;
345
+ if (opts.reference) out.reference = opts.reference;
346
+ return out;
347
+ }
348
+
349
+ /** 读数组字段,永远返回数组。 */
350
+ export function asArray(value) {
351
+ return Array.isArray(value) ? value : [];
352
+ }
353
+
354
+ /** 从任意 JSON 值里取 id → 对象的映射。 */
355
+ export function indexById(list) {
356
+ const map = new Map();
357
+ for (const row of asArray(list)) {
358
+ if (row && typeof row.id === 'string' && row.id) map.set(row.id, row);
359
+ }
360
+ return map;
361
+ }
362
+
363
+ /** 所有字符串里出现过的 id 集合(用于引用检查)。 */
364
+ export function stringsOf(value, out = []) {
365
+ if (typeof value === 'string') out.push(value);
366
+ else if (Array.isArray(value)) for (const item of value) stringsOf(item, out);
367
+ else if (value && typeof value === 'object') for (const item of Object.values(value)) stringsOf(item, out);
368
+ return out;
369
+ }
370
+
371
+ /** 字段路径取值:a.b[0].c */
372
+ export function getByPath(obj, dotted) {
373
+ let cur = obj;
374
+ for (const part of String(dotted || '').split('.')) {
375
+ if (!part) continue;
376
+ const m = /^([^\[\]]+)(?:\[(\d+)\])?$/.exec(part);
377
+ if (!m) return undefined;
378
+ if (cur === null || cur === undefined) return undefined;
379
+ cur = cur[m[1]];
380
+ if (m[2] !== undefined) {
381
+ if (!Array.isArray(cur)) return undefined;
382
+ cur = cur[Number(m[2])];
383
+ }
384
+ }
385
+ return cur;
386
+ }
387
+
388
+ export function setByPath(obj, dotted, value) {
389
+ const parts = String(dotted || '').split('.').filter(Boolean);
390
+ let cur = obj;
391
+ for (let i = 0; i < parts.length - 1; i++) {
392
+ const key = parts[i];
393
+ if (!cur[key] || typeof cur[key] !== 'object') cur[key] = {};
394
+ cur = cur[key];
395
+ }
396
+ if (parts.length) cur[parts[parts.length - 1]] = value;
397
+ return obj;
398
+ }
399
+
400
+ export function formatErrorLine(e) {
401
+ const code = e && e.code ? '[' + e.code + '] ' : '';
402
+ const p = e && e.path ? e.path + ':' : '';
403
+ const msg = e && (e.message || e.detail) ? (e.message || e.detail) : '';
404
+ const hint = e && e.hint ? '(' + e.hint + ')' : '';
405
+ return code + p + msg + hint;
406
+ }
407
+
408
+ export function summarizeReport(report, limit = 12) {
409
+ if (!report) return '未知错误';
410
+ const errors = Array.isArray(report.errors) ? report.errors : [];
411
+ const warnings = Array.isArray(report.warnings) ? report.warnings : [];
412
+ const lines = [];
413
+ for (const e of errors.slice(0, limit)) lines.push('- ' + formatErrorLine(e));
414
+ if (errors.length > limit) lines.push('- … 其余 ' + (errors.length - limit) + ' 个错误');
415
+ if (!errors.length && warnings.length) {
416
+ for (const w of warnings.slice(0, limit)) lines.push('- [warning] ' + formatErrorLine(w));
417
+ }
418
+ return lines.join('\n') || '无错误';
419
+ }