novel-writer-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/agents/chapter-writer.md +142 -0
  4. package/agents/character-weaver.md +117 -0
  5. package/agents/consistency-auditor.md +85 -0
  6. package/agents/plot-architect.md +128 -0
  7. package/agents/quality-judge.md +232 -0
  8. package/agents/style-analyzer.md +109 -0
  9. package/agents/style-refiner.md +97 -0
  10. package/agents/summarizer.md +128 -0
  11. package/agents/world-builder.md +161 -0
  12. package/dist/__tests__/character-voice.test.js +445 -0
  13. package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
  14. package/dist/__tests__/engagement.test.js +382 -0
  15. package/dist/__tests__/foreshadow-visibility.test.js +131 -0
  16. package/dist/__tests__/hook-ledger.test.js +1028 -0
  17. package/dist/__tests__/naming-lint.test.js +132 -0
  18. package/dist/__tests__/narrative-health-injection.test.js +359 -0
  19. package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
  20. package/dist/__tests__/next-step-title-fix.test.js +153 -0
  21. package/dist/__tests__/platform-profile.test.js +274 -0
  22. package/dist/__tests__/promise-ledger.test.js +189 -0
  23. package/dist/__tests__/readability-lint.test.js +209 -0
  24. package/dist/__tests__/text-utils.test.js +39 -0
  25. package/dist/__tests__/title-policy.test.js +147 -0
  26. package/dist/advance.js +75 -0
  27. package/dist/character-voice.js +805 -0
  28. package/dist/checkpoint.js +126 -0
  29. package/dist/cli.js +563 -0
  30. package/dist/cliche-lint.js +515 -0
  31. package/dist/commit.js +1460 -0
  32. package/dist/consistency-auditor.js +684 -0
  33. package/dist/engagement.js +687 -0
  34. package/dist/errors.js +7 -0
  35. package/dist/fingerprint.js +16 -0
  36. package/dist/foreshadow-visibility.js +214 -0
  37. package/dist/fs-utils.js +68 -0
  38. package/dist/hook-ledger.js +721 -0
  39. package/dist/hook-policy.js +107 -0
  40. package/dist/instruction-gates.js +51 -0
  41. package/dist/instructions.js +406 -0
  42. package/dist/latest-summary-loader.js +29 -0
  43. package/dist/lock.js +121 -0
  44. package/dist/naming-lint.js +531 -0
  45. package/dist/ner.js +73 -0
  46. package/dist/next-step.js +408 -0
  47. package/dist/novel-ask.js +270 -0
  48. package/dist/output.js +9 -0
  49. package/dist/platform-constraints.js +518 -0
  50. package/dist/platform-profile.js +325 -0
  51. package/dist/prejudge-guardrails.js +370 -0
  52. package/dist/project.js +40 -0
  53. package/dist/promise-ledger.js +723 -0
  54. package/dist/readability-lint.js +555 -0
  55. package/dist/safe-parse.js +36 -0
  56. package/dist/safe-path.js +29 -0
  57. package/dist/scoring-weights.js +290 -0
  58. package/dist/steps.js +60 -0
  59. package/dist/text-utils.js +18 -0
  60. package/dist/title-policy.js +251 -0
  61. package/dist/type-guards.js +6 -0
  62. package/dist/validate.js +131 -0
  63. package/docs/user/README.md +17 -0
  64. package/docs/user/guardrails.md +179 -0
  65. package/docs/user/interactive-gates.md +124 -0
  66. package/docs/user/novel-cli.md +289 -0
  67. package/docs/user/ops.md +123 -0
  68. package/docs/user/quick-start.md +97 -0
  69. package/docs/user/spec-system.md +166 -0
  70. package/docs/user/storylines.md +144 -0
  71. package/package.json +48 -0
  72. package/schemas/README.md +18 -0
  73. package/schemas/character-voice-drift.schema.json +135 -0
  74. package/schemas/character-voice-profiles.schema.json +141 -0
  75. package/schemas/engagement-metrics.schema.json +38 -0
  76. package/schemas/hook-ledger.schema.json +108 -0
  77. package/schemas/platform-profile.schema.json +235 -0
  78. package/schemas/promise-ledger.schema.json +97 -0
  79. package/scripts/calibrate-quality-judge.sh +91 -0
  80. package/scripts/compare-regression-runs.sh +86 -0
  81. package/scripts/lib/_common.py +131 -0
  82. package/scripts/lib/calibrate_quality_judge.py +312 -0
  83. package/scripts/lib/compare_regression_runs.py +142 -0
  84. package/scripts/lib/run_regression.py +621 -0
  85. package/scripts/lint-blacklist.sh +201 -0
  86. package/scripts/lint-cliche.sh +370 -0
  87. package/scripts/lint-readability.sh +404 -0
  88. package/scripts/query-foreshadow.sh +252 -0
  89. package/scripts/run-ner.sh +669 -0
  90. package/scripts/run-regression.sh +122 -0
  91. package/skills/cli-step/SKILL.md +158 -0
  92. package/skills/continue/SKILL.md +348 -0
  93. package/skills/continue/references/context-contracts.md +169 -0
  94. package/skills/continue/references/continuity-checks.md +187 -0
  95. package/skills/continue/references/file-protocols.md +64 -0
  96. package/skills/continue/references/foreshadowing.md +130 -0
  97. package/skills/continue/references/gate-decision.md +53 -0
  98. package/skills/continue/references/periodic-maintenance.md +46 -0
  99. package/skills/novel-writing/SKILL.md +77 -0
  100. package/skills/novel-writing/references/quality-rubric.md +140 -0
  101. package/skills/novel-writing/references/style-guide.md +145 -0
  102. package/skills/start/SKILL.md +458 -0
  103. package/skills/start/references/quality-review.md +86 -0
  104. package/skills/start/references/setting-update.md +44 -0
  105. package/skills/start/references/vol-planning.md +61 -0
  106. package/skills/start/references/vol-review.md +58 -0
  107. package/skills/status/SKILL.md +116 -0
  108. package/skills/status/references/sample-output.md +60 -0
  109. package/templates/ai-blacklist.json +79 -0
  110. package/templates/brief-template.md +46 -0
  111. package/templates/genre-weight-profiles.json +90 -0
  112. package/templates/novel-ask/example.answer.json +12 -0
  113. package/templates/novel-ask/example.question.json +51 -0
  114. package/templates/platform-profile.json +148 -0
  115. package/templates/style-profile-template.json +58 -0
  116. package/templates/web-novel-cliche-lint.json +41 -0
@@ -0,0 +1,325 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, symlink, 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 { buildInstructionPacket } from "../instructions.js";
7
+ import { computeNextStep } from "../next-step.js";
8
+ async function writeJson(absPath, payload) {
9
+ await mkdir(dirname(absPath), { recursive: true });
10
+ await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
11
+ }
12
+ function makePlatformProfileRaw(extra) {
13
+ return {
14
+ schema_version: 1,
15
+ platform: "qidian",
16
+ created_at: "2026-01-01T00:00:00Z",
17
+ word_count: { target_min: 1, target_max: 2, hard_min: 1, hard_max: 2 },
18
+ hook_policy: { required: false, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
19
+ info_load: { max_new_entities_per_chapter: 0, max_unknown_entities_per_chapter: 0, max_new_terms_per_1k_words: 0 },
20
+ compliance: { banned_words: [], duplicate_name_policy: "warn" },
21
+ ...extra
22
+ };
23
+ }
24
+ async function setupProjectDir() {
25
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-prejudge-guardrails-"));
26
+ await writeJson(join(rootDir, ".checkpoint.json"), { last_completed_chapter: 0, current_volume: 1, pipeline_stage: null, inflight_chapter: null });
27
+ return rootDir;
28
+ }
29
+ test("computeNextStep returns review when naming lint has blocking issues", async () => {
30
+ const rootDir = await setupProjectDir();
31
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
32
+ retention: null,
33
+ readability: null,
34
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
35
+ }));
36
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
37
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
38
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
39
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
40
+ await mkdir(join(rootDir, "characters/active"), { recursive: true });
41
+ await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
42
+ await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
43
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
44
+ const next = await computeNextStep(rootDir, checkpoint);
45
+ assert.equal(next.step, "chapter:001:review");
46
+ assert.equal(next.reason, "judged:prejudge_guardrails_blocking:naming_lint");
47
+ });
48
+ test("computeNextStep returns review when readability lint has blocking issues (deterministic script)", async () => {
49
+ const rootDir = await setupProjectDir();
50
+ await mkdir(join(rootDir, "scripts"), { recursive: true });
51
+ const stubJson = '{"schema_version":1,"generated_at":"2026-01-01T00:00:00.000Z","scope":{"chapter":1},"policy":{"enabled":true,"max_paragraph_chars":10,"max_consecutive_exposition_paragraphs":2,"blocking_severity":"hard_only"},"issues":[{"id":"readability.mobile.overlong_paragraph","severity":"hard","summary":"Hard issue blocks."}]}';
52
+ const stubScript = `#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' '${stubJson}'\n`;
53
+ await writeFile(join(rootDir, "scripts/lint-readability.sh"), stubScript, "utf8");
54
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
55
+ compliance: { banned_words: [], duplicate_name_policy: "warn", script_paths: { lint_readability: "scripts/lint-readability.sh" } },
56
+ retention: null,
57
+ readability: { mobile: { enabled: true, max_paragraph_chars: 10, max_consecutive_exposition_paragraphs: 2, blocking_severity: "hard_only" } },
58
+ naming: null
59
+ }));
60
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
61
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
62
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
63
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
64
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
65
+ const next = await computeNextStep(rootDir, checkpoint);
66
+ assert.equal(next.step, "chapter:001:review");
67
+ assert.equal(next.reason, "judged:prejudge_guardrails_blocking:readability_lint");
68
+ });
69
+ test("buildInstructionPacket (judge) includes prejudge guardrails report path and writes the report file", async () => {
70
+ const rootDir = await setupProjectDir();
71
+ await mkdir(join(rootDir, "scripts"), { recursive: true });
72
+ const stubJson = '{"schema_version":1,"generated_at":"2026-01-01T00:00:00.000Z","scope":{"chapter":1},"policy":{"enabled":true,"max_paragraph_chars":10,"max_consecutive_exposition_paragraphs":2,"blocking_severity":"hard_only"},"issues":[]}';
73
+ const stubScript = `#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' '${stubJson}'\n`;
74
+ await writeFile(join(rootDir, "scripts/lint-readability.sh"), stubScript, "utf8");
75
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
76
+ compliance: { banned_words: [], duplicate_name_policy: "warn", script_paths: { lint_readability: "scripts/lint-readability.sh" } },
77
+ retention: null,
78
+ readability: { mobile: { enabled: true, max_paragraph_chars: 10, max_consecutive_exposition_paragraphs: 2, blocking_severity: "hard_only" } },
79
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
80
+ }));
81
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
82
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
83
+ await mkdir(join(rootDir, "staging/state"), { recursive: true });
84
+ await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), {});
85
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
86
+ const built = await buildInstructionPacket({
87
+ rootDir,
88
+ checkpoint,
89
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
90
+ embedMode: null,
91
+ writeManifest: false
92
+ });
93
+ const packet = built.packet;
94
+ const guardrailRel = packet?.manifest?.paths?.prejudge_guardrails;
95
+ assert.equal(typeof guardrailRel, "string");
96
+ const reportRaw = JSON.parse(await readFile(join(rootDir, guardrailRel), "utf8"));
97
+ assert.equal(reportRaw.schema_version, 2);
98
+ assert.equal(reportRaw.scope?.chapter, 1);
99
+ assert.equal(reportRaw.dependencies?.characters_active?.rel_path, "characters/active");
100
+ assert.equal(typeof reportRaw.dependencies?.characters_active?.fingerprint, "string");
101
+ assert.equal(reportRaw.readability_lint?.schema_version, 1);
102
+ const inlineRef = packet?.manifest?.inline?.prejudge_guardrails?.report_path;
103
+ assert.equal(inlineRef, guardrailRel);
104
+ });
105
+ test("computeNextStep returns review on refined stage when naming lint blocks", async () => {
106
+ const rootDir = await setupProjectDir();
107
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
108
+ retention: null,
109
+ readability: null,
110
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
111
+ }));
112
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
113
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
114
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
115
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
116
+ await mkdir(join(rootDir, "characters/active"), { recursive: true });
117
+ await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
118
+ await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
119
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
120
+ const next = await computeNextStep(rootDir, checkpoint);
121
+ assert.equal(next.step, "chapter:001:review");
122
+ assert.equal(next.reason, "refined:prejudge_guardrails_blocking:naming_lint");
123
+ });
124
+ test("computeNextStep returns draft (not crash) when judged but staging chapter is missing", async () => {
125
+ const rootDir = await setupProjectDir();
126
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
127
+ retention: null,
128
+ readability: null,
129
+ naming: null
130
+ }));
131
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
132
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
133
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
134
+ const next = await computeNextStep(rootDir, checkpoint);
135
+ assert.equal(next.step, "chapter:001:draft");
136
+ assert.equal(next.reason, "judged:missing_chapter");
137
+ });
138
+ test("computeNextStep tolerates invalid cached prejudge guardrails JSON (recomputes)", async () => {
139
+ const rootDir = await setupProjectDir();
140
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
141
+ retention: null,
142
+ readability: null,
143
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
144
+ }));
145
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
146
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
147
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
148
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
149
+ await mkdir(join(rootDir, "characters/active"), { recursive: true });
150
+ await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
151
+ await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
152
+ await mkdir(join(rootDir, "staging/guardrails"), { recursive: true });
153
+ await writeFile(join(rootDir, "staging/guardrails/prejudge-guardrails-chapter-001.json"), "{not-json", "utf8");
154
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
155
+ const next = await computeNextStep(rootDir, checkpoint);
156
+ assert.equal(next.step, "chapter:001:review");
157
+ assert.equal(next.reason, "judged:prejudge_guardrails_blocking:naming_lint");
158
+ assert.equal(next.evidence?.prejudge_guardrails?.cache?.status, "miss");
159
+ });
160
+ test("computeNextStep does not use cached report when platform profile changes (fingerprint invalidation)", async () => {
161
+ const rootDir = await setupProjectDir();
162
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
163
+ retention: null,
164
+ readability: null,
165
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
166
+ }));
167
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
168
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
169
+ await mkdir(join(rootDir, "characters/active"), { recursive: true });
170
+ await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
171
+ await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
172
+ // Generate and persist a cached guardrails report via judge instructions.
173
+ const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
174
+ await buildInstructionPacket({
175
+ rootDir,
176
+ checkpoint: checkpointRefined,
177
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
178
+ embedMode: null,
179
+ writeManifest: false
180
+ });
181
+ // Now change platform profile to disable naming (cached report should be ignored).
182
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ retention: null, readability: null, naming: null }));
183
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
184
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
185
+ const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
186
+ const next = await computeNextStep(rootDir, checkpointJudged);
187
+ assert.equal(next.step, "chapter:001:commit");
188
+ });
189
+ test("computeNextStep uses cached prejudge guardrails report when fresh", async () => {
190
+ const rootDir = await setupProjectDir();
191
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
192
+ retention: null,
193
+ readability: null,
194
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
195
+ }));
196
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
197
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
198
+ await mkdir(join(rootDir, "staging/state"), { recursive: true });
199
+ await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), {});
200
+ await mkdir(join(rootDir, "characters/active"), { recursive: true });
201
+ await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
202
+ await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
203
+ // Generate and persist a cached guardrails report via judge instructions.
204
+ const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
205
+ await buildInstructionPacket({
206
+ rootDir,
207
+ checkpoint: checkpointRefined,
208
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
209
+ embedMode: null,
210
+ writeManifest: false
211
+ });
212
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
213
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
214
+ const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
215
+ const next = await computeNextStep(rootDir, checkpointJudged);
216
+ assert.equal(next.step, "chapter:001:review");
217
+ assert.equal(next.reason, "judged:prejudge_guardrails_blocking:naming_lint");
218
+ assert.equal(next.evidence?.prejudge_guardrails?.cache?.status, "hit");
219
+ });
220
+ test("computeNextStep ignores cached guardrails report when characters change", async () => {
221
+ const rootDir = await setupProjectDir();
222
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
223
+ retention: null,
224
+ readability: null,
225
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
226
+ }));
227
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
228
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
229
+ await mkdir(join(rootDir, "staging/state"), { recursive: true });
230
+ await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), {});
231
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
232
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
233
+ await mkdir(join(rootDir, "characters/active"), { recursive: true });
234
+ await writeJson(join(rootDir, "characters/active/a.json"), { id: "a", display_name: "张三", aliases: [] });
235
+ await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "张三", aliases: [] });
236
+ // Generate cached report with a blocking duplicate.
237
+ const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
238
+ await buildInstructionPacket({
239
+ rootDir,
240
+ checkpoint: checkpointRefined,
241
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
242
+ embedMode: null,
243
+ writeManifest: false
244
+ });
245
+ // Fix the duplicate by renaming a character; cache must be ignored.
246
+ await writeJson(join(rootDir, "characters/active/b.json"), { id: "b", display_name: "李四五", aliases: [] });
247
+ const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
248
+ const next = await computeNextStep(rootDir, checkpointJudged);
249
+ assert.equal(next.step, "chapter:001:commit");
250
+ });
251
+ test("computeNextStep ignores cached guardrails report when characters/active is a symlink", { skip: process.platform === "win32" }, async () => {
252
+ const rootDir = await setupProjectDir();
253
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
254
+ retention: null,
255
+ readability: null,
256
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
257
+ }));
258
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
259
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 标题\n正文\n", "utf8");
260
+ await mkdir(join(rootDir, "staging/state"), { recursive: true });
261
+ await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), {});
262
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
263
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
264
+ await mkdir(join(rootDir, "characters/shared-active"), { recursive: true });
265
+ await writeJson(join(rootDir, "characters/shared-active/a.json"), { id: "a", display_name: "张三", aliases: [] });
266
+ await writeJson(join(rootDir, "characters/shared-active/b.json"), { id: "b", display_name: "张三", aliases: [] });
267
+ await symlink(join(rootDir, "characters/shared-active"), join(rootDir, "characters/active"));
268
+ // Generate cached report with a blocking duplicate (via judge instructions).
269
+ const checkpointRefined = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
270
+ const built = await buildInstructionPacket({
271
+ rootDir,
272
+ checkpoint: checkpointRefined,
273
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
274
+ embedMode: null,
275
+ writeManifest: false
276
+ });
277
+ const packet = built.packet;
278
+ const guardrailRel = packet?.manifest?.paths?.prejudge_guardrails;
279
+ assert.equal(typeof guardrailRel, "string");
280
+ const reportRaw = JSON.parse(await readFile(join(rootDir, guardrailRel), "utf8"));
281
+ assert.equal(reportRaw.has_blocking_issues, true);
282
+ // Fix the duplicate in the symlink target; cache must be ignored.
283
+ await writeJson(join(rootDir, "characters/shared-active/b.json"), { id: "b", display_name: "李四五", aliases: [] });
284
+ const checkpointJudged = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
285
+ const next = await computeNextStep(rootDir, checkpointJudged);
286
+ assert.equal(next.step, "chapter:001:commit");
287
+ });
288
+ test("computeNextStep returns review when guardrails computation errors", async () => {
289
+ const rootDir = await setupProjectDir();
290
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ retention: null, readability: null, naming: null }));
291
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
292
+ // Make chapter path a directory to trigger EISDIR read error.
293
+ await mkdir(join(rootDir, "staging/chapters/chapter-001.md"), { recursive: true });
294
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
295
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4, recommendation: "pass" });
296
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1 };
297
+ const next = await computeNextStep(rootDir, checkpoint);
298
+ assert.equal(next.step, "chapter:001:review");
299
+ assert.equal(next.reason, "judged:prejudge_guardrails_error");
300
+ assert.equal(next.evidence?.prejudge_guardrails?.cache?.status, "miss");
301
+ assert.equal(typeof next.evidence?.prejudge_guardrails?.error, "string");
302
+ });
303
+ test("buildInstructionPacket (judge) sets prejudge_guardrails_degraded when report compute fails", async () => {
304
+ const rootDir = await setupProjectDir();
305
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({
306
+ retention: null,
307
+ readability: null,
308
+ naming: { enabled: true, near_duplicate_threshold: 0.9, blocking_conflict_types: ["duplicate"], exemptions: {} }
309
+ }));
310
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
311
+ // Intentionally create a directory at the chapter path to trigger fingerprint/read failure.
312
+ await mkdir(join(rootDir, "staging/chapters/chapter-001.md"), { recursive: true });
313
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1 };
314
+ const built = await buildInstructionPacket({
315
+ rootDir,
316
+ checkpoint,
317
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
318
+ embedMode: null,
319
+ writeManifest: false
320
+ });
321
+ const packet = built.packet;
322
+ assert.equal(packet?.manifest?.inline?.prejudge_guardrails, null);
323
+ assert.equal(packet?.manifest?.inline?.prejudge_guardrails_degraded, true);
324
+ assert.equal(packet?.manifest?.paths?.prejudge_guardrails, undefined);
325
+ });
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, 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 { advanceCheckpointForStep } from "../advance.js";
7
+ import { buildInstructionPacket } from "../instructions.js";
8
+ import { computeNextStep } from "../next-step.js";
9
+ import { titleFixSnapshotRel } from "../steps.js";
10
+ import { validateStep } from "../validate.js";
11
+ async function writeJson(absPath, payload) {
12
+ await mkdir(dirname(absPath), { recursive: true });
13
+ await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
14
+ }
15
+ function makePlatformProfileRaw(args) {
16
+ const max_chars = args.max_chars ?? 10;
17
+ return {
18
+ schema_version: 1,
19
+ platform: "qidian",
20
+ created_at: "2026-01-01T00:00:00Z",
21
+ word_count: { target_min: 1, target_max: 2, hard_min: 1, hard_max: 2 },
22
+ hook_policy: { required: false, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
23
+ info_load: { max_new_entities_per_chapter: 0, max_unknown_entities_per_chapter: 0, max_new_terms_per_1k_words: 0 },
24
+ compliance: { banned_words: [], duplicate_name_policy: "warn" },
25
+ scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
26
+ retention: args.enabled
27
+ ? {
28
+ title_policy: {
29
+ enabled: true,
30
+ min_chars: 2,
31
+ max_chars,
32
+ forbidden_patterns: [],
33
+ auto_fix: args.auto_fix
34
+ },
35
+ hook_ledger: {
36
+ enabled: false,
37
+ fulfillment_window_chapters: 10,
38
+ diversity_window_chapters: 5,
39
+ max_same_type_streak: 2,
40
+ min_distinct_types_in_window: 2,
41
+ overdue_policy: "warn"
42
+ }
43
+ }
44
+ : null
45
+ };
46
+ }
47
+ async function setupProjectDir() {
48
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-next-title-fix-"));
49
+ await writeJson(join(rootDir, ".checkpoint.json"), { last_completed_chapter: 0, current_volume: 1, pipeline_stage: null, inflight_chapter: null });
50
+ return rootDir;
51
+ }
52
+ test("computeNextStep returns title-fix on hard title violations when auto_fix=true", async () => {
53
+ const rootDir = await setupProjectDir();
54
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
55
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
56
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
57
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
58
+ const next = await computeNextStep(rootDir, checkpoint);
59
+ assert.equal(next.step, "chapter:001:title-fix");
60
+ });
61
+ test("computeNextStep returns review after title-fix was already attempted", async () => {
62
+ const rootDir = await setupProjectDir();
63
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
64
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
65
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
66
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 1 };
67
+ const next = await computeNextStep(rootDir, checkpoint);
68
+ assert.equal(next.step, "chapter:001:review");
69
+ });
70
+ test("computeNextStep returns review on hard title violations when auto_fix=false", async () => {
71
+ const rootDir = await setupProjectDir();
72
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: false }));
73
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
74
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
75
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
76
+ const next = await computeNextStep(rootDir, checkpoint);
77
+ assert.equal(next.step, "chapter:001:review");
78
+ });
79
+ test("computeNextStep does not block on warn-only title issues when auto_fix=false", async () => {
80
+ const rootDir = await setupProjectDir();
81
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: false, max_chars: 3 }));
82
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
83
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 太长的标题\n正文\n", "utf8");
84
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
85
+ const next = await computeNextStep(rootDir, checkpoint);
86
+ assert.equal(next.step, "chapter:001:judge");
87
+ });
88
+ test("computeNextStep returns title-fix on warn-only title issues when auto_fix=true", async () => {
89
+ const rootDir = await setupProjectDir();
90
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true, max_chars: 3 }));
91
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
92
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "# 太长的标题\n正文\n", "utf8");
93
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
94
+ const next = await computeNextStep(rootDir, checkpoint);
95
+ assert.equal(next.step, "chapter:001:title-fix");
96
+ });
97
+ test("computeNextStep returns title-fix on judged stage when eval exists and title violates policy (auto_fix=true)", async () => {
98
+ const rootDir = await setupProjectDir();
99
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
100
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
101
+ await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "正文\n", "utf8");
102
+ await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
103
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {});
104
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "judged", inflight_chapter: 1, title_fix_count: 0 };
105
+ const next = await computeNextStep(rootDir, checkpoint);
106
+ assert.equal(next.step, "chapter:001:title-fix");
107
+ });
108
+ test("title-fix snapshot is write-once (rerunning instructions does not bypass body guard)", async () => {
109
+ const rootDir = await setupProjectDir();
110
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
111
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
112
+ const chapterAbs = join(rootDir, "staging/chapters/chapter-001.md");
113
+ await writeFile(chapterAbs, "# 标题\n正文\n", "utf8");
114
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: "refined", inflight_chapter: 1, title_fix_count: 0 };
115
+ await buildInstructionPacket({
116
+ rootDir,
117
+ checkpoint,
118
+ step: { kind: "chapter", chapter: 1, stage: "title-fix" },
119
+ embedMode: null,
120
+ writeManifest: false
121
+ });
122
+ const snapshotRel = titleFixSnapshotRel(1);
123
+ const snapshotAbs = join(rootDir, snapshotRel);
124
+ const snapshot1 = await readFile(snapshotAbs, "utf8");
125
+ // Illegal edit: body changed.
126
+ await writeFile(chapterAbs, "# 标题\n正文改了\n", "utf8");
127
+ // Re-run instructions (snapshot must not be overwritten).
128
+ await buildInstructionPacket({
129
+ rootDir,
130
+ checkpoint,
131
+ step: { kind: "chapter", chapter: 1, stage: "title-fix" },
132
+ embedMode: null,
133
+ writeManifest: false
134
+ });
135
+ const snapshot2 = await readFile(snapshotAbs, "utf8");
136
+ assert.equal(snapshot2, snapshot1);
137
+ await assert.rejects(() => validateStep({ rootDir, checkpoint, step: { kind: "chapter", chapter: 1, stage: "title-fix" } }), /chapter body changed/i);
138
+ });
139
+ test("advance draft cleans up title-fix snapshot to avoid stale reuse", async () => {
140
+ const rootDir = await setupProjectDir();
141
+ await writeJson(join(rootDir, "platform-profile.json"), makePlatformProfileRaw({ enabled: true, auto_fix: true }));
142
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
143
+ const chapterAbs = join(rootDir, "staging/chapters/chapter-001.md");
144
+ await writeFile(chapterAbs, "# 标题\n正文\n", "utf8");
145
+ await mkdir(join(rootDir, "staging/logs"), { recursive: true });
146
+ const snapshotRel = titleFixSnapshotRel(1);
147
+ await writeFile(join(rootDir, snapshotRel), "old snapshot\n", "utf8");
148
+ const checkpoint = { last_completed_chapter: 0, current_volume: 1, pipeline_stage: null, inflight_chapter: null, title_fix_count: 1 };
149
+ await writeJson(join(rootDir, ".checkpoint.json"), checkpoint);
150
+ await advanceCheckpointForStep({ rootDir, step: { kind: "chapter", chapter: 1, stage: "draft" } });
151
+ // validate cleanup is best-effort; the file should be gone.
152
+ await assert.rejects(() => readFile(join(rootDir, snapshotRel), "utf8"));
153
+ });