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.
- package/README.md +9 -1
- package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
- package/dist/__tests__/character-voice.test.js +1 -1
- package/dist/__tests__/gate-decision.test.js +66 -0
- package/dist/__tests__/init.test.js +245 -0
- package/dist/__tests__/narrative-health-injection.test.js +8 -8
- package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
- package/dist/__tests__/next-step-title-fix.test.js +64 -8
- package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
- package/dist/__tests__/steps-id.test.js +23 -0
- package/dist/__tests__/volume-pipeline.test.js +227 -0
- package/dist/__tests__/volume-review-pipeline.test.js +112 -0
- package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
- package/dist/advance.js +145 -48
- package/dist/checkpoint.js +83 -12
- package/dist/cli.js +235 -8
- package/dist/commit.js +1 -0
- package/dist/fs-utils.js +18 -3
- package/dist/gate-decision.js +59 -0
- package/dist/init.js +165 -0
- package/dist/instructions.js +322 -24
- package/dist/next-step.js +198 -34
- package/dist/platform-profile.js +3 -0
- package/dist/steps.js +60 -17
- package/dist/validate.js +275 -2
- package/dist/volume-commit.js +101 -0
- package/dist/volume-planning.js +143 -0
- package/dist/volume-review.js +448 -0
- package/docs/user/novel-cli.md +57 -0
- package/package.json +3 -2
- 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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return
|
|
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
|
}
|