novel-writer-cli 0.0.3 → 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 (32) hide show
  1. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  2. package/dist/__tests__/character-voice.test.js +1 -1
  3. package/dist/__tests__/gate-decision.test.js +66 -0
  4. package/dist/__tests__/init.test.js +7 -2
  5. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  6. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  7. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  8. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  9. package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
  10. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  11. package/dist/__tests__/steps-id.test.js +23 -0
  12. package/dist/__tests__/volume-pipeline.test.js +227 -0
  13. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  14. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  15. package/dist/advance.js +145 -48
  16. package/dist/checkpoint.js +71 -12
  17. package/dist/cli.js +202 -8
  18. package/dist/commit.js +1 -0
  19. package/dist/fs-utils.js +18 -3
  20. package/dist/gate-decision.js +59 -0
  21. package/dist/init.js +2 -0
  22. package/dist/instructions.js +322 -24
  23. package/dist/next-step.js +198 -34
  24. package/dist/platform-profile.js +3 -0
  25. package/dist/steps.js +60 -17
  26. package/dist/validate.js +275 -2
  27. package/dist/volume-commit.js +101 -0
  28. package/dist/volume-planning.js +143 -0
  29. package/dist/volume-review.js +448 -0
  30. package/docs/user/novel-cli.md +29 -0
  31. package/package.json +3 -2
  32. package/schemas/platform-profile.schema.json +5 -0
@@ -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
+ });
@@ -0,0 +1,227 @@
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 { commitVolume } from "../volume-commit.js";
8
+ import { readCheckpoint, writeCheckpoint } from "../checkpoint.js";
9
+ import { computeNextStep } from "../next-step.js";
10
+ import { validateStep } from "../validate.js";
11
+ import { computeVolumeChapterRange, volumeStagingRelPaths } from "../volume-planning.js";
12
+ async function writeText(absPath, contents) {
13
+ await mkdir(dirname(absPath), { recursive: true });
14
+ await writeFile(absPath, contents, "utf8");
15
+ }
16
+ async function writeJson(absPath, payload) {
17
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
18
+ }
19
+ async function exists(absPath) {
20
+ try {
21
+ await stat(absPath);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ test("computeVolumeChapterRange enforces deterministic bounds", () => {
29
+ assert.deepEqual(computeVolumeChapterRange({ current_volume: 1, last_completed_chapter: 0 }), { start: 1, end: 30 });
30
+ assert.deepEqual(computeVolumeChapterRange({ current_volume: 2, last_completed_chapter: 58 }), { start: 59, end: 60 });
31
+ assert.throws(() => computeVolumeChapterRange({ current_volume: 1, last_completed_chapter: 30 }), /plan_start=31 > plan_end=30/);
32
+ });
33
+ test("volume planning pipeline routes outline -> validate -> commit -> writing", async () => {
34
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-pipeline-"));
35
+ try {
36
+ const initial = {
37
+ last_completed_chapter: 58,
38
+ current_volume: 2,
39
+ orchestrator_state: "VOL_PLANNING",
40
+ pipeline_stage: null,
41
+ inflight_chapter: null,
42
+ volume_pipeline_stage: null
43
+ };
44
+ await writeJson(join(rootDir, ".checkpoint.json"), initial);
45
+ const volume = 2;
46
+ const range = computeVolumeChapterRange({ current_volume: volume, last_completed_chapter: initial.last_completed_chapter });
47
+ const rels = volumeStagingRelPaths(volume);
48
+ // Minimal planning artifacts for a 2-chapter range (59-60)
49
+ await writeText(join(rootDir, rels.outlineMd), [
50
+ `## 第 ${volume} 卷大纲`,
51
+ ``,
52
+ `### 第${range.start}章: 测试`,
53
+ `- **Storyline**: main-arc`,
54
+ `- **POV**: hero`,
55
+ `- **Location**: city`,
56
+ `- **Conflict**: test`,
57
+ `- **Arc**: test`,
58
+ `- **Foreshadowing**: test`,
59
+ `- **StateChanges**: test`,
60
+ `- **TransitionHint**: {}`,
61
+ ``,
62
+ `### 第 ${range.end}章: 测试`,
63
+ `- **Storyline**: main-arc`,
64
+ `- **POV**: hero`,
65
+ `- **Location**: city`,
66
+ `- **Conflict**: test`,
67
+ `- **Arc**: test`,
68
+ `- **Foreshadowing**: test`,
69
+ `- **StateChanges**: test`,
70
+ `- **TransitionHint**: {}`,
71
+ ``
72
+ ].join("\n"));
73
+ await writeJson(join(rootDir, rels.storylineScheduleJson), { active_storylines: ["main-arc"] });
74
+ await writeJson(join(rootDir, rels.foreshadowingJson), { schema_version: 1, items: [] });
75
+ await writeJson(join(rootDir, rels.newCharactersJson), []);
76
+ const contractBase = {
77
+ storyline_id: "main-arc",
78
+ objectives: [{ id: "OBJ", required: true, description: "x" }],
79
+ preconditions: { character_states: { Alice: { location: "city" } } },
80
+ postconditions: { state_changes: {} }
81
+ };
82
+ await writeJson(join(rootDir, rels.chapterContractJson(range.start)), { chapter: range.start, ...contractBase });
83
+ await writeJson(join(rootDir, rels.chapterContractJson(range.end)), { chapter: range.end, ...contractBase });
84
+ // Next step: outline
85
+ let checkpoint = await readCheckpoint(rootDir);
86
+ let next = await computeNextStep(rootDir, checkpoint);
87
+ assert.equal(next.step, "volume:outline");
88
+ // Validate outputs for outline, then advance outline -> validate stage.
89
+ await validateStep({ rootDir, checkpoint, step: { kind: "volume", phase: "outline" } });
90
+ checkpoint = await advanceCheckpointForStep({ rootDir, step: { kind: "volume", phase: "outline" } });
91
+ next = await computeNextStep(rootDir, checkpoint);
92
+ assert.equal(next.step, "volume:validate");
93
+ // Advance validate -> commit stage.
94
+ await validateStep({ rootDir, checkpoint, step: { kind: "volume", phase: "validate" } });
95
+ checkpoint = await advanceCheckpointForStep({ rootDir, step: { kind: "volume", phase: "validate" } });
96
+ next = await computeNextStep(rootDir, checkpoint);
97
+ assert.equal(next.step, "volume:commit");
98
+ // Commit volume plan.
99
+ const result = await commitVolume({ rootDir, volume, dryRun: false });
100
+ assert.ok(result.plan.length > 0);
101
+ const after = await readCheckpoint(rootDir);
102
+ assert.equal(after.orchestrator_state, "WRITING");
103
+ assert.equal(after.volume_pipeline_stage, null);
104
+ assert.equal(after.pipeline_stage, "committed");
105
+ assert.equal(after.inflight_chapter, null);
106
+ assert.ok(await exists(join(rootDir, `volumes/vol-02/outline.md`)));
107
+ assert.equal(await exists(join(rootDir, rels.dir)), false);
108
+ }
109
+ finally {
110
+ await rm(rootDir, { recursive: true, force: true });
111
+ }
112
+ });
113
+ test("commitVolume normalizes checkpoint when final dir exists but staging is missing", async () => {
114
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-recover-"));
115
+ try {
116
+ await writeJson(join(rootDir, ".checkpoint.json"), {
117
+ last_completed_chapter: 0,
118
+ current_volume: 1,
119
+ orchestrator_state: "VOL_PLANNING",
120
+ pipeline_stage: null,
121
+ inflight_chapter: null,
122
+ volume_pipeline_stage: "commit"
123
+ });
124
+ await writeText(join(rootDir, "volumes/vol-01/outline.md"), "# vol 1\n");
125
+ const result = await commitVolume({ rootDir, volume: 1, dryRun: false });
126
+ assert.ok(result.warnings.some((w) => /already exists/i.test(w)));
127
+ const after = await readCheckpoint(rootDir);
128
+ assert.equal(after.orchestrator_state, "WRITING");
129
+ assert.equal(after.volume_pipeline_stage, null);
130
+ }
131
+ finally {
132
+ await rm(rootDir, { recursive: true, force: true });
133
+ }
134
+ });
135
+ test("advanceCheckpointForStep rejects volume advance outside VOL_PLANNING", async () => {
136
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-advance-guard-"));
137
+ try {
138
+ await writeCheckpoint(rootDir, {
139
+ last_completed_chapter: 0,
140
+ current_volume: 1,
141
+ orchestrator_state: "WRITING",
142
+ pipeline_stage: "committed",
143
+ volume_pipeline_stage: null,
144
+ inflight_chapter: null
145
+ });
146
+ await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "volume", phase: "outline" } }), /orchestrator_state=VOL_PLANNING/);
147
+ }
148
+ finally {
149
+ await rm(rootDir, { recursive: true, force: true });
150
+ }
151
+ });
152
+ test("computeNextStep falls back to volume:outline when validate stage is selected but artifacts are missing", async () => {
153
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-next-missing-"));
154
+ try {
155
+ const next = await computeNextStep(rootDir, {
156
+ last_completed_chapter: 58,
157
+ current_volume: 2,
158
+ orchestrator_state: "VOL_PLANNING",
159
+ pipeline_stage: null,
160
+ inflight_chapter: null,
161
+ volume_pipeline_stage: "validate"
162
+ });
163
+ assert.equal(next.step, "volume:outline");
164
+ assert.equal(next.reason, "vol_planning:validate:missing_artifacts");
165
+ }
166
+ finally {
167
+ await rm(rootDir, { recursive: true, force: true });
168
+ }
169
+ });
170
+ test("commitVolume dryRun does not touch checkpoint", async () => {
171
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-dryrun-"));
172
+ try {
173
+ await writeJson(join(rootDir, ".checkpoint.json"), {
174
+ last_completed_chapter: 0,
175
+ current_volume: 1,
176
+ orchestrator_state: "VOL_PLANNING",
177
+ pipeline_stage: null,
178
+ inflight_chapter: null,
179
+ volume_pipeline_stage: "commit"
180
+ });
181
+ const before = await readFile(join(rootDir, ".checkpoint.json"), "utf8");
182
+ const result = await commitVolume({ rootDir, volume: 1, dryRun: true });
183
+ assert.ok(result.plan.some((l) => l.includes("MOVE")));
184
+ const after = await readFile(join(rootDir, ".checkpoint.json"), "utf8");
185
+ assert.equal(after, before);
186
+ }
187
+ finally {
188
+ await rm(rootDir, { recursive: true, force: true });
189
+ }
190
+ });
191
+ test("commitVolume rejects when both staging and final volume dirs exist", async () => {
192
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-conflict-"));
193
+ try {
194
+ await writeJson(join(rootDir, ".checkpoint.json"), {
195
+ last_completed_chapter: 0,
196
+ current_volume: 1,
197
+ orchestrator_state: "VOL_PLANNING",
198
+ pipeline_stage: null,
199
+ inflight_chapter: null,
200
+ volume_pipeline_stage: "commit"
201
+ });
202
+ await mkdir(join(rootDir, "staging/volumes/vol-01"), { recursive: true });
203
+ await mkdir(join(rootDir, "volumes/vol-01"), { recursive: true });
204
+ await assert.rejects(() => commitVolume({ rootDir, volume: 1, dryRun: false }), /Commit conflict/);
205
+ }
206
+ finally {
207
+ await rm(rootDir, { recursive: true, force: true });
208
+ }
209
+ });
210
+ test("commitVolume recovery refuses when final dir exists but outline.md is missing", async () => {
211
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-recover-missing-outline-"));
212
+ try {
213
+ await writeJson(join(rootDir, ".checkpoint.json"), {
214
+ last_completed_chapter: 0,
215
+ current_volume: 1,
216
+ orchestrator_state: "VOL_PLANNING",
217
+ pipeline_stage: null,
218
+ inflight_chapter: null,
219
+ volume_pipeline_stage: "commit"
220
+ });
221
+ await mkdir(join(rootDir, "volumes/vol-01"), { recursive: true });
222
+ await assert.rejects(() => commitVolume({ rootDir, volume: 1, dryRun: false }), /missing .*outline\.md/i);
223
+ }
224
+ finally {
225
+ await rm(rootDir, { recursive: true, force: true });
226
+ }
227
+ });