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
@@ -0,0 +1,481 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, 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("issue 169 prompts and skill docs describe split planned character context", async () => {
32
+ const chapterWriter = await readRepoText("agents/chapter-writer.md");
33
+ const qualityJudge = await readRepoText("agents/quality-judge.md");
34
+ const continueSkill = await readRepoText("skills/continue/SKILL.md");
35
+ const contextContracts = await readRepoText("skills/continue/references/context-contracts.md");
36
+ assert.match(chapterWriter, /planned_character_contracts/);
37
+ assert.match(chapterWriter, /planned_character_profiles/);
38
+ assert.match(qualityJudge, /planned \/ deprecated 不会进入 judge packet/);
39
+ assert.match(continueSkill, /planned_character_contracts/);
40
+ assert.match(contextContracts, /planned_character_contracts\?:/);
41
+ assert.match(contextContracts, /character_contracts\?: .*仅 established \/ 缺失 canon_status/);
42
+ assert.match(contextContracts, /preferred 路径,不受 fallback 的 15 角色上限约束/);
43
+ });
44
+ test("buildInstructionPacket splits active and planned character context by canon_status", async () => {
45
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-canon-status-"));
46
+ try {
47
+ await writeJson(join(rootDir, "world/rules.json"), {
48
+ schema_version: 1,
49
+ rules: [
50
+ { id: "W-001", category: "physics", rule: "旧规则也要生效", constraint_type: "hard" },
51
+ { id: "W-002", category: "physics", rule: "当前已生效规则", constraint_type: "hard", canon_status: "established" },
52
+ { id: "W-003", category: "magic_system", rule: "未来卷才生效的设定", constraint_type: "hard", canon_status: " Planned " },
53
+ { id: "W-004", category: "social", rule: "已废弃规则", constraint_type: "hard", canon_status: "deprecated" },
54
+ { id: "W-005", category: "social", rule: "未来的软规则提示", constraint_type: "soft", canon_status: " PLANNED " }
55
+ ]
56
+ });
57
+ await writeJson(join(rootDir, "volumes/vol-01/chapter-contracts/chapter-001.json"), {
58
+ chapter: 1,
59
+ storyline_id: "main-arc",
60
+ preconditions: {
61
+ character_states: {
62
+ Alice: { location: "city" },
63
+ Bob: { location: "city" },
64
+ Carol: { location: "city" }
65
+ }
66
+ },
67
+ objectives: [{ id: "OBJ-1", required: true, description: "x" }]
68
+ });
69
+ for (const [slug, displayName, canonStatus] of [
70
+ ["alice", "Alice", undefined],
71
+ ["bob", "Bob", " Planned "],
72
+ ["carol", "Carol", "deprecated"],
73
+ ["dave", "Dave", "established"]
74
+ ]) {
75
+ await writeJson(join(rootDir, `characters/active/${slug}.json`), {
76
+ id: slug,
77
+ display_name: displayName,
78
+ ...(canonStatus ? { canon_status: canonStatus } : {}),
79
+ contracts: [{ id: `C-${slug.toUpperCase()}-001`, type: "personality", rule: "rule" }]
80
+ });
81
+ await writeText(join(rootDir, `characters/active/${slug}.md`), `# ${displayName}\n`);
82
+ }
83
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
84
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
85
+ const draftPacket = (await buildInstructionPacket({
86
+ rootDir,
87
+ checkpoint: makeCheckpoint("committed"),
88
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
89
+ embedMode: null,
90
+ writeManifest: false
91
+ }));
92
+ assert.deepEqual(draftPacket.packet.manifest.inline.hard_rules_list, [
93
+ "W-001: 旧规则也要生效",
94
+ "W-002: 当前已生效规则"
95
+ ]);
96
+ assert.deepEqual(draftPacket.packet.manifest.inline.planned_rules_info, [
97
+ {
98
+ id: "W-003",
99
+ category: "magic_system",
100
+ constraint_type: "hard",
101
+ canon_status: "planned",
102
+ rule: "未来卷才生效的设定"
103
+ },
104
+ {
105
+ id: "W-005",
106
+ category: "social",
107
+ constraint_type: "soft",
108
+ canon_status: "planned",
109
+ rule: "未来的软规则提示"
110
+ }
111
+ ]);
112
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.inline, "world_rules_context_degraded"), false);
113
+ assert.deepEqual(draftPacket.packet.manifest.paths.character_contracts, ["characters/active/alice.json"]);
114
+ assert.deepEqual(draftPacket.packet.manifest.paths.character_profiles, ["characters/active/alice.md"]);
115
+ assert.deepEqual(draftPacket.packet.manifest.paths.planned_character_contracts, ["characters/active/bob.json"]);
116
+ assert.deepEqual(draftPacket.packet.manifest.paths.planned_character_profiles, ["characters/active/bob.md"]);
117
+ const judgePacket = (await buildInstructionPacket({
118
+ rootDir,
119
+ checkpoint: makeCheckpoint("refined"),
120
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
121
+ embedMode: null,
122
+ writeManifest: false
123
+ }));
124
+ assert.deepEqual(judgePacket.packet.manifest.inline.hard_rules_list, [
125
+ "W-001: 旧规则也要生效",
126
+ "W-002: 当前已生效规则"
127
+ ]);
128
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.inline, "planned_rules_info"), false);
129
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.inline, "world_rules_context_degraded"), false);
130
+ assert.deepEqual(judgePacket.packet.manifest.paths.character_contracts, ["characters/active/alice.json"]);
131
+ assert.deepEqual(judgePacket.packet.manifest.paths.character_profiles, ["characters/active/alice.md"]);
132
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.paths, "planned_character_contracts"), false);
133
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.paths, "planned_character_profiles"), false);
134
+ }
135
+ finally {
136
+ await rm(rootDir, { recursive: true, force: true });
137
+ }
138
+ });
139
+ test("buildInstructionPacket prioritizes planned draft characters on fallback and keeps judge active-only", async () => {
140
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-canon-status-fallback-"));
141
+ try {
142
+ await writeJson(join(rootDir, "world/rules.json"), {
143
+ schema_version: 1,
144
+ rules: []
145
+ });
146
+ for (let index = 1; index <= 18; index += 1) {
147
+ const slug = `char-${String(index).padStart(2, "0")}`;
148
+ const canonStatus = index === 17 ? "planned" : index === 18 ? "deprecated" : "established";
149
+ await writeJson(join(rootDir, `characters/active/${slug}.json`), {
150
+ id: slug,
151
+ display_name: `角色${index}`,
152
+ canon_status: canonStatus,
153
+ contracts: [{ id: `C-${index}`, type: "personality", rule: `rule-${index}` }]
154
+ });
155
+ await writeText(join(rootDir, `characters/active/${slug}.md`), `# 角色${index}\n`);
156
+ }
157
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
158
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
159
+ const draftPacket = (await buildInstructionPacket({
160
+ rootDir,
161
+ checkpoint: makeCheckpoint("committed"),
162
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
163
+ embedMode: null,
164
+ writeManifest: false
165
+ }));
166
+ assert.deepEqual(draftPacket.packet.manifest.inline.hard_rules_list, []);
167
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.inline, "world_rules_context_degraded"), false);
168
+ assert.deepEqual(draftPacket.packet.manifest.paths.character_contracts, [
169
+ "characters/active/char-01.json",
170
+ "characters/active/char-02.json",
171
+ "characters/active/char-03.json",
172
+ "characters/active/char-04.json",
173
+ "characters/active/char-05.json",
174
+ "characters/active/char-06.json",
175
+ "characters/active/char-07.json",
176
+ "characters/active/char-08.json",
177
+ "characters/active/char-09.json",
178
+ "characters/active/char-10.json",
179
+ "characters/active/char-11.json",
180
+ "characters/active/char-12.json",
181
+ "characters/active/char-13.json",
182
+ "characters/active/char-14.json"
183
+ ]);
184
+ assert.deepEqual(draftPacket.packet.manifest.paths.character_profiles, [
185
+ "characters/active/char-01.md",
186
+ "characters/active/char-02.md",
187
+ "characters/active/char-03.md",
188
+ "characters/active/char-04.md",
189
+ "characters/active/char-05.md",
190
+ "characters/active/char-06.md",
191
+ "characters/active/char-07.md",
192
+ "characters/active/char-08.md",
193
+ "characters/active/char-09.md",
194
+ "characters/active/char-10.md",
195
+ "characters/active/char-11.md",
196
+ "characters/active/char-12.md",
197
+ "characters/active/char-13.md",
198
+ "characters/active/char-14.md"
199
+ ]);
200
+ assert.deepEqual(draftPacket.packet.manifest.paths.planned_character_contracts, ["characters/active/char-17.json"]);
201
+ assert.deepEqual(draftPacket.packet.manifest.paths.planned_character_profiles, ["characters/active/char-17.md"]);
202
+ assert.equal(draftPacket.packet.manifest.paths.character_contracts.includes("characters/active/char-15.json"), false);
203
+ await writeJson(join(rootDir, "volumes/vol-01/chapter-contracts/chapter-001.json"), {
204
+ chapter: 1,
205
+ storyline_id: "main-arc",
206
+ preconditions: {
207
+ character_states: {
208
+ "角色17": { location: "city" }
209
+ }
210
+ },
211
+ objectives: [{ id: "OBJ-1", required: true, description: "x" }]
212
+ });
213
+ const judgePacket = (await buildInstructionPacket({
214
+ rootDir,
215
+ checkpoint: makeCheckpoint("refined"),
216
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
217
+ embedMode: null,
218
+ writeManifest: false
219
+ }));
220
+ assert.deepEqual(judgePacket.packet.manifest.inline.hard_rules_list, []);
221
+ assert.deepEqual(judgePacket.packet.manifest.paths.character_contracts, [
222
+ "characters/active/char-01.json",
223
+ "characters/active/char-02.json",
224
+ "characters/active/char-03.json",
225
+ "characters/active/char-04.json",
226
+ "characters/active/char-05.json",
227
+ "characters/active/char-06.json",
228
+ "characters/active/char-07.json",
229
+ "characters/active/char-08.json",
230
+ "characters/active/char-09.json",
231
+ "characters/active/char-10.json",
232
+ "characters/active/char-11.json",
233
+ "characters/active/char-12.json",
234
+ "characters/active/char-13.json",
235
+ "characters/active/char-14.json",
236
+ "characters/active/char-15.json"
237
+ ]);
238
+ assert.deepEqual(judgePacket.packet.manifest.paths.character_profiles, [
239
+ "characters/active/char-01.md",
240
+ "characters/active/char-02.md",
241
+ "characters/active/char-03.md",
242
+ "characters/active/char-04.md",
243
+ "characters/active/char-05.md",
244
+ "characters/active/char-06.md",
245
+ "characters/active/char-07.md",
246
+ "characters/active/char-08.md",
247
+ "characters/active/char-09.md",
248
+ "characters/active/char-10.md",
249
+ "characters/active/char-11.md",
250
+ "characters/active/char-12.md",
251
+ "characters/active/char-13.md",
252
+ "characters/active/char-14.md",
253
+ "characters/active/char-15.md"
254
+ ]);
255
+ assert.equal(judgePacket.packet.manifest.paths.character_contracts.includes("characters/active/char-17.json"), false);
256
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.paths, "planned_character_contracts"), false);
257
+ }
258
+ finally {
259
+ await rm(rootDir, { recursive: true, force: true });
260
+ }
261
+ });
262
+ test("buildInstructionPacket fallback uses the shared draft budget to keep planned characters first", async () => {
263
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-canon-status-budget-"));
264
+ try {
265
+ await writeJson(join(rootDir, "world/rules.json"), {
266
+ schema_version: 1,
267
+ rules: []
268
+ });
269
+ for (let index = 1; index <= 10; index += 1) {
270
+ const slug = `active-${String(index).padStart(2, "0")}`;
271
+ await writeJson(join(rootDir, `characters/active/${slug}.json`), {
272
+ id: slug,
273
+ display_name: `已生效角色${index}`,
274
+ canon_status: "established",
275
+ contracts: [{ id: `A-${index}`, type: "personality", rule: `active-${index}` }]
276
+ });
277
+ await writeText(join(rootDir, `characters/active/${slug}.md`), `# 已生效角色${index}\n`);
278
+ }
279
+ for (let index = 1; index <= 12; index += 1) {
280
+ const slug = `planned-${String(index).padStart(2, "0")}`;
281
+ await writeJson(join(rootDir, `characters/active/${slug}.json`), {
282
+ id: slug,
283
+ display_name: `计划角色${index}`,
284
+ canon_status: "planned",
285
+ contracts: [{ id: `P-${index}`, type: "personality", rule: `planned-${index}` }]
286
+ });
287
+ await writeText(join(rootDir, `characters/active/${slug}.md`), `# 计划角色${index}\n`);
288
+ }
289
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
290
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
291
+ const draftPacket = (await buildInstructionPacket({
292
+ rootDir,
293
+ checkpoint: makeCheckpoint("committed"),
294
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
295
+ embedMode: null,
296
+ writeManifest: false
297
+ }));
298
+ assert.deepEqual(draftPacket.packet.manifest.paths.character_contracts, [
299
+ "characters/active/active-01.json",
300
+ "characters/active/active-02.json",
301
+ "characters/active/active-03.json"
302
+ ]);
303
+ assert.deepEqual(draftPacket.packet.manifest.paths.planned_character_contracts, [
304
+ "characters/active/planned-01.json",
305
+ "characters/active/planned-02.json",
306
+ "characters/active/planned-03.json",
307
+ "characters/active/planned-04.json",
308
+ "characters/active/planned-05.json",
309
+ "characters/active/planned-06.json",
310
+ "characters/active/planned-07.json",
311
+ "characters/active/planned-08.json",
312
+ "characters/active/planned-09.json",
313
+ "characters/active/planned-10.json",
314
+ "characters/active/planned-11.json",
315
+ "characters/active/planned-12.json"
316
+ ]);
317
+ assert.equal(draftPacket.packet.manifest.paths.character_contracts.includes("characters/active/active-04.json"), false);
318
+ }
319
+ finally {
320
+ await rm(rootDir, { recursive: true, force: true });
321
+ }
322
+ });
323
+ test("buildInstructionPacket keeps all-planned characters out of judge packets", async () => {
324
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-canon-status-all-planned-"));
325
+ try {
326
+ await writeJson(join(rootDir, "world/rules.json"), {
327
+ schema_version: 1,
328
+ rules: []
329
+ });
330
+ for (let index = 1; index <= 3; index += 1) {
331
+ const slug = `planned-${String(index).padStart(2, "0")}`;
332
+ await writeJson(join(rootDir, `characters/active/${slug}.json`), {
333
+ id: slug,
334
+ display_name: `计划角色${index}`,
335
+ canon_status: "planned",
336
+ contracts: [{ id: `P-${index}`, type: "personality", rule: `planned-${index}` }]
337
+ });
338
+ await writeText(join(rootDir, `characters/active/${slug}.md`), `# 计划角色${index}\n`);
339
+ }
340
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
341
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
342
+ const draftPacket = (await buildInstructionPacket({
343
+ rootDir,
344
+ checkpoint: makeCheckpoint("committed"),
345
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
346
+ embedMode: null,
347
+ writeManifest: false
348
+ }));
349
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.paths, "character_contracts"), false);
350
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.paths, "character_profiles"), false);
351
+ assert.deepEqual(draftPacket.packet.manifest.paths.planned_character_contracts, [
352
+ "characters/active/planned-01.json",
353
+ "characters/active/planned-02.json",
354
+ "characters/active/planned-03.json"
355
+ ]);
356
+ assert.deepEqual(draftPacket.packet.manifest.paths.planned_character_profiles, [
357
+ "characters/active/planned-01.md",
358
+ "characters/active/planned-02.md",
359
+ "characters/active/planned-03.md"
360
+ ]);
361
+ const judgePacket = (await buildInstructionPacket({
362
+ rootDir,
363
+ checkpoint: makeCheckpoint("refined"),
364
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
365
+ embedMode: null,
366
+ writeManifest: false
367
+ }));
368
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.paths, "character_contracts"), false);
369
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.paths, "character_profiles"), false);
370
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.paths, "planned_character_contracts"), false);
371
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.paths, "planned_character_profiles"), false);
372
+ }
373
+ finally {
374
+ await rm(rootDir, { recursive: true, force: true });
375
+ }
376
+ });
377
+ test("buildInstructionPacket returns empty character context for all deprecated characters and soft rules", async () => {
378
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-canon-status-empty-"));
379
+ try {
380
+ await writeJson(join(rootDir, "world/rules.json"), {
381
+ schema_version: 1,
382
+ rules: [
383
+ { id: "W-001", category: "physics", rule: "soft established", constraint_type: "soft", canon_status: "established" },
384
+ { id: "W-002", category: "magic", rule: "soft default", constraint_type: "soft" }
385
+ ]
386
+ });
387
+ for (const slug of ["alice", "bob"]) {
388
+ await writeJson(join(rootDir, `characters/active/${slug}.json`), {
389
+ id: slug,
390
+ display_name: slug,
391
+ canon_status: "deprecated",
392
+ contracts: [{ id: `C-${slug}`, type: "personality", rule: "rule" }]
393
+ });
394
+ await writeText(join(rootDir, `characters/active/${slug}.md`), `# ${slug}\n`);
395
+ }
396
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
397
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
398
+ const draftPacket = (await buildInstructionPacket({
399
+ rootDir,
400
+ checkpoint: makeCheckpoint("committed"),
401
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
402
+ embedMode: null,
403
+ writeManifest: false
404
+ }));
405
+ assert.deepEqual(draftPacket.packet.manifest.inline.hard_rules_list, []);
406
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.paths, "character_contracts"), false);
407
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.paths, "planned_character_contracts"), false);
408
+ }
409
+ finally {
410
+ await rm(rootDir, { recursive: true, force: true });
411
+ }
412
+ });
413
+ test("buildInstructionPacket warns and degrades invalid canon_status values to established", async () => {
414
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-canon-status-invalid-"));
415
+ const originalWarn = console.warn;
416
+ const warnings = [];
417
+ console.warn = (...args) => {
418
+ warnings.push(args.map((arg) => String(arg)).join(" "));
419
+ };
420
+ try {
421
+ await writeJson(join(rootDir, "world/rules.json"), {
422
+ schema_version: 1,
423
+ rules: [{ id: "W-001", category: "physics", rule: "非法值也按 established", constraint_type: "hard", canon_status: "garbage" }]
424
+ });
425
+ await writeJson(join(rootDir, "volumes/vol-01/chapter-contracts/chapter-001.json"), {
426
+ chapter: 1,
427
+ storyline_id: "main-arc",
428
+ preconditions: { character_states: { Alice: { location: "city" } } },
429
+ objectives: [{ id: "OBJ-1", required: true, description: "x" }]
430
+ });
431
+ await writeJson(join(rootDir, "characters/active/alice.json"), {
432
+ id: "alice",
433
+ display_name: "Alice",
434
+ canon_status: true,
435
+ contracts: [{ id: "C-ALICE-001", type: "personality", rule: "rule" }]
436
+ });
437
+ await writeText(join(rootDir, "characters/active/alice.md"), "# Alice\n");
438
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
439
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
440
+ const draftPacket = (await buildInstructionPacket({
441
+ rootDir,
442
+ checkpoint: makeCheckpoint("committed"),
443
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
444
+ embedMode: null,
445
+ writeManifest: false
446
+ }));
447
+ assert.deepEqual(draftPacket.packet.manifest.inline.hard_rules_list, ["W-001: 非法值也按 established"]);
448
+ assert.deepEqual(draftPacket.packet.manifest.paths.character_contracts, ["characters/active/alice.json"]);
449
+ assert.equal(warnings.length, 2);
450
+ const firstWarning = warnings[0] ?? "";
451
+ const secondWarning = warnings[1] ?? "";
452
+ assert.match(firstWarning, /Invalid canon_status/);
453
+ assert.match(secondWarning, /Invalid non-string canon_status/);
454
+ }
455
+ finally {
456
+ console.warn = originalWarn;
457
+ await rm(rootDir, { recursive: true, force: true });
458
+ }
459
+ });
460
+ test("buildInstructionPacket marks malformed world rules as degraded and tolerates missing character directory", async () => {
461
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-canon-status-degraded-"));
462
+ try {
463
+ await writeText(join(rootDir, "world/rules.json"), '{"rules": [}\n');
464
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
465
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
466
+ const draftPacket = (await buildInstructionPacket({
467
+ rootDir,
468
+ checkpoint: makeCheckpoint("committed"),
469
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
470
+ embedMode: null,
471
+ writeManifest: false
472
+ }));
473
+ assert.deepEqual(draftPacket.packet.manifest.inline.hard_rules_list, []);
474
+ assert.equal(draftPacket.packet.manifest.inline.world_rules_context_degraded, true);
475
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.paths, "character_contracts"), false);
476
+ assert.equal(Object.prototype.hasOwnProperty.call(draftPacket.packet.manifest.paths, "character_profiles"), false);
477
+ }
478
+ finally {
479
+ await rm(rootDir, { recursive: true, force: true });
480
+ }
481
+ });
@@ -0,0 +1,65 @@
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 { readCheckpoint } from "../checkpoint.js";
7
+ import { commitChapter } from "../commit.js";
8
+ import { NovelCliError } from "../errors.js";
9
+ async function writeText(absPath, contents) {
10
+ await mkdir(dirname(absPath), { recursive: true });
11
+ await writeFile(absPath, contents, "utf8");
12
+ }
13
+ async function writeJson(absPath, payload) {
14
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
15
+ }
16
+ async function seedCommitFixture(rootDir, evalPayload, revisionCount = 0) {
17
+ await writeJson(join(rootDir, ".checkpoint.json"), {
18
+ last_completed_chapter: 0,
19
+ current_volume: 1,
20
+ orchestrator_state: "WRITING",
21
+ pipeline_stage: "refined",
22
+ inflight_chapter: 1,
23
+ revision_count: revisionCount
24
+ });
25
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
26
+ await writeText(join(rootDir, "staging/summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n- 测试事件\n`);
27
+ await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), { schema_version: 1, chapter: 1, entities: [] });
28
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), evalPayload);
29
+ await writeText(join(rootDir, "staging/storylines/main-arc/memory.md"), `- 测试记忆\n`);
30
+ await writeJson(join(rootDir, "staging/state/chapter-001-delta.json"), {
31
+ chapter: 1,
32
+ base_state_version: 0,
33
+ storyline_id: "main-arc",
34
+ ops: [{ op: "set", path: "characters.hero.display_name", value: "阿宁" }]
35
+ });
36
+ }
37
+ test("commitChapter rejects gated evals that still require revision", async () => {
38
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-commit-gate-block-"));
39
+ await seedCommitFixture(rootDir, { chapter: 1, overall: 3.2, recommendation: "revise" });
40
+ await assert.rejects(() => commitChapter({ rootDir, chapter: 1, dryRun: false }), (err) => err instanceof NovelCliError && /gate decision is 'revise'/i.test(err.message));
41
+ });
42
+ test("commitChapter rejects golden chapter gate failures even when overall is high", async () => {
43
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-commit-golden-gate-block-"));
44
+ await seedCommitFixture(rootDir, {
45
+ chapter: 1,
46
+ overall: 4.8,
47
+ recommendation: "pass",
48
+ golden_chapter_gates: {
49
+ activated: true,
50
+ passed: false,
51
+ checks: [{ id: "hook_present", status: "fail" }]
52
+ }
53
+ });
54
+ await assert.rejects(() => commitChapter({ rootDir, chapter: 1, dryRun: false }), (err) => err instanceof NovelCliError && /gate decision is 'revise'/i.test(err.message));
55
+ });
56
+ test("commitChapter allows force_passed when revisions are exhausted", async () => {
57
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-commit-force-passed-"));
58
+ await seedCommitFixture(rootDir, { chapter: 1, overall: 3.6, recommendation: "polish" }, 2);
59
+ await commitChapter({ rootDir, chapter: 1, dryRun: false });
60
+ const checkpoint = await readCheckpoint(rootDir);
61
+ assert.equal(checkpoint.pipeline_stage, "committed");
62
+ assert.equal(checkpoint.inflight_chapter, null);
63
+ const finalChapter = await readFile(join(rootDir, "chapters/chapter-001.md"), "utf8");
64
+ assert.match(finalChapter, /第1章/);
65
+ });
@@ -24,7 +24,7 @@ test("commitChapter drops __proto__/constructor/prototype path segments to preve
24
24
  await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
25
25
  await writeText(join(rootDir, "staging/summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n- 测试事件\n`);
26
26
  await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), { schema_version: 1, chapter: 1, entities: [] });
27
- await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1 });
27
+ await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4.0, recommendation: "pass" });
28
28
  await writeText(join(rootDir, "staging/storylines/main-arc/memory.md"), `- 测试记忆\n`);
29
29
  await writeJson(join(rootDir, "staging/state/chapter-001-delta.json"), {
30
30
  chapter: 1,