novel-writer-cli 0.0.2 → 0.1.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 (33) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  3. package/dist/__tests__/character-voice.test.js +1 -1
  4. package/dist/__tests__/gate-decision.test.js +66 -0
  5. package/dist/__tests__/init.test.js +245 -0
  6. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  7. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  8. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  9. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  10. package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
  11. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  12. package/dist/__tests__/steps-id.test.js +23 -0
  13. package/dist/__tests__/volume-pipeline.test.js +227 -0
  14. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  15. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  16. package/dist/advance.js +145 -48
  17. package/dist/checkpoint.js +83 -12
  18. package/dist/cli.js +235 -8
  19. package/dist/commit.js +1 -0
  20. package/dist/fs-utils.js +18 -3
  21. package/dist/gate-decision.js +59 -0
  22. package/dist/init.js +165 -0
  23. package/dist/instructions.js +322 -24
  24. package/dist/next-step.js +198 -34
  25. package/dist/platform-profile.js +3 -0
  26. package/dist/steps.js +60 -17
  27. package/dist/validate.js +275 -2
  28. package/dist/volume-commit.js +101 -0
  29. package/dist/volume-planning.js +143 -0
  30. package/dist/volume-review.js +448 -0
  31. package/docs/user/novel-cli.md +57 -0
  32. package/package.json +3 -2
  33. package/schemas/platform-profile.schema.json +5 -0
@@ -40,7 +40,13 @@ test("computeNextStep returns review when naming lint has blocking issues", asyn
40
40
  await mkdir(join(rootDir, "characters/active"), { recursive: true });
41
41
  await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
42
42
  await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
43
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
43
+ const checkpoint = {
44
+ last_completed_chapter: 0,
45
+ current_volume: 1,
46
+ orchestrator_state: "WRITING",
47
+ pipeline_stage: "judged",
48
+ inflight_chapter: 1
49
+ };
44
50
  const next = await computeNextStep(rootDir, checkpoint);
45
51
  assert.equal(next.step, "chapter:001:review");
46
52
  assert.equal(next.reason, "judged:prejudge_guardrails_blocking:naming_lint");
@@ -61,7 +67,13 @@ test("computeNextStep returns review when readability lint has blocking issues (
61
67
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
62
68
  await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
63
69
  await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
64
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
70
+ const checkpoint = {
71
+ last_completed_chapter: 0,
72
+ current_volume: 1,
73
+ orchestrator_state: "WRITING",
74
+ pipeline_stage: "judged",
75
+ inflight_chapter: 1
76
+ };
65
77
  const next = await computeNextStep(rootDir, checkpoint);
66
78
  assert.equal(next.step, "chapter:001:review");
67
79
  assert.equal(next.reason, "judged:prejudge_guardrails_blocking:readability_lint");
@@ -82,7 +94,13 @@ test("buildInstructionPacket (judge) includes prejudge guardrails report path an
82
94
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
83
95
  await mkdir(join(rootDir, "staging/state"), { recursive: true });
84
96
  await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), {});
85
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
97
+ const checkpoint = {
98
+ last_completed_chapter: 0,
99
+ current_volume: 1,
100
+ orchestrator_state: "WRITING",
101
+ pipeline_stage: "refined",
102
+ inflight_chapter: 1
103
+ };
86
104
  const built = await buildInstructionPacket({
87
105
  rootDir,
88
106
  checkpoint,
@@ -116,7 +134,13 @@ test("computeNextStep returns review on refined stage when naming lint blocks",
116
134
  await mkdir(join(rootDir, "characters/active"), { recursive: true });
117
135
  await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
118
136
  await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
119
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
137
+ const checkpoint = {
138
+ last_completed_chapter: 0,
139
+ current_volume: 1,
140
+ orchestrator_state: "WRITING",
141
+ pipeline_stage: "refined",
142
+ inflight_chapter: 1
143
+ };
120
144
  const next = await computeNextStep(rootDir, checkpoint);
121
145
  assert.equal(next.step, "chapter:001:review");
122
146
  assert.equal(next.reason, "refined:prejudge_guardrails_blocking:naming_lint");
@@ -130,7 +154,13 @@ test("computeNextStep returns draft (not crash) when judged but staging chapter
130
154
  }));
131
155
  await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
132
156
  await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
133
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
157
+ const checkpoint = {
158
+ last_completed_chapter: 0,
159
+ current_volume: 1,
160
+ orchestrator_state: "WRITING",
161
+ pipeline_stage: "judged",
162
+ inflight_chapter: 1
163
+ };
134
164
  const next = await computeNextStep(rootDir, checkpoint);
135
165
  assert.equal(next.step, "chapter:001:draft");
136
166
  assert.equal(next.reason, "judged:missing_chapter");
@@ -151,7 +181,13 @@ test("computeNextStep tolerates invalid cached prejudge guardrails JSON (recompu
151
181
  await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
152
182
  await mkdir(join(rootDir, "staging/guardrails"), { recursive: true });
153
183
  await writeFile(join(rootDir, "staging/guardrails/prejudge-guardrails-chapter-001.json"), "{not-json", "utf8");
154
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
184
+ const checkpoint = {
185
+ last_completed_chapter: 0,
186
+ current_volume: 1,
187
+ orchestrator_state: "WRITING",
188
+ pipeline_stage: "judged",
189
+ inflight_chapter: 1
190
+ };
155
191
  const next = await computeNextStep(rootDir, checkpoint);
156
192
  assert.equal(next.step, "chapter:001:review");
157
193
  assert.equal(next.reason, "judged:prejudge_guardrails_blocking:naming_lint");
@@ -170,7 +206,13 @@ test("computeNextStep does not use cached report when platform profile changes (
170
206
  await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
171
207
  await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
172
208
  // Generate and persist a cached guardrails report via judge instructions.
173
- const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
209
+ const checkpointRefined = {
210
+ last_completed_chapter: 0,
211
+ current_volume: 1,
212
+ orchestrator_state: "WRITING",
213
+ pipeline_stage: "refined",
214
+ inflight_chapter: 1
215
+ };
174
216
  await buildInstructionPacket({
175
217
  rootDir,
176
218
  checkpoint: checkpointRefined,
@@ -182,7 +224,13 @@ test("computeNextStep does not use cached report when platform profile changes (
182
224
  await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ retention: null, readability: null, naming: null }));
183
225
  await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
184
226
  await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
185
- const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
227
+ const checkpointJudged = {
228
+ last_completed_chapter: 0,
229
+ current_volume: 1,
230
+ orchestrator_state: "WRITING",
231
+ pipeline_stage: "judged",
232
+ inflight_chapter: 1
233
+ };
186
234
  const next = await computeNextStep(rootDir, checkpointJudged);
187
235
  assert.equal(next.step, "chapter:001:commit");
188
236
  });
@@ -201,7 +249,13 @@ test("computeNextStep uses cached prejudge guardrails report when fresh", async
201
249
  await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
202
250
  await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
203
251
  // Generate and persist a cached guardrails report via judge instructions.
204
- const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
252
+ const checkpointRefined = {
253
+ last_completed_chapter: 0,
254
+ current_volume: 1,
255
+ orchestrator_state: "WRITING",
256
+ pipeline_stage: "refined",
257
+ inflight_chapter: 1
258
+ };
205
259
  await buildInstructionPacket({
206
260
  rootDir,
207
261
  checkpoint: checkpointRefined,
@@ -211,7 +265,13 @@ test("computeNextStep uses cached prejudge guardrails report when fresh", async
211
265
  });
212
266
  await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
213
267
  await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
214
- const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
268
+ const checkpointJudged = {
269
+ last_completed_chapter: 0,
270
+ current_volume: 1,
271
+ orchestrator_state: "WRITING",
272
+ pipeline_stage: "judged",
273
+ inflight_chapter: 1
274
+ };
215
275
  const next = await computeNextStep(rootDir, checkpointJudged);
216
276
  assert.equal(next.step, "chapter:001:review");
217
277
  assert.equal(next.reason, "judged:prejudge_guardrails_blocking:naming_lint");
@@ -234,7 +294,13 @@ test("computeNextStep ignores cached guardrails report when characters change",
234
294
  await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
235
295
  await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
236
296
  // Generate cached report with a blocking duplicate.
237
- const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
297
+ const checkpointRefined = {
298
+ last_completed_chapter: 0,
299
+ current_volume: 1,
300
+ orchestrator_state: "WRITING",
301
+ pipeline_stage: "refined",
302
+ inflight_chapter: 1
303
+ };
238
304
  await buildInstructionPacket({
239
305
  rootDir,
240
306
  checkpoint: checkpointRefined,
@@ -244,7 +310,13 @@ test("computeNextStep ignores cached guardrails report when characters change",
244
310
  });
245
311
  // Fix the duplicate by renaming a character; cache must be ignored.
246
312
  await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "李四五", aliases: [] });
247
- const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
313
+ const checkpointJudged = {
314
+ last_completed_chapter: 0,
315
+ current_volume: 1,
316
+ orchestrator_state: "WRITING",
317
+ pipeline_stage: "judged",
318
+ inflight_chapter: 1
319
+ };
248
320
  const next = await computeNextStep(rootDir, checkpointJudged);
249
321
  assert.equal(next.step, "chapter:001:commit");
250
322
  });
@@ -266,7 +338,13 @@ test("computeNextStep ignores cached guardrails report when characters/active is
266
338
  await writeJson(join(rootDir, "characters/shared-active/b.json"), { id: "b", display_name: "张三", aliases: [] });
267
339
  await symlink(join(rootDir, "characters/shared-active"), join(rootDir, "characters/active"));
268
340
  // Generate cached report with a blocking duplicate (via judge instructions).
269
- const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
341
+ const checkpointRefined = {
342
+ last_completed_chapter: 0,
343
+ current_volume: 1,
344
+ orchestrator_state: "WRITING",
345
+ pipeline_stage: "refined",
346
+ inflight_chapter: 1
347
+ };
270
348
  const built = await buildInstructionPacket({
271
349
  rootDir,
272
350
  checkpoint: checkpointRefined,
@@ -281,7 +359,13 @@ test("computeNextStep ignores cached guardrails report when characters/active is
281
359
  assert.equal(reportRaw.has_blocking_issues, true);
282
360
  // Fix the duplicate in the symlink target; cache must be ignored.
283
361
  await writeJson(join(rootDir, "characters/shared-active/b.json"), { id: "b", display_name: "李四五", aliases: [] });
284
- const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
362
+ const checkpointJudged = {
363
+ last_completed_chapter: 0,
364
+ current_volume: 1,
365
+ orchestrator_state: "WRITING",
366
+ pipeline_stage: "judged",
367
+ inflight_chapter: 1
368
+ };
285
369
  const next = await computeNextStep(rootDir, checkpointJudged);
286
370
  assert.equal(next.step, "chapter:001:commit");
287
371
  });
@@ -293,7 +377,13 @@ test("computeNextStep returns review when guardrails computation errors", async
293
377
  await mkdir(join(rootDir, "staging/chapters/chapter-001.md"), { recursive: true });
294
378
  await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
295
379
  await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
296
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
380
+ const checkpoint = {
381
+ last_completed_chapter: 0,
382
+ current_volume: 1,
383
+ orchestrator_state: "WRITING",
384
+ pipeline_stage: "judged",
385
+ inflight_chapter: 1
386
+ };
297
387
  const next = await computeNextStep(rootDir, checkpoint);
298
388
  assert.equal(next.step, "chapter:001:review");
299
389
  assert.equal(next.reason, "judged:prejudge_guardrails_error");
@@ -310,7 +400,13 @@ test("buildInstructionPacket (judge) sets prejudge_guardrails_degraded when repo
310
400
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
311
401
  // Intentionally create a directory at the chapter path to trigger fingerprint/read failure.
312
402
  await mkdir(join(rootDir, "staging/chapters/chapter-001.md"), { recursive: true });
313
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
403
+ const checkpoint = {
404
+ last_completed_chapter: 0,
405
+ current_volume: 1,
406
+ orchestrator_state: "WRITING",
407
+ pipeline_stage: "refined",
408
+ inflight_chapter: 1
409
+ };
314
410
  const built = await buildInstructionPacket({
315
411
  rootDir,
316
412
  checkpoint,
@@ -54,7 +54,14 @@ test("computeNextStep returns title-fix on hard title violations when auto_fix=t
54
54
  await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
55
55
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
56
56
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
57
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
57
+ const checkpoint = {
58
+ last_completed_chapter: 0,
59
+ current_volume: 1,
60
+ orchestrator_state: "WRITING",
61
+ pipeline_stage: "refined",
62
+ inflight_chapter: 1,
63
+ title_fix_count: 0
64
+ };
58
65
  const next = await computeNextStep(rootDir, checkpoint);
59
66
  assert.equal(next.step, "chapter:001:title-fix");
60
67
  });
@@ -63,7 +70,14 @@ test("computeNextStep returns review after title-fix was already attempted", asy
63
70
  await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
64
71
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
65
72
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
66
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 1 };
73
+ const checkpoint = {
74
+ last_completed_chapter: 0,
75
+ current_volume: 1,
76
+ orchestrator_state: "WRITING",
77
+ pipeline_stage: "refined",
78
+ inflight_chapter: 1,
79
+ title_fix_count: 1
80
+ };
67
81
  const next = await computeNextStep(rootDir, checkpoint);
68
82
  assert.equal(next.step, "chapter:001:review");
69
83
  });
@@ -72,7 +86,14 @@ test("computeNextStep returns review on hard title violations when auto_fix=fals
72
86
  await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: false }));
73
87
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
74
88
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
75
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
89
+ const checkpoint = {
90
+ last_completed_chapter: 0,
91
+ current_volume: 1,
92
+ orchestrator_state: "WRITING",
93
+ pipeline_stage: "refined",
94
+ inflight_chapter: 1,
95
+ title_fix_count: 0
96
+ };
76
97
  const next = await computeNextStep(rootDir, checkpoint);
77
98
  assert.equal(next.step, "chapter:001:review");
78
99
  });
@@ -81,7 +102,14 @@ test("computeNextStep does not block on warn-only title issues when auto_fix=fal
81
102
  await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: false, max_chars: 3 }));
82
103
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
83
104
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 太长的标题\n正文\n", "utf8");
84
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
105
+ const checkpoint = {
106
+ last_completed_chapter: 0,
107
+ current_volume: 1,
108
+ orchestrator_state: "WRITING",
109
+ pipeline_stage: "refined",
110
+ inflight_chapter: 1,
111
+ title_fix_count: 0
112
+ };
85
113
  const next = await computeNextStep(rootDir, checkpoint);
86
114
  assert.equal(next.step, "chapter:001:judge");
87
115
  });
@@ -90,7 +118,14 @@ test("computeNextStep returns title-fix on warn-only title issues when auto_fix=
90
118
  await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true, max_chars: 3 }));
91
119
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
92
120
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 太长的标题\n正文\n", "utf8");
93
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
121
+ const checkpoint = {
122
+ last_completed_chapter: 0,
123
+ current_volume: 1,
124
+ orchestrator_state: "WRITING",
125
+ pipeline_stage: "refined",
126
+ inflight_chapter: 1,
127
+ title_fix_count: 0
128
+ };
94
129
  const next = await computeNextStep(rootDir, checkpoint);
95
130
  assert.equal(next.step, "chapter:001:title-fix");
96
131
  });
@@ -101,7 +136,14 @@ test("computeNextStep returns title-fix on judged stage when eval exists and tit
101
136
  await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
102
137
  await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
103
138
  await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {});
104
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1, title_fix_count: 0 };
139
+ const checkpoint = {
140
+ last_completed_chapter: 0,
141
+ current_volume: 1,
142
+ orchestrator_state: "WRITING",
143
+ pipeline_stage: "judged",
144
+ inflight_chapter: 1,
145
+ title_fix_count: 0
146
+ };
105
147
  const next = await computeNextStep(rootDir, checkpoint);
106
148
  assert.equal(next.step, "chapter:001:title-fix");
107
149
  });
@@ -111,7 +153,14 @@ test("title-fix snapshot is write-once (rerunning instructions does not bypass b
111
153
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
112
154
  const chapterAbs = join(rootDir, "staging/chapters/chapter-001.md");
113
155
  await writeFile(chapterAbs, "# 标题\n正文\n", "utf8");
114
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
156
+ const checkpoint = {
157
+ last_completed_chapter: 0,
158
+ current_volume: 1,
159
+ orchestrator_state: "WRITING",
160
+ pipeline_stage: "refined",
161
+ inflight_chapter: 1,
162
+ title_fix_count: 0
163
+ };
115
164
  await buildInstructionPacket({
116
165
  rootDir,
117
166
  checkpoint,
@@ -145,7 +194,14 @@ test("advance draft cleans up title-fix snapshot to avoid stale reuse", async ()
145
194
  await mkdir(join(rootDir, "staging/logs"), { recursive: true });
146
195
  const snapshotRel = titleFixSnapshotRel(1);
147
196
  await writeFile(join(rootDir, snapshotRel), "old snapshot\n", "utf8");
148
- const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: null, inflight_chapter: null, title_fix_count: 1 };
197
+ const checkpoint = {
198
+ last_completed_chapter: 0,
199
+ current_volume: 1,
200
+ orchestrator_state: "WRITING",
201
+ pipeline_stage: null,
202
+ inflight_chapter: null,
203
+ title_fix_count: 1
204
+ };
149
205
  await writeJson(join(rootDir, ".checkpoint.json"), checkpoint);
150
206
  await advanceCheckpointForStep({ rootDir, step: { kind: "chapter", chapter: 1, stage: "draft" } });
151
207
  // validate cleanup is best-effort; the file should be gone.
@@ -0,0 +1,168 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { readCheckpoint } from "../checkpoint.js";
7
+ import { computeNextStep } from "../next-step.js";
8
+ async function writeJson(absPath, payload) {
9
+ await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
10
+ }
11
+ test("readCheckpoint injects orchestrator_state via legacy inference", async () => {
12
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-legacy-"));
13
+ await writeJson(join(rootDir, ".checkpoint.json"), {
14
+ last_completed_chapter: 0,
15
+ current_volume: 1,
16
+ pipeline_stage: null,
17
+ inflight_chapter: null
18
+ });
19
+ const checkpoint = await readCheckpoint(rootDir);
20
+ assert.equal(checkpoint.orchestrator_state, "WRITING");
21
+ });
22
+ test("readCheckpoint infers CHAPTER_REWRITE when pipeline_stage=revising", async () => {
23
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-revising-"));
24
+ await writeJson(join(rootDir, ".checkpoint.json"), {
25
+ last_completed_chapter: 0,
26
+ current_volume: 1,
27
+ pipeline_stage: "revising",
28
+ inflight_chapter: 7
29
+ });
30
+ const checkpoint = await readCheckpoint(rootDir);
31
+ assert.equal(checkpoint.orchestrator_state, "CHAPTER_REWRITE");
32
+ });
33
+ test("readCheckpoint infers ERROR_RETRY when pipeline_stage=revising but inflight_chapter is missing", async () => {
34
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-revising-missing-inflight-"));
35
+ await writeJson(join(rootDir, ".checkpoint.json"), {
36
+ last_completed_chapter: 0,
37
+ current_volume: 1,
38
+ pipeline_stage: "revising",
39
+ inflight_chapter: null
40
+ });
41
+ const checkpoint = await readCheckpoint(rootDir);
42
+ assert.equal(checkpoint.orchestrator_state, "ERROR_RETRY");
43
+ });
44
+ test("readCheckpoint infers ERROR_RETRY when inflight_chapter is set but pipeline_stage is idle", async () => {
45
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-idle-inflight-"));
46
+ await writeJson(join(rootDir, ".checkpoint.json"), {
47
+ last_completed_chapter: 0,
48
+ current_volume: 1,
49
+ pipeline_stage: null,
50
+ inflight_chapter: 7
51
+ });
52
+ const checkpoint = await readCheckpoint(rootDir);
53
+ assert.equal(checkpoint.orchestrator_state, "ERROR_RETRY");
54
+ });
55
+ test("readCheckpoint rejects inflight_chapter=0", async () => {
56
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-inflight-zero-"));
57
+ await writeJson(join(rootDir, ".checkpoint.json"), {
58
+ last_completed_chapter: 0,
59
+ current_volume: 1,
60
+ pipeline_stage: "drafting",
61
+ inflight_chapter: 0
62
+ });
63
+ await assert.rejects(() => readCheckpoint(rootDir), /inflight_chapter must be an int >= 1/);
64
+ });
65
+ test("computeNextStep throws for INIT placeholder", async () => {
66
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-init-"));
67
+ await assert.rejects(() => computeNextStep(rootDir, {
68
+ last_completed_chapter: 0,
69
+ current_volume: 1,
70
+ orchestrator_state: "INIT",
71
+ pipeline_stage: null,
72
+ inflight_chapter: null
73
+ }), /Not implemented: orchestrator_state=INIT/);
74
+ });
75
+ test("computeNextStep throws when pipeline_stage=committed but inflight_chapter is set", async () => {
76
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-committed-inflight-"));
77
+ await assert.rejects(() => computeNextStep(rootDir, {
78
+ last_completed_chapter: 0,
79
+ current_volume: 1,
80
+ orchestrator_state: "WRITING",
81
+ pipeline_stage: "committed",
82
+ inflight_chapter: 7
83
+ }), /Checkpoint inconsistent: pipeline_stage=committed but inflight_chapter=7/);
84
+ });
85
+ test("computeNextStep throws for QUICK_START placeholder", async () => {
86
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-quickstart-"));
87
+ await assert.rejects(() => computeNextStep(rootDir, {
88
+ last_completed_chapter: 0,
89
+ current_volume: 1,
90
+ orchestrator_state: "QUICK_START",
91
+ pipeline_stage: null,
92
+ inflight_chapter: null
93
+ }), /Not implemented: orchestrator_state=QUICK_START/);
94
+ });
95
+ test("computeNextStep prefixes reason for ERROR_RETRY", async () => {
96
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-error-retry-"));
97
+ const next = await computeNextStep(rootDir, {
98
+ last_completed_chapter: 0,
99
+ current_volume: 1,
100
+ orchestrator_state: "ERROR_RETRY",
101
+ pipeline_stage: null,
102
+ inflight_chapter: null
103
+ });
104
+ assert.equal(next.step, "chapter:001:draft");
105
+ assert.equal(next.reason, "error_retry:fresh");
106
+ });
107
+ test("computeNextStep heals ERROR_RETRY when pipeline_stage is committed but inflight_chapter is set", async () => {
108
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-error-retry-heal-committed-"));
109
+ const next = await computeNextStep(rootDir, {
110
+ last_completed_chapter: 0,
111
+ current_volume: 1,
112
+ orchestrator_state: "ERROR_RETRY",
113
+ pipeline_stage: "committed",
114
+ inflight_chapter: 7
115
+ });
116
+ assert.equal(next.step, "chapter:001:draft");
117
+ assert.equal(next.reason, "error_retry:healed_drop_inflight:fresh");
118
+ });
119
+ test("computeNextStep heals ERROR_RETRY when pipeline_stage is in-flight but inflight_chapter is missing", async () => {
120
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-error-retry-heal-drafting-"));
121
+ const next = await computeNextStep(rootDir, {
122
+ last_completed_chapter: 6,
123
+ current_volume: 1,
124
+ orchestrator_state: "ERROR_RETRY",
125
+ pipeline_stage: "drafting",
126
+ inflight_chapter: null
127
+ });
128
+ assert.equal(next.step, "chapter:007:draft");
129
+ assert.equal(next.reason, "error_retry:healed_infer_inflight:drafting:missing_chapter");
130
+ });
131
+ test("computeNextStep delegates CHAPTER_REWRITE to chapter routing", async () => {
132
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-chapter-rewrite-"));
133
+ const next = await computeNextStep(rootDir, {
134
+ last_completed_chapter: 6,
135
+ current_volume: 1,
136
+ orchestrator_state: "CHAPTER_REWRITE",
137
+ pipeline_stage: "revising",
138
+ inflight_chapter: 7
139
+ });
140
+ assert.equal(next.step, "chapter:007:draft");
141
+ assert.equal(next.reason, "revising:restart_draft");
142
+ assert.deepEqual(next.inflight, { chapter: 7, pipeline_stage: "revising" });
143
+ });
144
+ test("computeNextStep routes VOL_PLANNING to volume pipeline", async () => {
145
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-vol-planning-"));
146
+ const next = await computeNextStep(rootDir, {
147
+ last_completed_chapter: 0,
148
+ current_volume: 1,
149
+ orchestrator_state: "VOL_PLANNING",
150
+ pipeline_stage: null,
151
+ inflight_chapter: null,
152
+ volume_pipeline_stage: null
153
+ });
154
+ assert.equal(next.step, "volume:outline");
155
+ assert.equal(next.reason, "vol_planning:outline");
156
+ });
157
+ test("computeNextStep routes VOL_REVIEW to review pipeline", async () => {
158
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-vol-review-"));
159
+ const next = await computeNextStep(rootDir, {
160
+ last_completed_chapter: 0,
161
+ current_volume: 1,
162
+ orchestrator_state: "VOL_REVIEW",
163
+ pipeline_stage: null,
164
+ inflight_chapter: null
165
+ });
166
+ assert.equal(next.step, "review:collect");
167
+ assert.equal(next.reason, "vol_review:missing_quality_summary");
168
+ });
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, 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 { readCheckpoint } from "../checkpoint.js";
8
+ import { commitChapter } from "../commit.js";
9
+ async function writeText(absPath, contents) {
10
+ await mkdir(dirname(absPath), { recursive: true });
11
+ await writeFile(absPath, contents, "utf8");
12
+ }
13
+ async function writeJson(absPath, payload) {
14
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
15
+ }
16
+ test("advanceCheckpointForStep normalizes orchestrator_state to WRITING for chapter pipeline", async () => {
17
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-advance-state-"));
18
+ await writeJson(join(rootDir, ".checkpoint.json"), {
19
+ last_completed_chapter: 0,
20
+ current_volume: 1,
21
+ orchestrator_state: "INIT",
22
+ pipeline_stage: null,
23
+ inflight_chapter: null
24
+ });
25
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
26
+ const updated = await advanceCheckpointForStep({
27
+ rootDir,
28
+ step: { kind: "chapter", chapter: 1, stage: "draft" }
29
+ });
30
+ assert.equal(updated.orchestrator_state, "WRITING");
31
+ const checkpoint = await readCheckpoint(rootDir);
32
+ assert.equal(checkpoint.orchestrator_state, "WRITING");
33
+ });
34
+ test("commitChapter resets orchestrator_state to WRITING", async () => {
35
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-commit-state-"));
36
+ await writeJson(join(rootDir, ".checkpoint.json"), {
37
+ last_completed_chapter: 0,
38
+ current_volume: 1,
39
+ orchestrator_state: "CHAPTER_REWRITE",
40
+ pipeline_stage: "judged",
41
+ inflight_chapter: 1
42
+ });
43
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
44
+ await writeText(join(rootDir, "staging/summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n- 测试事件\n`);
45
+ await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), { schema_version: 1, chapter: 1, entities: [] });
46
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1 });
47
+ await writeText(join(rootDir, "staging/storylines/main-arc/memory.md"), `- 测试记忆\n`);
48
+ await writeJson(join(rootDir, "staging/state/chapter-001-delta.json"), {
49
+ chapter: 1,
50
+ base_state_version: 0,
51
+ storyline_id: "main-arc",
52
+ ops: [{ op: "set", path: "characters.hero.display_name", value: "阿宁" }]
53
+ });
54
+ await commitChapter({ rootDir, chapter: 1, dryRun: false });
55
+ const checkpoint = await readCheckpoint(rootDir);
56
+ assert.equal(checkpoint.orchestrator_state, "WRITING");
57
+ assert.equal(checkpoint.pipeline_stage, "committed");
58
+ assert.equal(checkpoint.inflight_chapter, null);
59
+ });
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { formatStepId, parseStepId } from "../steps.js";
4
+ test("formatStepId formats chapter ids with pad3", () => {
5
+ assert.equal(formatStepId({ kind: "chapter", chapter: 7, stage: "draft" }), "chapter:007:draft");
6
+ });
7
+ test("formatStepId formats volume/quickstart/review ids", () => {
8
+ assert.equal(formatStepId({ kind: "volume", phase: "outline" }), "volume:outline");
9
+ assert.equal(formatStepId({ kind: "quickstart", phase: "world" }), "quickstart:world");
10
+ assert.equal(formatStepId({ kind: "review", phase: "report" }), "review:report");
11
+ });
12
+ test("parseStepId parses chapter ids and trims whitespace", () => {
13
+ assert.deepEqual(parseStepId(" chapter:7:refine "), { kind: "chapter", chapter: 7, stage: "refine" });
14
+ });
15
+ test("parseStepId parses volume/quickstart/review ids", () => {
16
+ assert.deepEqual(parseStepId("volume:validate"), { kind: "volume", phase: "validate" });
17
+ assert.deepEqual(parseStepId("quickstart:trial"), { kind: "quickstart", phase: "trial" });
18
+ assert.deepEqual(parseStepId("review:cleanup"), { kind: "review", phase: "cleanup" });
19
+ });
20
+ test("parseStepId rejects unknown kind and invalid phases", () => {
21
+ assert.throws(() => parseStepId("foo:bar"), /Supported kinds: chapter, volume, quickstart, review/);
22
+ assert.throws(() => parseStepId("volume:badphase"), /Phase must be one of:/);
23
+ });