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
@@ -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
+ });
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, 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 { computeNextStep } from "../next-step.js";
8
+ async function writeJson(absPath, payload) {
9
+ await mkdir(dirname(absPath), { recursive: true });
10
+ await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
11
+ }
12
+ async function exists(absPath) {
13
+ try {
14
+ await stat(absPath);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ test("computeNextStep progresses through volume review phases based on artifacts", async () => {
22
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-phases-"));
23
+ // 1) no artifacts => collect
24
+ let next = await computeNextStep(rootDir, {
25
+ last_completed_chapter: 10,
26
+ current_volume: 1,
27
+ orchestrator_state: "VOL_REVIEW",
28
+ pipeline_stage: "committed",
29
+ inflight_chapter: null
30
+ });
31
+ assert.equal(next.step, "review:collect");
32
+ // 2) quality summary exists => audit
33
+ await mkdir(join(rootDir, "staging/vol-review"), { recursive: true });
34
+ await writeJson(join(rootDir, "staging/vol-review/quality-summary.json"), { schema_version: 1, generated_at: new Date().toISOString() });
35
+ next = await computeNextStep(rootDir, {
36
+ last_completed_chapter: 10,
37
+ current_volume: 1,
38
+ orchestrator_state: "VOL_REVIEW",
39
+ pipeline_stage: "committed",
40
+ inflight_chapter: null
41
+ });
42
+ assert.equal(next.step, "review:audit");
43
+ // 3) audit report exists => report
44
+ await writeJson(join(rootDir, "staging/vol-review/audit-report.json"), { schema_version: 1, generated_at: new Date().toISOString(), stats: {} });
45
+ next = await computeNextStep(rootDir, {
46
+ last_completed_chapter: 10,
47
+ current_volume: 1,
48
+ orchestrator_state: "VOL_REVIEW",
49
+ pipeline_stage: "committed",
50
+ inflight_chapter: null
51
+ });
52
+ assert.equal(next.step, "review:report");
53
+ // 4) review report exists => cleanup
54
+ await writeFile(join(rootDir, "staging/vol-review/review-report.md"), "# report\n", "utf8");
55
+ next = await computeNextStep(rootDir, {
56
+ last_completed_chapter: 10,
57
+ current_volume: 1,
58
+ orchestrator_state: "VOL_REVIEW",
59
+ pipeline_stage: "committed",
60
+ inflight_chapter: null
61
+ });
62
+ assert.equal(next.step, "review:cleanup");
63
+ // 5) foreshadow status exists => transition
64
+ await writeJson(join(rootDir, "staging/vol-review/foreshadow-status.json"), { schema_version: 1, generated_at: new Date().toISOString() });
65
+ next = await computeNextStep(rootDir, {
66
+ last_completed_chapter: 10,
67
+ current_volume: 1,
68
+ orchestrator_state: "VOL_REVIEW",
69
+ pipeline_stage: "committed",
70
+ inflight_chapter: null
71
+ });
72
+ assert.equal(next.step, "review:transition");
73
+ });
74
+ test("advanceCheckpointForStep(review:transition) increments volume, returns to WRITING, and clears review artifacts", async () => {
75
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-transition-"));
76
+ await mkdir(join(rootDir, "staging/vol-review"), { recursive: true });
77
+ // checkpoint
78
+ await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 10, current_volume: 1, orchestrator_state: "VOL_REVIEW", pipeline_stage: "committed", inflight_chapter: null }, null, 2)}\n`, "utf8");
79
+ // required artifacts for transition validation
80
+ await writeJson(join(rootDir, "staging/vol-review/quality-summary.json"), { schema_version: 1, generated_at: new Date().toISOString() });
81
+ await writeJson(join(rootDir, "staging/vol-review/audit-report.json"), { schema_version: 1, generated_at: new Date().toISOString(), stats: {} });
82
+ await writeFile(join(rootDir, "staging/vol-review/review-report.md"), "# report\n", "utf8");
83
+ await writeJson(join(rootDir, "staging/vol-review/foreshadow-status.json"), { schema_version: 1, generated_at: new Date().toISOString() });
84
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "review", phase: "transition" } });
85
+ assert.equal(updated.current_volume, 2);
86
+ assert.equal(updated.orchestrator_state, "WRITING");
87
+ assert.equal(updated.pipeline_stage, "committed");
88
+ assert.equal(updated.inflight_chapter, null);
89
+ assert.equal(updated.revision_count, 0);
90
+ assert.equal(updated.hook_fix_count, 0);
91
+ assert.equal(updated.title_fix_count, 0);
92
+ assert.equal(await exists(join(rootDir, "staging/vol-review")), false);
93
+ });
94
+ test("advanceCheckpointForStep refuses to advance review steps from CHAPTER_REWRITE", async () => {
95
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-guard-"));
96
+ await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 10, current_volume: 1, orchestrator_state: "CHAPTER_REWRITE", pipeline_stage: "revising", inflight_chapter: 11 }, null, 2)}\n`, "utf8");
97
+ await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "review", phase: "collect" } }), /Refusing to advance review step from orchestrator_state=CHAPTER_REWRITE/);
98
+ });
99
+ test("computeNextStep enters volume review after committing volume-end chapter when outline range matches", async () => {
100
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-enter-"));
101
+ await mkdir(join(rootDir, "volumes/vol-01"), { recursive: true });
102
+ await writeFile(join(rootDir, "volumes/vol-01/outline.md"), "### 第 1 章\n\n### 第 2 章\n", "utf8");
103
+ const next = await computeNextStep(rootDir, {
104
+ last_completed_chapter: 2,
105
+ current_volume: 1,
106
+ orchestrator_state: "WRITING",
107
+ pipeline_stage: "committed",
108
+ inflight_chapter: null
109
+ });
110
+ assert.equal(next.step, "review:collect");
111
+ assert.equal(next.reason, "volume_end:vol_review:missing_quality_summary");
112
+ });
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, 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 { computeStorylineRhythm } from "../volume-review.js";
7
+ test("computeStorylineRhythm counts multiple storyline_id entries per chapter (unique per chapter)", async () => {
8
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-storyline-rhythm-"));
9
+ await mkdir(join(rootDir, "summaries"), { recursive: true });
10
+ await writeFile(join(rootDir, "summaries/chapter-001-summary.md"), `storyline_id: main\nstoryline_id: side\nstoryline_id: side\n`, "utf8");
11
+ await writeFile(join(rootDir, "summaries/chapter-002-summary.md"), `storyline_id: side\n`, "utf8");
12
+ const res = await computeStorylineRhythm({ rootDir, volume: 1, chapter_range: [1, 2] });
13
+ const obj = res;
14
+ assert.equal(obj.schema_version, 1);
15
+ assert.deepEqual(obj.appearances, { main: 1, side: 2 });
16
+ assert.deepEqual(obj.last_seen, { main: 1, side: 2 });
17
+ assert.ok(Array.isArray(obj.warnings));
18
+ assert.ok(obj.warnings.some((w) => w.includes("Missing optional file: volumes/vol-01/storyline-schedule.json")));
19
+ });
package/dist/advance.js CHANGED
@@ -3,11 +3,10 @@ import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
3
3
  import { NovelCliError } from "./errors.js";
4
4
  import { removePath } from "./fs-utils.js";
5
5
  import { withWriteLock } from "./lock.js";
6
- import { chapterRelPaths, titleFixSnapshotRel } from "./steps.js";
6
+ import { chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
7
7
  import { validateStep } from "./validate.js";
8
+ import { VOL_REVIEW_RELS } from "./volume-review.js";
8
9
  function stageForStep(step) {
9
- if (step.kind !== "chapter")
10
- throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
11
10
  switch (step.stage) {
12
11
  case "draft":
13
12
  return "drafting";
@@ -21,55 +20,153 @@ function stageForStep(step) {
21
20
  return "refined";
22
21
  case "hook-fix":
23
22
  return "refined";
24
- default:
25
- throw new NovelCliError(`Unsupported step stage: ${step.stage}`, 2);
23
+ case "review":
24
+ case "commit":
25
+ throw new NovelCliError(`Unsupported step stage for advance: ${step.stage}`, 2);
26
+ default: {
27
+ const _exhaustive = step.stage;
28
+ throw new NovelCliError(`Unsupported step stage: ${_exhaustive}`, 2);
29
+ }
26
30
  }
27
31
  }
28
32
  export async function advanceCheckpointForStep(args) {
29
- if (args.step.kind !== "chapter")
30
- throw new NovelCliError(`Unsupported step kind: ${args.step.kind}`, 2);
31
- if (args.step.stage === "commit")
32
- throw new NovelCliError(`Use 'novel commit' for commit.`, 2);
33
- if (args.step.stage === "review")
34
- throw new NovelCliError(`Review is a manual step; do not advance it.`, 2);
35
- return await withWriteLock(args.rootDir, { chapter: args.step.chapter }, async () => {
36
- const checkpoint = await readCheckpoint(args.rootDir);
37
- // Enforce validate-before-advance to keep deterministic semantics.
38
- await validateStep({ rootDir: args.rootDir, checkpoint, step: args.step });
39
- const updated = { ...checkpoint };
40
- const nextStage = stageForStep(args.step);
41
- updated.pipeline_stage = nextStage;
42
- updated.inflight_chapter = args.step.chapter;
43
- // Ensure revision counter is initialized when starting from draft (revision loops may preserve it).
44
- if (args.step.stage === "draft") {
45
- if (typeof updated.revision_count !== "number")
33
+ const step = args.step;
34
+ if (step.kind === "chapter") {
35
+ if (step.stage === "commit")
36
+ throw new NovelCliError(`Use 'novel commit' for commit.`, 2);
37
+ if (step.stage === "review")
38
+ throw new NovelCliError(`Review is a manual step; do not advance it.`, 2);
39
+ return await withWriteLock(args.rootDir, { chapter: step.chapter }, async () => {
40
+ const checkpoint = await readCheckpoint(args.rootDir);
41
+ // Enforce validate-before-advance to keep deterministic semantics.
42
+ await validateStep({ rootDir: args.rootDir, checkpoint, step });
43
+ const updated = { ...checkpoint };
44
+ const nextStage = stageForStep(step);
45
+ updated.pipeline_stage = nextStage;
46
+ updated.inflight_chapter = step.chapter;
47
+ updated.orchestrator_state =
48
+ checkpoint.orchestrator_state === "CHAPTER_REWRITE" || checkpoint.pipeline_stage === "revising" ? "CHAPTER_REWRITE" : "WRITING";
49
+ // Ensure revision counter is initialized when starting from draft (revision loops may preserve it).
50
+ if (step.stage === "draft") {
51
+ if (typeof updated.revision_count !== "number")
52
+ updated.revision_count = 0;
53
+ updated.hook_fix_count = 0;
54
+ updated.title_fix_count = 0;
55
+ await removePath(join(args.rootDir, titleFixSnapshotRel(step.chapter)));
56
+ // If rewinding from a later stage, clear downstream staging artifacts to avoid skipping steps with stale data.
57
+ const prevStage = checkpoint.pipeline_stage ?? null;
58
+ const prevInflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
59
+ if (prevInflight === step.chapter && prevStage && prevStage !== "drafting") {
60
+ const rel = chapterRelPaths(step.chapter);
61
+ await removePath(join(args.rootDir, rel.staging.summaryMd));
62
+ await removePath(join(args.rootDir, rel.staging.deltaJson));
63
+ await removePath(join(args.rootDir, rel.staging.crossrefJson));
64
+ await removePath(join(args.rootDir, rel.staging.evalJson));
65
+ await removePath(join(args.rootDir, rel.staging.styleRefinerChangesJson));
66
+ // Revision loops are driven by gate decision after judge.
67
+ if (prevStage === "judged") {
68
+ const prev = typeof updated.revision_count === "number" ? updated.revision_count : 0;
69
+ updated.revision_count = prev + 1;
70
+ updated.orchestrator_state = "CHAPTER_REWRITE";
71
+ }
72
+ }
73
+ }
74
+ // Title-fix counts as a bounded micro-revision and invalidates the current eval.
75
+ if (step.stage === "title-fix") {
76
+ const prev = typeof updated.title_fix_count === "number" ? updated.title_fix_count : 0;
77
+ if (prev >= 1) {
78
+ throw new NovelCliError(`Title-fix already attempted for chapter ${step.chapter}; manual review required.`, 2);
79
+ }
80
+ updated.title_fix_count = prev + 1;
81
+ const rel = chapterRelPaths(step.chapter);
82
+ await removePath(join(args.rootDir, rel.staging.evalJson));
83
+ }
84
+ // Hook-fix counts as a bounded micro-revision and invalidates the current eval.
85
+ if (step.stage === "hook-fix") {
86
+ const prev = typeof updated.hook_fix_count === "number" ? updated.hook_fix_count : 0;
87
+ if (prev >= 1) {
88
+ throw new NovelCliError(`Hook-fix already attempted for chapter ${step.chapter}; manual review required.`, 2);
89
+ }
90
+ updated.hook_fix_count = prev + 1;
91
+ const rel = chapterRelPaths(step.chapter);
92
+ await removePath(join(args.rootDir, rel.staging.evalJson));
93
+ }
94
+ // Refine rewrites the chapter draft; invalidate prior eval to force re-judge.
95
+ if (step.stage === "refine") {
96
+ const rel = chapterRelPaths(step.chapter);
97
+ await removePath(join(args.rootDir, rel.staging.evalJson));
98
+ // If we're polishing after a judged gate decision, count it as a revision loop.
99
+ const prevStage = checkpoint.pipeline_stage ?? null;
100
+ const prevInflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
101
+ if (prevInflight === step.chapter && prevStage === "judged") {
102
+ const prev = typeof updated.revision_count === "number" ? updated.revision_count : 0;
103
+ updated.revision_count = prev + 1;
104
+ updated.orchestrator_state = "CHAPTER_REWRITE";
105
+ }
106
+ }
107
+ updated.last_checkpoint_time = new Date().toISOString();
108
+ await writeCheckpoint(args.rootDir, updated);
109
+ return updated;
110
+ });
111
+ }
112
+ if (step.kind === "review") {
113
+ const reviewStep = step;
114
+ return await withWriteLock(args.rootDir, {}, async () => {
115
+ const checkpoint = await readCheckpoint(args.rootDir);
116
+ if (checkpoint.orchestrator_state !== "WRITING" && checkpoint.orchestrator_state !== "VOL_REVIEW") {
117
+ throw new NovelCliError(`Refusing to advance review step from orchestrator_state=${checkpoint.orchestrator_state}. Expected WRITING (volume_end) or VOL_REVIEW.`, 2);
118
+ }
119
+ // Enforce validate-before-advance to keep deterministic semantics.
120
+ await validateStep({ rootDir: args.rootDir, checkpoint, step: reviewStep });
121
+ const updated = { ...checkpoint };
122
+ updated.inflight_chapter = null;
123
+ if (reviewStep.phase === "transition") {
124
+ updated.current_volume = checkpoint.current_volume + 1;
125
+ updated.orchestrator_state = "WRITING";
126
+ updated.pipeline_stage = "committed";
46
127
  updated.revision_count = 0;
47
- updated.hook_fix_count = 0;
48
- updated.title_fix_count = 0;
49
- await removePath(join(args.rootDir, titleFixSnapshotRel(args.step.chapter)));
50
- }
51
- // Title-fix counts as a bounded micro-revision and invalidates the current eval.
52
- if (args.step.stage === "title-fix") {
53
- const prev = typeof updated.title_fix_count === "number" ? updated.title_fix_count : 0;
54
- if (prev >= 1) {
55
- throw new NovelCliError(`Title-fix already attempted for chapter ${args.step.chapter}; manual review required.`, 2);
128
+ updated.hook_fix_count = 0;
129
+ updated.title_fix_count = 0;
130
+ await removePath(join(args.rootDir, VOL_REVIEW_RELS.dir));
56
131
  }
57
- updated.title_fix_count = prev + 1;
58
- const rel = chapterRelPaths(args.step.chapter);
59
- await removePath(join(args.rootDir, rel.staging.evalJson));
60
- }
61
- // Hook-fix counts as a bounded micro-revision and invalidates the current eval.
62
- if (args.step.stage === "hook-fix") {
63
- const prev = typeof updated.hook_fix_count === "number" ? updated.hook_fix_count : 0;
64
- if (prev >= 1) {
65
- throw new NovelCliError(`Hook-fix already attempted for chapter ${args.step.chapter}; manual review required.`, 2);
132
+ else {
133
+ updated.orchestrator_state = "VOL_REVIEW";
66
134
  }
67
- updated.hook_fix_count = prev + 1;
68
- const rel = chapterRelPaths(args.step.chapter);
69
- await removePath(join(args.rootDir, rel.staging.evalJson));
70
- }
71
- updated.last_checkpoint_time = new Date().toISOString();
72
- await writeCheckpoint(args.rootDir, updated);
73
- return updated;
74
- });
135
+ updated.last_checkpoint_time = new Date().toISOString();
136
+ await writeCheckpoint(args.rootDir, updated);
137
+ return updated;
138
+ });
139
+ }
140
+ if (step.kind === "volume") {
141
+ return await withWriteLock(args.rootDir, {}, async () => {
142
+ const checkpoint = await readCheckpoint(args.rootDir);
143
+ if (checkpoint.orchestrator_state !== "VOL_PLANNING") {
144
+ throw new NovelCliError(`Cannot advance ${formatStepId(step)} unless orchestrator_state=VOL_PLANNING.`, 2);
145
+ }
146
+ await validateStep({ rootDir: args.rootDir, checkpoint, step });
147
+ const updated = { ...checkpoint };
148
+ updated.orchestrator_state = "VOL_PLANNING";
149
+ updated.pipeline_stage = "committed";
150
+ updated.inflight_chapter = null;
151
+ const phase = step.phase;
152
+ switch (phase) {
153
+ case "outline":
154
+ updated.volume_pipeline_stage = "validate";
155
+ break;
156
+ case "validate":
157
+ updated.volume_pipeline_stage = "commit";
158
+ break;
159
+ case "commit":
160
+ throw new NovelCliError(`Use 'novel commit --volume <n>' for volume commit.`, 2);
161
+ default: {
162
+ const _exhaustive = phase;
163
+ throw new NovelCliError(`Unsupported volume phase: ${String(_exhaustive)}`, 2);
164
+ }
165
+ }
166
+ updated.last_checkpoint_time = new Date().toISOString();
167
+ await writeCheckpoint(args.rootDir, updated);
168
+ return updated;
169
+ });
170
+ }
171
+ throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
75
172
  }