@cloud411716/fancy-webnovel 1.0.46 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. 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 +427 -0
  238. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  239. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  240. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  241. 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 +168 -0
  242. 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 +184 -0
  243. 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
  244. 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 +131 -0
  245. 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 +85 -0
  246. 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 +103 -0
  247. 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 +68 -0
  248. 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 +142 -0
  249. 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 +292 -0
  250. 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 +266 -0
  251. 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 +336 -0
  252. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  253. 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 +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
@@ -0,0 +1,1049 @@
1
+ /**
2
+ * stage-driver.js v1.0
3
+ * 阶段驱动的通用实现:插件当司机,脚本当裁判,LLM 只负责按指令生成文件。
4
+ *
5
+ * 为什么要这么改(2026-10 tt5 实测):
6
+ * - 原来血肉阶段的"循环"写在提示词里,由 LLM 自己调 flesh-validate.js、
7
+ * 自己读错误码、自己决定改哪里、自己决定下一步。实测后果:
8
+ * 102 次工具调用里 50 次是模型自己在调校验器,60 次 edit 是模型自己
9
+ * 解读错误码改文件,而全量校验(--all)**一次都没跑**
10
+ * (.stage-status.json 里的 all_check:PENDING 是 flesh-validate.js:925
11
+ * 的初始化默认值,不是判定结果)。
12
+ * - 结局.json 写到一半被 provider 中断(output new_sensitive 1027),
13
+ * 插件只报了"阶段开始",用户对失败完全无感。
14
+ *
15
+ * 现在:生成粒度 = 批次,校验粒度 = 文件,判定权 = 插件 + 脚本。
16
+ * LLM 收到的是"本轮写哪个文件、写哪几条"的指令和脚本判出来的错误清单,
17
+ * 不再自己调脚本、不再自己判断通过与否。
18
+ *
19
+ * 本模块不直接依赖 dsh 运行时:sendPrompt / waitIdle / validateFile
20
+ * 都由调用方注入,因此可以在没有 LLM 的情况下用测试夹具驱动。
21
+ */
22
+
23
+ import { existsSync, readdirSync, readFileSync, renameSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { classifyIssues, describeInfraIssues } from './scripts/error-classes.js';
26
+
27
+ /**
28
+ * 读一个分片文件的文本(不存在或读不了返回 null)。
29
+ *
30
+ * 用途:重投前记下"旧内容"当基线,用来判断模型这一轮是不是真写了。
31
+ * 为什么需要它:旧分片不能删(删了 DSH 的 write 会报 FS_STALE_VERSION,
32
+ * 见 generateBatch 里的注释),所以完成判据只能改成"内容变了没有"。
33
+ */
34
+ function readShardText(shardPath) {
35
+ try {
36
+ if (!shardPath || !existsSync(shardPath)) return null;
37
+ return readFileSync(shardPath, 'utf-8');
38
+ } catch (e) {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ // 单批生成的最大尝试次数(含首次)
44
+ export const STAGE_MAX_BATCH_ATTEMPTS = 3;
45
+
46
+ /**
47
+ * provider 报错里哪些是**瞬时**的(重投一次可能好)。
48
+ *
49
+ * 码字面量来自 DSH:dsh-llm/lib/index.js:238 RATE_LIMIT、:133 QUOTA、
50
+ * :143 EMPTY_RESPONSE。鉴权失败(INVALID_CREDENTIAL)、上下文超限
51
+ * (CONTEXT_WINDOW_EXCEEDED)和 UNKNOWN 重投必然同样失败 —— 直接报出去,
52
+ * 别白烧三个回合的额度。
53
+ *
54
+ * @param {string} [code] - provider 错误码
55
+ * @returns {boolean}
56
+ */
57
+ export function isTransientProviderCode(code) {
58
+ return code === 'RATE_LIMIT' || code === 'QUOTA' || code === 'EMPTY_RESPONSE';
59
+ }
60
+
61
+ // 中途校验最多允许重启几次生成(防死循环)
62
+ export const MAX_CHECKPOINT_RESTARTS = 3;
63
+
64
+ // 单个文件的最大校验轮次(第 1 轮是首次生成,后面的轮次是回改)
65
+ export const STAGE_MAX_FILE_ROUNDS = 3;
66
+
67
+ /**
68
+ * 生成一个批次:投递提示词 -> 等 LLM 空闲 -> 确认分片落盘。
69
+ *
70
+ * 失败(分片没落盘 / JSON 非法)时用同一批的失败原因再投一次,
71
+ * 最多 STAGE_MAX_BATCH_ATTEMPTS 次。
72
+ *
73
+ * @param {Object} opts
74
+ * @param {Object} opts.batch 批次描述
75
+ * @param {Array} [opts.failures] 上一轮校验指出的错误(回喂用)
76
+ * @param {number} [opts.attempt] 当前是第几次尝试(1 起)
77
+ * @param {(batch, attempt, failures) => string} opts.buildPrompt
78
+ * @param {(prompt) => Promise<{ok: boolean, error?: string}>} opts.sendPrompt
79
+ * @param {(files) => Promise<{ok: boolean, reason?: string}>} opts.waitIdle
80
+ * @param {(batch) => {ok: boolean, error?: string}} opts.readShard
81
+ * @param {(batch) => boolean} [opts.removeShard] 重投前删掉旧分片
82
+ * @param {(batch) => boolean} [opts.adoptShard] 分片名不对时的兜底认领
83
+ * @param {(msg: string) => void} [opts.onProgress]
84
+ * @returns {Promise<Object>} { ok, error, attempts }
85
+ */
86
+ export async function generateBatch(opts) {
87
+ const o = opts || {};
88
+ const batch = o.batch;
89
+ const maxAttempts = o.maxAttempts || STAGE_MAX_BATCH_ATTEMPTS;
90
+ let attempt = o.attempt || 1;
91
+ let failures = o.failures || [];
92
+ let lastError = null;
93
+ let lastCode = null;
94
+
95
+ while (attempt <= maxAttempts) {
96
+ // 重投前**不要删旧分片**。
97
+ //
98
+ // 删了会跟 DSH 的文件版本守卫撞车(tt5 实测 2026-10-24 22:00):
99
+ // DSH 的 write 工具记着"本会话上次看到这个文件时的版本",
100
+ // 文件被外部删掉后再写同一路径 → FS_STALE_VERSION
101
+ // → "cannot write ...: file no longer exists — re-read the file, then retry";
102
+ // 而 re-read 同样失败(文件已经没了)→ 模型卡死,
103
+ // 最后只能改用 bash heredoc 绕路。实测 3 次 write 全废,turn 废掉一半。
104
+ //
105
+ // 改成 deslop 那套判据(见 index.js 的 deslop 阶段):文件留着,
106
+ // 用"内容相对基线有没有变"判断本轮是不是真写了。
107
+ //
108
+ // **首轮也要取基线**:分片目录从不清理,而分片名是确定性的
109
+ // (骨架-<type>.b<seq>.<group>.json),所以重跑时上一轮的分片一定还在。
110
+ // 首轮不取基线的话,旧分片会立刻满足"文件在" → 批次被判 done →
111
+ // 旧内容被合并进产物,插件还跑到模型前面去了(实测 0ms 就判成功)。
112
+ // 首轮时文件本来不存在 → before 为 null → 内容判据自动放行,行为不变。
113
+ const before = readShardText(batch.shardPath);
114
+
115
+ const prompt = o.buildPrompt(batch, attempt, failures);
116
+ const sent = await o.sendPrompt(prompt);
117
+ if (!sent || sent.ok !== true) {
118
+ lastError = (sent && sent.error) || '提示词投递失败';
119
+ failures = [{ code: 'PROMPT_DELIVERY_FAILED', path: '', message: lastError, hint: null }];
120
+ attempt += 1;
121
+ continue;
122
+ }
123
+
124
+ const waited = await o.waitIdle([batch.shardPath], before);
125
+ if (!waited || waited.ok !== true) {
126
+ lastError = (waited && waited.reason) || '等待 LLM 结束失败';
127
+ // LLM 写到了别的文件名:尝试认领(只认领唯一一个多出来的 json)
128
+ let adopted = false;
129
+ if (typeof o.adoptShard === 'function') {
130
+ adopted = o.adoptShard(batch);
131
+ if (adopted) {
132
+ const re = o.readShard(batch);
133
+ if (re && re.ok) {
134
+ if (o.onProgress) o.onProgress('分片 ' + batch.shard + ' 落在别的文件名上,已认领');
135
+ return { ok: true, error: null, attempts: attempt };
136
+ }
137
+ }
138
+ }
139
+ failures = [{ code: 'BATCH_SHARD_MISSING', path: batch.shard, message: lastError, hint: null }];
140
+ lastCode = (waited && waited.code) || 'BATCH_SHARD_MISSING';
141
+ // 环境类失败(超时 / 用户中断 / 运行时缺失)重试没有意义:
142
+ // 再投一次还是同样失败,白烧额度(用户按了 Ctrl-C 还在喂 LLM)。
143
+ // 与 deslop-driver 的处理口径一致。
144
+ const waitCode = (waited && waited.code) || '';
145
+ if (waitCode === 'TIMEOUT' || waitCode === 'ABORTED' || waitCode === 'NO_AGENT') {
146
+ return { ok: false, error: lastError, attempts: attempt, code: waitCode };
147
+ }
148
+ // NO_CHANGE:模型这轮**没动**这个文件。
149
+ //
150
+ // 常见于**重跑 / 重投**场景:文件里已经是上一轮写对的内容,
151
+ // 模型读完说"已经是对的"就不改了(tt5 实测 2026-10-25:
152
+ // 实体 b03.factions 死在这 —— 模型回"The file already has the correct
153
+ // 6 factions",插件却判它没写,整段卡死)。
154
+ //
155
+ // 直接判失败会卡死整个阶段。正确处置是**让脚本裁判**:
156
+ // 对这个分片跑一次局部校验 —— 通过就说明内容本来就对(模型说得没错),
157
+ // 这批算成功;不过就照常报失败,重投时把错误清单喂回去。
158
+ if (waitCode === 'NO_CHANGE' && typeof o.validateBatch === 'function') {
159
+ const verdict = await o.validateBatch(batch);
160
+ if (verdict && verdict.status === 0) {
161
+ if (o.onProgress) {
162
+ o.onProgress('分片 ' + batch.shard + ' 内容未变,但局部校验通过,按已完成计入');
163
+ }
164
+ return { ok: true, error: null, attempts: attempt, unchanged: true };
165
+ }
166
+ }
167
+ // provider 报错同理:只有**瞬时**错误值得再投一次。
168
+ // 限流 / 配额 / 空响应 → 重投可能好(dsh-llm/lib/index.js:238 RATE_LIMIT、
169
+ // :133 QUOTA、:143 EMPTY_RESPONSE);
170
+ // 鉴权失败 / 上下文超限 / 未知错误 → 重投必然同样失败,直接报出去。
171
+ if (waitCode === 'TURN_ERROR' && !isTransientProviderCode(waited && waited.providerCode)) {
172
+ return { ok: false, error: lastError, attempts: attempt, code: waitCode };
173
+ }
174
+ attempt += 1;
175
+ continue;
176
+ }
177
+
178
+ const read = o.readShard(batch);
179
+ if (!read || read.ok !== true) {
180
+ lastError = (read && read.error) || '分片内容不是合法 JSON';
181
+ failures = [{ code: 'BATCH_SHARD_INVALID', path: batch.shard, message: lastError, hint: null }];
182
+ attempt += 1;
183
+ continue;
184
+ }
185
+
186
+ return { ok: true, error: null, attempts: attempt };
187
+ }
188
+
189
+ return { ok: false, error: lastError || '批次生成失败', attempts: attempt - 1, code: lastCode || null };
190
+ }
191
+
192
+ /**
193
+ * 按文件驱动整个阶段。
194
+ *
195
+ * 单个文件的流程:
196
+ * 1. 逐批生成(未通过校验的批次用错误清单回喂重投)
197
+ * 2. 合并该文件的全部分片 -> 目标文件
198
+ * 3. 插件跑校验脚本(判定权在脚本)
199
+ * 4. status != 0 -> 按 path 把错误归因回具体批次 -> 回到第 1 步
200
+ * 5. 轮次用尽 -> 该文件失败,带着完整错误清单上报
201
+ *
202
+ * @param {Object} opts
203
+ * @param {Array} opts.files 文件列表 [{ file, type, template, batches }]
204
+ * @param {(batch, attempt, failures) => string} opts.buildPrompt
205
+ * @param {(prompt) => Promise<{ok: boolean, error?: string}>} opts.sendPrompt
206
+ * @param {(files) => Promise<{ok: boolean, reason?: string}>} opts.waitIdle
207
+ * @param {(batch) => {ok: boolean, error?: string}} opts.readShard
208
+ * @param {(file) => Promise<{ok: boolean, error?: string, layout?: Object}>} opts.applyFile
209
+ * @param {(file) => Promise<{status: number, errors: Array, warnings: Array}>} opts.validateFile
210
+ * @param {(errors, layout) => {byBatch: Object, unattributed: Array}} opts.attribute
211
+ * @param {(batch) => boolean} [opts.removeShard]
212
+ * @param {(batch) => boolean} [opts.adoptShard]
213
+ * @param {(msg: string) => void} [opts.onProgress]
214
+ * @param {number} [opts.maxAttempts]
215
+ * @param {number} [opts.maxFileRounds]
216
+ * @returns {Promise<Object>} 汇总报告
217
+ */
218
+ export async function runStageBatches(opts) {
219
+ const o = opts || {};
220
+ // 校验粒度:
221
+ // 'file' —— 每个文件生成完就单检(血肉:引用都指向骨架,单文件就能判)
222
+ // 'stage' —— 全部生成完再跑全量校验(节奏:章节号范围 / 覆盖率 / 重复
223
+ // 这些问题基本都是跨文件的,单文件查不出来)
224
+ // 'none' —— 只生成,不校验(节奏的"时序基础"必须等细纲生成完才能全量判)
225
+ if (o.validateScope === 'stage') return runStageScope(o);
226
+ if (o.validateScope === 'none') return runNoneScope(o);
227
+ return runFileScope(o);
228
+ }
229
+
230
+ /**
231
+ * 阶段会话:跨多次调用保存批次状态。
232
+ *
233
+ * 节奏阶段需要分两次驱动:
234
+ * 1) 生成"章节规划 + 节奏参数 + 时序映射"(校验范围 'none')
235
+ * 2) 读已合并的时序映射算出重点章,规划"章节细纲"的批次,再生成
236
+ * 3) 跑全量校验 + 归因重投(校验范围 'stage')
237
+ * 第 2、3 步不能重跑第 1 步的批次,所以批次状态必须跨调用保留。
238
+ */
239
+ export function createStageSession() {
240
+ return { state: new Map(), files: [], rounds: 0 };
241
+ }
242
+
243
+ /**
244
+ * 只生成不校验的一轮(节奏的"时序基础")。
245
+ */
246
+ async function runNoneScope(o) {
247
+ const session = o.session || createStageSession();
248
+ const files = o.files || [];
249
+ const maxAttempts = o.maxAttempts || STAGE_MAX_BATCH_ATTEMPTS;
250
+
251
+ const report = {
252
+ ok: true, error: null, files: [], failedFiles: [], batches: [],
253
+ errors: [], warnings: [], unattributed: [],
254
+ // 脚本/环境问题:单独存,不进重投队列(模型改不了)
255
+ infraErrors: [],
256
+ totalBatches: 0, totalRounds: 0
257
+ };
258
+
259
+ for (const file of files) {
260
+ const fileReport = {
261
+ file: file.file,
262
+ type: file.type,
263
+ ok: false,
264
+ rounds: 0,
265
+ batches: [],
266
+ errors: [],
267
+ warnings: [],
268
+ infraErrors: [],
269
+ error: null
270
+ };
271
+ report.files.push(fileReport);
272
+ for (const batch of file.batches) {
273
+ session.state.set(batch.key, session.state.get(batch.key) || { status: 'pending', errors: [], attempts: 0 });
274
+ report.totalBatches += 1;
275
+ }
276
+ }
277
+ rememberFiles(session, files);
278
+
279
+ for (const file of files) {
280
+ const fileReport = report.files.find((f) => f.file === file.file);
281
+ for (const batch of file.batches) {
282
+ const st = session.state.get(batch.key);
283
+ if (st.status === 'done') continue;
284
+ const gen = await generateBatch({
285
+ batch: batch,
286
+ failures: st.errors,
287
+ attempt: st.attempts + 1,
288
+ maxAttempts: st.attempts + maxAttempts,
289
+ buildPrompt: o.buildPrompt,
290
+ sendPrompt: o.sendPrompt,
291
+ waitIdle: o.waitIdle,
292
+ readShard: o.readShard,
293
+ removeShard: o.removeShard,
294
+ adoptShard: o.adoptShard,
295
+ onProgress: o.onProgress,
296
+ validateBatch: o.validateBatch
297
+ });
298
+ st.attempts = gen.attempts;
299
+ const batchReport = {
300
+ key: batch.key, file: batch.file, shard: batch.shard,
301
+ count: batch.count, attempts: gen.attempts, ok: gen.ok,
302
+ error: gen.error || null, code: gen.code || null
303
+ };
304
+ report.batches.push(batchReport);
305
+ if (fileReport) fileReport.batches.push(batchReport);
306
+ if (gen.ok) {
307
+ st.status = 'done';
308
+ st.errors = [];
309
+ } else {
310
+ st.status = 'failed';
311
+ st.errors = [{ code: 'BATCH_GENERATION_FAILED', path: batch.shard, message: gen.error || '批次生成失败', hint: null }];
312
+ report.ok = false;
313
+ report.error = '批次生成失败:' + batch.shard
314
+ + (gen.error ? '(' + (gen.code || 'BATCH_FAILED') + ':' + String(gen.error).slice(0, 80) + ')' : '');
315
+ report.failedFiles.push(batch.file);
316
+ if (fileReport) {
317
+ fileReport.error = report.error;
318
+ fileReport.rounds = 1;
319
+ }
320
+ return report;
321
+ }
322
+ }
323
+ }
324
+
325
+ // 生成完就把分片合并成目标文件(下一阶段要读)
326
+ for (const file of files) {
327
+ const fileReport = report.files.find((f) => f.file === file.file);
328
+ const merged = await o.applyFile(file);
329
+ if (!merged || merged.ok !== true) {
330
+ report.ok = false;
331
+ report.error = (merged && merged.error) || ('合并失败:' + file.file);
332
+ report.failedFiles.push(file.file);
333
+ if (fileReport) fileReport.error = report.error;
334
+ return report;
335
+ }
336
+ if (fileReport) fileReport.ok = true;
337
+ }
338
+
339
+ return report;
340
+ }
341
+
342
+ /**
343
+ * 把文件登记进会话(跨调用共享文件列表)。
344
+ *
345
+ * @returns {Array} 会话里累计的文件列表(跨多次调用)
346
+ */
347
+ function rememberFiles(session, files) {
348
+ for (const f of files || []) {
349
+ if (!session.files.find((x) => x.file === f.file)) session.files.push(f);
350
+ }
351
+ return session.files;
352
+ }
353
+
354
+ /**
355
+ * 阶段级驱动:全部生成完 -> 全量校验 -> 跨文件归因 -> 只重投被点名的批次。
356
+ */
357
+ async function runStageScope(o) {
358
+ const files = o.files || [];
359
+ const maxAttempts = o.maxAttempts || STAGE_MAX_BATCH_ATTEMPTS;
360
+ const maxFileRounds = o.maxFileRounds || STAGE_MAX_FILE_ROUNDS;
361
+ // 中途校验:每生成多少个批次做一次局部判(0 = 不做)
362
+ const checkpointEvery = o.checkpointEvery || 0;
363
+ let sinceCheckpoint = 0;
364
+ // 本轮 checkpoint 点名的批次(用于到上限时把点名写进报告)
365
+ let lastCheckpointHits = {};
366
+ // **本段**的批次(归因失败兜底只能动它们,不能动上一段已通过的)
367
+ const currentSegmentBatches = files.flatMap((f) => f.batches || []);
368
+ const session = o.session || createStageSession();
369
+
370
+ const report = {
371
+ ok: true,
372
+ error: null,
373
+ files: [],
374
+ failedFiles: [],
375
+ batches: [],
376
+ errors: [],
377
+ warnings: [],
378
+ unattributed: [],
379
+ // 脚本/环境问题:单独存,不进重投队列(模型改不了)
380
+ infraErrors: [],
381
+ totalBatches: 0,
382
+ totalRounds: 0
383
+ };
384
+
385
+ // 全量校验要覆盖**所有**文件(可能跨多次调用产生)
386
+ const allFiles = rememberFiles(session, files);
387
+ const state = session.state;
388
+ for (const f of files) {
389
+ for (const b of f.batches) {
390
+ if (!state.has(b.key)) state.set(b.key, { status: 'pending', errors: [], attempts: 0 });
391
+ report.totalBatches += 1;
392
+ }
393
+ }
394
+
395
+ // 每个文件一份小结(血肉的 buildFleshBatchNote 靠它统计批次数)
396
+ for (const f of allFiles) {
397
+ if (report.files.find((x) => x.file === f.file)) continue;
398
+ report.files.push({
399
+ file: f.file,
400
+ type: f.type,
401
+ ok: false,
402
+ rounds: 0,
403
+ batches: [],
404
+ errors: [],
405
+ warnings: [],
406
+ error: null
407
+ });
408
+ }
409
+ const fileReportOf = (file) => report.files.find((x) => x.file === file);
410
+
411
+ /**
412
+ * 中途校验:把已生成的批次局部合并 + 局部判一次。
413
+ *
414
+ * 存在的原因(tt5 实测):关系+层级段 31 个批次连续派活、全部写完才判一次,
415
+ * 第 8 批写错行数要等 23 批都写完才发现。模型在没有反馈通道的那 31 轮里
416
+ * 自己造了 45 个检查脚本在猜判据。
417
+ *
418
+ * 为什么是"局部"而不是全量:中途文件还没写完,全量校验会误报
419
+ * (别的数组还缺 → REQUIRED;关系网还不全 → G_ISOLATED)。
420
+ * 局部模式只判当场可判的:模板子树 / 引用完整性 / 重复 / 自环 / 对称性 / 行数对账。
421
+ *
422
+ * @returns {boolean} true = 发现了问题,需要重启本轮生成
423
+ */
424
+ async function runCheckpoint() {
425
+ if (typeof o.applyPartial !== 'function' || typeof o.validatePartial !== 'function') return false;
426
+ // 先把**已定稿产物**用分片覆盖回去(见 index.js 的 restoreSettled)。
427
+ //
428
+ // 为什么必须做:模型可能被错误 hint 引导去改已经 PASSED 过的产物
429
+ // (tt5 实测 2026-10-25:QUOTA_MISMATCH 的 hint 说"或者把 quota 改成 28",
430
+ // 模型就把 框架.json 改了 → 框架.json 与它的分片失配 →
431
+ // 错误从 3 条涨到 126 条 → 重投到上限 → 关系段 18 个分片没生成)。
432
+ //
433
+ // 分片才是真源(产物是分片合并出来的),所以用分片覆盖回去,
434
+ // 模型的越权修改就自动失效了 —— 不指望模型听话,脚本兜底。
435
+ if (typeof o.restoreSettled === 'function') {
436
+ try {
437
+ const restored = await o.restoreSettled();
438
+ if (restored && restored.length) {
439
+ report.errors.push({
440
+ code: 'SETTLED_PRODUCT_TAMPERED',
441
+ path: restored.join(','),
442
+ message: '模型改动了已定稿产物(' + restored.join('、') + '),已用分片覆盖回去',
443
+ hint: '模型只该写本轮的分片文件;已定稿的产物由分片决定,改了会被覆盖'
444
+ });
445
+ if (o.onProgress) {
446
+ o.onProgress('模型改动了已定稿产物(' + restored.join('、') + '),已用分片覆盖回去');
447
+ }
448
+ }
449
+ } catch (e) {
450
+ report.infraErrors.push({
451
+ code: 'RESTORE_SETTLED_FAILED',
452
+ path: '',
453
+ message: '恢复已定稿产物失败:' + String(e && e.message ? e.message : e),
454
+ hint: null
455
+ });
456
+ }
457
+ }
458
+ let hit = false;
459
+ // 只校验**本段**的文件,不能用 allFiles。
460
+ //
461
+ // allFiles 是跨段累积的(session 共享),到关系段时里面已经有
462
+ // 框架.json / 实体.json —— 它们早就 PASSED 了,拿它们做局部校验会误判:
463
+ // - 框架.json 是纯对象结构,局部口径把它的顶层键当"数组名"传给 --arrays
464
+ // → 4 条 [TYPE] capacity/input_snapshot/quota/derivations 假阳性;
465
+ // - 假阳性被归因到框架批次 → 重投**已经通过**的框架,
466
+ // 模型把已定稿的 volume_ratio 改坏(tt5 实测 2026-10-25 00:00:
467
+ // 框架分片 mtime 变成 23:58,volume_ratio 被改,而框架.json 还是旧的),
468
+ // 关系段也跟着拖挂:写到 22/25 批就停,关系.json / 层级.json 没生成。
469
+ for (const file of files) {
470
+ // 整文件型产物(框架.json)没有数组,局部口径(--arrays)本来就不适用。
471
+ const hasArrayBatch = (file.batches || [])
472
+ .some((b) => b.wholeObject !== true && (b.arrays || []).length);
473
+ if (!hasArrayBatch) continue;
474
+ const part = await o.applyPartial(file);
475
+ if (!part || part.ok !== true) continue;
476
+ const arrays = part.writtenArrays || [];
477
+ if (!arrays.length) continue;
478
+ const v = await o.validatePartial(file, part);
479
+ const errs = (v && v.errors) || [];
480
+ if (!errs.length) continue;
481
+ const attr = o.attribute(errs, part.layout || {});
482
+ let marked = 0;
483
+ for (const batchKey of Object.keys(attr.byBatch || {})) {
484
+ const st = state.get(batchKey);
485
+ if (!st) continue;
486
+ st.status = 'pending';
487
+ st.errors = attr.byBatch[batchKey];
488
+ marked += 1;
489
+ }
490
+ if (!marked) continue;
491
+ hit = true;
492
+ lastCheckpointHits = attr.byBatch || {};
493
+ if (o.onProgress) {
494
+ o.onProgress('中途校验:' + file.file + ' 有 ' + errs.length + ' 条错,' + marked + ' 个批次要重写(' + arrays.join('、') + ')');
495
+ }
496
+ }
497
+ return hit;
498
+ }
499
+
500
+ for (let round = 1; round <= maxFileRounds; round++) {
501
+ report.totalRounds += 1;
502
+ for (const f of allFiles) {
503
+ const fr = fileReportOf(f.file);
504
+ if (fr) fr.rounds = round;
505
+ }
506
+
507
+ // ---- 1. 生成所有还没通过的批次 ----
508
+ let generationFailed = false;
509
+ // 中途校验(checkpoint):每生成 checkpointEvery 个批次就局部判一次。
510
+ // 发现问题就把被点名的批次标回 pending 并**重启本轮生成** ——
511
+ // 那个批次会在剩余批次之前被重写,错误不会拖到整段写完才暴露。
512
+ let restartGeneration = true;
513
+ // 重启**必须有上限**:中途校验一次点名 ≥ checkpointEvery 个批次时,
514
+ // 这些批次会被标回 pending → 重写 → 又攒够 checkpointEvery 个 → 再被点名
515
+ // → 无限循环、无限烧额度、命令永不返回(实测:点名 8 个批次就不收敛)。
516
+ let restarts = 0;
517
+ while (restartGeneration) {
518
+ if (restarts >= MAX_CHECKPOINT_RESTARTS) {
519
+ // 到上限就停:把这一轮的点名写进报告,让用户看得见"哪几个批次反复不过"
520
+ for (const batchKey of Object.keys(lastCheckpointHits || {})) {
521
+ const st = state.get(batchKey);
522
+ if (!st) continue;
523
+ st.status = 'pending';
524
+ st.errors = lastCheckpointHits[batchKey];
525
+ }
526
+ report.errors.push({
527
+ code: 'CHECKPOINT_RESTART_LIMIT',
528
+ path: '',
529
+ message: '中途校验反复点名同一批批次,已停止重启(第 ' + MAX_CHECKPOINT_RESTARTS + ' 次)',
530
+ hint: '这些批次连续几轮校验都没过,请直接看下面的错误清单'
531
+ });
532
+ if (o.onProgress) {
533
+ o.onProgress('中途校验重启次数达上限(' + MAX_CHECKPOINT_RESTARTS + ' 次),停止重启');
534
+ }
535
+ break;
536
+ }
537
+ restarts += 1;
538
+ restartGeneration = false;
539
+ for (const file of allFiles) {
540
+ for (const batch of file.batches) {
541
+ const st = state.get(batch.key);
542
+ // allFiles 是跨段累积的,state 里可能没有对应条目(上一段登记过文件
543
+ // 但批次还没进 state)—— 不能直接读 st.status,会 TypeError。
544
+ if (!st) continue;
545
+ if (st.status === 'done') continue;
546
+ const gen = await generateBatch({
547
+ batch: batch,
548
+ failures: st.errors,
549
+ attempt: st.attempts + 1,
550
+ maxAttempts: st.attempts + maxAttempts,
551
+ buildPrompt: o.buildPrompt,
552
+ sendPrompt: o.sendPrompt,
553
+ waitIdle: o.waitIdle,
554
+ readShard: o.readShard,
555
+ removeShard: o.removeShard,
556
+ adoptShard: o.adoptShard,
557
+ onProgress: o.onProgress,
558
+ validateBatch: o.validateBatch
559
+ });
560
+ st.attempts = gen.attempts;
561
+ const batchReport = {
562
+ key: batch.key,
563
+ file: batch.file,
564
+ shard: batch.shard,
565
+ count: batch.count,
566
+ attempts: gen.attempts,
567
+ ok: gen.ok,
568
+ // 失败原因必须带出来:只写分片名的话,用户分不清是超时、
569
+ // 被中断、分片没落盘还是 JSON 非法(失败必须可见)。
570
+ error: gen.error || null,
571
+ code: gen.code || null
572
+ };
573
+ report.batches.push(batchReport);
574
+ const fr = fileReportOf(batch.file);
575
+ if (fr) fr.batches.push(batchReport);
576
+ if (gen.ok) {
577
+ st.status = 'done';
578
+ st.errors = [];
579
+ } else {
580
+ st.status = 'failed';
581
+ st.errors = [{ code: 'BATCH_GENERATION_FAILED', path: batch.shard, message: gen.error || '批次生成失败', hint: null }];
582
+ generationFailed = true;
583
+ break;
584
+ }
585
+ // ---- 中途校验点 ----
586
+ if (checkpointEvery > 0) {
587
+ sinceCheckpoint += 1;
588
+ if (sinceCheckpoint >= checkpointEvery) {
589
+ sinceCheckpoint = 0;
590
+ const hit = await runCheckpoint();
591
+ if (hit) restartGeneration = true;
592
+ }
593
+ }
594
+ if (restartGeneration) break;
595
+ }
596
+ if (generationFailed || restartGeneration) break;
597
+ }
598
+ }
599
+
600
+ if (generationFailed) {
601
+ const failed = report.batches.filter((b) => !b.ok).map((b) => b.shard);
602
+ // 带上每个失败批次的原因(截断,避免刷屏):
603
+ // 只写分片名的话,用户分不清是超时、被中断、分片没落盘还是 JSON 非法。
604
+ const why = report.batches
605
+ .filter((b) => !b.ok && b.error)
606
+ .slice(0, 3)
607
+ .map((b) => b.shard + '(' + (b.code || 'BATCH_FAILED') + ':' + String(b.error).slice(0, 80) + ')')
608
+ .join(';');
609
+ report.error = '批次生成失败:' + failed.join('、') + (why ? ' —— ' + why : '');
610
+ report.ok = false;
611
+ for (const b of report.batches.filter((x) => !x.ok)) {
612
+ const fr = fileReportOf(b.file);
613
+ if (fr) fr.error = '批次生成失败:' + b.shard;
614
+ }
615
+ break;
616
+ }
617
+
618
+ // ---- 2. 合并全部分片(所有文件,含之前调用生成的) ----
619
+ const layouts = {};
620
+ let mergeFailed = false;
621
+ // 行归属越界(ownerField 不在本批实体里)——
622
+ // 这是"每批写哪些行"的代码级保证:模型写串行时立刻能发现,
623
+ // 而不是等合并后被校验器报一个说不清来源的 ID_DUP。
624
+ const ownershipErrors = [];
625
+ for (const file of allFiles) {
626
+ const merged = await o.applyFile(file);
627
+ if (!merged || merged.ok !== true) {
628
+ report.error = (merged && merged.error) || ('合并失败:' + file.file);
629
+ report.ok = false;
630
+ mergeFailed = true;
631
+ const fr = fileReportOf(file.file);
632
+ if (fr) fr.error = report.error;
633
+ break;
634
+ }
635
+ layouts[file.file] = merged.layout || {};
636
+ for (const v of (merged.ownershipViolations || [])) {
637
+ ownershipErrors.push(v);
638
+ }
639
+ }
640
+ if (mergeFailed) break;
641
+
642
+ // ---- 3. 阶段级全量校验(判定权在脚本) ----
643
+ const v = await o.validateStage(allFiles, layouts);
644
+ const errors = (v && v.errors) || [];
645
+ const warnings = (v && v.warnings) || [];
646
+ // 合并而不是覆盖:**结构性**提示(中途校验重启上限、模型越权改了已定稿产物…)
647
+ // 不能被本轮校验错误冲掉(否则用户看不到"为什么停了")。
648
+ // 这里保留 report.errors 里的**全部**条目 —— 它们都是驱动层自己记的,
649
+ // 不是校验器的内容错误;以前只留 CHECKPOINT_RESTART_LIMIT 一条,
650
+ // 其余插件级错误会被静默吞掉(失败不可见)。
651
+ report.errors = errors.concat(report.errors || []);
652
+ report.warnings = warnings;
653
+ // 脚本/环境问题(词表读不到 / 部署缺文件 / 参数错 …)不是产物内容的问题,
654
+ // 甩给模型没用(提示词禁止模型改 references/),重投多少次都一样。
655
+ // 所以把它们单独挑出来:进报告,但不进重投队列。
656
+ const split = classifyIssues(errors);
657
+ for (const e of split.infra) report.infraErrors.push(e);
658
+ // fileStatus 是"逐文件结论"({ 文件名: status }):
659
+ // 阶段级校验把多个文件的错误混在一起返回,但下游段只关心
660
+ // 自己依赖的那个文件过没过(比如时序映射只依赖节奏参数)。
661
+ const fileStatus = (v && v.fileStatus) || null;
662
+ for (const f of allFiles) {
663
+ const fr = fileReportOf(f.file);
664
+ if (!fr) continue;
665
+ fr.errors = errors;
666
+ fr.warnings = warnings;
667
+ if (fileStatus && fileStatus[f.file] !== undefined) {
668
+ fr.ok = fileStatus[f.file] === 0;
669
+ }
670
+ }
671
+
672
+ // 归属越界也算这一轮没过:校验器可能根本查不出来(它只看最终文件),
673
+ // 但产物结构是错的(同一行可能被两个批次各写一次)。
674
+ if (ownershipErrors.length) {
675
+ report.errors = errors.concat(ownershipErrors, report.errors || []);
676
+ report.ok = false;
677
+ if (o.onProgress) {
678
+ o.onProgress('有 ' + ownershipErrors.length + ' 行的归属字段不在本批实体里,需要重写对应批次');
679
+ }
680
+ }
681
+
682
+ if (v && v.status === 0 && !ownershipErrors.length) {
683
+ report.ok = true;
684
+ for (const f of allFiles) {
685
+ const fr = fileReportOf(f.file);
686
+ if (fr) { fr.ok = true; fr.error = null; }
687
+ }
688
+ break;
689
+ }
690
+
691
+ // 校验没过:这一轮的结果就是"没通过",不能留着 ok=true 装作成功。
692
+ // (血肉的 runFileScope 是按文件置 ok 的,阶段级这里必须同样置 false。)
693
+ report.ok = false;
694
+
695
+ // 只有内容错误才值得重投。没有内容错误 = 没过是脚本自己的问题,
696
+ // 重投模型改不动(也不该让它改),直接停下来报错。
697
+ if (!split.content.length && !ownershipErrors.length) {
698
+ report.error = describeInfraIssues(split.infra) || '校验没通过,但没有产物内容错误';
699
+ break;
700
+ }
701
+
702
+ // ---- 4. 跨文件归因 -> 标记要重跑的批次 ----
703
+ // 归属越界也进归因队列:它带的 batchKey 就是该重写的批次
704
+ // (path 形如 "character_relations[3].from",归因会落到写下标那一行的批次)。
705
+ const contentErrors = split.content.concat(ownershipErrors);
706
+ const attr = o.attribute(contentErrors, mergeLayouts(layouts));
707
+ // 归因不到的错误必须留在报告里:它们既没进任何批次,
708
+ // 也不会被重投修掉,只能让人看见(失败必须可见)。
709
+ //
710
+ // **累积而不是覆盖**:每轮都留。早先只留最后一轮,结果 tt5 骨架
711
+ // 第 1 轮归不了的一批错(15 条 NAME_DUP)在最终报告里整个消失了 ——
712
+ // 报告只显示最后一轮的 1 条,看起来像"就这 1 条没归因"。
713
+ // 累积时按 code+path 去重:同一条错在多轮重复出现只算一条。
714
+ report.unattributed = mergeUnattributed(report.unattributed, attr.unattributed);
715
+ let marked = 0;
716
+ for (const batchKey of Object.keys(attr.byBatch || {})) {
717
+ const st = state.get(batchKey);
718
+ if (!st) continue;
719
+ st.status = 'pending';
720
+ st.errors = attr.byBatch[batchKey];
721
+ marked += 1;
722
+ }
723
+ let attributionFailed = false;
724
+ if (marked === 0) {
725
+ // 一条都归不了:只能整阶段重跑,否则错误永远留在原地。
726
+ //
727
+ // 代价很大(tt5 骨架实测):同一份错误会发给**所有**批次,
728
+ // 框架那种整文件批次也会收到"改某个实体数组"的指令,模型去改
729
+ // 根本不属于它的批次 —— 白烧额度,还可能引入新错(实测第 3 轮
730
+ // 重写 climaxes 时编出了重复 ID)。
731
+ // 所以归因函数必须尽量归准(见 stage-batches.js 的形态六);
732
+ // 这里只负责兜底 + 把"归因失败"这件事说清楚。
733
+ attributionFailed = true;
734
+ const unattributed = (attr.unattributed && attr.unattributed.length) ? attr.unattributed : contentErrors;
735
+ // 只兜底**本段**的批次:state 是跨段共享的(骨架三段共用一个 session),
736
+ // 遍历 state.values() 会把上一段已经 PASSED 的批次也标回 pending ——
737
+ // 下一轮就会重投已定稿的产物,模型把定稿改坏(tt5 实测 2026-10-25:
738
+ // 框架分片被改写,框架.json 与分片对不上)。
739
+ for (const batch of currentSegmentBatches) {
740
+ const st = state.get(batch.key);
741
+ if (!st) continue;
742
+ st.status = 'pending';
743
+ st.errors = unattributed;
744
+ marked += 1;
745
+ }
746
+ }
747
+ if (o.onProgress) {
748
+ o.onProgress('第 ' + round + ' 轮校验未通过,' + marked + ' 个批次需要重跑'
749
+ + (attributionFailed
750
+ ? '(错误归不到具体批次,只能整阶段重跑:' + contentErrors.length + ' 条错会发给每个批次)'
751
+ : ''));
752
+ }
753
+ }
754
+
755
+ if (!report.ok && !report.error) {
756
+ report.error = '阶段在 ' + maxFileRounds + ' 轮内未通过校验';
757
+ }
758
+ return report;
759
+ }
760
+
761
+ /**
762
+ * 累积"归因不到批次"的错误,按 code+path 去重。
763
+ *
764
+ * 为什么累积:报告只显示最后一轮的话,早先轮次里归不了的错会凭空消失
765
+ * (tt5 骨架:第 1 轮 15 条 NAME_DUP 归不了,最终报告里一条都没有)。
766
+ * 失败必须可见 —— 归因失败本身就是需要人看的问题。
767
+ *
768
+ * @param {Array} prev - 之前累积的
769
+ * @param {Array} next - 本轮的
770
+ * @returns {Array}
771
+ */
772
+ function mergeUnattributed(prev, next) {
773
+ const out = Array.isArray(prev) ? prev.slice() : [];
774
+ const seen = new Set(out.map((e) => (e && e.code ? e.code : '') + '|' + (e && e.path ? e.path : '')));
775
+ for (const e of (Array.isArray(next) ? next : [])) {
776
+ const k = (e && e.code ? e.code : '') + '|' + (e && e.path ? e.path : '');
777
+ if (seen.has(k)) continue;
778
+ seen.add(k);
779
+ out.push(e);
780
+ }
781
+ return out;
782
+ }
783
+
784
+ /**
785
+ * 把多个文件的 layout 合成一张表(跨文件归因用)。
786
+ * 数组键在血肉/节奏里都不重名,所以直接合。
787
+ */
788
+ function mergeLayouts(layouts) {
789
+ const merged = { __arrays: {}, __byName: {}, __declared: {} };
790
+ for (const key of Object.keys(layouts || {})) {
791
+ const layout = layouts[key] || {};
792
+ for (const arrayKey of Object.keys(layout)) {
793
+ if (arrayKey === '__arrays' || arrayKey === '__byName' || arrayKey === '__whole' || arrayKey === '__declared') {
794
+ if (layout[arrayKey]) merged[arrayKey] = Object.assign(merged[arrayKey] || {}, layout[arrayKey]);
795
+ continue;
796
+ }
797
+ if (!Array.isArray(layout[arrayKey])) continue;
798
+ merged[arrayKey] = (merged[arrayKey] || []).concat(layout[arrayKey]);
799
+ }
800
+ }
801
+ return merged;
802
+ }
803
+
804
+ async function runFileScope(o) {
805
+ const files = o.files || [];
806
+ const maxAttempts = o.maxAttempts || STAGE_MAX_BATCH_ATTEMPTS;
807
+ const maxFileRounds = o.maxFileRounds || STAGE_MAX_FILE_ROUNDS;
808
+
809
+ const report = {
810
+ ok: true,
811
+ error: null,
812
+ files: [],
813
+ failedFiles: [],
814
+ errors: [],
815
+ warnings: [],
816
+ unattributed: [],
817
+ // 脚本/环境问题:单独存,不进重投队列(模型改不了)
818
+ infraErrors: [],
819
+ totalBatches: 0,
820
+ totalRounds: 0
821
+ };
822
+
823
+ for (const file of files) {
824
+ const fileReport = {
825
+ file: file.file,
826
+ type: file.type,
827
+ ok: false,
828
+ rounds: 0,
829
+ batches: [],
830
+ errors: [],
831
+ warnings: [],
832
+ infraErrors: [],
833
+ error: null
834
+ };
835
+ report.files.push(fileReport);
836
+ report.totalBatches += file.batches.length;
837
+
838
+ // batchKey -> { status, errors, attempts }
839
+ // attempts 是这个批次**累计**被投递过几次:文件级重跑时必须接着上次继续数,
840
+ // 否则提示词里的"第 N 次"会重置,模型不知道自己已经错过几轮。
841
+ const state = new Map();
842
+ for (const b of file.batches) state.set(b.key, { status: 'pending', errors: [], attempts: 0 });
843
+
844
+ let finished = false;
845
+ // 本文件累计"归因不到批次"的错误(跨轮累积,按 code+path 去重)。
846
+ // 每个文件各记各的:不同文件里 code+path 相同的是两条不同的错。
847
+ let fileUnattributed = [];
848
+
849
+ for (let round = 1; round <= maxFileRounds && !finished; round++) {
850
+ fileReport.rounds = round;
851
+ report.totalRounds += 1;
852
+
853
+ // ---- 1. 逐批生成 ----
854
+ let generationFailed = false;
855
+ for (const batch of file.batches) {
856
+ const st = state.get(batch.key);
857
+ if (!st) continue;
858
+ if (st.status === 'done') continue;
859
+
860
+ const gen = await generateBatch({
861
+ batch: batch,
862
+ failures: st.errors,
863
+ attempt: st.attempts + 1,
864
+ maxAttempts: st.attempts + maxAttempts,
865
+ buildPrompt: o.buildPrompt,
866
+ sendPrompt: o.sendPrompt,
867
+ waitIdle: o.waitIdle,
868
+ readShard: o.readShard,
869
+ removeShard: o.removeShard,
870
+ adoptShard: o.adoptShard,
871
+ onProgress: o.onProgress,
872
+ validateBatch: o.validateBatch
873
+ });
874
+ st.attempts = gen.attempts;
875
+
876
+ fileReport.batches.push({
877
+ key: batch.key,
878
+ shard: batch.shard,
879
+ count: batch.count,
880
+ attempts: gen.attempts,
881
+ ok: gen.ok
882
+ });
883
+
884
+ if (gen.ok) {
885
+ st.status = 'done';
886
+ st.errors = [];
887
+ } else {
888
+ st.status = 'failed';
889
+ st.errors = [{ code: 'BATCH_GENERATION_FAILED', path: batch.shard, message: gen.error || '批次生成失败', hint: null }];
890
+ generationFailed = true;
891
+ break;
892
+ }
893
+ }
894
+
895
+ if (generationFailed) {
896
+ const failedShards = file.batches
897
+ .filter((b) => {
898
+ const st = state.get(b.key);
899
+ return st && st.status === 'failed';
900
+ })
901
+ .map((b) => b.shard);
902
+ fileReport.error = '批次生成失败:' + failedShards.join('、');
903
+ report.failedFiles.push(fileReport.file);
904
+ break;
905
+ }
906
+
907
+ // ---- 2. 合并 ----
908
+ const applied = await o.applyFile(file);
909
+ if (!applied || applied.ok !== true) {
910
+ fileReport.error = (applied && applied.error) || '分片合并失败';
911
+ report.failedFiles.push(fileReport.file);
912
+ break;
913
+ }
914
+
915
+ // ---- 3. 校验(判定权在脚本) ----
916
+ const v = await o.validateFile(file);
917
+ const errors = (v && v.errors) || [];
918
+ const warnings = (v && v.warnings) || [];
919
+ fileReport.errors = errors;
920
+ fileReport.warnings = warnings;
921
+ // 脚本/环境问题不是产物内容的问题:不进重投队列(模型改不了,
922
+ // 提示词也禁止模型改 references/),单独记进报告。
923
+ const split = classifyIssues(errors);
924
+ fileReport.infraErrors = split.infra;
925
+ for (const e of split.infra) report.infraErrors.push(e);
926
+
927
+ if (v && v.status === 0) {
928
+ fileReport.ok = true;
929
+ finished = true;
930
+ break;
931
+ }
932
+
933
+ // 只有内容错误才值得重投。没有内容错误 = 没过是脚本自己的问题,
934
+ // 重投模型改不动,直接停下来报错。
935
+ if (!split.content.length) {
936
+ fileReport.error = describeInfraIssues(split.infra) || '校验没通过,但没有产物内容错误';
937
+ report.failedFiles.push(fileReport.file);
938
+ report.error = fileReport.error;
939
+ break;
940
+ }
941
+
942
+ // ---- 4. 归因 -> 标记要重跑的批次 ----
943
+ const attr = o.attribute(split.content, applied.layout || {});
944
+ const byBatch = attr.byBatch || {};
945
+ // 归因不到的错误必须留在报告里:它们既没进任何批次,
946
+ // 也不会被重投修掉,只能让人看见(失败必须可见)。
947
+ //
948
+ // 累积而不是覆盖:早先轮次里归不了的错不能凭空消失
949
+ // (tt5 骨架:第 1 轮 15 条 NAME_DUP 归不了,最终报告里一条都没有)。
950
+ //
951
+ // 去重范围是**本文件**:不同文件里 code+path 相同的错是两条不同的错,
952
+ // 一起去重会少报(所以只把本轮新增的并进总报告)。
953
+ const cumUnattributed = mergeUnattributed(fileUnattributed, attr.unattributed);
954
+ const freshUnattributed = cumUnattributed.slice(fileUnattributed.length);
955
+ fileUnattributed = cumUnattributed;
956
+ fileReport.unattributed = cumUnattributed;
957
+ for (const e of freshUnattributed) report.unattributed.push(e);
958
+ let marked = 0;
959
+ for (const batchKey of Object.keys(byBatch)) {
960
+ const st = state.get(batchKey);
961
+ if (!st) continue;
962
+ st.status = 'pending';
963
+ st.errors = byBatch[batchKey];
964
+ marked += 1;
965
+ }
966
+ let attributionFailed = false;
967
+
968
+ if (marked === 0) {
969
+ // 一条都归因不到(跨文件错误 / 全量口径的错误):
970
+ // 只能整份文件重跑,否则错误永远留在原地。
971
+ //
972
+ // 代价(tt5 骨架实测):同一份错误会发给**这个文件的所有批次**,
973
+ // 模型去改根本不属于它的批次 —— 白烧额度,还可能引入新错。
974
+ // 所以归因函数必须尽量归准(见 stage-batches.js 的形态六)。
975
+ attributionFailed = true;
976
+ const unattributed = (attr.unattributed && attr.unattributed.length)
977
+ ? attr.unattributed
978
+ : split.content;
979
+ for (const batch of file.batches) {
980
+ const st = state.get(batch.key);
981
+ if (!st) continue;
982
+ st.status = 'pending';
983
+ st.errors = unattributed;
984
+ marked += 1;
985
+ }
986
+ }
987
+
988
+ if (o.onProgress) {
989
+ o.onProgress(file.file + ' 第 ' + round + ' 轮校验未通过,' + marked + ' 个批次需要重跑'
990
+ + (attributionFailed
991
+ ? '(错误归不到具体批次,只能整份重跑:' + split.content.length + ' 条错会发给每个批次)'
992
+ : ''));
993
+ }
994
+ }
995
+
996
+ if (!fileReport.ok && !fileReport.error) {
997
+ fileReport.error = '文件在 ' + maxFileRounds + ' 轮内未通过校验';
998
+ report.failedFiles.push(fileReport.file);
999
+ }
1000
+ if (!fileReport.ok) report.ok = false;
1001
+ }
1002
+
1003
+ if (!report.ok && !report.error) {
1004
+ // 优先说清楚"为什么失败"(批次生成失败比"没过校验"更有指向性)
1005
+ const reasons = (report.files || [])
1006
+ .filter((f) => !f.ok && f.error)
1007
+ .map((f) => f.file + ':' + f.error);
1008
+ report.error = reasons.length
1009
+ ? reasons.join(';')
1010
+ : '有文件未通过校验:' + report.failedFiles.join('、');
1011
+ }
1012
+ return report;
1013
+ }
1014
+
1015
+ /**
1016
+ * 造一个"认领写歪了的分片"的函数:
1017
+ * 模型把内容写到了 buildDir 下另一个 json 文件名上时,
1018
+ * 如果只多出一个候选,就把它改名成期望的分片名。
1019
+ *
1020
+ * knownShards 每次调用时**重新读取**(而不是建函数时拍快照):
1021
+ * 节奏阶段是分三段驱动的,第三段的批次是后加进这个列表的,
1022
+ * 拍快照会让第三段自己刚写出来的分片被当成"多出来的候选"。
1023
+ *
1024
+ * @param {string} buildDir 分片目录
1025
+ * @param {Array} knownShards 全部批次的分片名(用于排除已知文件)
1026
+ * @returns {(batch) => boolean}
1027
+ */
1028
+ export function makeShardAdopter(buildDir, knownShards) {
1029
+ return function adoptShard(batch) {
1030
+ const known = new Set(knownShards || []);
1031
+ const expected = join(buildDir, batch.shard);
1032
+ if (existsSync(expected)) return false;
1033
+ let entries = [];
1034
+ try {
1035
+ entries = readdirSync(buildDir);
1036
+ } catch (e) {
1037
+ return false;
1038
+ }
1039
+ const candidates = entries.filter((f) => f.endsWith('.json') && !known.has(f));
1040
+ if (candidates.length !== 1) return false;
1041
+ try {
1042
+ renameSync(join(buildDir, candidates[0]), expected);
1043
+ return true;
1044
+ } catch (e) {
1045
+ return false;
1046
+ }
1047
+ };
1048
+ }
1049
+