novel-writer-cli 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  3. package/dist/__tests__/character-voice.test.js +1 -1
  4. package/dist/__tests__/gate-decision.test.js +66 -0
  5. package/dist/__tests__/init.test.js +245 -0
  6. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  7. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  8. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  9. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  10. package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
  11. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  12. package/dist/__tests__/steps-id.test.js +23 -0
  13. package/dist/__tests__/volume-pipeline.test.js +227 -0
  14. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  15. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  16. package/dist/advance.js +145 -48
  17. package/dist/checkpoint.js +83 -12
  18. package/dist/cli.js +235 -8
  19. package/dist/commit.js +1 -0
  20. package/dist/fs-utils.js +18 -3
  21. package/dist/gate-decision.js +59 -0
  22. package/dist/init.js +165 -0
  23. package/dist/instructions.js +322 -24
  24. package/dist/next-step.js +198 -34
  25. package/dist/platform-profile.js +3 -0
  26. package/dist/steps.js +60 -17
  27. package/dist/validate.js +275 -2
  28. package/dist/volume-commit.js +101 -0
  29. package/dist/volume-planning.js +143 -0
  30. package/dist/volume-review.js +448 -0
  31. package/docs/user/novel-cli.md +57 -0
  32. package/package.json +3 -2
  33. package/schemas/platform-profile.schema.json +5 -0
package/README.md CHANGED
@@ -46,7 +46,15 @@ node dist/cli.js --help
46
46
 
47
47
  ## 最小工作流:跑通一章
48
48
 
49
- 在**小说项目根目录**(含 `.checkpoint.json`)运行:
49
+ 如果你是从零开始,在空目录先执行初始化(会创建 `.checkpoint.json` + `staging/**`,并写入若干可选模板文件):
50
+
51
+ ```bash
52
+ mkdir my-novel && cd my-novel
53
+ novel init # --platform qidian|tomato 可选
54
+ novel status
55
+ ```
56
+
57
+ 之后在**小说项目根目录**(含 `.checkpoint.json`)运行:
50
58
 
51
59
  ```bash
52
60
  # 1) 计算下一步
@@ -0,0 +1,37 @@
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 { join } from "node:path";
5
+ import test from "node:test";
6
+ import { advanceCheckpointForStep } from "../advance.js";
7
+ async function exists(absPath) {
8
+ try {
9
+ await stat(absPath);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ test("advanceCheckpointForStep(chapter:refine) invalidates eval and counts polish revisions after judge", async () => {
17
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-advance-refine-"));
18
+ await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({
19
+ last_completed_chapter: 0,
20
+ current_volume: 1,
21
+ orchestrator_state: "WRITING",
22
+ pipeline_stage: "judged",
23
+ inflight_chapter: 1,
24
+ revision_count: 0
25
+ }, null, 2)}\n`, "utf8");
26
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
27
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "draft text\n", "utf8");
28
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
29
+ await writeFile(join(rootDir, "staging/evaluations/chapter-001-eval.json"), `{"chapter":1,"overall":3.6,"recommendation":"polish"}\n`, "utf8");
30
+ assert.equal(await exists(join(rootDir, "staging/evaluations/chapter-001-eval.json")), true);
31
+ const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "chapter", chapter: 1, stage: "refine" } });
32
+ assert.equal(updated.pipeline_stage, "refined");
33
+ assert.equal(updated.inflight_chapter, 1);
34
+ assert.equal(updated.revision_count, 1);
35
+ assert.equal(updated.orchestrator_state, "CHAPTER_REWRITE");
36
+ assert.equal(await exists(join(rootDir, "staging/evaluations/chapter-001-eval.json")), false);
37
+ });
@@ -407,7 +407,7 @@ test("buildInstructionPacket injects character voice drift directives into draft
407
407
  ]
408
408
  });
409
409
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
410
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
410
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
411
411
  const draftOut = (await buildInstructionPacket({
412
412
  rootDir,
413
413
  checkpoint,
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { computeGateDecision, detectHighConfidenceViolation } from "../gate-decision.js";
4
+ test("computeGateDecision maps score bands to decisions (no violations)", () => {
5
+ assert.equal(computeGateDecision({ overall_final: 4.0, revision_count: 0, has_high_confidence_violation: false }), "pass");
6
+ assert.equal(computeGateDecision({ overall_final: 3.9, revision_count: 0, has_high_confidence_violation: false }), "polish");
7
+ assert.equal(computeGateDecision({ overall_final: 3.5, revision_count: 0, has_high_confidence_violation: false }), "polish");
8
+ assert.equal(computeGateDecision({ overall_final: 3.4, revision_count: 0, has_high_confidence_violation: false }), "revise");
9
+ assert.equal(computeGateDecision({ overall_final: 3.0, revision_count: 0, has_high_confidence_violation: false }), "revise");
10
+ assert.equal(computeGateDecision({ overall_final: 2.9, revision_count: 0, has_high_confidence_violation: false }), "pause_for_user");
11
+ assert.equal(computeGateDecision({ overall_final: 2.0, revision_count: 0, has_high_confidence_violation: false }), "pause_for_user");
12
+ assert.equal(computeGateDecision({ overall_final: 1.99, revision_count: 0, has_high_confidence_violation: false }), "pause_for_user_force_rewrite");
13
+ });
14
+ test("computeGateDecision forces revise on high-confidence violations", () => {
15
+ assert.equal(computeGateDecision({ overall_final: 4.8, revision_count: 0, has_high_confidence_violation: true }), "revise");
16
+ });
17
+ test("computeGateDecision pauses for user when high-confidence violations persist beyond max_revisions", () => {
18
+ assert.equal(computeGateDecision({ overall_final: 4.8, revision_count: 2, has_high_confidence_violation: true }), "pause_for_user");
19
+ });
20
+ test("computeGateDecision allows force_passed when revisions exhausted and score >= 3.0", () => {
21
+ assert.equal(computeGateDecision({ overall_final: 3.2, revision_count: 2, has_high_confidence_violation: false }), "force_passed");
22
+ });
23
+ test("computeGateDecision force-passes polish band when revisions exhausted", () => {
24
+ assert.equal(computeGateDecision({ overall_final: 3.6, revision_count: 2, has_high_confidence_violation: false }), "force_passed");
25
+ });
26
+ test("computeGateDecision respects max_revisions override", () => {
27
+ assert.equal(computeGateDecision({ overall_final: 3.6, revision_count: 1, has_high_confidence_violation: false, max_revisions: 1 }), "force_passed");
28
+ });
29
+ test("computeGateDecision supports manual force_pass override", () => {
30
+ assert.equal(computeGateDecision({ overall_final: 1.0, revision_count: 0, has_high_confidence_violation: true, force_pass: true }), "force_passed");
31
+ });
32
+ test("detectHighConfidenceViolation returns false when contract_verification is missing", () => {
33
+ assert.deepEqual(detectHighConfidenceViolation({ overall: 4.0, recommendation: "pass" }), {
34
+ has_high_confidence_violation: false,
35
+ high_confidence_violations: []
36
+ });
37
+ });
38
+ test("detectHighConfidenceViolation detects l1/l2/l3 high-confidence violations", () => {
39
+ const res = detectHighConfidenceViolation({
40
+ contract_verification: {
41
+ l1_checks: [{ status: "violation", confidence: "high", rule: "L1-001" }],
42
+ l2_checks: [],
43
+ l3_checks: []
44
+ }
45
+ });
46
+ assert.equal(res.has_high_confidence_violation, true);
47
+ assert.equal(res.high_confidence_violations.length, 1);
48
+ });
49
+ test("detectHighConfidenceViolation ignores ls_checks soft violations", () => {
50
+ const res = detectHighConfidenceViolation({
51
+ contract_verification: {
52
+ ls_checks: [{ status: "violation", confidence: "high", constraint_type: "soft" }]
53
+ }
54
+ });
55
+ assert.equal(res.has_high_confidence_violation, false);
56
+ });
57
+ test("detectHighConfidenceViolation marks inferred constraint_type for ls_checks when missing", () => {
58
+ const res = detectHighConfidenceViolation({
59
+ contract_verification: {
60
+ ls_checks: [{ status: "violation", confidence: "high" }]
61
+ }
62
+ });
63
+ assert.equal(res.has_high_confidence_violation, true);
64
+ assert.equal(res.high_confidence_violations.length, 1);
65
+ assert.equal(res.high_confidence_violations[0].constraint_type_inferred, true);
66
+ });
@@ -0,0 +1,245 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, stat, 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 { NovelCliError } from "../errors.js";
8
+ import { initProject, normalizePlatformId, resolveInitRootDir } from "../init.js";
9
+ import { computeNextStep } from "../next-step.js";
10
+ import { parsePlatformProfile } from "../platform-profile.js";
11
+ // ── Helpers ──────────────────────────────────────────────────────────────
12
+ async function assertDir(absPath) {
13
+ const s = await stat(absPath);
14
+ assert.ok(s.isDirectory(), `Expected directory: ${absPath}`);
15
+ }
16
+ async function assertFile(absPath) {
17
+ const s = await stat(absPath);
18
+ assert.ok(s.isFile(), `Expected file: ${absPath}`);
19
+ }
20
+ async function statExists(absPath) {
21
+ try {
22
+ await stat(absPath);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function readJson(absPath) {
30
+ return JSON.parse(await readFile(absPath, "utf8"));
31
+ }
32
+ // ── resolveInitRootDir ──────────────────────────────────────────────────
33
+ test("resolveInitRootDir returns cwd when no projectOverride", () => {
34
+ const result = resolveInitRootDir({ cwd: "/tmp/foo" });
35
+ assert.equal(result, "/tmp/foo");
36
+ });
37
+ test("resolveInitRootDir resolves relative projectOverride against cwd", () => {
38
+ const result = resolveInitRootDir({ cwd: "/tmp", projectOverride: "my-novel" });
39
+ assert.equal(result, "/tmp/my-novel");
40
+ });
41
+ test("resolveInitRootDir rejects path traversal", () => {
42
+ assert.throws(() => resolveInitRootDir({ cwd: "/tmp", projectOverride: "../../etc" }), (err) => err instanceof NovelCliError && /path traversal/i.test(err.message));
43
+ });
44
+ // ── normalizePlatformId ─────────────────────────────────────────────────
45
+ test("normalizePlatformId accepts valid values", () => {
46
+ assert.equal(normalizePlatformId("qidian"), "qidian");
47
+ assert.equal(normalizePlatformId("tomato"), "tomato");
48
+ });
49
+ test("normalizePlatformId rejects invalid values", () => {
50
+ assert.throws(() => normalizePlatformId("jjwxc"), (err) => err instanceof NovelCliError && /Invalid --platform.*jjwxc/i.test(err.message));
51
+ assert.throws(() => normalizePlatformId(42), (err) => err instanceof NovelCliError && /Invalid --platform/i.test(err.message));
52
+ });
53
+ // ── initProject: basic skeleton ─────────────────────────────────────────
54
+ test("initProject creates a runnable skeleton with all checkpoint fields", async () => {
55
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-basic-"));
56
+ try {
57
+ const result = await initProject({ rootDir });
58
+ assert.equal(result.rootDir, rootDir);
59
+ // Exact created set (non-minimal = checkpoint + 4 templates)
60
+ assert.deepEqual(result.created.sort(), [".checkpoint.json", "ai-blacklist.json", "brief.md", "style-profile.json", "web-novel-cliche-lint.json"].sort());
61
+ // All 9 staging dirs ensured
62
+ assert.equal(result.ensuredDirs.length, 9);
63
+ assert.ok(result.ensuredDirs.includes("staging/chapters"));
64
+ assert.ok(result.ensuredDirs.includes("staging/manifests"));
65
+ assert.ok(result.ensuredDirs.includes("staging/volumes"));
66
+ assert.ok(result.ensuredDirs.includes("staging/foreshadowing"));
67
+ // Verify ALL checkpoint fields
68
+ const checkpoint = await readCheckpoint(rootDir);
69
+ assert.equal(checkpoint.last_completed_chapter, 0);
70
+ assert.equal(checkpoint.current_volume, 1);
71
+ assert.equal(checkpoint.pipeline_stage, "committed");
72
+ assert.equal(checkpoint.volume_pipeline_stage, null);
73
+ assert.equal(checkpoint.inflight_chapter, null);
74
+ assert.equal(checkpoint.revision_count, 0);
75
+ assert.equal(checkpoint.hook_fix_count, 0);
76
+ assert.equal(checkpoint.title_fix_count, 0);
77
+ assert.ok(typeof checkpoint.last_checkpoint_time === "string" && checkpoint.last_checkpoint_time.length > 0);
78
+ // Integration: next step should be chapter:001:draft
79
+ const next = await computeNextStep(rootDir, checkpoint);
80
+ assert.equal(next.step, "chapter:001:draft");
81
+ // All staging dirs exist
82
+ for (const relDir of [
83
+ "staging/chapters",
84
+ "staging/summaries",
85
+ "staging/state",
86
+ "staging/evaluations",
87
+ "staging/logs",
88
+ "staging/storylines",
89
+ "staging/volumes",
90
+ "staging/foreshadowing",
91
+ "staging/manifests"
92
+ ]) {
93
+ await assertDir(join(rootDir, relDir));
94
+ }
95
+ // All template files exist
96
+ for (const relFile of ["brief.md", "style-profile.json", "ai-blacklist.json", "web-novel-cliche-lint.json"]) {
97
+ await assertFile(join(rootDir, relFile));
98
+ }
99
+ }
100
+ finally {
101
+ await rm(rootDir, { recursive: true, force: true });
102
+ }
103
+ });
104
+ // ── Skip / Force: .checkpoint.json ──────────────────────────────────────
105
+ test("initProject does not overwrite .checkpoint.json without --force", async () => {
106
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-no-force-"));
107
+ try {
108
+ await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 5, current_volume: 1, pipeline_stage: "committed", inflight_chapter: null }, null, 2)}\n`, "utf8");
109
+ const result = await initProject({ rootDir, minimal: true });
110
+ assert.ok(result.skipped.includes(".checkpoint.json"));
111
+ assert.equal(result.overwritten.length, 0);
112
+ const checkpoint = await readCheckpoint(rootDir);
113
+ assert.equal(checkpoint.last_completed_chapter, 5);
114
+ }
115
+ finally {
116
+ await rm(rootDir, { recursive: true, force: true });
117
+ }
118
+ });
119
+ test("initProject overwrites .checkpoint.json with --force", async () => {
120
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-force-"));
121
+ try {
122
+ await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 5, current_volume: 1, pipeline_stage: "committed", inflight_chapter: null }, null, 2)}\n`, "utf8");
123
+ const result = await initProject({ rootDir, minimal: true, force: true });
124
+ assert.ok(result.overwritten.includes(".checkpoint.json"));
125
+ const checkpoint = await readCheckpoint(rootDir);
126
+ assert.equal(checkpoint.last_completed_chapter, 0);
127
+ }
128
+ finally {
129
+ await rm(rootDir, { recursive: true, force: true });
130
+ }
131
+ });
132
+ // ── Skip / Force: template files ────────────────────────────────────────
133
+ test("initProject skips existing template files without --force", async () => {
134
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-skip-tpl-"));
135
+ try {
136
+ // Pre-create a template file
137
+ await writeFile(join(rootDir, "brief.md"), "custom brief", "utf8");
138
+ await writeFile(join(rootDir, "ai-blacklist.json"), "{}", "utf8");
139
+ const result = await initProject({ rootDir });
140
+ assert.ok(result.skipped.includes("brief.md"));
141
+ assert.ok(result.skipped.includes("ai-blacklist.json"));
142
+ assert.ok(result.created.includes("style-profile.json"));
143
+ assert.ok(result.created.includes("web-novel-cliche-lint.json"));
144
+ // Verify content was NOT overwritten
145
+ const content = await readFile(join(rootDir, "brief.md"), "utf8");
146
+ assert.equal(content, "custom brief");
147
+ }
148
+ finally {
149
+ await rm(rootDir, { recursive: true, force: true });
150
+ }
151
+ });
152
+ test("initProject overwrites template files with --force", async () => {
153
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-force-tpl-"));
154
+ try {
155
+ await writeFile(join(rootDir, "brief.md"), "custom brief", "utf8");
156
+ const result = await initProject({ rootDir, force: true });
157
+ assert.ok(result.overwritten.includes("brief.md"));
158
+ // Verify content was overwritten with template content
159
+ const content = await readFile(join(rootDir, "brief.md"), "utf8");
160
+ assert.notEqual(content, "custom brief");
161
+ assert.ok(content.length > 0);
162
+ }
163
+ finally {
164
+ await rm(rootDir, { recursive: true, force: true });
165
+ }
166
+ });
167
+ // ── Platform: tomato ────────────────────────────────────────────────────
168
+ test("initProject writes platform-profile.json + genre-weight-profiles.json for --platform tomato", async () => {
169
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-platform-tomato-"));
170
+ try {
171
+ const result = await initProject({ rootDir, minimal: true, platform: "tomato" });
172
+ assert.ok(result.created.includes("platform-profile.json"));
173
+ assert.ok(result.created.includes("genre-weight-profiles.json"));
174
+ const raw = await readJson(join(rootDir, "platform-profile.json"));
175
+ const profile = parsePlatformProfile(raw, "platform-profile.json");
176
+ assert.equal(profile.platform, "tomato");
177
+ assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
178
+ assert.ok(typeof profile.schema_version === "number");
179
+ // genre-weight-profiles.json should be a valid JSON object
180
+ const genreRaw = await readJson(join(rootDir, "genre-weight-profiles.json"));
181
+ assert.ok(typeof genreRaw === "object" && genreRaw !== null && !Array.isArray(genreRaw));
182
+ }
183
+ finally {
184
+ await rm(rootDir, { recursive: true, force: true });
185
+ }
186
+ });
187
+ // ── Platform: qidian ────────────────────────────────────────────────────
188
+ test("initProject writes platform-profile.json for --platform qidian", async () => {
189
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-platform-qidian-"));
190
+ try {
191
+ const result = await initProject({ rootDir, minimal: true, platform: "qidian" });
192
+ assert.ok(result.created.includes("platform-profile.json"));
193
+ assert.ok(result.created.includes("genre-weight-profiles.json"));
194
+ const raw = await readJson(join(rootDir, "platform-profile.json"));
195
+ const profile = parsePlatformProfile(raw, "platform-profile.json");
196
+ assert.equal(profile.platform, "qidian");
197
+ assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
198
+ }
199
+ finally {
200
+ await rm(rootDir, { recursive: true, force: true });
201
+ }
202
+ });
203
+ // ── Minimal mode ────────────────────────────────────────────────────────
204
+ test("initProject minimal mode skips templates", async () => {
205
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-minimal-"));
206
+ try {
207
+ const result = await initProject({ rootDir, minimal: true });
208
+ assert.ok(result.created.includes(".checkpoint.json"));
209
+ assert.equal(result.created.length, 1);
210
+ await assertFile(join(rootDir, ".checkpoint.json"));
211
+ await assertDir(join(rootDir, "staging/chapters"));
212
+ assert.equal(await statExists(join(rootDir, "brief.md")), false);
213
+ assert.equal(await statExists(join(rootDir, "style-profile.json")), false);
214
+ assert.equal(await statExists(join(rootDir, "ai-blacklist.json")), false);
215
+ assert.equal(await statExists(join(rootDir, "web-novel-cliche-lint.json")), false);
216
+ }
217
+ finally {
218
+ await rm(rootDir, { recursive: true, force: true });
219
+ }
220
+ });
221
+ // ── Non-existent --project directory ────────────────────────────────────
222
+ test("initProject can initialize a non-existent --project directory", async () => {
223
+ const parentDir = await mkdtemp(join(tmpdir(), "novel-init-project-"));
224
+ const rootDir = join(parentDir, "child-project");
225
+ try {
226
+ const result = await initProject({ rootDir, minimal: true });
227
+ assert.equal(result.rootDir, rootDir);
228
+ await assertFile(join(rootDir, ".checkpoint.json"));
229
+ }
230
+ finally {
231
+ await rm(parentDir, { recursive: true, force: true });
232
+ }
233
+ });
234
+ // ── Negative: rootDir is a file ─────────────────────────────────────────
235
+ test("initProject throws when rootDir is a file", async () => {
236
+ const parentDir = await mkdtemp(join(tmpdir(), "novel-init-file-"));
237
+ const filePath = join(parentDir, "not-a-dir");
238
+ await writeFile(filePath, "hello", "utf8");
239
+ try {
240
+ await assert.rejects(() => initProject({ rootDir: filePath, minimal: true }), (err) => err instanceof NovelCliError && /not a directory/i.test(err.message));
241
+ }
242
+ finally {
243
+ await rm(parentDir, { recursive: true, force: true });
244
+ }
245
+ });
@@ -74,7 +74,7 @@ test("buildInstructionPacket injects compact narrative health summaries into dra
74
74
  has_blocking_issues: false
75
75
  });
76
76
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
77
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
77
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
78
78
  const draftOut = (await buildInstructionPacket({
79
79
  rootDir,
80
80
  checkpoint,
@@ -126,7 +126,7 @@ test("buildInstructionPacket marks degraded when latest reports exist but are in
126
126
  has_blocking_issues: false
127
127
  });
128
128
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
129
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
129
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
130
130
  const out = (await buildInstructionPacket({
131
131
  rootDir,
132
132
  checkpoint,
@@ -143,7 +143,7 @@ test("buildInstructionPacket marks degraded when latest reports exist but are in
143
143
  test("buildInstructionPacket does not inject narrative health when logs are missing (no summary, no degraded)", async () => {
144
144
  const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-no-logs-"));
145
145
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
146
- const checkpoint = { last_completed_chapter: 0, current_volume: 1 };
146
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "WRITING" };
147
147
  const out = (await buildInstructionPacket({
148
148
  rootDir,
149
149
  checkpoint,
@@ -193,7 +193,7 @@ test("buildInstructionPacket does not inject narrative health summaries for stag
193
193
  has_blocking_issues: false
194
194
  });
195
195
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
196
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
196
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
197
197
  const summarizeOut = (await buildInstructionPacket({
198
198
  rootDir,
199
199
  checkpoint,
@@ -239,7 +239,7 @@ test("buildInstructionPacket marks degraded on schema_version mismatch when late
239
239
  has_blocking_issues: false
240
240
  });
241
241
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
242
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
242
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
243
243
  const out = (await buildInstructionPacket({
244
244
  rootDir,
245
245
  checkpoint,
@@ -279,7 +279,7 @@ test("buildInstructionPacket marks promise ledger degraded on schema_version mis
279
279
  issues: []
280
280
  });
281
281
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
282
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
282
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
283
283
  const out = (await buildInstructionPacket({
284
284
  rootDir,
285
285
  checkpoint,
@@ -298,7 +298,7 @@ test("buildInstructionPacket marks both degraded when both latest files are inva
298
298
  await writeText(join(rootDir, "logs/engagement/latest.json"), "not-json");
299
299
  await writeText(join(rootDir, "logs/promises/latest.json"), "not-json");
300
300
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n(占位)\n");
301
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
301
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
302
302
  const out = (await buildInstructionPacket({
303
303
  rootDir,
304
304
  checkpoint,
@@ -341,7 +341,7 @@ test("buildInstructionPacket treats oversized latest.json as degraded", async ()
341
341
  has_blocking_issues: false
342
342
  });
343
343
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n(占位)\n");
344
- const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
344
+ const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
345
345
  const out = (await buildInstructionPacket({
346
346
  rootDir,
347
347
  checkpoint,
@@ -0,0 +1,117 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, 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 { computeNextStep } from "../next-step.js";
7
+ async function writeJson(absPath, payload) {
8
+ await mkdir(dirname(absPath), { recursive: true });
9
+ await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
10
+ }
11
+ test("computeNextStep routes judged+eval to commit on gate pass", async () => {
12
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-pass-"));
13
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
14
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
15
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
16
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4.0, recommendation: "pass" });
17
+ const next = await computeNextStep(rootDir, {
18
+ last_completed_chapter: 0,
19
+ current_volume: 1,
20
+ orchestrator_state: "WRITING",
21
+ pipeline_stage: "judged",
22
+ inflight_chapter: 1,
23
+ revision_count: 0
24
+ });
25
+ assert.equal(next.step, "chapter:001:commit");
26
+ assert.equal(next.reason, "judged:gate:pass");
27
+ });
28
+ test("computeNextStep routes judged+eval to refine on gate polish", async () => {
29
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-polish-"));
30
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
31
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
32
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
33
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 3.6, recommendation: "polish" });
34
+ const next = await computeNextStep(rootDir, {
35
+ last_completed_chapter: 0,
36
+ current_volume: 1,
37
+ orchestrator_state: "WRITING",
38
+ pipeline_stage: "judged",
39
+ inflight_chapter: 1,
40
+ revision_count: 0
41
+ });
42
+ assert.equal(next.step, "chapter:001:refine");
43
+ assert.equal(next.reason, "judged:gate:polish");
44
+ });
45
+ test("computeNextStep routes judged+eval to draft on gate revise", async () => {
46
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-revise-"));
47
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
48
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
49
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
50
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 3.2, recommendation: "revise" });
51
+ const next = await computeNextStep(rootDir, {
52
+ last_completed_chapter: 0,
53
+ current_volume: 1,
54
+ orchestrator_state: "WRITING",
55
+ pipeline_stage: "judged",
56
+ inflight_chapter: 1,
57
+ revision_count: 0
58
+ });
59
+ assert.equal(next.step, "chapter:001:draft");
60
+ assert.equal(next.reason, "judged:gate:revise");
61
+ });
62
+ test("computeNextStep routes judged+eval to commit on force_passed when revisions exhausted", async () => {
63
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-force-passed-"));
64
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
65
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
66
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
67
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 3.2, recommendation: "revise" });
68
+ const next = await computeNextStep(rootDir, {
69
+ last_completed_chapter: 0,
70
+ current_volume: 1,
71
+ orchestrator_state: "WRITING",
72
+ pipeline_stage: "judged",
73
+ inflight_chapter: 1,
74
+ revision_count: 2
75
+ });
76
+ assert.equal(next.step, "chapter:001:commit");
77
+ assert.equal(next.reason, "judged:gate:force_passed");
78
+ });
79
+ test("computeNextStep routes judged+eval to manual review on pause bands", async () => {
80
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-pause-"));
81
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
82
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
83
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
84
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 2.4, recommendation: "pause" });
85
+ const next = await computeNextStep(rootDir, {
86
+ last_completed_chapter: 0,
87
+ current_volume: 1,
88
+ orchestrator_state: "WRITING",
89
+ pipeline_stage: "judged",
90
+ inflight_chapter: 1,
91
+ revision_count: 0
92
+ });
93
+ assert.equal(next.step, "chapter:001:review");
94
+ assert.equal(next.reason, "judged:gate:pause_for_user");
95
+ });
96
+ test("computeNextStep forces revise when eval has high-confidence violations", async () => {
97
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-violation-"));
98
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
99
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
100
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
101
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {
102
+ chapter: 1,
103
+ overall: 4.8,
104
+ recommendation: "pass",
105
+ contract_verification: { l1_checks: [{ status: "violation", confidence: "high" }] }
106
+ });
107
+ const next = await computeNextStep(rootDir, {
108
+ last_completed_chapter: 0,
109
+ current_volume: 1,
110
+ orchestrator_state: "WRITING",
111
+ pipeline_stage: "judged",
112
+ inflight_chapter: 1,
113
+ revision_count: 0
114
+ });
115
+ assert.equal(next.step, "chapter:001:draft");
116
+ assert.equal(next.reason, "judged:gate:revise");
117
+ });