@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,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * brief-validate.js —— 校验“每章一份细纲”是否齐全、引用是否成立
4
+ *
5
+ * 这一层不是替代 rhythm-validate.js:聚合 JSON 仍由 rhythm-validate 判结构。
6
+ * 这里只判**写作阶段真正会读的每章细纲**:
7
+ * - 1..T 每章是否都有文件;
8
+ * - 必须字段是否存在;
9
+ * - cast/地点/道具/冲突/伏笔/高潮/爽点/信息差引用是否都能在上游找到;
10
+ * - must_write / must_not_write 是否为空。
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ const SRC = ['设定', 'raw', 'sources'];
16
+
17
+ function readJson(file, fallback = null) {
18
+ try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return fallback; }
19
+ }
20
+
21
+ function idsOf(list) {
22
+ return new Set((list || []).filter((x) => x && x.id).map((x) => x.id));
23
+ }
24
+
25
+ export function validateChapterBriefs(projectRoot) {
26
+ const skeletonDir = path.join(projectRoot, ...SRC, '骨架');
27
+ const fleshDir = path.join(projectRoot, ...SRC, '血肉');
28
+ const rhythmDir = path.join(projectRoot, ...SRC, '节奏');
29
+ const briefDir = path.join(rhythmDir, '细纲');
30
+ const framework = readJson(path.join(skeletonDir, '框架.json'), {}) || {};
31
+ const entities = readJson(path.join(skeletonDir, '实体.json'), {}) || {};
32
+ const flesh = readJson(path.join(fleshDir, '冲突.json'), {}) || {};
33
+ const plan = readJson(path.join(rhythmDir, '章节规划.json'), {}) || {};
34
+ const T = Number(framework?.capacity?.target_chapters) || (plan.chapters || []).length || 0;
35
+ const errors = [];
36
+ const warnings = [];
37
+
38
+ const ids = {
39
+ characters: idsOf(entities.characters),
40
+ locations: idsOf(entities.locations),
41
+ items: idsOf(entities.items),
42
+ conflicts: idsOf(entities.conflicts),
43
+ foreshadows: idsOf(entities.foreshadows),
44
+ climaxes: idsOf(entities.climaxes),
45
+ pleasures: idsOf(entities.pleasures),
46
+ endings: idsOf(entities.endings),
47
+ };
48
+ const infoGapIds = new Set();
49
+ const gapDir = path.join(projectRoot, ...SRC, '档案', '信息差');
50
+ try {
51
+ for (const name of fs.readdirSync(gapDir)) {
52
+ if (name.endsWith('.json')) infoGapIds.add(name.slice(0, -5));
53
+ }
54
+ } catch { /* ignore */ }
55
+
56
+ const files = new Map();
57
+ try {
58
+ for (const name of fs.readdirSync(briefDir)) {
59
+ const match = /^第(\d+)章\.json$/.exec(name);
60
+ if (!match) continue;
61
+ files.set(Number(match[1]), path.join(briefDir, name));
62
+ }
63
+ } catch {
64
+ errors.push('细纲目录不存在:设定/raw/sources/节奏/细纲');
65
+ }
66
+
67
+ if (T > 0) {
68
+ for (let ch = 1; ch <= T; ch += 1) {
69
+ if (!files.has(ch)) errors.push('缺第 ' + ch + ' 章细纲文件');
70
+ }
71
+ }
72
+ for (const ch of files.keys()) {
73
+ if (T > 0 && (ch < 1 || ch > T)) warnings.push('细纲章号越界:第 ' + ch + ' 章');
74
+ }
75
+
76
+ for (const [ch, file] of [...files.entries()].sort((a, b) => a[0] - b[0])) {
77
+ const brief = readJson(file);
78
+ if (!brief) {
79
+ errors.push('第 ' + ch + ' 章细纲不是合法 JSON');
80
+ continue;
81
+ }
82
+ for (const key of ['ch', 'title', 'volume', 'scene_focus', 'pov', 'cast', 'must_write', 'must_not_write']) {
83
+ if (brief[key] === undefined || brief[key] === null || brief[key] === '') {
84
+ errors.push('第 ' + ch + ' 章缺字段:' + key);
85
+ }
86
+ }
87
+ if (!Array.isArray(brief.cast) || brief.cast.length === 0) {
88
+ warnings.push('第 ' + ch + ' 章没有 cast(可能只剩主角或上游时间线过窄)');
89
+ }
90
+ for (const c of (brief.cast || [])) {
91
+ if (c && c.id && !ids.characters.has(c.id)) errors.push('第 ' + ch + ' 章 cast 引用了不存在的人物:' + c.id);
92
+ }
93
+ if (brief.location && brief.location.id && !ids.locations.has(brief.location.id)) {
94
+ errors.push('第 ' + ch + ' 章地点引用了不存在的地点:' + brief.location.id);
95
+ }
96
+ for (const item of (brief.items || [])) {
97
+ if (item && item.id && !ids.items.has(item.id)) errors.push('第 ' + ch + ' 章道具引用了不存在的道具:' + item.id);
98
+ }
99
+ for (const conflict of (brief.conflicts || [])) {
100
+ if (conflict && conflict.id && !ids.conflicts.has(conflict.id)) {
101
+ errors.push('第 ' + ch + ' 章冲突引用了不存在的冲突:' + conflict.id);
102
+ }
103
+ }
104
+ for (const rel of (brief.relationship_changes || [])) {
105
+ if (rel && rel.from && !ids.characters.has(rel.from)) {
106
+ errors.push('第 ' + ch + ' 章关系变化引用了不存在的人物:' + rel.from);
107
+ }
108
+ if (rel && rel.to && !ids.characters.has(rel.to)) {
109
+ errors.push('第 ' + ch + ' 章关系变化引用了不存在的人物:' + rel.to);
110
+ }
111
+ }
112
+ for (const f of (brief.foreshadow_actions || [])) {
113
+ if (f && f.id && !ids.foreshadows.has(f.id)) errors.push('第 ' + ch + ' 章伏笔引用了不存在的伏笔:' + f.id);
114
+ }
115
+ if (brief.climax && brief.climax.id && !ids.climaxes.has(brief.climax.id)) {
116
+ errors.push('第 ' + ch + ' 章高潮引用了不存在的高潮:' + brief.climax.id);
117
+ }
118
+ if (brief.pleasure && brief.pleasure.id && !ids.pleasures.has(brief.pleasure.id)) {
119
+ errors.push('第 ' + ch + ' 章爽点引用了不存在的爽点:' + brief.pleasure.id);
120
+ }
121
+ if (brief.ending && brief.ending.id && !ids.endings.has(brief.ending.id)) {
122
+ errors.push('第 ' + ch + ' 章结局引用了不存在的结局:' + brief.ending.id);
123
+ }
124
+ for (const gap of (brief.information_gaps || [])) {
125
+ if (gap && gap.id && !infoGapIds.has(gap.id)) {
126
+ errors.push('第 ' + ch + ' 章信息差引用了不存在的档案:' + gap.id);
127
+ }
128
+ }
129
+ if (Array.isArray(brief.must_write) && brief.must_write.length === 0) {
130
+ warnings.push('第 ' + ch + ' 章 must_write 为空');
131
+ }
132
+ if (Array.isArray(brief.must_not_write) && brief.must_not_write.length === 0) {
133
+ warnings.push('第 ' + ch + ' 章 must_not_write 为空');
134
+ }
135
+ }
136
+
137
+ return {
138
+ status: errors.length ? 1 : 0,
139
+ stage: '章节细纲',
140
+ total: T,
141
+ files: files.size,
142
+ errors: errors.slice(0, 200),
143
+ warnings: warnings.slice(0, 200),
144
+ };
145
+ }
146
+
147
+ function main() {
148
+ const args = process.argv.slice(2);
149
+ const rootArg = args.find((a) => a.startsWith('--project-root='));
150
+ const dirArg = args.find((a) => a.startsWith('--stage-dir='));
151
+ let projectRoot = rootArg ? rootArg.slice('--project-root='.length) : '';
152
+ if (!projectRoot && dirArg) {
153
+ projectRoot = path.resolve(dirArg.slice('--stage-dir='.length), '..', '..', '..', '..');
154
+ }
155
+ if (!projectRoot) {
156
+ process.stderr.write('需要 --project-root=<项目根> 或 --stage-dir=<节奏目录>\n');
157
+ process.exit(2);
158
+ }
159
+ const result = validateChapterBriefs(projectRoot);
160
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
161
+ process.exitCode = result.status;
162
+ }
163
+
164
+ if (process.argv[1] && process.argv[1].endsWith('brief-validate.js')) main();
@@ -69,7 +69,8 @@ const SHORT_VALUE_EXEMPT_FIELDS = new Set(['exception', 'limit']);
69
69
  // 这条错就永远修不好、还挂在报告里(tt5 实测 2026-10-25:
70
70
  // "genre 字段内容过短(有效字符 2,要求至少 3):'历史'")。
71
71
  const LENGTH_CHECK_EXEMPT_FIELDS = new Set([
72
- 'name', 'target_name', 'id', 'type', 'stage', 'gender', 'age_group',
72
+ 'name', 'target_name', 'id', 'type', 'stage', 'timing', 'gender', 'age_group',
73
+ 'kind', 'relation_label', 'state', 'traits', 'abilities', 'limits',
73
74
  'catchphrase', 'behavior_repeat_points',
74
75
  'position_level', 'theme_short', 'stance', 'core_emotion', 'info_name',
75
76
  'focus_points', 'key_event_descriptions', 'memorable_scenes', 'epilogue_hints',
@@ -83,6 +84,8 @@ const LENGTH_CHECK_EXEMPT_FIELDS = new Set([
83
84
  // 这些字段承载的是叙述性内容,几个字表达不了一个完整信息。
84
85
  const LONG_PROSE_FIELDS = new Set([
85
86
  'description', 'content', 'core_summary', 'core_conflict', 'conflict_summary',
87
+ 'positioning', 'reader_value', 'core_setting', 'payoff_model', 'emotion_curve',
88
+ 'opening_hook', 'pacing_plan', 'character_design', 'differentiation', 'risk_validation',
86
89
  'conflict_solution', 'core_goal', 'core_drive', 'influence_scope',
87
90
  'surface_contradiction', 'deep_contradiction', 'ultimate_contradiction',
88
91
  'internal_structure', 'suppression_method', 'resource_seizure_path',
@@ -136,6 +136,98 @@ function fleshSelfRelationCheck(data, fileType, errors) {
136
136
  }
137
137
  }
138
138
 
139
+ // ============================================================
140
+ // 个性化特征与关系阶段检查
141
+ // ============================================================
142
+ // `traits` 是角色的"独有特征",不是性格形容词堆砌。
143
+ // 模板只能要求字段存在,数量和重复要靠这里兜住。
144
+ function traitsAndChangesCheck(data, fileType, errors, warnings) {
145
+ const checkTraits = (item, path, label, min, max) => {
146
+ if (!item || item.traits === undefined) {
147
+ if (label === '角色') {
148
+ errors.push({
149
+ code: 'FLESH_TRAITS_MISSING',
150
+ path: path + '.traits',
151
+ message: '角色缺少 traits(独有特征/习惯)',
152
+ hint: '写 3-5 条能直接写进场景的具体特征,例如"紧张时反复摸腕环"'
153
+ });
154
+ }
155
+ return;
156
+ }
157
+ if (!Array.isArray(item.traits)) return; // TYPE 已报
158
+ const values = item.traits.map((x) => (typeof x === 'string' ? x.trim() : '')).filter(Boolean);
159
+ if (values.length < min || values.length > max) {
160
+ errors.push({
161
+ code: 'FLESH_TRAITS_COUNT',
162
+ path: path + '.traits',
163
+ message: label + '的 traits 应为 ' + min + '-' + max + ' 条,实际 ' + values.length + ' 条',
164
+ hint: '特征要少而准:每条都能在场景里演出来,不要用"善良""勇敢"这类评价词凑数'
165
+ });
166
+ }
167
+ if (new Set(values).size !== values.length) {
168
+ errors.push({
169
+ code: 'FLESH_TRAITS_DUPLICATE',
170
+ path: path + '.traits',
171
+ message: label + '的 traits 有重复条目',
172
+ hint: '删掉重复项,换成不同的习惯/感官锚点'
173
+ });
174
+ }
175
+ };
176
+
177
+ const checkChanges = (rel, path, label) => {
178
+ if (!rel || !Array.isArray(rel.changes) || rel.changes.length === 0) return;
179
+ for (const [k, ch] of rel.changes.entries()) {
180
+ if (!ch || typeof ch !== 'object') continue;
181
+ for (const key of ['stage', 'from', 'to', 'trigger', 'description']) {
182
+ const v = ch[key];
183
+ if (typeof v !== 'string' || !v.trim()) {
184
+ errors.push({
185
+ code: 'REL_CHANGE_INCOMPLETE',
186
+ path: path + '.changes[' + k + '].' + key,
187
+ message: label + '的关系变化缺少 ' + key + ',无法判断这一阶段到底发生了什么',
188
+ hint: '每条变化至少写清:阶段名、变化前状态、变化后状态、触发事件、变化描述'
189
+ });
190
+ }
191
+ }
192
+ for (const key of ['stage', 'from', 'to', 'trigger', 'description', 'timing']) {
193
+ if (typeof ch[key] === 'string' && /第\s*(?:\d+|[一二三四五六七八九十百千万]+)\s*章/.test(ch[key])) {
194
+ errors.push({
195
+ code: 'FLESH_FREETEXT_CHAPTER_REF',
196
+ path: path + '.changes[' + k + '].' + key,
197
+ message: '关系变化里出现"第 N 章"引用;章节号属于节奏阶段',
198
+ hint: '这里只写"前期/转折/后期/某卷"或事件本身,章节落点由节奏阶段 relationship_timeline 决定'
199
+ });
200
+ }
201
+ }
202
+ }
203
+ };
204
+
205
+ if (fileType === '角色') {
206
+ for (const [i, c] of (data.characters || []).entries()) {
207
+ if (!c) continue;
208
+ checkTraits(c, 'characters[' + i + ']', '角色', 3, 5);
209
+ for (const [j, rel] of (c.relationships || []).entries()) {
210
+ if (!rel) continue;
211
+ if (rel.relationship_type === '其他' && !String(rel.relation_label || '').trim()) {
212
+ errors.push({
213
+ code: 'REL_LABEL_REQUIRED',
214
+ path: 'characters[' + i + '].relationships[' + j + '].relation_label',
215
+ message: 'relationship_type="其他" 时必须填 relation_label,写清具体关系(如 附身/寄生/契约/共生)',
216
+ hint: 'relation_label 是词表外关系的唯一语义来源'
217
+ });
218
+ }
219
+ checkChanges(rel, 'characters[' + i + '].relationships[' + j + ']', c.name || c.id);
220
+ }
221
+ }
222
+ }
223
+ if (fileType === '其他实体') {
224
+ for (const [i, o] of (data.other_entities || []).entries()) {
225
+ if (!o) continue;
226
+ checkTraits(o, 'other_entities[' + i + ']', '其他实体', 2, 6);
227
+ }
228
+ }
229
+ }
230
+
139
231
  // ============================================================
140
232
  // 跨阶段:血肉关系 ↔ 骨架关系一致性
141
233
  // ============================================================
@@ -169,11 +261,14 @@ function skeletonRelationConsistencyCheck(fleshData, sk, errors, warnings, opts)
169
261
  }
170
262
  const resolve = (v) => (v && idByKey.has(v) ? idByKey.get(v) : null);
171
263
 
172
- // 骨架关系:'A→B' → relation_kind
264
+ // 骨架关系:'A→B' → { kind, label }
173
265
  const skRel = new Map();
174
266
  for (const r of (sk.relations.character_relations || [])) {
175
267
  if (!r || !r.from || !r.to) continue;
176
- skRel.set(r.from + '→' + r.to, r.relation_kind);
268
+ skRel.set(r.from + '→' + r.to, {
269
+ kind: r.relation_kind,
270
+ label: typeof r.relation_label === 'string' ? r.relation_label.trim() : ''
271
+ });
177
272
  }
178
273
 
179
274
  // 血肉关系:有序对 → { kind, path },并记录血肉已覆盖的无序对
@@ -194,19 +289,36 @@ function skeletonRelationConsistencyCheck(fleshData, sk, errors, warnings, opts)
194
289
 
195
290
  const fwd = skRel.get(key);
196
291
  if (fwd !== undefined) {
197
- if (r.relationship_type !== fwd) {
292
+ if (r.relationship_type !== fwd.kind) {
198
293
  errors.push({
199
294
  code: 'REL_TYPE_MISMATCH',
200
295
  path: p + '.relationship_type',
201
- message: '骨架 ' + srcId + '→' + tgtId + ' 的关系类型是 "' + fwd + '",血肉写的是 "' + r.relationship_type + '"',
296
+ message: '骨架 ' + srcId + '→' + tgtId + ' 的关系类型是 "' + fwd.kind + '",血肉写的是 "' + r.relationship_type + '"',
202
297
  hint: '血肉阶段的关系类型必须与骨架一致;若语义确实变了,请在 description 里写清"关系在剧情中如何演变",而不是换掉类型'
203
298
  });
204
299
  }
300
+ const fleshLabel = typeof r.relation_label === 'string' ? r.relation_label.trim() : '';
301
+ if (fwd.label && fleshLabel && fwd.label !== fleshLabel) {
302
+ errors.push({
303
+ code: 'REL_LABEL_MISMATCH',
304
+ path: p + '.relation_label',
305
+ message: '骨架 ' + srcId + '→' + tgtId + ' 的关系标签是 "' + fwd.label + '",血肉写的是 "' + fleshLabel + '"',
306
+ hint: '关系标签必须与骨架完全一致'
307
+ });
308
+ }
309
+ if (fwd.kind === '其他' && !fleshLabel) {
310
+ errors.push({
311
+ code: 'REL_LABEL_REQUIRED',
312
+ path: p + '.relation_label',
313
+ message: '骨架把 ' + srcId + '→' + tgtId + ' 标为"其他",血肉必须写 relation_label',
314
+ hint: '照抄骨架的 relation_label,或补齐具体关系名'
315
+ });
316
+ }
205
317
  continue;
206
318
  }
207
319
 
208
- const rev = skRel.get(tgtId + '→' + srcId);
209
- if (rev === undefined) {
320
+ const revRow = skRel.get(tgtId + '→' + srcId);
321
+ if (revRow === undefined) {
210
322
  errors.push({
211
323
  code: 'REL_NOT_IN_SKELETON',
212
324
  path: p,
@@ -216,6 +328,7 @@ function skeletonRelationConsistencyCheck(fleshData, sk, errors, warnings, opts)
216
328
  continue;
217
329
  }
218
330
 
331
+ const rev = revRow.kind;
219
332
  const meta = RELATION_KIND_META[rev];
220
333
  if (meta && meta.direction === 'symmetric') {
221
334
  if (r.relationship_type !== rev) {
@@ -246,7 +359,7 @@ function skeletonRelationConsistencyCheck(fleshData, sk, errors, warnings, opts)
246
359
  warnings.push({
247
360
  code: 'REL_UNDECLARED',
248
361
  path: '骨架关系 ' + key,
249
- message: '骨架 ' + key + '("' + kind + '")在血肉阶段没有对应的 relationships 条目',
362
+ message: '骨架 ' + key + '("' + kind.kind + '")在血肉阶段没有对应的 relationships 条目',
250
363
  hint: '该对角色的关系尚未血肉化;若刻意留白可忽略'
251
364
  });
252
365
  }
@@ -459,6 +572,7 @@ function charsetAndForbiddenCheck(data, errors) {
459
572
  // ============================================================
460
573
  const FLESH_ID_ARRAYS = {
461
574
  '角色': ['characters'],
575
+ '其他实体': ['other_entities'],
462
576
  '冲突': ['conflicts'],
463
577
  '势力': ['factions'],
464
578
  '剧情': ['main_lines', 'branches'],
@@ -488,6 +602,7 @@ function idUniquenessCheck(data, fileType, errors) {
488
602
  // ============================================================
489
603
  const FLESH_TO_SKELETON = {
490
604
  '角色': { fleshKey: 'characters', skeletonKey: 'characters' },
605
+ '其他实体': { fleshKey: 'other_entities', skeletonKey: 'other_entities' },
491
606
  '冲突': { fleshKey: 'conflicts', skeletonKey: 'conflicts' },
492
607
  '势力': { fleshKey: 'factions', skeletonKey: 'factions' },
493
608
  '地点': { fleshKey: 'locations', skeletonKey: 'locations' },
@@ -545,7 +660,7 @@ function foreshadowRefCheck(data, skeletonEntity, errors) {
545
660
  code: 'FLESH_FORESHADOW_REF_MISSING',
546
661
  path: 'endings[' + i + '].foreshadow_recovery_plan[' + j + '].foreshadow_id',
547
662
  message: '伏笔 id "' + fid + '" 不存在于骨架的 foreshadows',
548
- hint: '伏笔 id 必须来自骨架 实体.json 的 foreshadows(可用 upstream-query.js --stage=骨架 --file=实体 查)'
663
+ hint: '伏笔 id 必须来自骨架 实体.json 的 foreshadows(可用 node references/upstream-query.js --stage=骨架 --file=实体 --list 查全部 id 名单)'
549
664
  });
550
665
  }
551
666
  }
@@ -733,7 +848,7 @@ function xrefCheck(data, fileType, skeletonEntity, errors) {
733
848
  message: '引用' + label + ' "' + name + '" 不存在于骨架',
734
849
  hint: guess
735
850
  ? '疑似拼错:骨架里有 "' + guess.name + '",请改成它'
736
- : '引用必须用骨架里已有的' + label + '名字;可用 upstream-query.js --stage=骨架 --file=实体 查全量名单'
851
+ : '引用必须用骨架里已有的' + label + '名字;可用 node references/upstream-query.js --stage=骨架 --file=实体 --list 查全量名单'
737
852
  });
738
853
  }
739
854
  };
@@ -843,6 +958,7 @@ function checkEntityCoverage(skeletonIds, fleshIds, label, errors, warnings) {
843
958
  // P0-3:数组名 → 文件类型 的映射
844
959
  const DIRECT_FILE_TYPE_MAP = {
845
960
  characters: '角色',
961
+ other_entities: '其他实体',
846
962
  conflicts: '冲突',
847
963
  factions: '势力',
848
964
  locations: '地点',
@@ -852,6 +968,7 @@ const DIRECT_FILE_TYPE_MAP = {
852
968
  function completenessCheck(fleshData, skeletonEntity, errors, warnings) {
853
969
  const directChecks = [
854
970
  { fleshKey: 'characters', skKey: 'characters', label: 'character' },
971
+ { fleshKey: 'other_entities', skKey: 'other_entities', label: 'other_entity' },
855
972
  { fleshKey: 'conflicts', skKey: 'conflicts', label: 'conflict' },
856
973
  { fleshKey: 'factions', skKey: 'factions', label: 'faction' },
857
974
  { fleshKey: 'locations', skKey: 'locations', label: 'location' },
@@ -1017,7 +1134,7 @@ function validateSingle(args) {
1017
1134
  safeCheck('freeTextChapterCheck', () => freeTextChapterCheck(data, warnings), errors);
1018
1135
  safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, file, errors), errors);
1019
1136
 
1020
- if (file === '角色' || file === '冲突' || file === '势力' || file === '地点' || file === '结局') {
1137
+ if (file === '角色' || file === '其他实体' || file === '冲突' || file === '势力' || file === '地点' || file === '结局') {
1021
1138
  safeCheck('entityRefCheck', () => entityRefCheck(data, file, sk.entity, errors), errors);
1022
1139
  } else if (file === '剧情') {
1023
1140
  safeCheck('storyRefCheck', () => storyRefCheck(data, sk.entity, errors), errors);
@@ -1035,6 +1152,7 @@ function validateSingle(args) {
1035
1152
  safeCheck('xrefCheck', () => xrefCheck(data, file, sk.entity, errors), errors);
1036
1153
  safeCheck('fleshSelfRelationCheck', () => fleshSelfRelationCheck(data, file, errors), errors);
1037
1154
  safeCheck('contentQualityCheck', () => runContentQualityChecks(data, errors, { enumLeaves: collectEnumLeaves(tplRes.data.structure) }), errors);
1155
+ safeCheck('traitsAndChangesCheck', () => traitsAndChangesCheck(data, file, errors, warnings), errors);
1038
1156
 
1039
1157
  if (file === '角色') {
1040
1158
  safeCheck('arcConsistencyCheck', () => arcConsistencyCheck(data, warnings, sk.hierarchy), errors);
@@ -1091,6 +1209,7 @@ function validateAll(args) {
1091
1209
  return emit(2, 'ALL', [{ code: 'UPSTREAM_NOT_PASSED', path: '', message: gate.reason, hint: '请先对骨架阶段执行全量校验并通过' }], []);
1092
1210
  }
1093
1211
 
1212
+ const otherEntityCount = Array.isArray(sk.entity.other_entities) ? sk.entity.other_entities.length : 0;
1094
1213
  const FILES = [
1095
1214
  { type: '角色', name: '角色.json', tpl: '角色模板.json' },
1096
1215
  { type: '冲突', name: '冲突.json', tpl: '冲突模板.json' },
@@ -1099,7 +1218,10 @@ function validateAll(args) {
1099
1218
  { type: '地点', name: '地点.json', tpl: '地点模板.json' },
1100
1219
  { type: '结局', name: '结局.json', tpl: '结局模板.json' },
1101
1220
  { type: '世界观规则', name: '世界观规则.json', tpl: '世界观规则模板.json' },
1102
- { type: '关键道具', name: '关键道具.json', tpl: '关键道具模板.json' }
1221
+ { type: '关键道具', name: '关键道具.json', tpl: '关键道具模板.json' },
1222
+ ...(otherEntityCount > 0 || fs.existsSync(path.join(stageDir, '其他实体.json'))
1223
+ ? [{ type: '其他实体', name: '其他实体.json', tpl: '其他实体模板.json' }]
1224
+ : [])
1103
1225
  ];
1104
1226
 
1105
1227
  const loaded = {};
@@ -1128,7 +1250,7 @@ function validateAll(args) {
1128
1250
  safeCheck('freeTextChapterCheck', () => freeTextChapterCheck(data, warnings), errors);
1129
1251
  safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, f.type, errors), errors);
1130
1252
 
1131
- if (f.type === '角色' || f.type === '冲突' || f.type === '势力' || f.type === '地点' || f.type === '结局') {
1253
+ if (f.type === '角色' || f.type === '其他实体' || f.type === '冲突' || f.type === '势力' || f.type === '地点' || f.type === '结局') {
1132
1254
  safeCheck('entityRefCheck', () => entityRefCheck(data, f.type, sk.entity, errors), errors);
1133
1255
  } else if (f.type === '剧情') {
1134
1256
  safeCheck('storyRefCheck', () => storyRefCheck(data, sk.entity, errors), errors);
@@ -1146,6 +1268,7 @@ function validateAll(args) {
1146
1268
  safeCheck('xrefCheck', () => xrefCheck(data, f.type, sk.entity, errors), errors);
1147
1269
  safeCheck('fleshSelfRelationCheck', () => fleshSelfRelationCheck(data, f.type, errors), errors);
1148
1270
  safeCheck('contentQualityCheck', () => runContentQualityChecks(data, errors, { enumLeaves: collectEnumLeaves(tplRes.data.structure) }), errors);
1271
+ safeCheck('traitsAndChangesCheck', () => traitsAndChangesCheck(data, f.type, errors, warnings), errors);
1149
1272
 
1150
1273
  if (f.type === '角色') safeCheck('arcConsistencyCheck', () => arcConsistencyCheck(data, warnings, sk.hierarchy), errors);
1151
1274
  }