novel-writer-cli 0.2.1 → 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 (76) hide show
  1. package/README.md +1 -1
  2. package/agents/chapter-writer.md +69 -29
  3. package/agents/character-weaver.md +7 -1
  4. package/agents/plot-architect.md +20 -7
  5. package/agents/quality-judge.md +239 -15
  6. package/agents/style-analyzer.md +14 -8
  7. package/agents/style-refiner.md +48 -25
  8. package/agents/world-builder.md +8 -1
  9. package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +311 -0
  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 +156 -0
  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 +416 -28
  62. package/skills/start/SKILL.md +23 -3
  63. package/skills/start/references/vol-planning.md +12 -3
  64. package/templates/ai-blacklist.json +1275 -54
  65. package/templates/ai-sentence-patterns.json +167 -0
  66. package/templates/brief-template.md +5 -0
  67. package/templates/genre-excitement-map.json +48 -0
  68. package/templates/genre-golden-standards.json +80 -0
  69. package/templates/genre-weight-profiles.json +15 -0
  70. package/templates/golden-chapter-gates.json +230 -0
  71. package/templates/novel-ask/example.question.json +3 -2
  72. package/templates/platform-profile.json +141 -1
  73. package/templates/platforms/fanqie.md +35 -0
  74. package/templates/platforms/jinjiang.md +35 -0
  75. package/templates/platforms/qidian.md +35 -0
  76. package/templates/style-profile-template.json +18 -1
@@ -0,0 +1,156 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
7
+ function repoPath(relPath) {
8
+ return join(repoRoot, relPath);
9
+ }
10
+ async function readJson(relPath) {
11
+ return JSON.parse(await readFile(repoPath(relPath), "utf8"));
12
+ }
13
+ function assertPlainObject(value, label) {
14
+ assert.ok(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be a JSON object`);
15
+ }
16
+ test("templates/style-profile-template.json includes nullable statistical fields (v0.2+ anti-AI)", async () => {
17
+ const raw = await readJson("templates/style-profile-template.json");
18
+ assertPlainObject(raw, "style-profile-template.json");
19
+ assert.equal(raw.sentence_length_std_dev, null);
20
+ assert.equal(raw.paragraph_length_cv, null);
21
+ assert.equal(raw.emotional_volatility, null);
22
+ assert.equal(raw.register_mixing, null);
23
+ assert.equal(raw.vocabulary_richness, null);
24
+ assert.equal(typeof raw._sentence_length_std_dev_comment, "string");
25
+ assert.match(raw._sentence_length_std_dev_comment, /8-18/);
26
+ assert.match(raw._sentence_length_std_dev_comment, /<\s*6/);
27
+ assert.equal(typeof raw._paragraph_length_cv_comment, "string");
28
+ assert.match(raw._paragraph_length_cv_comment, /0\.4-1\.2/);
29
+ assert.match(raw._paragraph_length_cv_comment, /<\s*0\.3/);
30
+ for (const key of ["_emotional_volatility_comment", "_register_mixing_comment", "_vocabulary_richness_comment"]) {
31
+ assert.equal(typeof raw[key], "string");
32
+ assert.match(raw[key], /high\|medium\|low/);
33
+ }
34
+ });
35
+ test("templates/ai-blacklist.json v2 expands entries and supports metadata", async () => {
36
+ const raw = await readJson("templates/ai-blacklist.json");
37
+ assertPlainObject(raw, "ai-blacklist.json");
38
+ assert.match(String(raw.version), /^2\./, "ai-blacklist.json.version must remain in v2.x series");
39
+ assert.equal(raw.max_words, 250);
40
+ assert.equal(typeof raw.last_updated, "string");
41
+ assert.ok(Array.isArray(raw.words), "ai-blacklist.json.words must be an array");
42
+ assert.ok(raw.words.every((w) => typeof w === "string" && w.trim().length > 0), "words must be non-empty strings");
43
+ const words = raw.words.map((w) => w.trim());
44
+ assert.ok(words.length >= 190, `words.length must be >= 190, got ${words.length}`);
45
+ assert.ok(words.length <= raw.max_words, `words.length must be <= max_words (${raw.max_words})`);
46
+ const wordSet = new Set(words);
47
+ assert.equal(wordSet.size, words.length, "words must be unique");
48
+ assert.ok(Array.isArray(raw.whitelist), "ai-blacklist.json.whitelist must be an array");
49
+ assert.ok(raw.whitelist.every((w) => typeof w === "string" && w.trim().length > 0), "whitelist entries must be non-empty strings");
50
+ assertPlainObject(raw.categories, "ai-blacklist.json.categories");
51
+ const categories = raw.categories;
52
+ const requiredCategories = [
53
+ "summary_word",
54
+ "enumeration_template",
55
+ "academic_tone",
56
+ "narration_connector",
57
+ "paragraph_opener",
58
+ "smooth_transition",
59
+ "emotion_cliche",
60
+ "expression_cliche",
61
+ "action_cliche",
62
+ "environment_cliche",
63
+ "narrative_filler",
64
+ "abstract_filler",
65
+ "mechanical_opening",
66
+ "simile_cliche"
67
+ ];
68
+ for (const key of requiredCategories) {
69
+ assert.ok(key in categories, `Missing category: ${key}`);
70
+ }
71
+ assertPlainObject(raw.category_metadata, "ai-blacklist.json.category_metadata");
72
+ const meta = raw.category_metadata;
73
+ assertPlainObject(meta.narration_connector, "category_metadata.narration_connector");
74
+ assert.equal(meta.narration_connector.context, "narration_only");
75
+ assertPlainObject(meta.abstract_filler, "category_metadata.abstract_filler");
76
+ assertPlainObject(meta.abstract_filler.genre_override, "category_metadata.abstract_filler.genre_override");
77
+ assertPlainObject(meta.abstract_filler.genre_override["sci-fi"], "category_metadata.abstract_filler.genre_override.sci-fi");
78
+ assertPlainObject(meta.abstract_filler.genre_override["sci-fi"].per_chapter_max, "category_metadata.abstract_filler.genre_override.sci-fi.per_chapter_max");
79
+ const allCategoryWords = new Set();
80
+ const narrationConnectorWords = new Set();
81
+ const abstractFillerWords = new Set();
82
+ const categorizedWordCounts = new Map();
83
+ const categoryWordSets = new Map();
84
+ const entryIndex = new Map();
85
+ for (const [categoryName, entries] of Object.entries(categories)) {
86
+ assert.ok(Array.isArray(entries), `categories.${categoryName} must be an array`);
87
+ const categoryWords = new Set();
88
+ categoryWordSets.set(categoryName, categoryWords);
89
+ for (const entry of entries) {
90
+ assertPlainObject(entry, `categories.${categoryName}[]`);
91
+ assert.equal(typeof entry.word, "string");
92
+ const word = entry.word.trim();
93
+ assert.ok(word.length > 0, `categories.${categoryName}[] word must be non-empty`);
94
+ categoryWords.add(word);
95
+ entryIndex.set(`${categoryName}:${word}`, entry);
96
+ assert.equal(typeof entry.replacement_hint, "string");
97
+ assert.ok(entry.replacement_hint.trim().length > 0, `categories.${categoryName}[] replacement_hint must be non-empty`);
98
+ const perChapterMax = entry.per_chapter_max;
99
+ if (perChapterMax !== undefined) {
100
+ assert.ok(Number.isInteger(perChapterMax) && perChapterMax > 0, `Invalid per_chapter_max for word: ${word}`);
101
+ }
102
+ if (categoryName === "narration_connector") {
103
+ narrationConnectorWords.add(word);
104
+ continue;
105
+ }
106
+ if (categoryName === "abstract_filler")
107
+ abstractFillerWords.add(word);
108
+ allCategoryWords.add(word);
109
+ categorizedWordCounts.set(word, (categorizedWordCounts.get(word) ?? 0) + 1);
110
+ }
111
+ }
112
+ // narration_connector is intentionally excluded from flat words until context-aware lint exists.
113
+ for (const w of narrationConnectorWords) {
114
+ assert.equal(wordSet.has(w), false, `narration_connector word must not appear in words[]: ${w}`);
115
+ }
116
+ assert.equal(allCategoryWords.size, wordSet.size, "categories (excluding narration_connector) must cover words[] exactly");
117
+ for (const w of allCategoryWords) {
118
+ assert.ok(wordSet.has(w), `Missing from words[]: ${w}`);
119
+ }
120
+ for (const w of wordSet) {
121
+ assert.equal(categorizedWordCounts.get(w), 1, `Word must appear exactly once across categories: ${w}`);
122
+ }
123
+ const sciFiPerChapterMax = meta.abstract_filler.genre_override["sci-fi"].per_chapter_max;
124
+ for (const [key, value] of Object.entries(sciFiPerChapterMax)) {
125
+ assert.ok(abstractFillerWords.has(key), `genre_override.sci-fi.per_chapter_max references missing abstract_filler word: ${key}`);
126
+ assert.ok(Number.isInteger(value), `genre_override.sci-fi.per_chapter_max must be int: ${key}`);
127
+ assert.ok(value > 0, `genre_override.sci-fi.per_chapter_max must be positive: ${key}`);
128
+ }
129
+ for (const word of ["宛如", "恍若", "仿佛置身于"]) {
130
+ assert.ok(wordSet.has(word), `Missing from words[]: ${word}`);
131
+ assert.ok(categoryWordSets.get("simile_cliche")?.has(word), `Missing from simile_cliche: ${word}`);
132
+ }
133
+ assert.ok(categoryWordSets.get("paragraph_opener")?.has("下一刻"), "下一刻 should be classified as paragraph_opener");
134
+ assert.equal(categoryWordSets.get("narrative_filler")?.has("下一刻"), false, "下一刻 should not remain in narrative_filler");
135
+ for (const [categoryName, word, expectedMax] of [
136
+ ["enumeration_template", "首先", 2],
137
+ ["enumeration_template", "其次", 2],
138
+ ["enumeration_template", "最后", 2],
139
+ ["academic_tone", "例如", 2],
140
+ ["emotion_cliche", "不禁", 1],
141
+ ["emotion_cliche", "心中暗道", 1],
142
+ ["action_cliche", "缓缓说道", 1],
143
+ ["action_cliche", "微微一笑", 1]
144
+ ]) {
145
+ const entry = entryIndex.get(`${categoryName}:${word}`);
146
+ assert.ok(entry, `Missing entry metadata: ${categoryName}:${word}`);
147
+ assert.equal(entry?.per_chapter_max, expectedMax, `Unexpected per_chapter_max for ${categoryName}:${word}`);
148
+ }
149
+ assert.ok(Array.isArray(raw.update_log), "ai-blacklist.json.update_log must be an array");
150
+ const updateLog = raw.update_log;
151
+ assert.ok(updateLog.length >= 1, "update_log should have at least one entry");
152
+ const latest = updateLog[updateLog.length - 1];
153
+ assertPlainObject(latest, "update_log[-1]");
154
+ assert.equal(latest.version, raw.version);
155
+ assert.equal(latest.words_count, words.length);
156
+ });
@@ -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
+ });