novel-writer-cli 0.3.0 → 0.5.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 (75) hide show
  1. package/README.md +1 -1
  2. package/agents/chapter-writer.md +43 -14
  3. package/agents/character-weaver.md +7 -1
  4. package/agents/plot-architect.md +20 -7
  5. package/agents/quality-judge.md +199 -20
  6. package/agents/style-analyzer.md +14 -8
  7. package/agents/style-refiner.md +10 -3
  8. package/agents/world-builder.md +8 -1
  9. package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +194 -6
  10. package/dist/__tests__/agent-prompts-platform-expansion.test.js +33 -0
  11. package/dist/__tests__/anti-ai-infrastructure.test.js +548 -0
  12. package/dist/__tests__/anti-ai-templates.test.js +2 -2
  13. package/dist/__tests__/canon-status-lifecycle.test.js +481 -0
  14. package/dist/__tests__/commit-gate-decision.test.js +65 -0
  15. package/dist/__tests__/commit-prototype-pollution.test.js +1 -1
  16. package/dist/__tests__/excitement-type-annotation.test.js +240 -0
  17. package/dist/__tests__/excitement-type.test.js +21 -0
  18. package/dist/__tests__/gate-decision.test.js +62 -15
  19. package/dist/__tests__/genre-excitement-mapping.test.js +355 -0
  20. package/dist/__tests__/golden-chapter-gates.test.js +79 -0
  21. package/dist/__tests__/golden-chapter-mini-planning.test.js +485 -0
  22. package/dist/__tests__/helpers/quickstart-mini-planning.js +61 -0
  23. package/dist/__tests__/init.test.js +57 -5
  24. package/dist/__tests__/instructions-platform-expansion.test.js +125 -0
  25. package/dist/__tests__/next-step-gate-decision-routing.test.js +98 -0
  26. package/dist/__tests__/orchestrator-state-write-path.test.js +1 -1
  27. package/dist/__tests__/platform-profile.test.js +57 -1
  28. package/dist/__tests__/quickstart-pipeline.test.js +73 -6
  29. package/dist/__tests__/scoring-weights.test.js +193 -0
  30. package/dist/__tests__/steps-id.test.js +2 -0
  31. package/dist/__tests__/validate-quickstart-prereqs.test.js +2 -0
  32. package/dist/advance.js +27 -2
  33. package/dist/anti-ai-context.js +535 -0
  34. package/dist/cli.js +3 -1
  35. package/dist/commit.js +22 -0
  36. package/dist/excitement-type.js +12 -0
  37. package/dist/gate-decision.js +98 -2
  38. package/dist/golden-chapter-gates.js +143 -0
  39. package/dist/init.js +76 -7
  40. package/dist/instructions.js +552 -6
  41. package/dist/next-step.js +124 -88
  42. package/dist/platform-profile.js +20 -8
  43. package/dist/quickstart-mini-planning.js +30 -0
  44. package/dist/scoring-weights.js +38 -3
  45. package/dist/steps.js +1 -1
  46. package/dist/validate.js +293 -214
  47. package/dist/volume-commit.js +271 -5
  48. package/dist/volume-planning.js +78 -3
  49. package/docs/user/README.md +1 -0
  50. package/docs/user/migration-guide.md +166 -0
  51. package/docs/user/novel-cli.md +4 -3
  52. package/docs/user/quick-start.md +354 -57
  53. package/package.json +1 -1
  54. package/schemas/platform-profile.schema.json +2 -2
  55. package/scripts/lint-blacklist.sh +221 -76
  56. package/scripts/lint-structural.sh +538 -0
  57. package/skills/continue/SKILL.md +6 -0
  58. package/skills/continue/references/context-contracts.md +71 -6
  59. package/skills/continue/references/periodic-maintenance.md +12 -1
  60. package/skills/novel-writing/references/quality-rubric.md +79 -26
  61. package/skills/novel-writing/references/style-guide.md +129 -19
  62. package/skills/start/SKILL.md +23 -3
  63. package/skills/start/references/vol-planning.md +12 -3
  64. package/templates/ai-blacklist.json +1024 -246
  65. package/templates/ai-sentence-patterns.json +167 -0
  66. package/templates/genre-excitement-map.json +48 -0
  67. package/templates/genre-golden-standards.json +80 -0
  68. package/templates/genre-weight-profiles.json +15 -0
  69. package/templates/golden-chapter-gates.json +230 -0
  70. package/templates/novel-ask/example.question.json +3 -2
  71. package/templates/platform-profile.json +141 -1
  72. package/templates/platforms/fanqie.md +35 -0
  73. package/templates/platforms/jinjiang.md +35 -0
  74. package/templates/platforms/qidian.md +35 -0
  75. package/templates/style-profile-template.json +3 -0
@@ -0,0 +1,485 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+ import { advanceCheckpointForStep } from "../advance.js";
7
+ import { buildInstructionPacket } from "../instructions.js";
8
+ import { validateStep } from "../validate.js";
9
+ import { commitVolume } from "../volume-commit.js";
10
+ import { resolveVolumeChapterRange, volumeFinalRelPaths, volumeStagingRelPaths } from "../volume-planning.js";
11
+ async function writeText(absPath, contents) {
12
+ await mkdir(dirname(absPath), { recursive: true });
13
+ await writeFile(absPath, contents, "utf8");
14
+ }
15
+ async function writeJson(absPath, payload) {
16
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
17
+ }
18
+ async function exists(absPath) {
19
+ try {
20
+ await stat(absPath);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ function makeQuickstartCheckpoint(phase) {
28
+ return {
29
+ last_completed_chapter: 0,
30
+ current_volume: 1,
31
+ orchestrator_state: "QUICK_START",
32
+ pipeline_stage: null,
33
+ volume_pipeline_stage: null,
34
+ inflight_chapter: null,
35
+ quickstart_phase: phase ?? null,
36
+ revision_count: 0,
37
+ hook_fix_count: 0,
38
+ title_fix_count: 0
39
+ };
40
+ }
41
+ async function writeQuickstartPrereqs(rootDir) {
42
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
43
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
44
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
45
+ }
46
+ function outlineForRange(range, prefix) {
47
+ const excitementByChapter = ["setup", "reveal", "cliffhanger", "power_up", "reversal", "setup"];
48
+ const lines = ["## 第 1 卷大纲", ""];
49
+ for (let chapter = range.start; chapter <= range.end; chapter++) {
50
+ lines.push(`### 第 ${chapter} 章: ${prefix}${chapter}`);
51
+ lines.push("- **Storyline**: main-arc");
52
+ lines.push("- **POV**: hero");
53
+ lines.push(`- **Location**: zone-${chapter}`);
54
+ lines.push(`- **Conflict**: 冲突 ${chapter}`);
55
+ lines.push(`- **Arc**: 弧线 ${chapter}`);
56
+ lines.push(`- **Foreshadowing**: seed-${chapter}`);
57
+ lines.push(`- **StateChanges**: Hero 抵达 zone-${chapter}`);
58
+ lines.push(`- **TransitionHint**: next-${chapter}`);
59
+ lines.push(`- **ExcitementType**: ${excitementByChapter[(chapter - range.start) % excitementByChapter.length]}`);
60
+ lines.push("");
61
+ }
62
+ return lines.join("\n");
63
+ }
64
+ async function writeVolumePlanArtifacts(args) {
65
+ const { rootDir, rels, range, prefix } = args;
66
+ let previousLocation = args.initialHeroLocation;
67
+ await writeText(join(rootDir, rels.outlineMd), outlineForRange(range, prefix));
68
+ await writeJson(join(rootDir, rels.storylineScheduleJson), { active_storylines: ["main-arc"] });
69
+ await writeJson(join(rootDir, rels.foreshadowingJson), {
70
+ schema_version: 1,
71
+ items: [{ id: `seed-${range.start}`, scope: "short", status: "planned" }]
72
+ });
73
+ await writeJson(join(rootDir, rels.newCharactersJson), []);
74
+ for (let chapter = range.start; chapter <= range.end; chapter++) {
75
+ const nextLocation = `zone-${chapter}`;
76
+ await writeJson(join(rootDir, rels.chapterContractJson(chapter)), {
77
+ chapter,
78
+ storyline_id: "main-arc",
79
+ storyline_context: {
80
+ last_chapter_summary: `summary-${chapter - 1}`,
81
+ chapters_since_last: 0,
82
+ line_arc_progress: `progress-${chapter}`,
83
+ concurrent_state: "steady"
84
+ },
85
+ excitement_type: chapter % 2 === 0 ? "reveal" : "setup",
86
+ preconditions: { character_states: { Hero: { location: previousLocation } }, required_world_rules: [] },
87
+ objectives: [{ id: `OBJ-${chapter}-1`, type: "plot", required: true, description: `推进 ${chapter}` }],
88
+ postconditions: { state_changes: { Hero: { location: nextLocation } }, foreshadowing_updates: {} },
89
+ acceptance_criteria: [`OBJ-${chapter}-1 落地`]
90
+ });
91
+ previousLocation = nextLocation;
92
+ }
93
+ }
94
+ test("buildInstructionPacket(quickstart:f0) emits mini-planning packet and genre map", async () => {
95
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-f0-packet-"));
96
+ try {
97
+ await writeQuickstartPrereqs(rootDir);
98
+ await writeText(join(rootDir, "brief.md"), ["# brief", "", "- **题材**:言情", ""].join("\n"));
99
+ await writeJson(join(rootDir, "genre-excitement-map.json"), {
100
+ schema_version: 1,
101
+ genres: {
102
+ romance: { chapters: { "1": "setup", "2": "reveal", "3": "reversal" } }
103
+ }
104
+ });
105
+ const built = (await buildInstructionPacket({
106
+ rootDir,
107
+ checkpoint: makeQuickstartCheckpoint("style"),
108
+ step: { kind: "quickstart", phase: "f0" },
109
+ embedMode: null,
110
+ writeManifest: false
111
+ }));
112
+ assert.equal(built.packet.step, "quickstart:f0");
113
+ assert.equal(built.packet.agent.name, "plot-architect");
114
+ assert.equal(built.packet.manifest.inline.quickstart_mini_planning, true);
115
+ assert.deepEqual(built.packet.manifest.inline.volume_plan, { volume: 1, chapter_range: [1, 3] });
116
+ assert.deepEqual(built.packet.manifest.inline.genre_excitement_map, {
117
+ genre: "romance",
118
+ chapters: { "1": "setup", "2": "reveal", "3": "reversal" },
119
+ source: "genre-excitement-map.json"
120
+ });
121
+ assert.ok(built.packet.expected_outputs.some((item) => item.path === "staging/volumes/vol-01/outline.md"));
122
+ assert.ok(built.packet.expected_outputs.some((item) => item.path === "staging/volumes/vol-01/chapter-contracts/chapter-003.json"));
123
+ }
124
+ finally {
125
+ await rm(rootDir, { recursive: true, force: true });
126
+ }
127
+ });
128
+ test("buildInstructionPacket(quickstart:trial) uses committed mini-planning artifacts when available", async () => {
129
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-trial-mini-plan-"));
130
+ try {
131
+ await writeQuickstartPrereqs(rootDir);
132
+ await writeVolumePlanArtifacts({
133
+ rootDir,
134
+ rels: volumeFinalRelPaths(1),
135
+ range: { start: 1, end: 3 },
136
+ prefix: "seed-",
137
+ initialHeroLocation: "prologue"
138
+ });
139
+ const built = (await buildInstructionPacket({
140
+ rootDir,
141
+ checkpoint: makeQuickstartCheckpoint("f0"),
142
+ step: { kind: "quickstart", phase: "trial" },
143
+ embedMode: null,
144
+ writeManifest: false
145
+ }));
146
+ assert.equal(built.packet.manifest.paths.chapter_contract, "volumes/vol-01/chapter-contracts/chapter-001.json");
147
+ assert.equal(built.packet.manifest.paths.volume_outline, "volumes/vol-01/outline.md");
148
+ assert.equal(built.packet.manifest.paths.volume_foreshadowing, "volumes/vol-01/foreshadowing.json");
149
+ }
150
+ finally {
151
+ await rm(rootDir, { recursive: true, force: true });
152
+ }
153
+ });
154
+ test("advanceCheckpointForStep(quickstart:f0) commits mini-planning into volumes/vol-01", async () => {
155
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-advance-f0-"));
156
+ try {
157
+ await writeJson(join(rootDir, ".checkpoint.json"), makeQuickstartCheckpoint("style"));
158
+ await writeQuickstartPrereqs(rootDir);
159
+ await writeVolumePlanArtifacts({
160
+ rootDir,
161
+ rels: volumeStagingRelPaths(1),
162
+ range: { start: 1, end: 3 },
163
+ prefix: "seed-",
164
+ initialHeroLocation: "prologue"
165
+ });
166
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "f0" } });
167
+ assert.equal(updated.orchestrator_state, "QUICK_START");
168
+ assert.equal(updated.quickstart_phase, "f0");
169
+ assert.equal(await exists(join(rootDir, "staging/volumes/vol-01")), false);
170
+ assert.equal(await exists(join(rootDir, "volumes/vol-01/outline.md")), true);
171
+ }
172
+ finally {
173
+ await rm(rootDir, { recursive: true, force: true });
174
+ }
175
+ });
176
+ test("validateStep(quickstart:f0) rejects missing mini-plan outline", async () => {
177
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-f0-outline-"));
178
+ try {
179
+ await writeQuickstartPrereqs(rootDir);
180
+ const rels = volumeStagingRelPaths(1);
181
+ await writeJson(join(rootDir, rels.storylineScheduleJson), { active_storylines: ["main-arc"] });
182
+ await writeJson(join(rootDir, rels.foreshadowingJson), { schema_version: 1, items: [] });
183
+ await writeJson(join(rootDir, rels.newCharactersJson), []);
184
+ for (const chapter of [1, 2, 3]) {
185
+ await writeJson(join(rootDir, rels.chapterContractJson(chapter)), {
186
+ chapter,
187
+ storyline_id: "main-arc",
188
+ objectives: [{ id: `OBJ-${chapter}-1`, required: true, description: "x" }],
189
+ preconditions: { character_states: { Hero: { location: "x" } } },
190
+ postconditions: { state_changes: { Hero: { location: "y" } } }
191
+ });
192
+ }
193
+ await assert.rejects(() => validateStep({
194
+ rootDir,
195
+ checkpoint: makeQuickstartCheckpoint("style"),
196
+ step: { kind: "quickstart", phase: "f0" }
197
+ }), /Missing required file: staging\/volumes\/vol-01\/outline\.md/);
198
+ }
199
+ finally {
200
+ await rm(rootDir, { recursive: true, force: true });
201
+ }
202
+ });
203
+ test("resolveVolumeChapterRange and volume:outline packet continue vol-01 from chapter 4 after F0", async () => {
204
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-range-after-f0-"));
205
+ try {
206
+ await writeVolumePlanArtifacts({
207
+ rootDir,
208
+ rels: volumeFinalRelPaths(1),
209
+ range: { start: 1, end: 3 },
210
+ prefix: "seed-",
211
+ initialHeroLocation: "prologue"
212
+ });
213
+ const range = await resolveVolumeChapterRange({ rootDir, current_volume: 1, last_completed_chapter: 0 });
214
+ assert.deepEqual(range, { start: 4, end: 30 });
215
+ const built = (await buildInstructionPacket({
216
+ rootDir,
217
+ checkpoint: {
218
+ last_completed_chapter: 0,
219
+ current_volume: 1,
220
+ orchestrator_state: "VOL_PLANNING",
221
+ pipeline_stage: null,
222
+ volume_pipeline_stage: null,
223
+ inflight_chapter: null,
224
+ revision_count: 0,
225
+ hook_fix_count: 0,
226
+ title_fix_count: 0
227
+ },
228
+ step: { kind: "volume", phase: "outline" },
229
+ embedMode: null,
230
+ writeManifest: false
231
+ }));
232
+ assert.deepEqual(built.packet.manifest.inline.volume_plan, { volume: 1, chapter_range: [4, 30] });
233
+ assert.deepEqual(built.packet.manifest.inline.volume_plan_seed_range, [1, 3]);
234
+ assert.equal(built.packet.manifest.paths.existing_volume_outline, "volumes/vol-01/outline.md");
235
+ assert.equal(built.packet.manifest.paths.existing_chapter_contracts_dir, "volumes/vol-01/chapter-contracts");
236
+ }
237
+ finally {
238
+ await rm(rootDir, { recursive: true, force: true });
239
+ }
240
+ });
241
+ test("commitVolume merges formal vol-01 plan into existing F0 seed artifacts", async () => {
242
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-merge-f0-"));
243
+ try {
244
+ await writeVolumePlanArtifacts({
245
+ rootDir,
246
+ rels: volumeFinalRelPaths(1),
247
+ range: { start: 1, end: 3 },
248
+ prefix: "seed-",
249
+ initialHeroLocation: "prologue"
250
+ });
251
+ await writeVolumePlanArtifacts({
252
+ rootDir,
253
+ rels: volumeStagingRelPaths(1),
254
+ range: { start: 4, end: 30 },
255
+ prefix: "formal-",
256
+ initialHeroLocation: "zone-3"
257
+ });
258
+ await writeJson(join(rootDir, ".checkpoint.json"), {
259
+ last_completed_chapter: 0,
260
+ current_volume: 1,
261
+ orchestrator_state: "VOL_PLANNING",
262
+ pipeline_stage: null,
263
+ inflight_chapter: null,
264
+ volume_pipeline_stage: "commit"
265
+ });
266
+ await commitVolume({ rootDir, volume: 1, dryRun: false });
267
+ const outline = await readFile(join(rootDir, "volumes/vol-01/outline.md"), "utf8");
268
+ assert.match(outline, /### 第 1 章: seed-1/);
269
+ assert.match(outline, /### 第 30 章: formal-30/);
270
+ assert.equal(await exists(join(rootDir, "staging/volumes/vol-01")), false);
271
+ const chapter1 = JSON.parse(await readFile(join(rootDir, "volumes/vol-01/chapter-contracts/chapter-001.json"), "utf8"));
272
+ const chapter4 = JSON.parse(await readFile(join(rootDir, "volumes/vol-01/chapter-contracts/chapter-004.json"), "utf8"));
273
+ assert.equal(chapter1.chapter, 1);
274
+ assert.equal(chapter4.chapter, 4);
275
+ }
276
+ finally {
277
+ await rm(rootDir, { recursive: true, force: true });
278
+ }
279
+ });
280
+ test("resolveVolumeChapterRange does not skip chapters when vol-01 seed already contains later contracts", async () => {
281
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-range-mixed-seed-"));
282
+ try {
283
+ await writeVolumePlanArtifacts({
284
+ rootDir,
285
+ rels: volumeFinalRelPaths(1),
286
+ range: { start: 1, end: 3 },
287
+ prefix: "seed-",
288
+ initialHeroLocation: "prologue"
289
+ });
290
+ await writeJson(join(rootDir, volumeFinalRelPaths(1).chapterContractJson(4)), {
291
+ chapter: 4,
292
+ storyline_id: "main-arc",
293
+ objectives: [{ id: "OBJ-4-1", required: true, description: "bad extra" }],
294
+ preconditions: { character_states: { Hero: { location: "zone-3" } } },
295
+ postconditions: { state_changes: { Hero: { location: "zone-4" } } }
296
+ });
297
+ const range = await resolveVolumeChapterRange({ rootDir, current_volume: 1, last_completed_chapter: 0 });
298
+ assert.deepEqual(range, { start: 1, end: 30 });
299
+ }
300
+ finally {
301
+ await rm(rootDir, { recursive: true, force: true });
302
+ }
303
+ });
304
+ test("commitVolume rejects duplicate seed contracts during merge without mutating final files", async () => {
305
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-merge-preflight-"));
306
+ try {
307
+ const finalRels = volumeFinalRelPaths(1);
308
+ const stagingRels = volumeStagingRelPaths(1);
309
+ await writeVolumePlanArtifacts({
310
+ rootDir,
311
+ rels: finalRels,
312
+ range: { start: 1, end: 3 },
313
+ prefix: "seed-",
314
+ initialHeroLocation: "prologue"
315
+ });
316
+ await writeVolumePlanArtifacts({
317
+ rootDir,
318
+ rels: stagingRels,
319
+ range: { start: 4, end: 30 },
320
+ prefix: "formal-",
321
+ initialHeroLocation: "zone-3"
322
+ });
323
+ await writeJson(join(rootDir, stagingRels.chapterContractJson(1)), {
324
+ chapter: 1,
325
+ storyline_id: "main-arc",
326
+ objectives: [{ id: "OBJ-1-1", required: true, description: "duplicate seed" }],
327
+ preconditions: { character_states: { Hero: { location: "prologue" } } },
328
+ postconditions: { state_changes: { Hero: { location: "zone-1" } } }
329
+ });
330
+ await writeJson(join(rootDir, ".checkpoint.json"), {
331
+ last_completed_chapter: 0,
332
+ current_volume: 1,
333
+ orchestrator_state: "VOL_PLANNING",
334
+ pipeline_stage: null,
335
+ inflight_chapter: null,
336
+ volume_pipeline_stage: "commit"
337
+ });
338
+ const outlineBefore = await readFile(join(rootDir, finalRels.outlineMd), "utf8");
339
+ const scheduleBefore = await readFile(join(rootDir, finalRels.storylineScheduleJson), "utf8");
340
+ const foreshadowingBefore = await readFile(join(rootDir, finalRels.foreshadowingJson), "utf8");
341
+ const newCharactersBefore = await readFile(join(rootDir, finalRels.newCharactersJson), "utf8");
342
+ await assert.rejects(() => commitVolume({ rootDir, volume: 1, dryRun: false }), /unexpected contract chapter 1 outside required range/);
343
+ assert.equal(await readFile(join(rootDir, finalRels.outlineMd), "utf8"), outlineBefore);
344
+ assert.equal(await readFile(join(rootDir, finalRels.storylineScheduleJson), "utf8"), scheduleBefore);
345
+ assert.equal(await readFile(join(rootDir, finalRels.foreshadowingJson), "utf8"), foreshadowingBefore);
346
+ assert.equal(await readFile(join(rootDir, finalRels.newCharactersJson), "utf8"), newCharactersBefore);
347
+ assert.equal(await exists(join(rootDir, stagingRels.dir)), true);
348
+ assert.equal(await exists(join(rootDir, finalRels.chapterContractJson(4))), false);
349
+ }
350
+ finally {
351
+ await rm(rootDir, { recursive: true, force: true });
352
+ }
353
+ });
354
+ test("validateStep(volume:validate) rejects staging outline that rewrites seed chapters", async () => {
355
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-validate-outline-seed-"));
356
+ try {
357
+ await writeVolumePlanArtifacts({
358
+ rootDir,
359
+ rels: volumeFinalRelPaths(1),
360
+ range: { start: 1, end: 3 },
361
+ prefix: "seed-",
362
+ initialHeroLocation: "prologue"
363
+ });
364
+ const stagingRels = volumeStagingRelPaths(1);
365
+ await writeVolumePlanArtifacts({
366
+ rootDir,
367
+ rels: stagingRels,
368
+ range: { start: 4, end: 30 },
369
+ prefix: "formal-",
370
+ initialHeroLocation: "zone-3"
371
+ });
372
+ const formalOutline = await readFile(join(rootDir, stagingRels.outlineMd), "utf8");
373
+ const formalBody = formalOutline.split(/\r?\n/u).slice(2).join("\n").trim();
374
+ const pollutedOutline = `${outlineForRange({ start: 1, end: 1 }, "bad-seed-").trimEnd()}\n\n${formalBody}\n`;
375
+ await writeText(join(rootDir, stagingRels.outlineMd), pollutedOutline);
376
+ await assert.rejects(() => validateStep({
377
+ rootDir,
378
+ checkpoint: {
379
+ last_completed_chapter: 0,
380
+ current_volume: 1,
381
+ orchestrator_state: "VOL_PLANNING",
382
+ pipeline_stage: null,
383
+ volume_pipeline_stage: "validate",
384
+ inflight_chapter: null,
385
+ quickstart_phase: null,
386
+ revision_count: 0,
387
+ hook_fix_count: 0,
388
+ title_fix_count: 0
389
+ },
390
+ step: { kind: "volume", phase: "validate" }
391
+ }), /unexpected chapter block\(s\) 1 outside required range/);
392
+ }
393
+ finally {
394
+ await rm(rootDir, { recursive: true, force: true });
395
+ }
396
+ });
397
+ test("validateStep(volume:validate) rejects staging contracts that rewrite seed chapters", async () => {
398
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-validate-contract-seed-"));
399
+ try {
400
+ await writeVolumePlanArtifacts({
401
+ rootDir,
402
+ rels: volumeFinalRelPaths(1),
403
+ range: { start: 1, end: 3 },
404
+ prefix: "seed-",
405
+ initialHeroLocation: "prologue"
406
+ });
407
+ const stagingRels = volumeStagingRelPaths(1);
408
+ await writeVolumePlanArtifacts({
409
+ rootDir,
410
+ rels: stagingRels,
411
+ range: { start: 4, end: 30 },
412
+ prefix: "formal-",
413
+ initialHeroLocation: "zone-3"
414
+ });
415
+ await writeJson(join(rootDir, stagingRels.chapterContractJson(1)), {
416
+ chapter: 1,
417
+ storyline_id: "main-arc",
418
+ objectives: [{ id: "OBJ-1-1", required: true, description: "duplicate seed" }],
419
+ preconditions: { character_states: { Hero: { location: "prologue" } } },
420
+ postconditions: { state_changes: { Hero: { location: "zone-1" } } }
421
+ });
422
+ await assert.rejects(() => validateStep({
423
+ rootDir,
424
+ checkpoint: {
425
+ last_completed_chapter: 0,
426
+ current_volume: 1,
427
+ orchestrator_state: "VOL_PLANNING",
428
+ pipeline_stage: null,
429
+ volume_pipeline_stage: "validate",
430
+ inflight_chapter: null,
431
+ quickstart_phase: null,
432
+ revision_count: 0,
433
+ hook_fix_count: 0,
434
+ title_fix_count: 0
435
+ },
436
+ step: { kind: "volume", phase: "validate" }
437
+ }), /unexpected contract chapter 1 outside required range/);
438
+ }
439
+ finally {
440
+ await rm(rootDir, { recursive: true, force: true });
441
+ }
442
+ });
443
+ test("commitVolume resumes merge when final already contains formal vol-01 artifacts", async () => {
444
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-merge-resume-"));
445
+ try {
446
+ const finalRels = volumeFinalRelPaths(1);
447
+ const stagingRels = volumeStagingRelPaths(1);
448
+ await writeVolumePlanArtifacts({
449
+ rootDir,
450
+ rels: finalRels,
451
+ range: { start: 1, end: 3 },
452
+ prefix: "seed-",
453
+ initialHeroLocation: "prologue"
454
+ });
455
+ await writeVolumePlanArtifacts({
456
+ rootDir,
457
+ rels: stagingRels,
458
+ range: { start: 4, end: 30 },
459
+ prefix: "formal-",
460
+ initialHeroLocation: "zone-3"
461
+ });
462
+ const formalOutline = await readFile(join(rootDir, stagingRels.outlineMd), "utf8");
463
+ const formalBody = formalOutline.split(/\r?\n/u).slice(2).join("\n").trim();
464
+ const mergedOutline = `${outlineForRange({ start: 1, end: 3 }, "seed-").trimEnd()}\n\n${formalBody}\n`;
465
+ await writeText(join(rootDir, finalRels.outlineMd), mergedOutline);
466
+ await writeFile(join(rootDir, finalRels.chapterContractJson(4)), await readFile(join(rootDir, stagingRels.chapterContractJson(4))));
467
+ await writeJson(join(rootDir, ".checkpoint.json"), {
468
+ last_completed_chapter: 0,
469
+ current_volume: 1,
470
+ orchestrator_state: "VOL_PLANNING",
471
+ pipeline_stage: null,
472
+ inflight_chapter: null,
473
+ volume_pipeline_stage: "commit"
474
+ });
475
+ await commitVolume({ rootDir, volume: 1, dryRun: false });
476
+ assert.equal(await exists(join(rootDir, stagingRels.dir)), false);
477
+ assert.equal(await exists(join(rootDir, finalRels.chapterContractJson(30))), true);
478
+ const outline = await readFile(join(rootDir, finalRels.outlineMd), "utf8");
479
+ assert.match(outline, /### 第 1 章: seed-1/);
480
+ assert.match(outline, /### 第 30 章: formal-30/);
481
+ }
482
+ finally {
483
+ await rm(rootDir, { recursive: true, force: true });
484
+ }
485
+ });
@@ -0,0 +1,61 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ async function writeText(absPath, contents) {
4
+ await mkdir(dirname(absPath), { recursive: true });
5
+ await writeFile(absPath, contents, "utf8");
6
+ }
7
+ async function writeJson(absPath, payload) {
8
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
9
+ }
10
+ export async function writeCommittedMiniPlanning(rootDir) {
11
+ await writeText(join(rootDir, "volumes/vol-01/outline.md"), [
12
+ "## 第 1 卷大纲",
13
+ "",
14
+ "### 第 1 章: 开端",
15
+ "- **Storyline**: main-arc",
16
+ "- **POV**: hero",
17
+ "- **Location**: village",
18
+ "- **Conflict**: 离乡试炼",
19
+ "- **Arc**: 踏出第一步",
20
+ "- **Foreshadowing**: seed-1",
21
+ "- **StateChanges**: Hero 离开村庄",
22
+ "- **TransitionHint**: 继续前往外城",
23
+ "- **ExcitementType**: setup",
24
+ "",
25
+ "### 第 2 章: 入城",
26
+ "- **Storyline**: main-arc",
27
+ "- **POV**: hero",
28
+ "- **Location**: outer-city",
29
+ "- **Conflict**: 初次受挫",
30
+ "- **Arc**: 认清差距",
31
+ "- **Foreshadowing**: seed-1 触发",
32
+ "- **StateChanges**: Hero 进入外城",
33
+ "- **TransitionHint**: 目睹异常征兆",
34
+ "- **ExcitementType**: reveal",
35
+ "",
36
+ "### 第 3 章: 异兆",
37
+ "- **Storyline**: main-arc",
38
+ "- **POV**: hero",
39
+ "- **Location**: academy-gate",
40
+ "- **Conflict**: 秘密现身",
41
+ "- **Arc**: 决定追查",
42
+ "- **Foreshadowing**: seed-2",
43
+ "- **StateChanges**: Hero 站到学院门前",
44
+ "- **TransitionHint**: 进入正式剧情",
45
+ "- **ExcitementType**: cliffhanger",
46
+ ""
47
+ ].join("\n"));
48
+ await writeJson(join(rootDir, "volumes/vol-01/storyline-schedule.json"), { active_storylines: ["main-arc"] });
49
+ await writeJson(join(rootDir, "volumes/vol-01/foreshadowing.json"), { schema_version: 1, items: [{ id: "seed-1" }] });
50
+ await writeJson(join(rootDir, "volumes/vol-01/new-characters.json"), []);
51
+ for (const chapter of [1, 2, 3]) {
52
+ await writeJson(join(rootDir, `volumes/vol-01/chapter-contracts/chapter-${String(chapter).padStart(3, "0")}.json`), {
53
+ chapter,
54
+ storyline_id: "main-arc",
55
+ objectives: [{ id: `OBJ-${chapter}-1`, required: true, description: `推进第 ${chapter} 章` }],
56
+ preconditions: { character_states: { Hero: { location: chapter === 1 ? "village" : chapter === 2 ? "outer-city" : "academy-gate" } } },
57
+ postconditions: { state_changes: { Hero: { location: chapter === 1 ? "outer-city" : chapter === 2 ? "academy-gate" : "academy-gate" } } },
58
+ acceptance_criteria: ["required objective 落地"]
59
+ });
60
+ }
61
+ }