novel-writer-cli 0.1.0 → 0.2.1

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.
@@ -0,0 +1,346 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, 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 { readCheckpoint } from "../checkpoint.js";
8
+ import { buildInstructionPacket } from "../instructions.js";
9
+ import { computeNextStep } from "../next-step.js";
10
+ async function writeText(absPath, contents) {
11
+ await mkdir(dirname(absPath), { recursive: true });
12
+ await writeFile(absPath, contents, "utf8");
13
+ }
14
+ async function writeJson(absPath, payload) {
15
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
16
+ }
17
+ async function pathExists(absPath) {
18
+ try {
19
+ await stat(absPath);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ test("advance quickstart:world transitions INIT -> QUICK_START", async () => {
27
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-init-"));
28
+ await writeJson(join(rootDir, ".checkpoint.json"), {
29
+ last_completed_chapter: 0,
30
+ current_volume: 1,
31
+ orchestrator_state: "INIT",
32
+ pipeline_stage: null,
33
+ inflight_chapter: null
34
+ });
35
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), {
36
+ rules: [
37
+ {
38
+ id: "W-001",
39
+ category: "magic_system",
40
+ rule: "力量体系上限为九阶。",
41
+ constraint_type: "hard",
42
+ exceptions: [],
43
+ introduced_chapter: null,
44
+ last_verified: null
45
+ }
46
+ ]
47
+ });
48
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "world" } });
49
+ assert.equal(updated.orchestrator_state, "QUICK_START");
50
+ assert.equal(updated.quickstart_phase, "world");
51
+ const checkpoint = await readCheckpoint(rootDir);
52
+ assert.equal(checkpoint.orchestrator_state, "QUICK_START");
53
+ assert.equal(checkpoint.quickstart_phase, "world");
54
+ });
55
+ test("advance quickstart:characters writes quickstart_phase correctly", async () => {
56
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-characters-advance-"));
57
+ await writeJson(join(rootDir, ".checkpoint.json"), {
58
+ last_completed_chapter: 0,
59
+ current_volume: 1,
60
+ orchestrator_state: "QUICK_START",
61
+ pipeline_stage: null,
62
+ inflight_chapter: null
63
+ });
64
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
65
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
66
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "characters" } });
67
+ assert.equal(updated.orchestrator_state, "QUICK_START");
68
+ assert.equal(updated.quickstart_phase, "characters");
69
+ const checkpoint = await readCheckpoint(rootDir);
70
+ assert.equal(checkpoint.orchestrator_state, "QUICK_START");
71
+ assert.equal(checkpoint.quickstart_phase, "characters");
72
+ });
73
+ test("advance quickstart:style writes quickstart_phase correctly", async () => {
74
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-style-advance-"));
75
+ await writeJson(join(rootDir, ".checkpoint.json"), {
76
+ last_completed_chapter: 0,
77
+ current_volume: 1,
78
+ orchestrator_state: "QUICK_START",
79
+ pipeline_stage: null,
80
+ inflight_chapter: null
81
+ });
82
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
83
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
84
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
85
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "style" } });
86
+ assert.equal(updated.orchestrator_state, "QUICK_START");
87
+ assert.equal(updated.quickstart_phase, "style");
88
+ const checkpoint = await readCheckpoint(rootDir);
89
+ assert.equal(checkpoint.orchestrator_state, "QUICK_START");
90
+ assert.equal(checkpoint.quickstart_phase, "style");
91
+ });
92
+ test("advance quickstart:trial writes quickstart_phase correctly", async () => {
93
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-trial-advance-"));
94
+ await writeJson(join(rootDir, ".checkpoint.json"), {
95
+ last_completed_chapter: 0,
96
+ current_volume: 1,
97
+ orchestrator_state: "QUICK_START",
98
+ pipeline_stage: null,
99
+ inflight_chapter: null
100
+ });
101
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
102
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
103
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
104
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "# Trial\n\nText\n");
105
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "trial" } });
106
+ assert.equal(updated.orchestrator_state, "QUICK_START");
107
+ assert.equal(updated.quickstart_phase, "trial");
108
+ const checkpoint = await readCheckpoint(rootDir);
109
+ assert.equal(checkpoint.orchestrator_state, "QUICK_START");
110
+ assert.equal(checkpoint.quickstart_phase, "trial");
111
+ });
112
+ test("advance quickstart rejects wrong orchestrator_state", async () => {
113
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-advance-wrong-state-"));
114
+ await writeJson(join(rootDir, ".checkpoint.json"), {
115
+ last_completed_chapter: 0,
116
+ current_volume: 1,
117
+ orchestrator_state: "WRITING",
118
+ pipeline_stage: null,
119
+ inflight_chapter: null
120
+ });
121
+ await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "world" } }), /Cannot advance quickstart:world unless orchestrator_state=INIT or QUICK_START/);
122
+ });
123
+ test("computeNextStep recovers quickstart phase from staging artifacts", async () => {
124
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-resume-"));
125
+ await writeJson(join(rootDir, ".checkpoint.json"), {
126
+ last_completed_chapter: 0,
127
+ current_volume: 1,
128
+ orchestrator_state: "QUICK_START",
129
+ pipeline_stage: null,
130
+ inflight_chapter: null
131
+ });
132
+ // No artifacts yet → world
133
+ let next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
134
+ assert.equal(next.step, "quickstart:world");
135
+ // rules.json present → characters
136
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
137
+ next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
138
+ assert.equal(next.step, "quickstart:characters");
139
+ // contracts dir + one contract → style
140
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
141
+ next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
142
+ assert.equal(next.step, "quickstart:style");
143
+ // style profile present → trial
144
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
145
+ next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
146
+ assert.equal(next.step, "quickstart:trial");
147
+ // trial chapter present → results
148
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
149
+ next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
150
+ assert.equal(next.step, "quickstart:results");
151
+ assert.equal(next.reason, "quickstart:results");
152
+ // evaluation present → results (ready to advance/commit)
153
+ await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
154
+ next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
155
+ assert.equal(next.step, "quickstart:results");
156
+ assert.equal(next.reason, "quickstart:results:artifacts_present");
157
+ });
158
+ test("computeNextStep allows redoing current quickstart phase when artifacts are missing", async () => {
159
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-checkpoint-"));
160
+ await writeJson(join(rootDir, ".checkpoint.json"), {
161
+ last_completed_chapter: 0,
162
+ current_volume: 1,
163
+ orchestrator_state: "QUICK_START",
164
+ pipeline_stage: null,
165
+ inflight_chapter: null,
166
+ quickstart_phase: "world"
167
+ });
168
+ const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
169
+ assert.equal(next.step, "quickstart:world");
170
+ assert.equal(next.reason, "quickstart:world");
171
+ });
172
+ test("computeNextStep blocks quickstart rollback when quickstart_phase=characters but world artifacts are missing", async () => {
173
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-characters-"));
174
+ await writeJson(join(rootDir, ".checkpoint.json"), {
175
+ last_completed_chapter: 0,
176
+ current_volume: 1,
177
+ orchestrator_state: "QUICK_START",
178
+ pipeline_stage: null,
179
+ inflight_chapter: null,
180
+ quickstart_phase: "characters"
181
+ });
182
+ const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
183
+ assert.equal(next.step, "quickstart:world");
184
+ assert.match(next.reason, /quickstart:recovery_blocked/);
185
+ assert.equal(next.evidence.recovery_blocked.checkpoint_phase, "characters");
186
+ assert.equal(next.evidence.recovery_blocked.inferred_phase, "world");
187
+ assert.equal(next.evidence.recovery_blocked.expected_path, "staging/quickstart/rules.json");
188
+ });
189
+ test("computeNextStep allows redoing style phase when style profile is missing", async () => {
190
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-style-"));
191
+ await writeJson(join(rootDir, ".checkpoint.json"), {
192
+ last_completed_chapter: 0,
193
+ current_volume: 1,
194
+ orchestrator_state: "QUICK_START",
195
+ pipeline_stage: null,
196
+ inflight_chapter: null,
197
+ quickstart_phase: "style"
198
+ });
199
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
200
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
201
+ const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
202
+ assert.equal(next.step, "quickstart:style");
203
+ assert.equal(next.reason, "quickstart:style");
204
+ });
205
+ test("computeNextStep allows redoing trial phase when trial chapter is missing", async () => {
206
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-trial-"));
207
+ await writeJson(join(rootDir, ".checkpoint.json"), {
208
+ last_completed_chapter: 0,
209
+ current_volume: 1,
210
+ orchestrator_state: "QUICK_START",
211
+ pipeline_stage: null,
212
+ inflight_chapter: null,
213
+ quickstart_phase: "trial"
214
+ });
215
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
216
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
217
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
218
+ const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
219
+ assert.equal(next.step, "quickstart:trial");
220
+ assert.equal(next.reason, "quickstart:trial");
221
+ });
222
+ test("computeNextStep continues forward when checkpoint quickstart_phase is consistent with staging artifacts", async () => {
223
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-happy-"));
224
+ await writeJson(join(rootDir, ".checkpoint.json"), {
225
+ last_completed_chapter: 0,
226
+ current_volume: 1,
227
+ orchestrator_state: "QUICK_START",
228
+ pipeline_stage: null,
229
+ inflight_chapter: null,
230
+ quickstart_phase: "style"
231
+ });
232
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
233
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
234
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
235
+ const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
236
+ assert.equal(next.step, "quickstart:trial");
237
+ assert.equal(next.reason, "quickstart:trial");
238
+ assert.equal(next.evidence.recovery_blocked ?? null, null);
239
+ });
240
+ test("buildInstructionPacket (quickstart) includes NOVEL_ASK gate when provided", async () => {
241
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-novel-ask-"));
242
+ const questionSpec = {
243
+ version: 1,
244
+ topic: "quickstart_gate",
245
+ questions: [
246
+ {
247
+ id: "genre",
248
+ header: "Genre",
249
+ question: "Pick a genre.",
250
+ kind: "single_choice",
251
+ required: true,
252
+ options: [{ label: "xuanhuan", description: "玄幻" }]
253
+ }
254
+ ]
255
+ };
256
+ const answerPath = "staging/novel-ask/quickstart.json";
257
+ const built = (await buildInstructionPacket({
258
+ rootDir,
259
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "INIT" },
260
+ step: { kind: "quickstart", phase: "world" },
261
+ embedMode: null,
262
+ writeManifest: false,
263
+ novelAskGate: { novel_ask: questionSpec, answer_path: answerPath }
264
+ }));
265
+ assert.equal(built.packet.step, "quickstart:world");
266
+ assert.equal(built.packet.answer_path, answerPath);
267
+ assert.equal(built.packet.novel_ask.topic, questionSpec.topic);
268
+ assert.equal(built.packet.expected_outputs[0].path, answerPath);
269
+ });
270
+ test("advance quickstart:results commits artifacts and transitions to VOL_PLANNING", async () => {
271
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-commit-"));
272
+ await writeJson(join(rootDir, ".checkpoint.json"), {
273
+ last_completed_chapter: 0,
274
+ current_volume: 1,
275
+ orchestrator_state: "QUICK_START",
276
+ pipeline_stage: null,
277
+ inflight_chapter: null,
278
+ volume_pipeline_stage: null
279
+ });
280
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), {
281
+ rules: [
282
+ {
283
+ id: "W-001",
284
+ category: "magic_system",
285
+ rule: "力量体系上限为九阶。",
286
+ constraint_type: "hard",
287
+ exceptions: [],
288
+ introduced_chapter: null,
289
+ last_verified: null
290
+ }
291
+ ]
292
+ });
293
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
294
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
295
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
296
+ await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
297
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } });
298
+ assert.equal(updated.orchestrator_state, "VOL_PLANNING");
299
+ assert.equal(updated.volume_pipeline_stage, null);
300
+ assert.equal(updated.quickstart_phase ?? null, null);
301
+ // Staging quickstart cleared
302
+ assert.equal(await pathExists(join(rootDir, "staging/quickstart")), false);
303
+ // Final artifacts exist
304
+ assert.equal(await pathExists(join(rootDir, "world/rules.json")), true);
305
+ assert.equal(await pathExists(join(rootDir, "style-profile.json")), true);
306
+ assert.equal(await pathExists(join(rootDir, "characters/active/hero.json")), true);
307
+ assert.equal(await pathExists(join(rootDir, "logs/quickstart/trial-chapter.md")), true);
308
+ assert.equal(await pathExists(join(rootDir, "logs/quickstart/evaluation.json")), true);
309
+ // Pipeline now in volume planning and should not re-enter quickstart.
310
+ const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
311
+ assert.equal(next.step, "volume:outline");
312
+ });
313
+ test("advance quickstart:results validates all contracts (not just a slice)", async () => {
314
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-contracts-all-"));
315
+ await writeJson(join(rootDir, ".checkpoint.json"), {
316
+ last_completed_chapter: 0,
317
+ current_volume: 1,
318
+ orchestrator_state: "QUICK_START",
319
+ pipeline_stage: null,
320
+ inflight_chapter: null,
321
+ volume_pipeline_stage: null
322
+ });
323
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), {
324
+ rules: [
325
+ {
326
+ id: "W-001",
327
+ category: "magic_system",
328
+ rule: "力量体系上限为九阶。",
329
+ constraint_type: "hard",
330
+ exceptions: [],
331
+ introduced_chapter: null,
332
+ last_verified: null
333
+ }
334
+ ]
335
+ });
336
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
337
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
338
+ await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
339
+ for (let i = 1; i <= 10; i++) {
340
+ const id = String(i).padStart(2, "0");
341
+ await writeJson(join(rootDir, `staging/quickstart/contracts/contract-${id}.json`), { id: `c-${id}`, display_name: `角色${id}`, contracts: [] });
342
+ }
343
+ // 11th contract is invalid: validate:results must still catch it.
344
+ await writeJson(join(rootDir, "staging/quickstart/contracts/contract-11.json"), []);
345
+ await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } }), /Invalid contract JSON/);
346
+ });
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, symlink, 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 { resolveProjectRelativePath } from "../safe-path.js";
7
+ test("resolveProjectRelativePath rejects symlink escapes outside project root", async (t) => {
8
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-safe-path-root-"));
9
+ const outsideDir = await mkdtemp(join(tmpdir(), "novel-safe-path-outside-"));
10
+ await writeFile(join(outsideDir, "payload.json"), "{}\n", "utf8");
11
+ await mkdir(join(rootDir, "staging"), { recursive: true });
12
+ try {
13
+ await symlink(outsideDir, join(rootDir, "staging/evil"), "dir");
14
+ }
15
+ catch (err) {
16
+ const code = err instanceof Error ? err.code : null;
17
+ if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP" || code === "ENOSYS") {
18
+ t.skip(`symlink not permitted in this environment: ${code}`);
19
+ return;
20
+ }
21
+ throw err;
22
+ }
23
+ assert.throws(() => resolveProjectRelativePath(rootDir, "staging/evil/payload.json", "testPath"), /Unsafe path outside project root/);
24
+ });
25
+ test("resolveProjectRelativePath rejects symlink escapes for non-existent write targets", async (t) => {
26
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-safe-path-root-write-"));
27
+ const outsideDir = await mkdtemp(join(tmpdir(), "novel-safe-path-outside-write-"));
28
+ await mkdir(join(rootDir, "staging"), { recursive: true });
29
+ try {
30
+ await symlink(outsideDir, join(rootDir, "staging/evil"), "dir");
31
+ }
32
+ catch (err) {
33
+ const code = err instanceof Error ? err.code : null;
34
+ if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP" || code === "ENOSYS") {
35
+ t.skip(`symlink not permitted in this environment: ${code}`);
36
+ return;
37
+ }
38
+ throw err;
39
+ }
40
+ assert.throws(() => resolveProjectRelativePath(rootDir, "staging/evil/subdir/newfile.json", "testPath"), /Unsafe path outside project root/);
41
+ });
@@ -0,0 +1,73 @@
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 { validateStep } from "../validate.js";
7
+ async function writeText(absPath, contents) {
8
+ await mkdir(dirname(absPath), { recursive: true });
9
+ await writeFile(absPath, contents, "utf8");
10
+ }
11
+ async function writeJson(absPath, payload) {
12
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
13
+ }
14
+ test("validateStep(quickstart:characters) requires rules.json", async () => {
15
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-characters-"));
16
+ await assert.rejects(() => validateStep({
17
+ rootDir,
18
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
19
+ step: { kind: "quickstart", phase: "characters" }
20
+ }), /Missing required file: staging\/quickstart\/rules\.json/);
21
+ });
22
+ test("validateStep(quickstart:style) requires contracts dir", async () => {
23
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-style-"));
24
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
25
+ await assert.rejects(() => validateStep({
26
+ rootDir,
27
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
28
+ step: { kind: "quickstart", phase: "style" }
29
+ }), /Missing required file: staging\/quickstart\/contracts/);
30
+ });
31
+ test("validateStep(quickstart:characters) rejects empty contracts dir", async () => {
32
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-empty-contracts-"));
33
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
34
+ await mkdir(join(rootDir, "staging/quickstart/contracts"), { recursive: true });
35
+ await assert.rejects(() => validateStep({
36
+ rootDir,
37
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
38
+ step: { kind: "quickstart", phase: "characters" }
39
+ }), /expected at least 1 \*\.json contract file/);
40
+ });
41
+ test("validateStep(quickstart:style) rejects invalid style source_type", async () => {
42
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-invalid-style-"));
43
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
44
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
45
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "banana" });
46
+ await assert.rejects(() => validateStep({
47
+ rootDir,
48
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
49
+ step: { kind: "quickstart", phase: "style" }
50
+ }), /Invalid staging\/quickstart\/style-profile\.json: source_type must be one of:/);
51
+ });
52
+ test("validateStep(quickstart:trial) requires style-profile.json", async () => {
53
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-trial-"));
54
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
55
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
56
+ await assert.rejects(() => validateStep({
57
+ rootDir,
58
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
59
+ step: { kind: "quickstart", phase: "trial" }
60
+ }), /Missing required file: staging\/quickstart\/style-profile\.json/);
61
+ });
62
+ test("validateStep(quickstart:trial) rejects empty trial chapter", async () => {
63
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-empty-trial-"));
64
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
65
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
66
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
67
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "");
68
+ await assert.rejects(() => validateStep({
69
+ rootDir,
70
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
71
+ step: { kind: "quickstart", phase: "trial" }
72
+ }), /Empty draft file: staging\/quickstart\/trial-chapter\.md/);
73
+ });
package/dist/advance.js CHANGED
@@ -1,8 +1,10 @@
1
- import { join } from "node:path";
1
+ import { copyFile, readdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
2
3
  import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
3
4
  import { NovelCliError } from "./errors.js";
4
- import { removePath } from "./fs-utils.js";
5
+ import { ensureDir, pathExists, removePath } from "./fs-utils.js";
5
6
  import { withWriteLock } from "./lock.js";
7
+ import { QUICKSTART_FINAL_RELS, QUICKSTART_STAGING_RELS } from "./quickstart.js";
6
8
  import { chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
7
9
  import { validateStep } from "./validate.js";
8
10
  import { VOL_REVIEW_RELS } from "./volume-review.js";
@@ -109,6 +111,88 @@ export async function advanceCheckpointForStep(args) {
109
111
  return updated;
110
112
  });
111
113
  }
114
+ if (step.kind === "quickstart") {
115
+ const qsStep = step;
116
+ const copyFileSafe = async (fromRel, toRel) => {
117
+ const fromAbs = join(args.rootDir, fromRel);
118
+ const toAbs = join(args.rootDir, toRel);
119
+ await ensureDir(dirname(toAbs));
120
+ await copyFile(fromAbs, toAbs);
121
+ };
122
+ const commitQuickStartArtifacts = async () => {
123
+ // Core artifacts
124
+ const requiredRelPaths = [
125
+ QUICKSTART_STAGING_RELS.rulesJson,
126
+ QUICKSTART_STAGING_RELS.styleProfileJson,
127
+ QUICKSTART_STAGING_RELS.trialChapterMd,
128
+ QUICKSTART_STAGING_RELS.evaluationJson
129
+ ];
130
+ for (const rel of requiredRelPaths) {
131
+ const abs = join(args.rootDir, rel);
132
+ if (!(await pathExists(abs)))
133
+ throw new NovelCliError(`Missing required file: ${rel}`, 2);
134
+ }
135
+ await copyFileSafe(QUICKSTART_STAGING_RELS.rulesJson, QUICKSTART_FINAL_RELS.worldRulesJson);
136
+ await copyFileSafe(QUICKSTART_STAGING_RELS.styleProfileJson, QUICKSTART_FINAL_RELS.styleProfileJson);
137
+ await copyFileSafe(QUICKSTART_STAGING_RELS.trialChapterMd, QUICKSTART_FINAL_RELS.trialChapterMd);
138
+ await copyFileSafe(QUICKSTART_STAGING_RELS.evaluationJson, QUICKSTART_FINAL_RELS.evaluationJson);
139
+ // Contracts dir → characters/active/*.json (overwrite by filename).
140
+ const contractsAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.contractsDir);
141
+ if (!(await pathExists(contractsAbs)))
142
+ throw new NovelCliError(`Missing required directory: ${QUICKSTART_STAGING_RELS.contractsDir}`, 2);
143
+ const entries = await readdir(contractsAbs, { withFileTypes: true });
144
+ const jsonFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".json")).map((e) => e.name).sort();
145
+ if (jsonFiles.length === 0) {
146
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.contractsDir}: expected at least 1 *.json contract file.`, 2);
147
+ }
148
+ const activeDirAbs = join(args.rootDir, QUICKSTART_FINAL_RELS.charactersActiveDir);
149
+ await ensureDir(activeDirAbs);
150
+ for (const name of jsonFiles) {
151
+ await copyFile(join(contractsAbs, name), join(activeDirAbs, name));
152
+ }
153
+ };
154
+ return await withWriteLock(args.rootDir, {}, async () => {
155
+ const checkpoint = await readCheckpoint(args.rootDir);
156
+ if (checkpoint.orchestrator_state !== "INIT" && checkpoint.orchestrator_state !== "QUICK_START") {
157
+ throw new NovelCliError(`Cannot advance ${formatStepId(qsStep)} unless orchestrator_state=INIT or QUICK_START.`, 2);
158
+ }
159
+ const stage = checkpoint.pipeline_stage ?? null;
160
+ const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
161
+ if (inflight !== null) {
162
+ throw new NovelCliError(`Checkpoint inconsistent for QUICK_START advance: inflight_chapter=${inflight} (expected null). Finish the chapter pipeline or repair .checkpoint.json.`, 2);
163
+ }
164
+ if (stage !== null && stage !== "committed") {
165
+ throw new NovelCliError(`Checkpoint inconsistent for QUICK_START advance: pipeline_stage=${stage} (expected null or committed). Finish the chapter pipeline or repair .checkpoint.json.`, 2);
166
+ }
167
+ // Enforce validate-before-advance to keep deterministic semantics.
168
+ await validateStep({ rootDir: args.rootDir, checkpoint, step: qsStep });
169
+ const updated = { ...checkpoint };
170
+ updated.inflight_chapter = null;
171
+ updated.pipeline_stage = null;
172
+ updated.quickstart_phase = qsStep.phase;
173
+ if (qsStep.phase === "results") {
174
+ await commitQuickStartArtifacts();
175
+ updated.orchestrator_state = "VOL_PLANNING";
176
+ updated.volume_pipeline_stage = null;
177
+ updated.quickstart_phase = null;
178
+ }
179
+ else {
180
+ updated.orchestrator_state = "QUICK_START";
181
+ }
182
+ updated.last_checkpoint_time = new Date().toISOString();
183
+ await writeCheckpoint(args.rootDir, updated);
184
+ if (qsStep.phase === "results") {
185
+ // Best-effort cleanup: keep artifacts committed even if staging removal fails.
186
+ try {
187
+ await removePath(join(args.rootDir, QUICKSTART_STAGING_RELS.dir));
188
+ }
189
+ catch {
190
+ // ignore
191
+ }
192
+ }
193
+ return updated;
194
+ });
195
+ }
112
196
  if (step.kind === "review") {
113
197
  const reviewStep = step;
114
198
  return await withWriteLock(args.rootDir, {}, async () => {
@@ -168,5 +252,6 @@ export async function advanceCheckpointForStep(args) {
168
252
  return updated;
169
253
  });
170
254
  }
171
- throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
255
+ // parseStepId ensures this is exhaustive.
256
+ throw new NovelCliError(`Unsupported step kind.`, 2);
172
257
  }
@@ -1,17 +1,17 @@
1
1
  import { join } from "node:path";
2
2
  import { NovelCliError } from "./errors.js";
3
3
  import { readJsonFile, writeJsonFile } from "./fs-utils.js";
4
- import { ORCHESTRATOR_STATES, VOLUME_PHASES } from "./steps.js";
4
+ import { ORCHESTRATOR_STATES, QUICKSTART_PHASES, VOLUME_PHASES } from "./steps.js";
5
5
  import { isPlainObject } from "./type-guards.js";
6
6
  export const PIPELINE_STAGES = ["drafting", "drafted", "refined", "judged", "revising", "committed"];
7
7
  export function createDefaultCheckpoint(nowIso) {
8
8
  return {
9
9
  last_completed_chapter: 0,
10
10
  current_volume: 1,
11
- // TODO(CS-O3): Default to INIT once the quickstart pipeline is implemented.
12
- orchestrator_state: "WRITING",
13
- pipeline_stage: "committed",
11
+ orchestrator_state: "INIT",
12
+ pipeline_stage: null,
14
13
  volume_pipeline_stage: null,
14
+ quickstart_phase: null,
15
15
  inflight_chapter: null,
16
16
  revision_count: 0,
17
17
  hook_fix_count: 0,
@@ -113,6 +113,25 @@ function parseCheckpoint(data) {
113
113
  else {
114
114
  throw new NovelCliError(`.checkpoint.json.volume_pipeline_stage must be a string (or null)`, 2);
115
115
  }
116
+ const quickstartPhaseRaw = data.quickstart_phase;
117
+ let quickstartPhase;
118
+ if (quickstartPhaseRaw === undefined) {
119
+ quickstartPhase = undefined;
120
+ }
121
+ else if (quickstartPhaseRaw === null) {
122
+ quickstartPhase = null;
123
+ }
124
+ else if (typeof quickstartPhaseRaw === "string") {
125
+ if (QUICKSTART_PHASES.includes(quickstartPhaseRaw)) {
126
+ quickstartPhase = quickstartPhaseRaw;
127
+ }
128
+ else {
129
+ throw new NovelCliError(`.checkpoint.json.quickstart_phase must be one of: ${QUICKSTART_PHASES.join(", ")} (or null)`, 2);
130
+ }
131
+ }
132
+ else {
133
+ throw new NovelCliError(`.checkpoint.json.quickstart_phase must be a string (or null)`, 2);
134
+ }
116
135
  const lastCommitted = data.last_committed_volume;
117
136
  if (lastCommitted !== undefined) {
118
137
  const lc = asInt(lastCommitted);
@@ -174,6 +193,8 @@ function parseCheckpoint(data) {
174
193
  checkpoint.pipeline_stage = pipelineStage;
175
194
  if (volumeStage !== undefined)
176
195
  checkpoint.volume_pipeline_stage = volumeStage;
196
+ if (quickstartPhase !== undefined)
197
+ checkpoint.quickstart_phase = quickstartPhase;
177
198
  if (inflight !== undefined)
178
199
  checkpoint.inflight_chapter = inflight;
179
200
  return checkpoint;