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,548 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import test from "node:test";
7
+ import { tmpdir } from "node:os";
8
+ import { fileURLToPath } from "node:url";
9
+ import { buildInstructionPacket } from "../instructions.js";
10
+ const execFileAsync = promisify(execFile);
11
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
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
+ function extractInline(result) {
32
+ const packet = result;
33
+ assert.equal(typeof packet.packet?.manifest?.inline, "object");
34
+ return (packet.packet?.manifest?.inline ?? {});
35
+ }
36
+ async function setupProject(rootDir, options = {}) {
37
+ const genre = options.genre ?? "科幻";
38
+ const overrideNotes = Object.prototype.hasOwnProperty.call(options, "overrideNotes")
39
+ ? (options.overrideNotes ?? null)
40
+ : "单句段 15%-30%;段长上限 120 字;感叹号 ≤ 5/章。";
41
+ await writeText(join(rootDir, "brief.md"), [
42
+ "# brief",
43
+ "",
44
+ `- **题材**:${genre}`,
45
+ ...(overrideNotes !== null ? [`- **覆写说明**:${overrideNotes}`] : []),
46
+ ""
47
+ ].join("\n"));
48
+ await writeJson(join(rootDir, "style-profile.json"), {
49
+ source_type: "original",
50
+ reference_author: null,
51
+ avg_sentence_length: 18,
52
+ sentence_length_range: [8, 32],
53
+ dialogue_ratio: 0.35,
54
+ description_ratio: 0.3,
55
+ action_ratio: 0.35,
56
+ sentence_length_std_dev: 11.2,
57
+ paragraph_length_cv: null,
58
+ emotional_volatility: "high",
59
+ register_mixing: null,
60
+ vocabulary_richness: "high",
61
+ style_exemplars: ["示例片段"],
62
+ writing_directives: [{ directive: "短句推进", do: "他抬手。", dont: "他缓慢地抬起了自己的手臂。" }],
63
+ paragraph_style: { avg_paragraph_length: 78, dialogue_format: "引号式" },
64
+ narrative_voice: "第三人称限制",
65
+ rhetoric_preferences: [],
66
+ forbidden_words: [],
67
+ preferred_expressions: [],
68
+ character_speech_patterns: {},
69
+ analysis_notes: "fixture"
70
+ });
71
+ await writeJson(join(rootDir, "ai-blacklist.json"), {
72
+ version: "2.1.0",
73
+ max_words: 250,
74
+ words: ["深吸一口气"],
75
+ categories: {
76
+ narration_connector: [
77
+ { word: "然而", replacement_hint: "删掉连接词,改用动作或信息落差推进" }
78
+ ],
79
+ action_cliche: [
80
+ { word: "深吸一口气", replacement_hint: "直接写呼吸和动作变化", per_chapter_max: 1 }
81
+ ]
82
+ },
83
+ category_metadata: {
84
+ narration_connector: {
85
+ context: "narration_only",
86
+ description: "仅叙述文禁止"
87
+ }
88
+ },
89
+ whitelist: []
90
+ });
91
+ await writeJson(join(rootDir, "world/rules.json"), { schema_version: 1, rules: [] });
92
+ await writeText(join(rootDir, "volumes/vol-01/outline.md"), [
93
+ "## 第 1 卷大纲",
94
+ "",
95
+ "### 第 1 章: 开端",
96
+ "- **Storyline**: main-arc",
97
+ "- **POV**: hero",
98
+ "- **Location**: city",
99
+ "- **Conflict**: 初入险境",
100
+ "- **Arc**: 建立危机",
101
+ "- **Foreshadowing**: seed-1",
102
+ "- **StateChanges**: Hero 进入城门",
103
+ "- **TransitionHint**: 继续深入",
104
+ "- **ExcitementType**: setup",
105
+ ""
106
+ ].join("\n"));
107
+ await writeJson(join(rootDir, "volumes/vol-01/chapter-contracts/chapter-001.json"), {
108
+ chapter: 1,
109
+ storyline_id: "main-arc",
110
+ excitement_type: "setup",
111
+ objectives: [{ id: "OBJ-1", required: true, description: "推进开场" }],
112
+ preconditions: { character_states: { Hero: { location: "gate" } } },
113
+ postconditions: { state_changes: { Hero: { location: "city" } } },
114
+ acceptance_criteria: ["推进开场"]
115
+ });
116
+ await writeJson(join(rootDir, "state/current-state.json"), { state_version: 1, current_volume: 1, current_chapter: 0, characters: {} });
117
+ await writeText(join(rootDir, "storylines/main-arc/memory.md"), "# 主线\n");
118
+ await writeJson(join(rootDir, "storylines/storyline-spec.json"), { schema_version: 1, storylines: [] });
119
+ }
120
+ test("buildInstructionPacket injects anti-AI statistical targets and genre overrides for chapter draft", async () => {
121
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-draft-"));
122
+ try {
123
+ await setupProject(rootDir);
124
+ const built = (await buildInstructionPacket({
125
+ rootDir,
126
+ checkpoint: makeCheckpoint("committed"),
127
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
128
+ embedMode: null,
129
+ writeManifest: false
130
+ }));
131
+ const inline = extractInline(built);
132
+ const targets = inline.statistical_targets;
133
+ assert.equal(typeof targets, "object");
134
+ assert.equal(targets.sentence_length_std_dev.target, 11.2);
135
+ assert.deepEqual(targets.paragraph_length_cv.fallback_range, [0.4, 1.2]);
136
+ assert.equal(targets.paragraph_length_cv.fallback_applied, true);
137
+ assert.equal(targets.vocabulary_diversity.target, "high");
138
+ assert.equal(targets.register_mixing.target, "medium");
139
+ assert.equal(targets.narration_connectors.target, 0);
140
+ const overrides = inline.genre_overrides;
141
+ assert.equal(overrides.genre, "scifi");
142
+ assert.equal(overrides.source.mode, "brief_override_notes");
143
+ assert.equal(overrides.punctuation_rhythm.exclamation_max_per_chapter, 5);
144
+ assert.equal(overrides.paragraph_structure.max_paragraph_chars, 120);
145
+ }
146
+ finally {
147
+ await rm(rootDir, { recursive: true, force: true });
148
+ }
149
+ });
150
+ test("buildInstructionPacket accepts science-fiction alias for anti-AI genre overrides", async () => {
151
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-draft-science-fiction-"));
152
+ try {
153
+ await setupProject(rootDir, { genre: "science-fiction", overrideNotes: null });
154
+ const built = (await buildInstructionPacket({
155
+ rootDir,
156
+ checkpoint: makeCheckpoint("committed"),
157
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
158
+ embedMode: null,
159
+ writeManifest: false
160
+ }));
161
+ const inline = extractInline(built);
162
+ const overrides = inline.genre_overrides;
163
+ assert.equal(overrides.genre, "scifi");
164
+ assert.equal(overrides.source.mode, "brief_genre_fallback");
165
+ }
166
+ finally {
167
+ await rm(rootDir, { recursive: true, force: true });
168
+ }
169
+ });
170
+ test("buildInstructionPacket parses explicit brief overrides into genre overrides", async () => {
171
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-draft-explicit-"));
172
+ try {
173
+ await setupProject(rootDir, {
174
+ genre: "悬疑",
175
+ overrideNotes: "单句段 18%-28%;段长上限 140 字;省略号 ≤ 6/章;感叹号 ≤ 4/章。"
176
+ });
177
+ const built = (await buildInstructionPacket({
178
+ rootDir,
179
+ checkpoint: makeCheckpoint("committed"),
180
+ step: { kind: "chapter", chapter: 1, stage: "draft" },
181
+ embedMode: null,
182
+ writeManifest: false
183
+ }));
184
+ const inline = extractInline(built);
185
+ const overrides = inline.genre_overrides;
186
+ assert.equal(overrides.genre, "suspense");
187
+ assert.equal(overrides.source.mode, "brief_override_notes");
188
+ assert.deepEqual(overrides.paragraph_structure.single_sentence_ratio, { min: 0.18, max: 0.28 });
189
+ assert.equal(overrides.paragraph_structure.max_paragraph_chars, 140);
190
+ assert.equal(overrides.punctuation_rhythm.ellipsis_max_per_chapter, 6);
191
+ assert.equal(overrides.punctuation_rhythm.exclamation_max_per_chapter, 4);
192
+ }
193
+ finally {
194
+ await rm(rootDir, { recursive: true, force: true });
195
+ }
196
+ });
197
+ test("buildInstructionPacket judge keeps structural lint enabled for default genres without special overrides", async () => {
198
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-judge-default-genre-"));
199
+ try {
200
+ await setupProject(rootDir, { genre: "玄幻", overrideNotes: null });
201
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), [
202
+ "# 第1章",
203
+ "",
204
+ "他心潮澎湃、热血沸腾,抬头看见天门洞开!!",
205
+ ""
206
+ ].join("\n"));
207
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
208
+ const built = (await buildInstructionPacket({
209
+ rootDir,
210
+ checkpoint: makeCheckpoint("refined"),
211
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
212
+ embedMode: null,
213
+ writeManifest: false
214
+ }));
215
+ const inline = extractInline(built);
216
+ const structural = inline.structural_rule_violations;
217
+ assert.ok(Array.isArray(structural));
218
+ assert.ok(structural.length > 0);
219
+ assert.equal(inline.structural_rule_violations_degraded, undefined);
220
+ }
221
+ finally {
222
+ await rm(rootDir, { recursive: true, force: true });
223
+ }
224
+ });
225
+ test("buildInstructionPacket judge applies explicit brief overrides to structural lint thresholds", async () => {
226
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-judge-explicit-override-"));
227
+ try {
228
+ await setupProject(rootDir, {
229
+ genre: "悬疑",
230
+ overrideNotes: "感叹号 ≤ 4/章。"
231
+ });
232
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), [
233
+ "# 第1章",
234
+ "",
235
+ "他先喊了一声!",
236
+ "",
237
+ "她回头时又惊了一下!",
238
+ "",
239
+ "门外的人再次出声!",
240
+ "",
241
+ "走廊尽头还传来一声!",
242
+ "",
243
+ "最后那道门后还有人应了一声!",
244
+ ""
245
+ ].join("\n"));
246
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
247
+ const built = (await buildInstructionPacket({
248
+ rootDir,
249
+ checkpoint: makeCheckpoint("refined"),
250
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
251
+ embedMode: null,
252
+ writeManifest: false
253
+ }));
254
+ const inline = extractInline(built);
255
+ const structural = inline.structural_rule_violations ?? [];
256
+ assert.ok(structural.some((item) => item.rule_id === "L6.exclamation_per_chapter"));
257
+ assert.equal(inline.structural_rule_violations_degraded, undefined);
258
+ }
259
+ finally {
260
+ await rm(rootDir, { recursive: true, force: true });
261
+ }
262
+ });
263
+ test("buildInstructionPacket marks structural lint degraded when packaged structural script cannot run", async () => {
264
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-packaged-degraded-"));
265
+ const originalPath = process.env.PATH;
266
+ try {
267
+ await setupProject(rootDir);
268
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), ["# 第1章", "", "她推门进来。", "", "然而他还站在门口。", ""].join("\n"));
269
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
270
+ process.env.PATH = "";
271
+ const built = (await buildInstructionPacket({
272
+ rootDir,
273
+ checkpoint: makeCheckpoint("refined"),
274
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
275
+ embedMode: null,
276
+ writeManifest: false
277
+ }));
278
+ const inline = extractInline(built);
279
+ assert.equal(inline.blacklist_lint, undefined);
280
+ assert.equal(inline.blacklist_lint_degraded, true);
281
+ assert.equal(inline.structural_rule_violations, undefined);
282
+ assert.equal(inline.structural_rule_violations_degraded, true);
283
+ assert.equal(inline.statistical_profile.source, "deterministic_lint+heuristic");
284
+ }
285
+ finally {
286
+ process.env.PATH = originalPath;
287
+ await rm(rootDir, { recursive: true, force: true });
288
+ }
289
+ });
290
+ test("buildInstructionPacket injects deterministic statistical profile and structural violations for judge", async () => {
291
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-judge-"));
292
+ try {
293
+ await setupProject(rootDir);
294
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), [
295
+ "# 第1章",
296
+ "",
297
+ "“然而我偏要进去。”她说。",
298
+ "",
299
+ "然而他没有动。深吸一口气,深吸一口气。",
300
+ "",
301
+ "心潮澎湃、热血沸腾。门开了——是她!!",
302
+ ""
303
+ ].join("\n"));
304
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
305
+ const built = (await buildInstructionPacket({
306
+ rootDir,
307
+ checkpoint: makeCheckpoint("refined"),
308
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
309
+ embedMode: null,
310
+ writeManifest: false
311
+ }));
312
+ const inline = extractInline(built);
313
+ const blacklistLint = inline.blacklist_lint;
314
+ assert.equal(typeof blacklistLint, "object");
315
+ const profile = inline.statistical_profile;
316
+ assert.equal(profile.source, "deterministic_lint+heuristic");
317
+ assert.equal(profile.narration_connector_count, 1);
318
+ assert.equal(typeof profile.sentence_length_std_dev, "number");
319
+ assert.equal(typeof profile.paragraph_length_cv, "number");
320
+ assert.equal(typeof profile.vocabulary_diversity_score, "number");
321
+ assert.equal(typeof profile.humanize_technique_variety, "number");
322
+ const structural = inline.structural_rule_violations;
323
+ assert.ok(Array.isArray(structural));
324
+ assert.ok(structural.some((item) => item.rule_id === "L6.em_dash_per_chapter"));
325
+ assert.ok(structural.some((item) => item.rule_id === "L6.repeated_exclamation_marks"));
326
+ }
327
+ finally {
328
+ await rm(rootDir, { recursive: true, force: true });
329
+ }
330
+ });
331
+ test("buildInstructionPacket degrades anti-AI context when chapter draft resolves outside project root", async () => {
332
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-unsafe-"));
333
+ const externalDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-external-"));
334
+ try {
335
+ await setupProject(rootDir);
336
+ const externalChapter = join(externalDir, "chapter-001.md");
337
+ await writeText(externalChapter, ["# 第1章", "", "她推门进来。", ""].join("\n"));
338
+ await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
339
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
340
+ await symlink(externalChapter, join(rootDir, "staging/chapters/chapter-001.md"));
341
+ const built = (await buildInstructionPacket({
342
+ rootDir,
343
+ checkpoint: makeCheckpoint("refined"),
344
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
345
+ embedMode: null,
346
+ writeManifest: false
347
+ }));
348
+ const inline = extractInline(built);
349
+ assert.equal(inline.blacklist_lint, undefined);
350
+ assert.equal(inline.blacklist_lint_degraded, true);
351
+ assert.equal(inline.structural_rule_violations, undefined);
352
+ assert.equal(inline.structural_rule_violations_degraded, true);
353
+ assert.equal(inline.statistical_profile, undefined);
354
+ }
355
+ finally {
356
+ await rm(rootDir, { recursive: true, force: true });
357
+ await rm(externalDir, { recursive: true, force: true });
358
+ }
359
+ });
360
+ test("buildInstructionPacket marks anti-AI lints degraded when project override scripts emit invalid JSON", async () => {
361
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-degraded-"));
362
+ try {
363
+ await setupProject(rootDir);
364
+ await writeText(join(rootDir, "staging/chapters/chapter-001.md"), ["# 第1章", "", "她推门进来。", "", "然而他还站在门口。", ""].join("\n"));
365
+ await writeText(join(rootDir, "staging/state/chapter-001-crossref.json"), "{}\n");
366
+ await writeText(join(rootDir, "scripts/lint-blacklist.sh"), "#!/usr/bin/env bash\necho 'not-json'\n");
367
+ await writeText(join(rootDir, "scripts/lint-structural.sh"), "#!/usr/bin/env bash\necho 'not-json'\n");
368
+ const built = (await buildInstructionPacket({
369
+ rootDir,
370
+ checkpoint: makeCheckpoint("refined"),
371
+ step: { kind: "chapter", chapter: 1, stage: "judge" },
372
+ embedMode: null,
373
+ writeManifest: false
374
+ }));
375
+ const inline = extractInline(built);
376
+ assert.equal(inline.blacklist_lint, undefined);
377
+ assert.equal(inline.blacklist_lint_degraded, true);
378
+ assert.equal(inline.structural_rule_violations, undefined);
379
+ assert.equal(inline.structural_rule_violations_degraded, true);
380
+ assert.equal(inline.statistical_profile.source, "deterministic_lint+heuristic");
381
+ }
382
+ finally {
383
+ await rm(rootDir, { recursive: true, force: true });
384
+ }
385
+ });
386
+ test("lint-blacklist.sh skips narration_only hits in dialogue and reports per_chapter_max warnings", async () => {
387
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-lint-blacklist-"));
388
+ try {
389
+ const chapter = join(rootDir, "chapter.md");
390
+ const blacklist = join(rootDir, "ai-blacklist.json");
391
+ await writeText(chapter, ["# 第1章", "", "“然而我不想等。”", "", "然而他还是没动。深吸一口气,深吸一口气。", ""].join("\n"));
392
+ await writeJson(blacklist, {
393
+ words: ["深吸一口气"],
394
+ categories: {
395
+ narration_connector: [{ word: "然而", replacement_hint: "删掉连接词" }],
396
+ action_cliche: [{ word: "深吸一口气", replacement_hint: "写动作变化", per_chapter_max: 1 }]
397
+ },
398
+ category_metadata: {
399
+ narration_connector: { context: "narration_only", description: "仅叙述文" }
400
+ },
401
+ whitelist: []
402
+ });
403
+ const { stdout } = await execFileAsync("bash", [join(repoRoot, "scripts/lint-blacklist.sh"), chapter, blacklist], { cwd: repoRoot });
404
+ const report = JSON.parse(stdout);
405
+ assert.equal(report.statistical_profile.narration_connector_count, 1);
406
+ const hits = report.hits;
407
+ assert.ok(hits.some((item) => item.word === "然而" && item.count === 1));
408
+ assert.ok(hits.some((item) => item.word === "深吸一口气" && item.replacement_hint === "写动作变化"));
409
+ const warnings = report.warnings;
410
+ assert.ok(warnings.some((item) => item.code === "per_chapter_max_exceeded"));
411
+ const perChapterLimitHits = report.per_chapter_limit_hits;
412
+ assert.deepEqual(perChapterLimitHits[0]?.word, "深吸一口气");
413
+ }
414
+ finally {
415
+ await rm(rootDir, { recursive: true, force: true });
416
+ }
417
+ });
418
+ test("lint-blacklist.sh emits non-blocking warning for quote parity mismatch", async () => {
419
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-lint-blacklist-quotes-"));
420
+ try {
421
+ const chapter = join(rootDir, "chapter.md");
422
+ const blacklist = join(rootDir, "ai-blacklist.json");
423
+ await writeText(chapter, ["# 第1章", "", "“然而我不想等。", ""].join("\n"));
424
+ await writeJson(blacklist, { words: [], categories: {}, whitelist: [] });
425
+ const { stdout } = await execFileAsync("bash", [join(repoRoot, "scripts/lint-blacklist.sh"), chapter, blacklist], { cwd: repoRoot });
426
+ const report = JSON.parse(stdout);
427
+ const warnings = report.warnings;
428
+ assert.ok(warnings.some((item) => item.code === "quote_parity_mismatch"));
429
+ }
430
+ finally {
431
+ await rm(rootDir, { recursive: true, force: true });
432
+ }
433
+ });
434
+ test("lint-structural.sh flags violations and respects sci-fi genre overrides", async () => {
435
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-lint-structural-"));
436
+ try {
437
+ const cleanChapter = join(rootDir, "clean.md");
438
+ const noisyChapter = join(rootDir, "noisy.md");
439
+ const sciFiChapter = join(rootDir, "scifi.md");
440
+ const offsetChapter = join(rootDir, "offset.md");
441
+ await writeText(cleanChapter, [
442
+ "# 第1章",
443
+ "",
444
+ "他推门进去。屋里很安静。灯还亮着。",
445
+ "",
446
+ "她抬眼看他,没有说话。杯子边缘还冒着热气,她把杯底轻轻转了半圈,才把它推到他手边。",
447
+ "",
448
+ "他坐下后先看了一眼窗外。",
449
+ ""
450
+ ].join("\n"));
451
+ await writeText(noisyChapter, [
452
+ "# 第1章",
453
+ "",
454
+ "非常巨大冰冷漆黑荒凉的风猛地灌了进来,十分沉重,无比压抑,极其潮湿,苍白而急促。",
455
+ "",
456
+ "心潮澎湃、热血沸腾、激动万分。",
457
+ "",
458
+ "门开了——是她!!",
459
+ ""
460
+ ].join("\n"));
461
+ await writeText(sciFiChapter, [
462
+ "# 第1章",
463
+ "",
464
+ "他盯着舷窗外的碎光!她听见告警!舰桥尽头还有人喊!引擎又抖了一下!警报仍在催促!空气像烧红的铁!",
465
+ ""
466
+ ].join("\n"));
467
+ await writeText(offsetChapter, [
468
+ "# 第1章",
469
+ "",
470
+ "他只是站着,没有立刻开口。".repeat(24),
471
+ "",
472
+ "她把灯重新拨亮,又把窗帘轻轻掀开。".repeat(20),
473
+ "",
474
+ "非常巨大冰冷漆黑荒凉的风猛地灌了进来,十分沉重,无比压抑,极其潮湿,苍白而急促。",
475
+ ""
476
+ ].join("\n"));
477
+ const clean = JSON.parse((await execFileAsync("bash", [join(repoRoot, "scripts/lint-structural.sh"), cleanChapter], { cwd: repoRoot })).stdout);
478
+ assert.deepEqual(clean.summary.total, 0);
479
+ const noisy = JSON.parse((await execFileAsync("bash", [join(repoRoot, "scripts/lint-structural.sh"), noisyChapter], { cwd: repoRoot })).stdout);
480
+ const noisyViolations = noisy.violations ?? [];
481
+ const noisyRules = new Set(noisyViolations.map((item) => item.rule_id));
482
+ assert.ok(noisyRules.has("L2.emphasis_density"));
483
+ assert.ok(noisyRules.has("L3.idiom_chain"));
484
+ assert.ok(noisyRules.has("L6.em_dash_per_chapter"));
485
+ assert.ok(noisyRules.has("L6.repeated_exclamation_marks"));
486
+ const idiomChain = noisyViolations.find((item) => item.rule_id === "L3.idiom_chain");
487
+ assert.equal((idiomChain.location.line), 5);
488
+ assert.ok((idiomChain.location.char_start) > 0);
489
+ const offset = JSON.parse((await execFileAsync("bash", [join(repoRoot, "scripts/lint-structural.sh"), offsetChapter], { cwd: repoRoot })).stdout);
490
+ const offsetViolations = offset.violations ?? [];
491
+ const offsetEmphasis = offsetViolations.find((item) => item.rule_id === "L2.emphasis_density");
492
+ assert.ok(offsetEmphasis);
493
+ assert.ok((offsetEmphasis.location.line) > 1);
494
+ assert.ok((offsetEmphasis.location.char_start) > 0);
495
+ const sciFi = JSON.parse((await execFileAsync("bash", [join(repoRoot, "scripts/lint-structural.sh"), sciFiChapter, "--genre", "科幻"], { cwd: repoRoot })).stdout);
496
+ assert.ok((sciFi.violations ?? []).some((item) => item.rule_id === "L6.exclamation_per_chapter"));
497
+ const scienceFiction = JSON.parse((await execFileAsync("bash", [join(repoRoot, "scripts/lint-structural.sh"), sciFiChapter, "--genre", "science-fiction"], { cwd: repoRoot })).stdout);
498
+ assert.ok((scienceFiction.violations ?? []).some((item) => item.rule_id === "L6.exclamation_per_chapter"));
499
+ const xuanhuan = JSON.parse((await execFileAsync("bash", [join(repoRoot, "scripts/lint-structural.sh"), cleanChapter, "--genre", "玄幻"], { cwd: repoRoot })).stdout);
500
+ assert.deepEqual(xuanhuan.summary.total, 0);
501
+ }
502
+ finally {
503
+ await rm(rootDir, { recursive: true, force: true });
504
+ }
505
+ });
506
+ test("anti-AI docs and style-analyzer prompt describe the new infrastructure", async () => {
507
+ const styleAnalyzer = await readFile(join(repoRoot, "agents/style-analyzer.md"), "utf8");
508
+ const contextContracts = await readFile(join(repoRoot, "skills/continue/references/context-contracts.md"), "utf8");
509
+ const qualityRubric = await readFile(join(repoRoot, "skills/novel-writing/references/quality-rubric.md"), "utf8");
510
+ const periodicMaintenance = await readFile(join(repoRoot, "skills/continue/references/periodic-maintenance.md"), "utf8");
511
+ assert.match(styleAnalyzer, /sentence_length_std_dev/);
512
+ assert.match(styleAnalyzer, /paragraph_length_cv/);
513
+ assert.match(styleAnalyzer, /emotional_volatility/);
514
+ assert.match(styleAnalyzer, /register_mixing/);
515
+ assert.match(styleAnalyzer, /vocabulary_richness/);
516
+ assert.match(contextContracts, /statistical_targets/);
517
+ assert.match(contextContracts, /genre_overrides/);
518
+ assert.match(contextContracts, /structural_rule_violations_degraded/);
519
+ assert.doesNotMatch(contextContracts, /paragraph_char_max/);
520
+ assert.doesNotMatch(contextContracts, /ellipsis_per_chapter_max/);
521
+ assert.doesNotMatch(contextContracts, /blacklist_overrides/);
522
+ assert.match(qualityRubric, /zone → score 映射/);
523
+ assert.match(qualityRubric, /structural_rule_violations/);
524
+ assert.match(periodicMaintenance, /max_words=250/);
525
+ assert.match(periodicMaintenance, /logs\/anti-ai\/technique-history\.json/);
526
+ });
527
+ test("labeled chapter schema exposes optional anti-AI fields and keeps existing samples compatible", async () => {
528
+ const schema = JSON.parse(await readFile(join(repoRoot, "eval/schema/labeled-chapter.schema.json"), "utf8"));
529
+ const properties = schema.properties;
530
+ const defs = schema.$defs;
531
+ assert.equal(typeof properties.anti_ai_statistical_profile, "object");
532
+ assert.equal(typeof properties.structural_rule_violations, "object");
533
+ assert.equal(properties.anti_ai_statistical_profile.additionalProperties, true);
534
+ assert.equal(defs.structural_rule_violation.properties.severity.$ref, "#/$defs/diagnostic_severity");
535
+ const lines = (await readFile(join(repoRoot, "eval/fixtures/labels.demo.jsonl"), "utf8"))
536
+ .trim()
537
+ .split("\n")
538
+ .filter((line) => line.length > 0)
539
+ .map((line) => JSON.parse(line));
540
+ for (const record of lines) {
541
+ assert.equal(record.schema_version, 1);
542
+ assert.equal(typeof record.chapter, "number");
543
+ assert.equal(typeof record.labels, "object");
544
+ assert.equal(typeof record.human_scores, "object");
545
+ assert.equal(Object.prototype.hasOwnProperty.call(record, "anti_ai_statistical_profile"), false);
546
+ assert.equal(Object.prototype.hasOwnProperty.call(record, "structural_rule_violations"), false);
547
+ }
548
+ });