novel-writer-cli 0.3.0 → 0.5.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 (75) hide show
  1. package/README.md +1 -1
  2. package/agents/chapter-writer.md +43 -14
  3. package/agents/character-weaver.md +7 -1
  4. package/agents/plot-architect.md +20 -7
  5. package/agents/quality-judge.md +199 -20
  6. package/agents/style-analyzer.md +14 -8
  7. package/agents/style-refiner.md +10 -3
  8. package/agents/world-builder.md +8 -1
  9. package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +194 -6
  10. package/dist/__tests__/agent-prompts-platform-expansion.test.js +33 -0
  11. package/dist/__tests__/anti-ai-infrastructure.test.js +548 -0
  12. package/dist/__tests__/anti-ai-templates.test.js +2 -2
  13. package/dist/__tests__/canon-status-lifecycle.test.js +481 -0
  14. package/dist/__tests__/commit-gate-decision.test.js +65 -0
  15. package/dist/__tests__/commit-prototype-pollution.test.js +1 -1
  16. package/dist/__tests__/excitement-type-annotation.test.js +240 -0
  17. package/dist/__tests__/excitement-type.test.js +21 -0
  18. package/dist/__tests__/gate-decision.test.js +62 -15
  19. package/dist/__tests__/genre-excitement-mapping.test.js +355 -0
  20. package/dist/__tests__/golden-chapter-gates.test.js +79 -0
  21. package/dist/__tests__/golden-chapter-mini-planning.test.js +485 -0
  22. package/dist/__tests__/helpers/quickstart-mini-planning.js +61 -0
  23. package/dist/__tests__/init.test.js +57 -5
  24. package/dist/__tests__/instructions-platform-expansion.test.js +125 -0
  25. package/dist/__tests__/next-step-gate-decision-routing.test.js +98 -0
  26. package/dist/__tests__/orchestrator-state-write-path.test.js +1 -1
  27. package/dist/__tests__/platform-profile.test.js +57 -1
  28. package/dist/__tests__/quickstart-pipeline.test.js +73 -6
  29. package/dist/__tests__/scoring-weights.test.js +193 -0
  30. package/dist/__tests__/steps-id.test.js +2 -0
  31. package/dist/__tests__/validate-quickstart-prereqs.test.js +2 -0
  32. package/dist/advance.js +27 -2
  33. package/dist/anti-ai-context.js +535 -0
  34. package/dist/cli.js +3 -1
  35. package/dist/commit.js +22 -0
  36. package/dist/excitement-type.js +12 -0
  37. package/dist/gate-decision.js +98 -2
  38. package/dist/golden-chapter-gates.js +143 -0
  39. package/dist/init.js +76 -7
  40. package/dist/instructions.js +552 -6
  41. package/dist/next-step.js +124 -88
  42. package/dist/platform-profile.js +20 -8
  43. package/dist/quickstart-mini-planning.js +30 -0
  44. package/dist/scoring-weights.js +38 -3
  45. package/dist/steps.js +1 -1
  46. package/dist/validate.js +293 -214
  47. package/dist/volume-commit.js +271 -5
  48. package/dist/volume-planning.js +78 -3
  49. package/docs/user/README.md +1 -0
  50. package/docs/user/migration-guide.md +166 -0
  51. package/docs/user/novel-cli.md +4 -3
  52. package/docs/user/quick-start.md +354 -57
  53. package/package.json +1 -1
  54. package/schemas/platform-profile.schema.json +2 -2
  55. package/scripts/lint-blacklist.sh +221 -76
  56. package/scripts/lint-structural.sh +538 -0
  57. package/skills/continue/SKILL.md +6 -0
  58. package/skills/continue/references/context-contracts.md +71 -6
  59. package/skills/continue/references/periodic-maintenance.md +12 -1
  60. package/skills/novel-writing/references/quality-rubric.md +79 -26
  61. package/skills/novel-writing/references/style-guide.md +129 -19
  62. package/skills/start/SKILL.md +23 -3
  63. package/skills/start/references/vol-planning.md +12 -3
  64. package/templates/ai-blacklist.json +1024 -246
  65. package/templates/ai-sentence-patterns.json +167 -0
  66. package/templates/genre-excitement-map.json +48 -0
  67. package/templates/genre-golden-standards.json +80 -0
  68. package/templates/genre-weight-profiles.json +15 -0
  69. package/templates/golden-chapter-gates.json +230 -0
  70. package/templates/novel-ask/example.question.json +3 -2
  71. package/templates/platform-profile.json +141 -1
  72. package/templates/platforms/fanqie.md +35 -0
  73. package/templates/platforms/jinjiang.md +35 -0
  74. package/templates/platforms/qidian.md +35 -0
  75. package/templates/style-profile-template.json +3 -0
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
@@ -45,6 +45,8 @@ test("resolveInitRootDir rejects path traversal", () => {
45
45
  test("normalizePlatformId accepts valid values", () => {
46
46
  assert.equal(normalizePlatformId("qidian"), "qidian");
47
47
  assert.equal(normalizePlatformId("tomato"), "tomato");
48
+ assert.equal(normalizePlatformId("fanqie"), "fanqie");
49
+ assert.equal(normalizePlatformId("jinjiang"), "jinjiang");
48
50
  });
49
51
  test("normalizePlatformId rejects invalid values", () => {
50
52
  assert.throws(() => normalizePlatformId("jjwxc"), (err) => err instanceof NovelCliError && /Invalid --platform.*jjwxc/i.test(err.message));
@@ -56,8 +58,8 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
56
58
  try {
57
59
  const result = await initProject({ rootDir });
58
60
  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
+ // Exact created set (non-minimal = checkpoint + 6 base templates)
62
+ assert.deepEqual(result.created.sort(), [".checkpoint.json", "ai-blacklist.json", "brief.md", "genre-excitement-map.json", "genre-golden-standards.json", "style-profile.json", "web-novel-cliche-lint.json"].sort());
61
63
  // All staging dirs ensured
62
64
  assert.equal(result.ensuredDirs.length, 10);
63
65
  assert.ok(result.ensuredDirs.includes("staging/chapters"));
@@ -96,9 +98,10 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
96
98
  await assertDir(join(rootDir, relDir));
97
99
  }
98
100
  // All template files exist
99
- for (const relFile of ["brief.md", "style-profile.json", "ai-blacklist.json", "web-novel-cliche-lint.json"]) {
101
+ for (const relFile of ["brief.md", "style-profile.json", "genre-excitement-map.json", "genre-golden-standards.json", "ai-blacklist.json", "web-novel-cliche-lint.json"]) {
100
102
  await assertFile(join(rootDir, relFile));
101
103
  }
104
+ assert.equal(await statExists(join(rootDir, "golden-chapter-gates.json")), false);
102
105
  }
103
106
  finally {
104
107
  await rm(rootDir, { recursive: true, force: true });
@@ -152,6 +155,16 @@ test("initProject skips existing template files without --force", async () => {
152
155
  await rm(rootDir, { recursive: true, force: true });
153
156
  }
154
157
  });
158
+ test("initProject rejects path collisions when a template target is a directory", async () => {
159
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-dir-collision-"));
160
+ try {
161
+ await mkdir(join(rootDir, "brief.md"));
162
+ await assert.rejects(() => initProject({ rootDir }), (err) => err instanceof NovelCliError && /brief\.md.*not a file/i.test(err.message));
163
+ }
164
+ finally {
165
+ await rm(rootDir, { recursive: true, force: true });
166
+ }
167
+ });
155
168
  test("initProject overwrites template files with --force", async () => {
156
169
  const rootDir = await mkdtemp(join(tmpdir(), "novel-init-force-tpl-"));
157
170
  try {
@@ -171,14 +184,21 @@ test("initProject overwrites template files with --force", async () => {
171
184
  test("initProject writes platform-profile.json + genre-weight-profiles.json for --platform tomato", async () => {
172
185
  const rootDir = await mkdtemp(join(tmpdir(), "novel-init-platform-tomato-"));
173
186
  try {
174
- const result = await initProject({ rootDir, minimal: true, platform: "tomato" });
187
+ const result = await initProject({ rootDir, platform: "tomato" });
175
188
  assert.ok(result.created.includes("platform-profile.json"));
176
189
  assert.ok(result.created.includes("genre-weight-profiles.json"));
190
+ assert.ok(result.created.includes("platform-writing-guide.md"));
191
+ assert.ok(result.created.includes("style-profile.json"));
192
+ assert.ok(result.created.includes("golden-chapter-gates.json"));
177
193
  const raw = await readJson(join(rootDir, "platform-profile.json"));
178
194
  const profile = parsePlatformProfile(raw, "platform-profile.json");
179
195
  assert.equal(profile.platform, "tomato");
180
196
  assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
181
197
  assert.ok(typeof profile.schema_version === "number");
198
+ const styleProfileRaw = await readJson(join(rootDir, "style-profile.json"));
199
+ assert.equal(styleProfileRaw.platform, "tomato");
200
+ const guide = await readFile(join(rootDir, "platform-writing-guide.md"), "utf8");
201
+ assert.match(guide, /番茄平台写作指南/);
182
202
  // genre-weight-profiles.json should be a valid JSON object
183
203
  const genreRaw = await readJson(join(rootDir, "genre-weight-profiles.json"));
184
204
  assert.ok(typeof genreRaw === "object" && genreRaw !== null && !Array.isArray(genreRaw));
@@ -194,15 +214,44 @@ test("initProject writes platform-profile.json for --platform qidian", async ()
194
214
  const result = await initProject({ rootDir, minimal: true, platform: "qidian" });
195
215
  assert.ok(result.created.includes("platform-profile.json"));
196
216
  assert.ok(result.created.includes("genre-weight-profiles.json"));
217
+ assert.ok(result.created.includes("golden-chapter-gates.json"));
197
218
  const raw = await readJson(join(rootDir, "platform-profile.json"));
198
219
  const profile = parsePlatformProfile(raw, "platform-profile.json");
199
220
  assert.equal(profile.platform, "qidian");
200
221
  assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
222
+ await assertFile(join(rootDir, "golden-chapter-gates.json"));
201
223
  }
202
224
  finally {
203
225
  await rm(rootDir, { recursive: true, force: true });
204
226
  }
205
227
  });
228
+ test("initProject writes fanqie and jinjiang platform artifacts with populated style profile", async () => {
229
+ const fanqieRoot = await mkdtemp(join(tmpdir(), "novel-init-platform-fanqie-"));
230
+ const jinjiangRoot = await mkdtemp(join(tmpdir(), "novel-init-platform-jinjiang-"));
231
+ try {
232
+ await initProject({ rootDir: fanqieRoot, platform: "fanqie" });
233
+ await initProject({ rootDir: jinjiangRoot, platform: "jinjiang" });
234
+ const fanqieStyle = await readJson(join(fanqieRoot, "style-profile.json"));
235
+ const jinjiangStyle = await readJson(join(jinjiangRoot, "style-profile.json"));
236
+ assert.equal(fanqieStyle.platform, "fanqie");
237
+ assert.equal(jinjiangStyle.platform, "jinjiang");
238
+ const fanqieProfile = parsePlatformProfile(await readJson(join(fanqieRoot, "platform-profile.json")), "platform-profile.json");
239
+ const jinjiangProfile = parsePlatformProfile(await readJson(join(jinjiangRoot, "platform-profile.json")), "platform-profile.json");
240
+ assert.equal(fanqieProfile.platform, "fanqie");
241
+ assert.equal(jinjiangProfile.platform, "jinjiang");
242
+ assert.equal(jinjiangProfile.word_count.target_min, 2000);
243
+ assert.equal(jinjiangProfile.word_count.target_max, 3000);
244
+ assert.equal(jinjiangProfile.scoring?.genre_drive_type, "character");
245
+ const fanqieGuide = await readFile(join(fanqieRoot, "platform-writing-guide.md"), "utf8");
246
+ const jinjiangGuide = await readFile(join(jinjiangRoot, "platform-writing-guide.md"), "utf8");
247
+ assert.match(fanqieGuide, /番茄平台写作指南/);
248
+ assert.match(jinjiangGuide, /晋江平台写作指南/);
249
+ }
250
+ finally {
251
+ await rm(fanqieRoot, { recursive: true, force: true });
252
+ await rm(jinjiangRoot, { recursive: true, force: true });
253
+ }
254
+ });
206
255
  // ── Minimal mode ────────────────────────────────────────────────────────
207
256
  test("initProject minimal mode skips templates", async () => {
208
257
  const rootDir = await mkdtemp(join(tmpdir(), "novel-init-minimal-"));
@@ -215,6 +264,9 @@ test("initProject minimal mode skips templates", async () => {
215
264
  assert.equal(await statExists(join(rootDir, "brief.md")), false);
216
265
  assert.equal(await statExists(join(rootDir, "style-profile.json")), false);
217
266
  assert.equal(await statExists(join(rootDir, "ai-blacklist.json")), false);
267
+ assert.equal(await statExists(join(rootDir, "genre-excitement-map.json")), false);
268
+ assert.equal(await statExists(join(rootDir, "genre-golden-standards.json")), false);
269
+ assert.equal(await statExists(join(rootDir, "golden-chapter-gates.json")), false);
218
270
  assert.equal(await statExists(join(rootDir, "web-novel-cliche-lint.json")), false);
219
271
  }
220
272
  finally {
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, 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 { fileURLToPath } from "node:url";
7
+ import { buildInstructionPacket } from "../instructions.js";
8
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
9
+ async function readRepoText(relPath) {
10
+ return readFile(join(repoRoot, relPath), "utf8");
11
+ }
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
+ function makeCheckpoint(stage) {
20
+ return {
21
+ last_completed_chapter: 0,
22
+ current_volume: 1,
23
+ orchestrator_state: "WRITING",
24
+ pipeline_stage: stage,
25
+ inflight_chapter: 1,
26
+ revision_count: 0,
27
+ hook_fix_count: 0,
28
+ title_fix_count: 0
29
+ };
30
+ }
31
+ test("buildInstructionPacket includes platform writing guide for chapter and quickstart writer packets", async () => {
32
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-platform-guide-packet-"));
33
+ await writeJson(join(rootDir, "platform-profile.json"), {
34
+ schema_version: 1,
35
+ platform: "fanqie",
36
+ created_at: "2026-03-01T00:00:00Z",
37
+ word_count: { target_min: 1500, target_max: 2500, hard_min: 1000, hard_max: 3500 },
38
+ hook_policy: { required: true, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
39
+ info_load: { max_new_entities_per_chapter: 5, max_unknown_entities_per_chapter: 3, max_new_terms_per_1k_words: 5 },
40
+ compliance: { banned_words: [], duplicate_name_policy: "soft" },
41
+ scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" }
42
+ });
43
+ await writeText(join(rootDir, "platform-writing-guide.md"), "# 平台指南\n");
44
+ await writeText(join(rootDir, "skills/novel-writing/references/style-guide.md"), "# style guide\n");
45
+ const chapterPacket = (await buildInstructionPacket({
46
+ rootDir,
47
+ checkpoint: makeCheckpoint("committed"),
48
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
49
+ embedMode: null,
50
+ writeManifest: false
51
+ }));
52
+ assert.equal(chapterPacket.packet.manifest.paths.platform_writing_guide, "platform-writing-guide.md");
53
+ assert.equal(chapterPacket.packet.manifest.paths.style_guide, "skills/novel-writing/references/style-guide.md");
54
+ const quickstartPacket = (await buildInstructionPacket({
55
+ rootDir,
56
+ checkpoint: {
57
+ last_completed_chapter: 0,
58
+ current_volume: 1,
59
+ orchestrator_state: "QUICK_START",
60
+ pipeline_stage: null,
61
+ inflight_chapter: null
62
+ },
63
+ step: { kind: "quickstart", phase: "trial" },
64
+ embedMode: null,
65
+ writeManifest: false
66
+ }));
67
+ assert.equal(quickstartPacket.packet.manifest.paths.platform_writing_guide, "platform-writing-guide.md");
68
+ assert.equal(quickstartPacket.packet.manifest.paths.style_guide, "skills/novel-writing/references/style-guide.md");
69
+ });
70
+ test("buildInstructionPacket injects platform-aware scoring and golden chapter gates for judge packets", async () => {
71
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-platform-judge-packet-"));
72
+ await writeJson(join(rootDir, "platform-profile.json"), {
73
+ schema_version: 1,
74
+ platform: "tomato",
75
+ created_at: "2026-03-01T00:00:00Z",
76
+ word_count: { target_min: 1500, target_max: 2500, hard_min: 1000, hard_max: 3500 },
77
+ hook_policy: { required: true, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
78
+ info_load: { max_new_entities_per_chapter: 5, max_unknown_entities_per_chapter: 3, max_new_terms_per_1k_words: 5 },
79
+ compliance: { banned_words: [], duplicate_name_policy: "soft" },
80
+ scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" }
81
+ });
82
+ await writeText(join(rootDir, "platform-writing-guide.md"), "# 番茄平台写作指南\n");
83
+ await writeJson(join(rootDir, "genre-weight-profiles.json"), JSON.parse(await readRepoText("templates/genre-weight-profiles.json")));
84
+ await writeJson(join(rootDir, "golden-chapter-gates.json"), JSON.parse(await readRepoText("templates/golden-chapter-gates.json")));
85
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
86
+ const packet = (await buildInstructionPacket({
87
+ rootDir,
88
+ checkpoint: makeCheckpoint("refined"),
89
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
90
+ embedMode: null,
91
+ writeManifest: false
92
+ }));
93
+ assert.equal(packet.packet.manifest.paths.platform_writing_guide, "platform-writing-guide.md");
94
+ assert.equal(packet.packet.manifest.inline.golden_chapter_gates.platform, "fanqie");
95
+ assert.equal(packet.packet.manifest.inline.golden_chapter_gates.chapter, 1);
96
+ assert.equal(packet.packet.manifest.inline.golden_chapter_gates.source, "golden-chapter-gates.json");
97
+ assert.ok(packet.packet.manifest.inline.scoring_weights.weights.hook_strength > 0);
98
+ });
99
+ test("buildInstructionPacket omits golden chapter gates after chapter 3", async () => {
100
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-platform-judge-no-gates-"));
101
+ await writeJson(join(rootDir, "platform-profile.json"), {
102
+ schema_version: 1,
103
+ platform: "jinjiang",
104
+ created_at: "2026-03-01T00:00:00Z",
105
+ word_count: { target_min: 2000, target_max: 3000, hard_min: 1500, hard_max: 3800 },
106
+ hook_policy: { required: true, min_strength: 3, allowed_types: ["emotional_cliff"], fix_strategy: "hook-fix" },
107
+ info_load: { max_new_entities_per_chapter: 4, max_unknown_entities_per_chapter: 2, max_new_terms_per_1k_words: 4 },
108
+ compliance: { banned_words: [], duplicate_name_policy: "soft" },
109
+ scoring: { genre_drive_type: "character", weight_profile_id: "character:v1" }
110
+ });
111
+ await writeJson(join(rootDir, "genre-weight-profiles.json"), JSON.parse(await readRepoText("templates/genre-weight-profiles.json")));
112
+ await writeJson(join(rootDir, "golden-chapter-gates.json"), JSON.parse(await readRepoText("templates/golden-chapter-gates.json")));
113
+ await writeText(join(rootDir, "staging/chapters/chapter-004.md"), "# 第4章\n\n正文\n");
114
+ const packet = (await buildInstructionPacket({
115
+ rootDir,
116
+ checkpoint: {
117
+ ...makeCheckpoint("refined"),
118
+ inflight_chapter: 4
119
+ },
120
+ step: { kind: "chapter", chapter: 4, stage: "judge" },
121
+ embedMode: null,
122
+ writeManifest: false
123
+ }));
124
+ assert.equal(packet.packet.manifest.inline.golden_chapter_gates, undefined);
125
+ });
@@ -25,6 +25,23 @@ test("computeNextStep routes judged+eval to commit on gate pass", async () => {
25
25
  assert.equal(next.step, "chapter:001:commit");
26
26
  assert.equal(next.reason, "judged:gate:pass");
27
27
  });
28
+ test("computeNextStep routes refined+eval to commit only after gate pass", async () => {
29
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-refined-gate-pass-"));
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: 4.2, recommendation: "pass" });
34
+ const next = await computeNextStep(rootDir, {
35
+ last_completed_chapter: 0,
36
+ current_volume: 1,
37
+ orchestrator_state: "WRITING",
38
+ pipeline_stage: "refined",
39
+ inflight_chapter: 1,
40
+ revision_count: 0
41
+ });
42
+ assert.equal(next.step, "chapter:001:commit");
43
+ assert.equal(next.reason, "refined:gate:pass");
44
+ });
28
45
  test("computeNextStep routes judged+eval to refine on gate polish", async () => {
29
46
  const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-polish-"));
30
47
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
@@ -59,6 +76,33 @@ test("computeNextStep routes judged+eval to draft on gate revise", async () => {
59
76
  assert.equal(next.step, "chapter:001:draft");
60
77
  assert.equal(next.reason, "judged:gate:revise");
61
78
  });
79
+ test("computeNextStep routes refined+eval to draft when golden chapter gates fail", async () => {
80
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-refined-golden-gate-fail-"));
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"), {
85
+ chapter: 1,
86
+ overall: 4.8,
87
+ recommendation: "pass",
88
+ golden_chapter_gates: {
89
+ activated: true,
90
+ passed: false,
91
+ failed_gate_ids: ["hook_present"],
92
+ checks: [{ id: "hook_present", status: "fail" }]
93
+ }
94
+ });
95
+ const next = await computeNextStep(rootDir, {
96
+ last_completed_chapter: 0,
97
+ current_volume: 1,
98
+ orchestrator_state: "WRITING",
99
+ pipeline_stage: "refined",
100
+ inflight_chapter: 1,
101
+ revision_count: 0
102
+ });
103
+ assert.equal(next.step, "chapter:001:draft");
104
+ assert.equal(next.reason, "refined:gate:revise");
105
+ });
62
106
  test("computeNextStep routes judged+eval to commit on force_passed when revisions exhausted", async () => {
63
107
  const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-force-passed-"));
64
108
  await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
@@ -115,3 +159,57 @@ test("computeNextStep forces revise when eval has high-confidence violations", a
115
159
  assert.equal(next.step, "chapter:001:draft");
116
160
  assert.equal(next.reason, "judged:gate:revise");
117
161
  });
162
+ test("computeNextStep forces revise when golden chapter gates fail despite high overall", async () => {
163
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-golden-gate-fail-"));
164
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
165
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
166
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
167
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {
168
+ chapter: 1,
169
+ overall: 4.8,
170
+ recommendation: "pass",
171
+ golden_chapter_gates: {
172
+ activated: true,
173
+ passed: false,
174
+ failed_gate_ids: ["protagonist_within_200_words"],
175
+ checks: [{ id: "protagonist_within_200_words", status: "fail" }]
176
+ }
177
+ });
178
+ const next = await computeNextStep(rootDir, {
179
+ last_completed_chapter: 0,
180
+ current_volume: 1,
181
+ orchestrator_state: "WRITING",
182
+ pipeline_stage: "judged",
183
+ inflight_chapter: 1,
184
+ revision_count: 0
185
+ });
186
+ assert.equal(next.step, "chapter:001:draft");
187
+ assert.equal(next.reason, "judged:gate:revise");
188
+ });
189
+ test("computeNextStep routes to review when golden chapter gate failures persist beyond max revisions", async () => {
190
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-golden-gate-pause-"));
191
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
192
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
193
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
194
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {
195
+ chapter: 1,
196
+ overall: 4.8,
197
+ recommendation: "pass",
198
+ golden_chapter_gates: {
199
+ activated: true,
200
+ passed: false,
201
+ failed_gate_ids: ["hook_present"],
202
+ checks: [{ id: "hook_present", status: "fail" }]
203
+ }
204
+ });
205
+ const next = await computeNextStep(rootDir, {
206
+ last_completed_chapter: 0,
207
+ current_volume: 1,
208
+ orchestrator_state: "WRITING",
209
+ pipeline_stage: "judged",
210
+ inflight_chapter: 1,
211
+ revision_count: 2
212
+ });
213
+ assert.equal(next.step, "chapter:001:review");
214
+ assert.equal(next.reason, "judged:gate:pause_for_user");
215
+ });
@@ -43,7 +43,7 @@ test("commitChapter resets orchestrator_state to WRITING", async () => {
43
43
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
44
44
  await writeText(join(rootDir, "staging/summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n- 测试事件\n`);
45
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 });
46
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4.0, recommendation: "pass" });
47
47
  await writeText(join(rootDir, "staging/storylines/main-arc/memory.md"), `- 测试记忆\n`);
48
48
  await writeJson(join(rootDir, "staging/state/chapter-001-delta.json"), {
49
49
  chapter: 1,
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import test from "node:test";
4
- import { parsePlatformProfile } from "../platform-profile.js";
4
+ import { canonicalPlatformId, parsePlatformProfile } from "../platform-profile.js";
5
5
  function makeBaseRaw() {
6
6
  return {
7
7
  schema_version: 1,
@@ -21,6 +21,18 @@ test("parsePlatformProfile loads legacy profile without retention/readability/na
21
21
  assert.equal(Object.prototype.hasOwnProperty.call(profile, "readability"), false);
22
22
  assert.equal(Object.prototype.hasOwnProperty.call(profile, "naming"), false);
23
23
  });
24
+ test("canonicalPlatformId maps tomato to fanqie and preserves canonical ids", () => {
25
+ assert.equal(canonicalPlatformId("tomato"), "fanqie");
26
+ assert.equal(canonicalPlatformId("fanqie"), "fanqie");
27
+ assert.equal(canonicalPlatformId("qidian"), "qidian");
28
+ assert.equal(canonicalPlatformId("jinjiang"), "jinjiang");
29
+ });
30
+ test("parsePlatformProfile accepts fanqie and jinjiang platform ids", () => {
31
+ const fanqie = parsePlatformProfile({ ...makeBaseRaw(), platform: "fanqie" }, "platform-profile.json");
32
+ const jinjiang = parsePlatformProfile({ ...makeBaseRaw(), platform: "jinjiang" }, "platform-profile.json");
33
+ assert.equal(fanqie.platform, "fanqie");
34
+ assert.equal(jinjiang.platform, "jinjiang");
35
+ });
24
36
  test("parsePlatformProfile accepts explicit null retention/readability/naming", () => {
25
37
  const raw = {
26
38
  ...makeBaseRaw(),
@@ -33,6 +45,18 @@ test("parsePlatformProfile accepts explicit null retention/readability/naming",
33
45
  assert.equal(profile.readability, null);
34
46
  assert.equal(profile.naming, null);
35
47
  });
48
+ test("parsePlatformProfile accepts fractional max_new_terms_per_1k_words", () => {
49
+ const raw = {
50
+ ...makeBaseRaw(),
51
+ info_load: {
52
+ max_new_entities_per_chapter: 0,
53
+ max_unknown_entities_per_chapter: 0,
54
+ max_new_terms_per_1k_words: 2.5
55
+ }
56
+ };
57
+ const profile = parsePlatformProfile(raw, "platform-profile.json");
58
+ assert.equal(profile.info_load.max_new_terms_per_1k_words, 2.5);
59
+ });
36
60
  test("parsePlatformProfile loads extended profile with retention/readability/naming", () => {
37
61
  const raw = {
38
62
  ...makeBaseRaw(),
@@ -156,6 +180,20 @@ test("parsePlatformProfile rejects retention.title_policy min_chars > max_chars"
156
180
  };
157
181
  assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /min_chars.*<=.*max_chars/i);
158
182
  });
183
+ test("parsePlatformProfile rejects word_count target_min > target_max", () => {
184
+ const raw = {
185
+ ...makeBaseRaw(),
186
+ word_count: { target_min: 3000, target_max: 2000, hard_min: 1500, hard_max: 3500 }
187
+ };
188
+ assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /word_count\.target_min.*<=.*word_count\.target_max/i);
189
+ });
190
+ test("parsePlatformProfile rejects word_count hard_min > hard_max", () => {
191
+ const raw = {
192
+ ...makeBaseRaw(),
193
+ word_count: { target_min: 2000, target_max: 3000, hard_min: 3600, hard_max: 3500 }
194
+ };
195
+ assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /word_count\.hard_min.*<=.*word_count\.hard_max/i);
196
+ });
159
197
  test("parsePlatformProfile rejects retention.title_policy min_chars when float", () => {
160
198
  const raw = {
161
199
  ...makeBaseRaw(),
@@ -272,3 +310,21 @@ test("templates/platform-profile.json defaults parse as valid platform profiles"
272
310
  assert.ok(profile.naming, `expected defaults.${platform}.naming to be present`);
273
311
  }
274
312
  });
313
+ test("templates/platform-profile.json keeps fanqie and tomato shared defaults aligned", async () => {
314
+ const raw = JSON.parse(await readFile("templates/platform-profile.json", "utf8"));
315
+ assert.ok(raw.defaults, "expected templates/platform-profile.json to have defaults");
316
+ const fanqie = raw.defaults?.fanqie;
317
+ const tomato = raw.defaults?.tomato;
318
+ assert.ok(fanqie && tomato, "expected fanqie and tomato defaults");
319
+ const sharedSubset = (profile) => ({
320
+ word_count: profile.word_count,
321
+ hook_policy: profile.hook_policy,
322
+ info_load: profile.info_load,
323
+ compliance: profile.compliance,
324
+ scoring: profile.scoring,
325
+ retention: profile.retention,
326
+ readability: profile.readability,
327
+ naming: profile.naming
328
+ });
329
+ assert.deepEqual(sharedSubset(fanqie), sharedSubset(tomato));
330
+ });
@@ -7,6 +7,7 @@ import { advanceCheckpointForStep } from "../advance.js";
7
7
  import { readCheckpoint } from "../checkpoint.js";
8
8
  import { buildInstructionPacket } from "../instructions.js";
9
9
  import { computeNextStep } from "../next-step.js";
10
+ import { writeCommittedMiniPlanning } from "./helpers/quickstart-mini-planning.js";
10
11
  async function writeText(absPath, contents) {
11
12
  await mkdir(dirname(absPath), { recursive: true });
12
13
  await writeFile(absPath, contents, "utf8");
@@ -101,6 +102,7 @@ test("advance quickstart:trial writes quickstart_phase correctly", async () => {
101
102
  await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
102
103
  await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
103
104
  await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
105
+ await writeCommittedMiniPlanning(rootDir);
104
106
  await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "# Trial\n\nText\n");
105
107
  const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "trial" } });
106
108
  assert.equal(updated.orchestrator_state, "QUICK_START");
@@ -109,6 +111,21 @@ test("advance quickstart:trial writes quickstart_phase correctly", async () => {
109
111
  assert.equal(checkpoint.orchestrator_state, "QUICK_START");
110
112
  assert.equal(checkpoint.quickstart_phase, "trial");
111
113
  });
114
+ test("advance quickstart:trial requires committed mini-planning artifacts", async () => {
115
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-trial-requires-f0-"));
116
+ await writeJson(join(rootDir, ".checkpoint.json"), {
117
+ last_completed_chapter: 0,
118
+ current_volume: 1,
119
+ orchestrator_state: "QUICK_START",
120
+ pipeline_stage: null,
121
+ inflight_chapter: null
122
+ });
123
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
124
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
125
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
126
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "# Trial\n\nText\n");
127
+ await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "trial" } }), /Missing committed quickstart mini-planning artifacts/);
128
+ });
112
129
  test("advance quickstart rejects wrong orchestrator_state", async () => {
113
130
  const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-advance-wrong-state-"));
114
131
  await writeJson(join(rootDir, ".checkpoint.json"), {
@@ -140,12 +157,19 @@ test("computeNextStep recovers quickstart phase from staging artifacts", async (
140
157
  await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
141
158
  next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
142
159
  assert.equal(next.step, "quickstart:style");
143
- // style profile present → trial
160
+ // style profile present → f0
144
161
  await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
145
162
  next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
163
+ assert.equal(next.step, "quickstart:f0");
164
+ // committed mini-planning artifacts present → trial
165
+ await writeCommittedMiniPlanning(rootDir);
166
+ next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
146
167
  assert.equal(next.step, "quickstart:trial");
147
168
  // trial chapter present → results
148
- await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
169
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章
170
+
171
+ (测试)
172
+ `);
149
173
  next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
150
174
  assert.equal(next.step, "quickstart:results");
151
175
  assert.equal(next.reason, "quickstart:results");
@@ -202,7 +226,7 @@ test("computeNextStep allows redoing style phase when style profile is missing",
202
226
  assert.equal(next.step, "quickstart:style");
203
227
  assert.equal(next.reason, "quickstart:style");
204
228
  });
205
- test("computeNextStep allows redoing trial phase when trial chapter is missing", async () => {
229
+ test("computeNextStep falls back to f0 when trial phase is missing committed mini-planning artifacts", async () => {
206
230
  const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-trial-"));
207
231
  await writeJson(join(rootDir, ".checkpoint.json"), {
208
232
  last_completed_chapter: 0,
@@ -216,10 +240,12 @@ test("computeNextStep allows redoing trial phase when trial chapter is missing",
216
240
  await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
217
241
  await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
218
242
  const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
219
- assert.equal(next.step, "quickstart:trial");
220
- assert.equal(next.reason, "quickstart:trial");
243
+ assert.equal(next.step, "quickstart:f0");
244
+ assert.match(next.reason, /quickstart:recovery_blocked/);
245
+ assert.equal(next.evidence.recovery_blocked.checkpoint_phase, "trial");
246
+ assert.equal(next.evidence.recovery_blocked.expected_path, "volumes/vol-01/outline.md");
221
247
  });
222
- test("computeNextStep continues forward when checkpoint quickstart_phase is consistent with staging artifacts", async () => {
248
+ test("computeNextStep continues forward into f0 when checkpoint quickstart_phase is consistent with style artifacts", async () => {
223
249
  const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-happy-"));
224
250
  await writeJson(join(rootDir, ".checkpoint.json"), {
225
251
  last_completed_chapter: 0,
@@ -233,6 +259,25 @@ test("computeNextStep continues forward when checkpoint quickstart_phase is cons
233
259
  await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
234
260
  await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
235
261
  const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
262
+ assert.equal(next.step, "quickstart:f0");
263
+ assert.equal(next.reason, "quickstart:f0");
264
+ assert.equal(next.evidence.recovery_blocked ?? null, null);
265
+ });
266
+ test("computeNextStep resumes from quickstart_phase=f0 into trial when mini-planning is committed", async () => {
267
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-resume-f0-"));
268
+ await writeJson(join(rootDir, ".checkpoint.json"), {
269
+ last_completed_chapter: 0,
270
+ current_volume: 1,
271
+ orchestrator_state: "QUICK_START",
272
+ pipeline_stage: null,
273
+ inflight_chapter: null,
274
+ quickstart_phase: "f0"
275
+ });
276
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
277
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
278
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
279
+ await writeCommittedMiniPlanning(rootDir);
280
+ const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
236
281
  assert.equal(next.step, "quickstart:trial");
237
282
  assert.equal(next.reason, "quickstart:trial");
238
283
  assert.equal(next.evidence.recovery_blocked ?? null, null);
@@ -267,6 +312,26 @@ test("buildInstructionPacket (quickstart) includes NOVEL_ASK gate when provided"
267
312
  assert.equal(built.packet.novel_ask.topic, questionSpec.topic);
268
313
  assert.equal(built.packet.expected_outputs[0].path, answerPath);
269
314
  });
315
+ test("advance quickstart:results requires committed mini-planning artifacts", async () => {
316
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-results-requires-f0-"));
317
+ await writeJson(join(rootDir, ".checkpoint.json"), {
318
+ last_completed_chapter: 0,
319
+ current_volume: 1,
320
+ orchestrator_state: "QUICK_START",
321
+ pipeline_stage: null,
322
+ inflight_chapter: null,
323
+ volume_pipeline_stage: null
324
+ });
325
+ await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
326
+ await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
327
+ await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
328
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章
329
+
330
+ (测试)
331
+ `);
332
+ await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
333
+ await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } }), /Missing committed quickstart mini-planning artifacts/);
334
+ });
270
335
  test("advance quickstart:results commits artifacts and transitions to VOL_PLANNING", async () => {
271
336
  const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-commit-"));
272
337
  await writeJson(join(rootDir, ".checkpoint.json"), {
@@ -292,6 +357,7 @@ test("advance quickstart:results commits artifacts and transitions to VOL_PLANNI
292
357
  });
293
358
  await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
294
359
  await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
360
+ await writeCommittedMiniPlanning(rootDir);
295
361
  await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
296
362
  await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
297
363
  const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } });
@@ -334,6 +400,7 @@ test("advance quickstart:results validates all contracts (not just a slice)", as
334
400
  ]
335
401
  });
336
402
  await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
403
+ await writeCommittedMiniPlanning(rootDir);
337
404
  await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
338
405
  await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
339
406
  for (let i = 1; i <= 10; i++) {