novel-writer-cli 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +7 -2
- 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 +71 -12
- package/dist/cli.js +202 -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 +2 -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 +29 -0
- package/package.json +3 -2
- package/schemas/platform-profile.schema.json +5 -0
|
@@ -54,7 +54,14 @@ test("computeNextStep returns title-fix on hard title violations when auto_fix=t
|
|
|
54
54
|
await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
|
|
55
55
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
56
56
|
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
|
|
57
|
-
const checkpoint = {
|
|
57
|
+
const checkpoint = {
|
|
58
|
+
last_completed_chapter: 0,
|
|
59
|
+
current_volume: 1,
|
|
60
|
+
orchestrator_state: "WRITING",
|
|
61
|
+
pipeline_stage: "refined",
|
|
62
|
+
inflight_chapter: 1,
|
|
63
|
+
title_fix_count: 0
|
|
64
|
+
};
|
|
58
65
|
const next = await computeNextStep(rootDir, checkpoint);
|
|
59
66
|
assert.equal(next.step, "chapter:001:title-fix");
|
|
60
67
|
});
|
|
@@ -63,7 +70,14 @@ test("computeNextStep returns review after title-fix was already attempted", asy
|
|
|
63
70
|
await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
|
|
64
71
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
65
72
|
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
|
|
66
|
-
const checkpoint = {
|
|
73
|
+
const checkpoint = {
|
|
74
|
+
last_completed_chapter: 0,
|
|
75
|
+
current_volume: 1,
|
|
76
|
+
orchestrator_state: "WRITING",
|
|
77
|
+
pipeline_stage: "refined",
|
|
78
|
+
inflight_chapter: 1,
|
|
79
|
+
title_fix_count: 1
|
|
80
|
+
};
|
|
67
81
|
const next = await computeNextStep(rootDir, checkpoint);
|
|
68
82
|
assert.equal(next.step, "chapter:001:review");
|
|
69
83
|
});
|
|
@@ -72,7 +86,14 @@ test("computeNextStep returns review on hard title violations when auto_fix=fals
|
|
|
72
86
|
await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: false }));
|
|
73
87
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
74
88
|
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
|
|
75
|
-
const checkpoint = {
|
|
89
|
+
const checkpoint = {
|
|
90
|
+
last_completed_chapter: 0,
|
|
91
|
+
current_volume: 1,
|
|
92
|
+
orchestrator_state: "WRITING",
|
|
93
|
+
pipeline_stage: "refined",
|
|
94
|
+
inflight_chapter: 1,
|
|
95
|
+
title_fix_count: 0
|
|
96
|
+
};
|
|
76
97
|
const next = await computeNextStep(rootDir, checkpoint);
|
|
77
98
|
assert.equal(next.step, "chapter:001:review");
|
|
78
99
|
});
|
|
@@ -81,7 +102,14 @@ test("computeNextStep does not block on warn-only title issues when auto_fix=fal
|
|
|
81
102
|
await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: false, max_chars: 3 }));
|
|
82
103
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
83
104
|
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 太长的标题\n正文\n", "utf8");
|
|
84
|
-
const checkpoint = {
|
|
105
|
+
const checkpoint = {
|
|
106
|
+
last_completed_chapter: 0,
|
|
107
|
+
current_volume: 1,
|
|
108
|
+
orchestrator_state: "WRITING",
|
|
109
|
+
pipeline_stage: "refined",
|
|
110
|
+
inflight_chapter: 1,
|
|
111
|
+
title_fix_count: 0
|
|
112
|
+
};
|
|
85
113
|
const next = await computeNextStep(rootDir, checkpoint);
|
|
86
114
|
assert.equal(next.step, "chapter:001:judge");
|
|
87
115
|
});
|
|
@@ -90,7 +118,14 @@ test("computeNextStep returns title-fix on warn-only title issues when auto_fix=
|
|
|
90
118
|
await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true, max_chars: 3 }));
|
|
91
119
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
92
120
|
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 太长的标题\n正文\n", "utf8");
|
|
93
|
-
const checkpoint = {
|
|
121
|
+
const checkpoint = {
|
|
122
|
+
last_completed_chapter: 0,
|
|
123
|
+
current_volume: 1,
|
|
124
|
+
orchestrator_state: "WRITING",
|
|
125
|
+
pipeline_stage: "refined",
|
|
126
|
+
inflight_chapter: 1,
|
|
127
|
+
title_fix_count: 0
|
|
128
|
+
};
|
|
94
129
|
const next = await computeNextStep(rootDir, checkpoint);
|
|
95
130
|
assert.equal(next.step, "chapter:001:title-fix");
|
|
96
131
|
});
|
|
@@ -101,7 +136,14 @@ test("computeNextStep returns title-fix on judged stage when eval exists and tit
|
|
|
101
136
|
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
|
|
102
137
|
await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
|
|
103
138
|
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {});
|
|
104
|
-
const checkpoint = {
|
|
139
|
+
const checkpoint = {
|
|
140
|
+
last_completed_chapter: 0,
|
|
141
|
+
current_volume: 1,
|
|
142
|
+
orchestrator_state: "WRITING",
|
|
143
|
+
pipeline_stage: "judged",
|
|
144
|
+
inflight_chapter: 1,
|
|
145
|
+
title_fix_count: 0
|
|
146
|
+
};
|
|
105
147
|
const next = await computeNextStep(rootDir, checkpoint);
|
|
106
148
|
assert.equal(next.step, "chapter:001:title-fix");
|
|
107
149
|
});
|
|
@@ -111,7 +153,14 @@ test("title-fix snapshot is write-once (rerunning instructions does not bypass b
|
|
|
111
153
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
112
154
|
const chapterAbs = join(rootDir, "staging/chapters/chapter-001.md");
|
|
113
155
|
await writeFile(chapterAbs, "# 标题\n正文\n", "utf8");
|
|
114
|
-
const checkpoint = {
|
|
156
|
+
const checkpoint = {
|
|
157
|
+
last_completed_chapter: 0,
|
|
158
|
+
current_volume: 1,
|
|
159
|
+
orchestrator_state: "WRITING",
|
|
160
|
+
pipeline_stage: "refined",
|
|
161
|
+
inflight_chapter: 1,
|
|
162
|
+
title_fix_count: 0
|
|
163
|
+
};
|
|
115
164
|
await buildInstructionPacket({
|
|
116
165
|
rootDir,
|
|
117
166
|
checkpoint,
|
|
@@ -145,7 +194,14 @@ test("advance draft cleans up title-fix snapshot to avoid stale reuse", async ()
|
|
|
145
194
|
await mkdir(join(rootDir, "staging/logs"), { recursive: true });
|
|
146
195
|
const snapshotRel = titleFixSnapshotRel(1);
|
|
147
196
|
await writeFile(join(rootDir, snapshotRel), "old snapshot\n", "utf8");
|
|
148
|
-
const checkpoint = {
|
|
197
|
+
const checkpoint = {
|
|
198
|
+
last_completed_chapter: 0,
|
|
199
|
+
current_volume: 1,
|
|
200
|
+
orchestrator_state: "WRITING",
|
|
201
|
+
pipeline_stage: null,
|
|
202
|
+
inflight_chapter: null,
|
|
203
|
+
title_fix_count: 1
|
|
204
|
+
};
|
|
149
205
|
await writeJson(join(rootDir, ".checkpoint.json"), checkpoint);
|
|
150
206
|
await advanceCheckpointForStep({ rootDir, step: { kind: "chapter", chapter: 1, stage: "draft" } });
|
|
151
207
|
// validate cleanup is best-effort; the file should be gone.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { readCheckpoint } from "../checkpoint.js";
|
|
7
|
+
import { computeNextStep } from "../next-step.js";
|
|
8
|
+
async function writeJson(absPath, payload) {
|
|
9
|
+
await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
10
|
+
}
|
|
11
|
+
test("readCheckpoint injects orchestrator_state via legacy inference", async () => {
|
|
12
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-legacy-"));
|
|
13
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
14
|
+
last_completed_chapter: 0,
|
|
15
|
+
current_volume: 1,
|
|
16
|
+
pipeline_stage: null,
|
|
17
|
+
inflight_chapter: null
|
|
18
|
+
});
|
|
19
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
20
|
+
assert.equal(checkpoint.orchestrator_state, "WRITING");
|
|
21
|
+
});
|
|
22
|
+
test("readCheckpoint infers CHAPTER_REWRITE when pipeline_stage=revising", async () => {
|
|
23
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-revising-"));
|
|
24
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
25
|
+
last_completed_chapter: 0,
|
|
26
|
+
current_volume: 1,
|
|
27
|
+
pipeline_stage: "revising",
|
|
28
|
+
inflight_chapter: 7
|
|
29
|
+
});
|
|
30
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
31
|
+
assert.equal(checkpoint.orchestrator_state, "CHAPTER_REWRITE");
|
|
32
|
+
});
|
|
33
|
+
test("readCheckpoint infers ERROR_RETRY when pipeline_stage=revising but inflight_chapter is missing", async () => {
|
|
34
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-revising-missing-inflight-"));
|
|
35
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
36
|
+
last_completed_chapter: 0,
|
|
37
|
+
current_volume: 1,
|
|
38
|
+
pipeline_stage: "revising",
|
|
39
|
+
inflight_chapter: null
|
|
40
|
+
});
|
|
41
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
42
|
+
assert.equal(checkpoint.orchestrator_state, "ERROR_RETRY");
|
|
43
|
+
});
|
|
44
|
+
test("readCheckpoint infers ERROR_RETRY when inflight_chapter is set but pipeline_stage is idle", async () => {
|
|
45
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-idle-inflight-"));
|
|
46
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
47
|
+
last_completed_chapter: 0,
|
|
48
|
+
current_volume: 1,
|
|
49
|
+
pipeline_stage: null,
|
|
50
|
+
inflight_chapter: 7
|
|
51
|
+
});
|
|
52
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
53
|
+
assert.equal(checkpoint.orchestrator_state, "ERROR_RETRY");
|
|
54
|
+
});
|
|
55
|
+
test("readCheckpoint rejects inflight_chapter=0", async () => {
|
|
56
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-state-inflight-zero-"));
|
|
57
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
58
|
+
last_completed_chapter: 0,
|
|
59
|
+
current_volume: 1,
|
|
60
|
+
pipeline_stage: "drafting",
|
|
61
|
+
inflight_chapter: 0
|
|
62
|
+
});
|
|
63
|
+
await assert.rejects(() => readCheckpoint(rootDir), /inflight_chapter must be an int >= 1/);
|
|
64
|
+
});
|
|
65
|
+
test("computeNextStep throws for INIT placeholder", async () => {
|
|
66
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-init-"));
|
|
67
|
+
await assert.rejects(() => computeNextStep(rootDir, {
|
|
68
|
+
last_completed_chapter: 0,
|
|
69
|
+
current_volume: 1,
|
|
70
|
+
orchestrator_state: "INIT",
|
|
71
|
+
pipeline_stage: null,
|
|
72
|
+
inflight_chapter: null
|
|
73
|
+
}), /Not implemented: orchestrator_state=INIT/);
|
|
74
|
+
});
|
|
75
|
+
test("computeNextStep throws when pipeline_stage=committed but inflight_chapter is set", async () => {
|
|
76
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-committed-inflight-"));
|
|
77
|
+
await assert.rejects(() => computeNextStep(rootDir, {
|
|
78
|
+
last_completed_chapter: 0,
|
|
79
|
+
current_volume: 1,
|
|
80
|
+
orchestrator_state: "WRITING",
|
|
81
|
+
pipeline_stage: "committed",
|
|
82
|
+
inflight_chapter: 7
|
|
83
|
+
}), /Checkpoint inconsistent: pipeline_stage=committed but inflight_chapter=7/);
|
|
84
|
+
});
|
|
85
|
+
test("computeNextStep throws for QUICK_START placeholder", async () => {
|
|
86
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-quickstart-"));
|
|
87
|
+
await assert.rejects(() => computeNextStep(rootDir, {
|
|
88
|
+
last_completed_chapter: 0,
|
|
89
|
+
current_volume: 1,
|
|
90
|
+
orchestrator_state: "QUICK_START",
|
|
91
|
+
pipeline_stage: null,
|
|
92
|
+
inflight_chapter: null
|
|
93
|
+
}), /Not implemented: orchestrator_state=QUICK_START/);
|
|
94
|
+
});
|
|
95
|
+
test("computeNextStep prefixes reason for ERROR_RETRY", async () => {
|
|
96
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-error-retry-"));
|
|
97
|
+
const next = await computeNextStep(rootDir, {
|
|
98
|
+
last_completed_chapter: 0,
|
|
99
|
+
current_volume: 1,
|
|
100
|
+
orchestrator_state: "ERROR_RETRY",
|
|
101
|
+
pipeline_stage: null,
|
|
102
|
+
inflight_chapter: null
|
|
103
|
+
});
|
|
104
|
+
assert.equal(next.step, "chapter:001:draft");
|
|
105
|
+
assert.equal(next.reason, "error_retry:fresh");
|
|
106
|
+
});
|
|
107
|
+
test("computeNextStep heals ERROR_RETRY when pipeline_stage is committed but inflight_chapter is set", async () => {
|
|
108
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-error-retry-heal-committed-"));
|
|
109
|
+
const next = await computeNextStep(rootDir, {
|
|
110
|
+
last_completed_chapter: 0,
|
|
111
|
+
current_volume: 1,
|
|
112
|
+
orchestrator_state: "ERROR_RETRY",
|
|
113
|
+
pipeline_stage: "committed",
|
|
114
|
+
inflight_chapter: 7
|
|
115
|
+
});
|
|
116
|
+
assert.equal(next.step, "chapter:001:draft");
|
|
117
|
+
assert.equal(next.reason, "error_retry:healed_drop_inflight:fresh");
|
|
118
|
+
});
|
|
119
|
+
test("computeNextStep heals ERROR_RETRY when pipeline_stage is in-flight but inflight_chapter is missing", async () => {
|
|
120
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-error-retry-heal-drafting-"));
|
|
121
|
+
const next = await computeNextStep(rootDir, {
|
|
122
|
+
last_completed_chapter: 6,
|
|
123
|
+
current_volume: 1,
|
|
124
|
+
orchestrator_state: "ERROR_RETRY",
|
|
125
|
+
pipeline_stage: "drafting",
|
|
126
|
+
inflight_chapter: null
|
|
127
|
+
});
|
|
128
|
+
assert.equal(next.step, "chapter:007:draft");
|
|
129
|
+
assert.equal(next.reason, "error_retry:healed_infer_inflight:drafting:missing_chapter");
|
|
130
|
+
});
|
|
131
|
+
test("computeNextStep delegates CHAPTER_REWRITE to chapter routing", async () => {
|
|
132
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-chapter-rewrite-"));
|
|
133
|
+
const next = await computeNextStep(rootDir, {
|
|
134
|
+
last_completed_chapter: 6,
|
|
135
|
+
current_volume: 1,
|
|
136
|
+
orchestrator_state: "CHAPTER_REWRITE",
|
|
137
|
+
pipeline_stage: "revising",
|
|
138
|
+
inflight_chapter: 7
|
|
139
|
+
});
|
|
140
|
+
assert.equal(next.step, "chapter:007:draft");
|
|
141
|
+
assert.equal(next.reason, "revising:restart_draft");
|
|
142
|
+
assert.deepEqual(next.inflight, { chapter: 7, pipeline_stage: "revising" });
|
|
143
|
+
});
|
|
144
|
+
test("computeNextStep routes VOL_PLANNING to volume pipeline", async () => {
|
|
145
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-vol-planning-"));
|
|
146
|
+
const next = await computeNextStep(rootDir, {
|
|
147
|
+
last_completed_chapter: 0,
|
|
148
|
+
current_volume: 1,
|
|
149
|
+
orchestrator_state: "VOL_PLANNING",
|
|
150
|
+
pipeline_stage: null,
|
|
151
|
+
inflight_chapter: null,
|
|
152
|
+
volume_pipeline_stage: null
|
|
153
|
+
});
|
|
154
|
+
assert.equal(next.step, "volume:outline");
|
|
155
|
+
assert.equal(next.reason, "vol_planning:outline");
|
|
156
|
+
});
|
|
157
|
+
test("computeNextStep routes VOL_REVIEW to review pipeline", async () => {
|
|
158
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-vol-review-"));
|
|
159
|
+
const next = await computeNextStep(rootDir, {
|
|
160
|
+
last_completed_chapter: 0,
|
|
161
|
+
current_volume: 1,
|
|
162
|
+
orchestrator_state: "VOL_REVIEW",
|
|
163
|
+
pipeline_stage: null,
|
|
164
|
+
inflight_chapter: null
|
|
165
|
+
});
|
|
166
|
+
assert.equal(next.step, "review:collect");
|
|
167
|
+
assert.equal(next.reason, "vol_review:missing_quality_summary");
|
|
168
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { advanceCheckpointForStep } from "../advance.js";
|
|
7
|
+
import { readCheckpoint } from "../checkpoint.js";
|
|
8
|
+
import { commitChapter } from "../commit.js";
|
|
9
|
+
async function writeText(absPath, contents) {
|
|
10
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
11
|
+
await writeFile(absPath, contents, "utf8");
|
|
12
|
+
}
|
|
13
|
+
async function writeJson(absPath, payload) {
|
|
14
|
+
await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
15
|
+
}
|
|
16
|
+
test("advanceCheckpointForStep normalizes orchestrator_state to WRITING for chapter pipeline", async () => {
|
|
17
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-advance-state-"));
|
|
18
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
19
|
+
last_completed_chapter: 0,
|
|
20
|
+
current_volume: 1,
|
|
21
|
+
orchestrator_state: "INIT",
|
|
22
|
+
pipeline_stage: null,
|
|
23
|
+
inflight_chapter: null
|
|
24
|
+
});
|
|
25
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
|
|
26
|
+
const updated = await advanceCheckpointForStep({
|
|
27
|
+
rootDir,
|
|
28
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" }
|
|
29
|
+
});
|
|
30
|
+
assert.equal(updated.orchestrator_state, "WRITING");
|
|
31
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
32
|
+
assert.equal(checkpoint.orchestrator_state, "WRITING");
|
|
33
|
+
});
|
|
34
|
+
test("commitChapter resets orchestrator_state to WRITING", async () => {
|
|
35
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-commit-state-"));
|
|
36
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
37
|
+
last_completed_chapter: 0,
|
|
38
|
+
current_volume: 1,
|
|
39
|
+
orchestrator_state: "CHAPTER_REWRITE",
|
|
40
|
+
pipeline_stage: "judged",
|
|
41
|
+
inflight_chapter: 1
|
|
42
|
+
});
|
|
43
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
|
|
44
|
+
await writeText(join(rootDir, "staging/summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n- 测试事件\n`);
|
|
45
|
+
await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), { schema_version: 1, chapter: 1, entities: [] });
|
|
46
|
+
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1 });
|
|
47
|
+
await writeText(join(rootDir, "staging/storylines/main-arc/memory.md"), `- 测试记忆\n`);
|
|
48
|
+
await writeJson(join(rootDir, "staging/state/chapter-001-delta.json"), {
|
|
49
|
+
chapter: 1,
|
|
50
|
+
base_state_version: 0,
|
|
51
|
+
storyline_id: "main-arc",
|
|
52
|
+
ops: [{ op: "set", path: "characters.hero.display_name", value: "阿宁" }]
|
|
53
|
+
});
|
|
54
|
+
await commitChapter({ rootDir, chapter: 1, dryRun: false });
|
|
55
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
56
|
+
assert.equal(checkpoint.orchestrator_state, "WRITING");
|
|
57
|
+
assert.equal(checkpoint.pipeline_stage, "committed");
|
|
58
|
+
assert.equal(checkpoint.inflight_chapter, null);
|
|
59
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { formatStepId, parseStepId } from "../steps.js";
|
|
4
|
+
test("formatStepId formats chapter ids with pad3", () => {
|
|
5
|
+
assert.equal(formatStepId({ kind: "chapter", chapter: 7, stage: "draft" }), "chapter:007:draft");
|
|
6
|
+
});
|
|
7
|
+
test("formatStepId formats volume/quickstart/review ids", () => {
|
|
8
|
+
assert.equal(formatStepId({ kind: "volume", phase: "outline" }), "volume:outline");
|
|
9
|
+
assert.equal(formatStepId({ kind: "quickstart", phase: "world" }), "quickstart:world");
|
|
10
|
+
assert.equal(formatStepId({ kind: "review", phase: "report" }), "review:report");
|
|
11
|
+
});
|
|
12
|
+
test("parseStepId parses chapter ids and trims whitespace", () => {
|
|
13
|
+
assert.deepEqual(parseStepId(" chapter:7:refine "), { kind: "chapter", chapter: 7, stage: "refine" });
|
|
14
|
+
});
|
|
15
|
+
test("parseStepId parses volume/quickstart/review ids", () => {
|
|
16
|
+
assert.deepEqual(parseStepId("volume:validate"), { kind: "volume", phase: "validate" });
|
|
17
|
+
assert.deepEqual(parseStepId("quickstart:trial"), { kind: "quickstart", phase: "trial" });
|
|
18
|
+
assert.deepEqual(parseStepId("review:cleanup"), { kind: "review", phase: "cleanup" });
|
|
19
|
+
});
|
|
20
|
+
test("parseStepId rejects unknown kind and invalid phases", () => {
|
|
21
|
+
assert.throws(() => parseStepId("foo:bar"), /Supported kinds: chapter, volume, quickstart, review/);
|
|
22
|
+
assert.throws(() => parseStepId("volume:badphase"), /Phase must be one of:/);
|
|
23
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { advanceCheckpointForStep } from "../advance.js";
|
|
7
|
+
import { commitVolume } from "../volume-commit.js";
|
|
8
|
+
import { readCheckpoint, writeCheckpoint } from "../checkpoint.js";
|
|
9
|
+
import { computeNextStep } from "../next-step.js";
|
|
10
|
+
import { validateStep } from "../validate.js";
|
|
11
|
+
import { computeVolumeChapterRange, volumeStagingRelPaths } from "../volume-planning.js";
|
|
12
|
+
async function writeText(absPath, contents) {
|
|
13
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
14
|
+
await writeFile(absPath, contents, "utf8");
|
|
15
|
+
}
|
|
16
|
+
async function writeJson(absPath, payload) {
|
|
17
|
+
await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
18
|
+
}
|
|
19
|
+
async function exists(absPath) {
|
|
20
|
+
try {
|
|
21
|
+
await stat(absPath);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
test("computeVolumeChapterRange enforces deterministic bounds", () => {
|
|
29
|
+
assert.deepEqual(computeVolumeChapterRange({ current_volume: 1, last_completed_chapter: 0 }), { start: 1, end: 30 });
|
|
30
|
+
assert.deepEqual(computeVolumeChapterRange({ current_volume: 2, last_completed_chapter: 58 }), { start: 59, end: 60 });
|
|
31
|
+
assert.throws(() => computeVolumeChapterRange({ current_volume: 1, last_completed_chapter: 30 }), /plan_start=31 > plan_end=30/);
|
|
32
|
+
});
|
|
33
|
+
test("volume planning pipeline routes outline -> validate -> commit -> writing", async () => {
|
|
34
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-pipeline-"));
|
|
35
|
+
try {
|
|
36
|
+
const initial = {
|
|
37
|
+
last_completed_chapter: 58,
|
|
38
|
+
current_volume: 2,
|
|
39
|
+
orchestrator_state: "VOL_PLANNING",
|
|
40
|
+
pipeline_stage: null,
|
|
41
|
+
inflight_chapter: null,
|
|
42
|
+
volume_pipeline_stage: null
|
|
43
|
+
};
|
|
44
|
+
await writeJson(join(rootDir, ".checkpoint.json"), initial);
|
|
45
|
+
const volume = 2;
|
|
46
|
+
const range = computeVolumeChapterRange({ current_volume: volume, last_completed_chapter: initial.last_completed_chapter });
|
|
47
|
+
const rels = volumeStagingRelPaths(volume);
|
|
48
|
+
// Minimal planning artifacts for a 2-chapter range (59-60)
|
|
49
|
+
await writeText(join(rootDir, rels.outlineMd), [
|
|
50
|
+
`## 第 ${volume} 卷大纲`,
|
|
51
|
+
``,
|
|
52
|
+
`### 第${range.start}章: 测试`,
|
|
53
|
+
`- **Storyline**: main-arc`,
|
|
54
|
+
`- **POV**: hero`,
|
|
55
|
+
`- **Location**: city`,
|
|
56
|
+
`- **Conflict**: test`,
|
|
57
|
+
`- **Arc**: test`,
|
|
58
|
+
`- **Foreshadowing**: test`,
|
|
59
|
+
`- **StateChanges**: test`,
|
|
60
|
+
`- **TransitionHint**: {}`,
|
|
61
|
+
``,
|
|
62
|
+
`### 第 ${range.end}章: 测试`,
|
|
63
|
+
`- **Storyline**: main-arc`,
|
|
64
|
+
`- **POV**: hero`,
|
|
65
|
+
`- **Location**: city`,
|
|
66
|
+
`- **Conflict**: test`,
|
|
67
|
+
`- **Arc**: test`,
|
|
68
|
+
`- **Foreshadowing**: test`,
|
|
69
|
+
`- **StateChanges**: test`,
|
|
70
|
+
`- **TransitionHint**: {}`,
|
|
71
|
+
``
|
|
72
|
+
].join("\n"));
|
|
73
|
+
await writeJson(join(rootDir, rels.storylineScheduleJson), { active_storylines: ["main-arc"] });
|
|
74
|
+
await writeJson(join(rootDir, rels.foreshadowingJson), { schema_version: 1, items: [] });
|
|
75
|
+
await writeJson(join(rootDir, rels.newCharactersJson), []);
|
|
76
|
+
const contractBase = {
|
|
77
|
+
storyline_id: "main-arc",
|
|
78
|
+
objectives: [{ id: "OBJ", required: true, description: "x" }],
|
|
79
|
+
preconditions: { character_states: { Alice: { location: "city" } } },
|
|
80
|
+
postconditions: { state_changes: {} }
|
|
81
|
+
};
|
|
82
|
+
await writeJson(join(rootDir, rels.chapterContractJson(range.start)), { chapter: range.start, ...contractBase });
|
|
83
|
+
await writeJson(join(rootDir, rels.chapterContractJson(range.end)), { chapter: range.end, ...contractBase });
|
|
84
|
+
// Next step: outline
|
|
85
|
+
let checkpoint = await readCheckpoint(rootDir);
|
|
86
|
+
let next = await computeNextStep(rootDir, checkpoint);
|
|
87
|
+
assert.equal(next.step, "volume:outline");
|
|
88
|
+
// Validate outputs for outline, then advance outline -> validate stage.
|
|
89
|
+
await validateStep({ rootDir, checkpoint, step: { kind: "volume", phase: "outline" } });
|
|
90
|
+
checkpoint = await advanceCheckpointForStep({ rootDir, step: { kind: "volume", phase: "outline" } });
|
|
91
|
+
next = await computeNextStep(rootDir, checkpoint);
|
|
92
|
+
assert.equal(next.step, "volume:validate");
|
|
93
|
+
// Advance validate -> commit stage.
|
|
94
|
+
await validateStep({ rootDir, checkpoint, step: { kind: "volume", phase: "validate" } });
|
|
95
|
+
checkpoint = await advanceCheckpointForStep({ rootDir, step: { kind: "volume", phase: "validate" } });
|
|
96
|
+
next = await computeNextStep(rootDir, checkpoint);
|
|
97
|
+
assert.equal(next.step, "volume:commit");
|
|
98
|
+
// Commit volume plan.
|
|
99
|
+
const result = await commitVolume({ rootDir, volume, dryRun: false });
|
|
100
|
+
assert.ok(result.plan.length > 0);
|
|
101
|
+
const after = await readCheckpoint(rootDir);
|
|
102
|
+
assert.equal(after.orchestrator_state, "WRITING");
|
|
103
|
+
assert.equal(after.volume_pipeline_stage, null);
|
|
104
|
+
assert.equal(after.pipeline_stage, "committed");
|
|
105
|
+
assert.equal(after.inflight_chapter, null);
|
|
106
|
+
assert.ok(await exists(join(rootDir, `volumes/vol-02/outline.md`)));
|
|
107
|
+
assert.equal(await exists(join(rootDir, rels.dir)), false);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
test("commitVolume normalizes checkpoint when final dir exists but staging is missing", async () => {
|
|
114
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-recover-"));
|
|
115
|
+
try {
|
|
116
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
117
|
+
last_completed_chapter: 0,
|
|
118
|
+
current_volume: 1,
|
|
119
|
+
orchestrator_state: "VOL_PLANNING",
|
|
120
|
+
pipeline_stage: null,
|
|
121
|
+
inflight_chapter: null,
|
|
122
|
+
volume_pipeline_stage: "commit"
|
|
123
|
+
});
|
|
124
|
+
await writeText(join(rootDir, "volumes/vol-01/outline.md"), "# vol 1\n");
|
|
125
|
+
const result = await commitVolume({ rootDir, volume: 1, dryRun: false });
|
|
126
|
+
assert.ok(result.warnings.some((w) => /already exists/i.test(w)));
|
|
127
|
+
const after = await readCheckpoint(rootDir);
|
|
128
|
+
assert.equal(after.orchestrator_state, "WRITING");
|
|
129
|
+
assert.equal(after.volume_pipeline_stage, null);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
test("advanceCheckpointForStep rejects volume advance outside VOL_PLANNING", async () => {
|
|
136
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-advance-guard-"));
|
|
137
|
+
try {
|
|
138
|
+
await writeCheckpoint(rootDir, {
|
|
139
|
+
last_completed_chapter: 0,
|
|
140
|
+
current_volume: 1,
|
|
141
|
+
orchestrator_state: "WRITING",
|
|
142
|
+
pipeline_stage: "committed",
|
|
143
|
+
volume_pipeline_stage: null,
|
|
144
|
+
inflight_chapter: null
|
|
145
|
+
});
|
|
146
|
+
await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "volume", phase: "outline" } }), /orchestrator_state=VOL_PLANNING/);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
test("computeNextStep falls back to volume:outline when validate stage is selected but artifacts are missing", async () => {
|
|
153
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-next-missing-"));
|
|
154
|
+
try {
|
|
155
|
+
const next = await computeNextStep(rootDir, {
|
|
156
|
+
last_completed_chapter: 58,
|
|
157
|
+
current_volume: 2,
|
|
158
|
+
orchestrator_state: "VOL_PLANNING",
|
|
159
|
+
pipeline_stage: null,
|
|
160
|
+
inflight_chapter: null,
|
|
161
|
+
volume_pipeline_stage: "validate"
|
|
162
|
+
});
|
|
163
|
+
assert.equal(next.step, "volume:outline");
|
|
164
|
+
assert.equal(next.reason, "vol_planning:validate:missing_artifacts");
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
test("commitVolume dryRun does not touch checkpoint", async () => {
|
|
171
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-dryrun-"));
|
|
172
|
+
try {
|
|
173
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
174
|
+
last_completed_chapter: 0,
|
|
175
|
+
current_volume: 1,
|
|
176
|
+
orchestrator_state: "VOL_PLANNING",
|
|
177
|
+
pipeline_stage: null,
|
|
178
|
+
inflight_chapter: null,
|
|
179
|
+
volume_pipeline_stage: "commit"
|
|
180
|
+
});
|
|
181
|
+
const before = await readFile(join(rootDir, ".checkpoint.json"), "utf8");
|
|
182
|
+
const result = await commitVolume({ rootDir, volume: 1, dryRun: true });
|
|
183
|
+
assert.ok(result.plan.some((l) => l.includes("MOVE")));
|
|
184
|
+
const after = await readFile(join(rootDir, ".checkpoint.json"), "utf8");
|
|
185
|
+
assert.equal(after, before);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
test("commitVolume rejects when both staging and final volume dirs exist", async () => {
|
|
192
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-conflict-"));
|
|
193
|
+
try {
|
|
194
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
195
|
+
last_completed_chapter: 0,
|
|
196
|
+
current_volume: 1,
|
|
197
|
+
orchestrator_state: "VOL_PLANNING",
|
|
198
|
+
pipeline_stage: null,
|
|
199
|
+
inflight_chapter: null,
|
|
200
|
+
volume_pipeline_stage: "commit"
|
|
201
|
+
});
|
|
202
|
+
await mkdir(join(rootDir, "staging/volumes/vol-01"), { recursive: true });
|
|
203
|
+
await mkdir(join(rootDir, "volumes/vol-01"), { recursive: true });
|
|
204
|
+
await assert.rejects(() => commitVolume({ rootDir, volume: 1, dryRun: false }), /Commit conflict/);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
test("commitVolume recovery refuses when final dir exists but outline.md is missing", async () => {
|
|
211
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-volume-commit-recover-missing-outline-"));
|
|
212
|
+
try {
|
|
213
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
214
|
+
last_completed_chapter: 0,
|
|
215
|
+
current_volume: 1,
|
|
216
|
+
orchestrator_state: "VOL_PLANNING",
|
|
217
|
+
pipeline_stage: null,
|
|
218
|
+
inflight_chapter: null,
|
|
219
|
+
volume_pipeline_stage: "commit"
|
|
220
|
+
});
|
|
221
|
+
await mkdir(join(rootDir, "volumes/vol-01"), { recursive: true });
|
|
222
|
+
await assert.rejects(() => commitVolume({ rootDir, volume: 1, dryRun: false }), /missing .*outline\.md/i);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|