novel-writer-cli 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
- package/dist/__tests__/character-voice.test.js +1 -1
- package/dist/__tests__/gate-decision.test.js +66 -0
- package/dist/__tests__/init.test.js +245 -0
- package/dist/__tests__/narrative-health-injection.test.js +8 -8
- package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
- package/dist/__tests__/next-step-title-fix.test.js +64 -8
- package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
- package/dist/__tests__/steps-id.test.js +23 -0
- package/dist/__tests__/volume-pipeline.test.js +227 -0
- package/dist/__tests__/volume-review-pipeline.test.js +112 -0
- package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
- package/dist/advance.js +145 -48
- package/dist/checkpoint.js +83 -12
- package/dist/cli.js +235 -8
- package/dist/commit.js +1 -0
- package/dist/fs-utils.js +18 -3
- package/dist/gate-decision.js +59 -0
- package/dist/init.js +165 -0
- package/dist/instructions.js +322 -24
- package/dist/next-step.js +198 -34
- package/dist/platform-profile.js +3 -0
- package/dist/steps.js +60 -17
- package/dist/validate.js +275 -2
- package/dist/volume-commit.js +101 -0
- package/dist/volume-planning.js +143 -0
- package/dist/volume-review.js +448 -0
- package/docs/user/novel-cli.md +57 -0
- package/package.json +3 -2
- package/schemas/platform-profile.schema.json +5 -0
package/README.md
CHANGED
|
@@ -46,7 +46,15 @@ node dist/cli.js --help
|
|
|
46
46
|
|
|
47
47
|
## 最小工作流:跑通一章
|
|
48
48
|
|
|
49
|
-
|
|
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
|
+
});
|