novel-writer-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/agents/chapter-writer.md +142 -0
  4. package/agents/character-weaver.md +117 -0
  5. package/agents/consistency-auditor.md +85 -0
  6. package/agents/plot-architect.md +128 -0
  7. package/agents/quality-judge.md +232 -0
  8. package/agents/style-analyzer.md +109 -0
  9. package/agents/style-refiner.md +97 -0
  10. package/agents/summarizer.md +128 -0
  11. package/agents/world-builder.md +161 -0
  12. package/dist/__tests__/character-voice.test.js +445 -0
  13. package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
  14. package/dist/__tests__/engagement.test.js +382 -0
  15. package/dist/__tests__/foreshadow-visibility.test.js +131 -0
  16. package/dist/__tests__/hook-ledger.test.js +1028 -0
  17. package/dist/__tests__/naming-lint.test.js +132 -0
  18. package/dist/__tests__/narrative-health-injection.test.js +359 -0
  19. package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
  20. package/dist/__tests__/next-step-title-fix.test.js +153 -0
  21. package/dist/__tests__/platform-profile.test.js +274 -0
  22. package/dist/__tests__/promise-ledger.test.js +189 -0
  23. package/dist/__tests__/readability-lint.test.js +209 -0
  24. package/dist/__tests__/text-utils.test.js +39 -0
  25. package/dist/__tests__/title-policy.test.js +147 -0
  26. package/dist/advance.js +75 -0
  27. package/dist/character-voice.js +805 -0
  28. package/dist/checkpoint.js +126 -0
  29. package/dist/cli.js +563 -0
  30. package/dist/cliche-lint.js +515 -0
  31. package/dist/commit.js +1460 -0
  32. package/dist/consistency-auditor.js +684 -0
  33. package/dist/engagement.js +687 -0
  34. package/dist/errors.js +7 -0
  35. package/dist/fingerprint.js +16 -0
  36. package/dist/foreshadow-visibility.js +214 -0
  37. package/dist/fs-utils.js +68 -0
  38. package/dist/hook-ledger.js +721 -0
  39. package/dist/hook-policy.js +107 -0
  40. package/dist/instruction-gates.js +51 -0
  41. package/dist/instructions.js +406 -0
  42. package/dist/latest-summary-loader.js +29 -0
  43. package/dist/lock.js +121 -0
  44. package/dist/naming-lint.js +531 -0
  45. package/dist/ner.js +73 -0
  46. package/dist/next-step.js +408 -0
  47. package/dist/novel-ask.js +270 -0
  48. package/dist/output.js +9 -0
  49. package/dist/platform-constraints.js +518 -0
  50. package/dist/platform-profile.js +325 -0
  51. package/dist/prejudge-guardrails.js +370 -0
  52. package/dist/project.js +40 -0
  53. package/dist/promise-ledger.js +723 -0
  54. package/dist/readability-lint.js +555 -0
  55. package/dist/safe-parse.js +36 -0
  56. package/dist/safe-path.js +29 -0
  57. package/dist/scoring-weights.js +290 -0
  58. package/dist/steps.js +60 -0
  59. package/dist/text-utils.js +18 -0
  60. package/dist/title-policy.js +251 -0
  61. package/dist/type-guards.js +6 -0
  62. package/dist/validate.js +131 -0
  63. package/docs/user/README.md +17 -0
  64. package/docs/user/guardrails.md +179 -0
  65. package/docs/user/interactive-gates.md +124 -0
  66. package/docs/user/novel-cli.md +289 -0
  67. package/docs/user/ops.md +123 -0
  68. package/docs/user/quick-start.md +97 -0
  69. package/docs/user/spec-system.md +166 -0
  70. package/docs/user/storylines.md +144 -0
  71. package/package.json +48 -0
  72. package/schemas/README.md +18 -0
  73. package/schemas/character-voice-drift.schema.json +135 -0
  74. package/schemas/character-voice-profiles.schema.json +141 -0
  75. package/schemas/engagement-metrics.schema.json +38 -0
  76. package/schemas/hook-ledger.schema.json +108 -0
  77. package/schemas/platform-profile.schema.json +235 -0
  78. package/schemas/promise-ledger.schema.json +97 -0
  79. package/scripts/calibrate-quality-judge.sh +91 -0
  80. package/scripts/compare-regression-runs.sh +86 -0
  81. package/scripts/lib/_common.py +131 -0
  82. package/scripts/lib/calibrate_quality_judge.py +312 -0
  83. package/scripts/lib/compare_regression_runs.py +142 -0
  84. package/scripts/lib/run_regression.py +621 -0
  85. package/scripts/lint-blacklist.sh +201 -0
  86. package/scripts/lint-cliche.sh +370 -0
  87. package/scripts/lint-readability.sh +404 -0
  88. package/scripts/query-foreshadow.sh +252 -0
  89. package/scripts/run-ner.sh +669 -0
  90. package/scripts/run-regression.sh +122 -0
  91. package/skills/cli-step/SKILL.md +158 -0
  92. package/skills/continue/SKILL.md +348 -0
  93. package/skills/continue/references/context-contracts.md +169 -0
  94. package/skills/continue/references/continuity-checks.md +187 -0
  95. package/skills/continue/references/file-protocols.md +64 -0
  96. package/skills/continue/references/foreshadowing.md +130 -0
  97. package/skills/continue/references/gate-decision.md +53 -0
  98. package/skills/continue/references/periodic-maintenance.md +46 -0
  99. package/skills/novel-writing/SKILL.md +77 -0
  100. package/skills/novel-writing/references/quality-rubric.md +140 -0
  101. package/skills/novel-writing/references/style-guide.md +145 -0
  102. package/skills/start/SKILL.md +458 -0
  103. package/skills/start/references/quality-review.md +86 -0
  104. package/skills/start/references/setting-update.md +44 -0
  105. package/skills/start/references/vol-planning.md +61 -0
  106. package/skills/start/references/vol-review.md +58 -0
  107. package/skills/status/SKILL.md +116 -0
  108. package/skills/status/references/sample-output.md +60 -0
  109. package/templates/ai-blacklist.json +79 -0
  110. package/templates/brief-template.md +46 -0
  111. package/templates/genre-weight-profiles.json +90 -0
  112. package/templates/novel-ask/example.answer.json +12 -0
  113. package/templates/novel-ask/example.question.json +51 -0
  114. package/templates/platform-profile.json +148 -0
  115. package/templates/style-profile-template.json +58 -0
  116. package/templates/web-novel-cliche-lint.json +41 -0
@@ -0,0 +1,382 @@
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 { appendEngagementMetricRecord, computeEngagementMetricRecord, computeEngagementReport, loadEngagementMetricsStream, summarizeEngagementReport, writeEngagementLogs } from "../engagement.js";
7
+ async function writeText(absPath, contents) {
8
+ await mkdir(dirname(absPath), { recursive: true });
9
+ await writeFile(absPath, contents, "utf8");
10
+ }
11
+ async function writeJson(absPath, payload) {
12
+ await mkdir(dirname(absPath), { recursive: true });
13
+ await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
14
+ }
15
+ test("computeEngagementMetricRecord prefers platform_constraints word_count and key events beats", async () => {
16
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-engagement-metric-"));
17
+ await writeText(join(rootDir, "chapters/chapter-001.md"), "你好 世界\n");
18
+ await writeText(join(rootDir, "summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n### 关键事件\n- 主角遭遇危机\n- 反转揭示真相\n`);
19
+ await writeJson(join(rootDir, "evaluations/chapter-001-eval.json"), {
20
+ platform_constraints: { word_count: { chars: 123 }, info_load: { new_terms_per_1k_words: 2.5 } }
21
+ });
22
+ const computed = await computeEngagementMetricRecord({
23
+ rootDir,
24
+ chapter: 1,
25
+ volume: 1,
26
+ chapterRel: "chapters/chapter-001.md",
27
+ summaryRel: "summaries/chapter-001-summary.md",
28
+ evalRel: "evaluations/chapter-001-eval.json"
29
+ });
30
+ assert.equal(computed.record.word_count, 123);
31
+ assert.equal(computed.record.plot_progression_beats, 2);
32
+ assert.ok(computed.record.notes.includes("platform_constraints"));
33
+ assert.ok(computed.record.notes.includes("key_events"));
34
+ });
35
+ test("computeEngagementMetricRecord falls back to summary bullets when key events are missing", async () => {
36
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-engagement-metric-bullets-"));
37
+ await writeText(join(rootDir, "chapters/chapter-001.md"), "你好 世界\n");
38
+ await writeText(join(rootDir, "summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n- 主角遭遇危机,与强敌对峙\n- 反转揭示真相,获得奖励\n`);
39
+ const computed = await computeEngagementMetricRecord({
40
+ rootDir,
41
+ chapter: 1,
42
+ volume: 1,
43
+ chapterRel: "chapters/chapter-001.md",
44
+ summaryRel: "summaries/chapter-001-summary.md",
45
+ evalRel: "evaluations/chapter-001-eval.json"
46
+ });
47
+ assert.equal(computed.record.plot_progression_beats, 2);
48
+ assert.ok(computed.record.conflict_intensity >= 2);
49
+ assert.ok(computed.record.payoff_score >= 2);
50
+ assert.ok(computed.record.notes.includes("summary_bullets"));
51
+ });
52
+ test("computeEngagementReport flags low-density stretches and trends", () => {
53
+ const records = [];
54
+ for (let chapter = 1; chapter <= 5; chapter += 1) {
55
+ records.push({
56
+ schema_version: 1,
57
+ generated_at: "2026-01-01T00:00:00.000Z",
58
+ chapter,
59
+ volume: 1,
60
+ word_count: 1000,
61
+ plot_progression_beats: 1,
62
+ conflict_intensity: 1,
63
+ payoff_score: 1,
64
+ new_info_load_score: 1,
65
+ notes: "test"
66
+ });
67
+ }
68
+ const report = computeEngagementReport({ records, asOfChapter: 5, volume: 1, chapterRange: { start: 1, end: 5 } });
69
+ const ids = new Set(report.issues.map((i) => i.id));
70
+ assert.ok(ids.has("engagement.low_density.low_plot_beats_stretch"));
71
+ assert.ok(ids.has("engagement.low_density.low_payoff_trend"));
72
+ assert.ok(ids.has("engagement.low_density.conflict_plateau"));
73
+ assert.equal(report.has_blocking_issues, false);
74
+ });
75
+ test("computeEngagementReport does not treat gaps as consecutive stretches", () => {
76
+ const records = [
77
+ {
78
+ schema_version: 1,
79
+ generated_at: "2026-01-01T00:00:00.000Z",
80
+ chapter: 1,
81
+ volume: 1,
82
+ word_count: 1000,
83
+ plot_progression_beats: 1,
84
+ conflict_intensity: 1,
85
+ payoff_score: 1,
86
+ new_info_load_score: 1,
87
+ notes: "test"
88
+ },
89
+ {
90
+ schema_version: 1,
91
+ generated_at: "2026-01-01T00:00:00.000Z",
92
+ chapter: 3,
93
+ volume: 1,
94
+ word_count: 1000,
95
+ plot_progression_beats: 1,
96
+ conflict_intensity: 1,
97
+ payoff_score: 1,
98
+ new_info_load_score: 1,
99
+ notes: "test"
100
+ },
101
+ {
102
+ schema_version: 1,
103
+ generated_at: "2026-01-01T00:00:00.000Z",
104
+ chapter: 4,
105
+ volume: 1,
106
+ word_count: 1000,
107
+ plot_progression_beats: 1,
108
+ conflict_intensity: 1,
109
+ payoff_score: 1,
110
+ new_info_load_score: 1,
111
+ notes: "test"
112
+ }
113
+ ];
114
+ const report = computeEngagementReport({ records, asOfChapter: 4, volume: 1, chapterRange: { start: 1, end: 4 } });
115
+ assert.equal(report.issues.length, 0);
116
+ });
117
+ test("computeEngagementReport skips tail-based warnings when the last 5 chapters are incomplete", () => {
118
+ const records = [
119
+ {
120
+ schema_version: 1,
121
+ generated_at: "2026-01-01T00:00:00.000Z",
122
+ chapter: 1,
123
+ volume: 1,
124
+ word_count: 1000,
125
+ plot_progression_beats: 2,
126
+ conflict_intensity: 1,
127
+ payoff_score: 1,
128
+ new_info_load_score: 1,
129
+ notes: "test"
130
+ },
131
+ {
132
+ schema_version: 1,
133
+ generated_at: "2026-01-01T00:00:00.000Z",
134
+ chapter: 2,
135
+ volume: 1,
136
+ word_count: 1000,
137
+ plot_progression_beats: 2,
138
+ conflict_intensity: 1,
139
+ payoff_score: 1,
140
+ new_info_load_score: 1,
141
+ notes: "test"
142
+ },
143
+ {
144
+ schema_version: 1,
145
+ generated_at: "2026-01-01T00:00:00.000Z",
146
+ chapter: 3,
147
+ volume: 1,
148
+ word_count: 1000,
149
+ plot_progression_beats: 2,
150
+ conflict_intensity: 1,
151
+ payoff_score: 1,
152
+ new_info_load_score: 1,
153
+ notes: "test"
154
+ },
155
+ {
156
+ schema_version: 1,
157
+ generated_at: "2026-01-01T00:00:00.000Z",
158
+ chapter: 4,
159
+ volume: 1,
160
+ word_count: 1000,
161
+ plot_progression_beats: 2,
162
+ conflict_intensity: 1,
163
+ payoff_score: 1,
164
+ new_info_load_score: 1,
165
+ notes: "test"
166
+ },
167
+ {
168
+ schema_version: 1,
169
+ generated_at: "2026-01-01T00:00:00.000Z",
170
+ chapter: 6,
171
+ volume: 1,
172
+ word_count: 1000,
173
+ plot_progression_beats: 2,
174
+ conflict_intensity: 1,
175
+ payoff_score: 1,
176
+ new_info_load_score: 1,
177
+ notes: "test"
178
+ }
179
+ ];
180
+ const report = computeEngagementReport({ records, asOfChapter: 6, volume: 1, chapterRange: { start: 1, end: 6 } });
181
+ assert.equal(report.issues.length, 0);
182
+ });
183
+ test("writeEngagementLogs keeps latest.json monotonic by chapter (and generated_at tie-break)", async () => {
184
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-engagement-log-mono-"));
185
+ const mkReport = (chapter, generated_at) => ({
186
+ schema_version: 1,
187
+ generated_at,
188
+ as_of: { chapter, volume: 1 },
189
+ scope: { volume: 1, chapter_start: Math.max(1, chapter - 9), chapter_end: chapter },
190
+ metrics_stream_path: "engagement-metrics.jsonl",
191
+ metrics: [],
192
+ stats: {
193
+ chapters: 0,
194
+ avg_word_count: null,
195
+ avg_plot_progression_beats: null,
196
+ avg_conflict_intensity: null,
197
+ avg_payoff_score: null,
198
+ avg_new_info_load_score: null
199
+ },
200
+ issues: [],
201
+ has_blocking_issues: false
202
+ });
203
+ await writeEngagementLogs({ rootDir, report: mkReport(5, "2026-01-01T00:00:00.000Z"), historyRange: null });
204
+ await writeEngagementLogs({ rootDir, report: mkReport(4, "2026-01-02T00:00:00.000Z"), historyRange: null });
205
+ const latestAbs = join(rootDir, "logs", "engagement", "latest.json");
206
+ const raw = JSON.parse(await readFile(latestAbs, "utf8"));
207
+ assert.equal(raw.as_of.chapter, 5);
208
+ await writeEngagementLogs({ rootDir, report: mkReport(5, "2025-01-01T00:00:00.000Z"), historyRange: null });
209
+ const raw2 = JSON.parse(await readFile(latestAbs, "utf8"));
210
+ assert.equal(raw2.generated_at, "2026-01-01T00:00:00.000Z");
211
+ await writeEngagementLogs({ rootDir, report: mkReport(5, "2027-01-01T00:00:00.000Z"), historyRange: null });
212
+ const raw3 = JSON.parse(await readFile(latestAbs, "utf8"));
213
+ assert.equal(raw3.generated_at, "2027-01-01T00:00:00.000Z");
214
+ });
215
+ test("writeEngagementLogs writes latest + history when requested", async () => {
216
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-engagement-logs-"));
217
+ const report = computeEngagementReport({ records: [], asOfChapter: 10, volume: 2, chapterRange: { start: 1, end: 10 } });
218
+ const written = await writeEngagementLogs({ rootDir, report, historyRange: { start: 1, end: 10 } });
219
+ assert.equal(written.latestRel, "logs/engagement/latest.json");
220
+ assert.equal(written.historyRel, "logs/engagement/engagement-report-vol-02-ch001-ch010.json");
221
+ const latestRaw = JSON.parse(await readFile(join(rootDir, written.latestRel), "utf8"));
222
+ assert.equal(latestRaw.schema_version, 1);
223
+ const historyRaw = JSON.parse(await readFile(join(rootDir, written.historyRel), "utf8"));
224
+ assert.equal(historyRaw.schema_version, 1);
225
+ });
226
+ test("appendEngagementMetricRecord writes a JSONL line under the default path", async () => {
227
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-engagement-jsonl-"));
228
+ const record = {
229
+ schema_version: 1,
230
+ generated_at: "2026-01-01T00:00:00.000Z",
231
+ chapter: 1,
232
+ volume: 1,
233
+ word_count: 100,
234
+ plot_progression_beats: 2,
235
+ conflict_intensity: 2,
236
+ payoff_score: 3,
237
+ new_info_load_score: 2,
238
+ notes: "test"
239
+ };
240
+ const written = await appendEngagementMetricRecord({ rootDir, record });
241
+ assert.equal(written.rel, "engagement-metrics.jsonl");
242
+ const raw = await readFile(join(rootDir, written.rel), "utf8");
243
+ const lines = raw.trim().split(/\r?\n/gu);
244
+ assert.equal(lines.length, 1);
245
+ const parsed = JSON.parse(lines[0] ?? "");
246
+ assert.equal(parsed.chapter, 1);
247
+ assert.equal(parsed.word_count, 100);
248
+ });
249
+ test("loadEngagementMetricsStream skips invalid JSON and invalid records", async () => {
250
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-engagement-load-"));
251
+ const lines = [];
252
+ lines.push(JSON.stringify({
253
+ schema_version: 1,
254
+ generated_at: "2026-01-01T00:00:00.000Z",
255
+ chapter: 1,
256
+ volume: 1,
257
+ word_count: 100,
258
+ plot_progression_beats: 2,
259
+ conflict_intensity: 2,
260
+ payoff_score: 3,
261
+ new_info_load_score: 2,
262
+ notes: "ok"
263
+ }));
264
+ lines.push("{");
265
+ lines.push(JSON.stringify({
266
+ schema_version: 2,
267
+ generated_at: "2026-01-01T00:00:00.000Z",
268
+ chapter: 2,
269
+ volume: 1,
270
+ word_count: 100,
271
+ plot_progression_beats: 2,
272
+ conflict_intensity: 2,
273
+ payoff_score: 3,
274
+ new_info_load_score: 2,
275
+ notes: "wrong schema"
276
+ }));
277
+ lines.push(JSON.stringify({
278
+ schema_version: 1,
279
+ generated_at: "2026-01-01T00:00:00.000Z",
280
+ chapter: 3,
281
+ volume: 1,
282
+ word_count: 100,
283
+ plot_progression_beats: 2,
284
+ conflict_intensity: 2,
285
+ payoff_score: 3,
286
+ new_info_load_score: 2
287
+ }));
288
+ lines.push(JSON.stringify({
289
+ schema_version: 1,
290
+ generated_at: "2026-01-01",
291
+ chapter: 4,
292
+ volume: 1,
293
+ word_count: 100,
294
+ plot_progression_beats: 2,
295
+ conflict_intensity: 2,
296
+ payoff_score: 3,
297
+ new_info_load_score: 2,
298
+ notes: "bad timestamp"
299
+ }));
300
+ lines.push(JSON.stringify({
301
+ schema_version: 1,
302
+ generated_at: "2026-02-29T00:00:00.000Z",
303
+ chapter: 5,
304
+ volume: 1,
305
+ word_count: 100,
306
+ plot_progression_beats: 2,
307
+ conflict_intensity: 2,
308
+ payoff_score: 3,
309
+ new_info_load_score: 2,
310
+ notes: "invalid calendar date"
311
+ }));
312
+ lines.push(JSON.stringify({
313
+ schema_version: 1,
314
+ generated_at: "2026-01-02T00:00:00.000Z",
315
+ chapter: 5,
316
+ volume: 1,
317
+ word_count: 100,
318
+ plot_progression_beats: 2,
319
+ conflict_intensity: 2,
320
+ payoff_score: 3,
321
+ new_info_load_score: 2,
322
+ notes: "ok2"
323
+ }));
324
+ await writeText(join(rootDir, "engagement-metrics.jsonl"), `${lines.join("\n")}\n`);
325
+ const loaded = await loadEngagementMetricsStream({ rootDir });
326
+ assert.equal(loaded.rel, "engagement-metrics.jsonl");
327
+ assert.equal(loaded.records.length, 2);
328
+ assert.equal(loaded.records[0]?.chapter, 1);
329
+ assert.equal(loaded.records[1]?.chapter, 5);
330
+ assert.ok(loaded.warnings.length >= 5);
331
+ });
332
+ test("loadEngagementMetricsStream rejects invalid calendar dates (no Date.parse normalization)", async () => {
333
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-engagement-load-date-"));
334
+ await writeText(join(rootDir, "engagement-metrics.jsonl"), `${JSON.stringify({
335
+ schema_version: 1,
336
+ generated_at: "2026-02-29T00:00:00Z",
337
+ chapter: 1,
338
+ volume: 1,
339
+ word_count: 100,
340
+ plot_progression_beats: 2,
341
+ conflict_intensity: 2,
342
+ payoff_score: 3,
343
+ new_info_load_score: 2,
344
+ notes: "invalid calendar date"
345
+ })}\n`);
346
+ const loaded = await loadEngagementMetricsStream({ rootDir });
347
+ assert.equal(loaded.records.length, 0);
348
+ assert.equal(loaded.warnings.length, 1);
349
+ });
350
+ test("summarizeEngagementReport trims, caps issues, and truncates surrogate-pair-safe", () => {
351
+ const longSummary = `${"x".repeat(238)}😀${"y".repeat(20)}`;
352
+ const raw = {
353
+ schema_version: 1,
354
+ as_of: { chapter: 10, volume: 1 },
355
+ scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
356
+ stats: {
357
+ chapters: 10,
358
+ avg_word_count: 3000,
359
+ avg_plot_progression_beats: 2,
360
+ avg_conflict_intensity: 3,
361
+ avg_payoff_score: 2,
362
+ avg_new_info_load_score: 3
363
+ },
364
+ issues: Array.from({ length: 7 }).map((_, i) => ({
365
+ id: `engagement.issue.${i + 1}`,
366
+ severity: "warn",
367
+ summary: i === 0 ? ` ${longSummary} ` : `Issue ${i + 1}`,
368
+ suggestion: "Add a small reveal or reward beat in the next chapter."
369
+ })),
370
+ has_blocking_issues: false
371
+ };
372
+ const summary = summarizeEngagementReport(raw);
373
+ assert.equal(summary.as_of.chapter, 10);
374
+ assert.equal(summary.stats.chapters, 10);
375
+ assert.equal(summary.has_blocking_issues, false);
376
+ assert.equal(summary.issues.length, 5);
377
+ const truncated = String(summary.issues[0]?.summary ?? "");
378
+ assert.ok(truncated.endsWith("…"));
379
+ const lastBeforeEllipsis = truncated.charCodeAt(Math.max(0, truncated.length - 2));
380
+ assert.ok(lastBeforeEllipsis < 0xd800 || lastBeforeEllipsis > 0xdbff);
381
+ assert.equal(summarizeEngagementReport({ schema_version: 2 }), null);
382
+ });
@@ -0,0 +1,131 @@
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 { join } from "node:path";
5
+ import test from "node:test";
6
+ import { computeForeshadowVisibilityReport, deriveForeshadowDormancyThresholds, loadForeshadowGlobalItems, writeForeshadowVisibilityLogs } from "../foreshadow-visibility.js";
7
+ test("deriveForeshadowDormancyThresholds adjusts by genre_drive_type", () => {
8
+ assert.deepEqual(deriveForeshadowDormancyThresholds(null), { short: 6, medium: 12, long: 24 });
9
+ assert.deepEqual(deriveForeshadowDormancyThresholds("plot"), { short: 4, medium: 10, long: 22 });
10
+ assert.deepEqual(deriveForeshadowDormancyThresholds("character"), { short: 5, medium: 11, long: 23 });
11
+ assert.deepEqual(deriveForeshadowDormancyThresholds("slice_of_life"), { short: 8, medium: 14, long: 26 });
12
+ assert.deepEqual(deriveForeshadowDormancyThresholds("suspense"), { short: 3, medium: 9, long: 21 });
13
+ });
14
+ test("computeForeshadowVisibilityReport includes items at the dormancy threshold", () => {
15
+ const items = [
16
+ { id: "a", scope: "short", status: "planted", last_updated_chapter: 10, description: "A" },
17
+ { id: "b", scope: "short", status: "planted", last_updated_chapter: 11, description: "B" }
18
+ ];
19
+ const report = computeForeshadowVisibilityReport({
20
+ items,
21
+ asOfChapter: 16, // short base threshold is 6
22
+ volume: 1,
23
+ platform: null,
24
+ genreDriveType: null
25
+ });
26
+ const ids = report.dormant_items.map((it) => it.id);
27
+ assert.ok(ids.includes("a"), "expected item at threshold to be included");
28
+ assert.ok(!ids.includes("b"), "expected item below threshold to be excluded");
29
+ });
30
+ test("computeForeshadowVisibilityReport excludes resolved items (even if dormant)", () => {
31
+ const items = [
32
+ { id: "a", scope: "short", status: "resolved", last_updated_chapter: 0, description: "A" }
33
+ ];
34
+ const report = computeForeshadowVisibilityReport({
35
+ items,
36
+ asOfChapter: 100,
37
+ volume: 1,
38
+ platform: null,
39
+ genreDriveType: null
40
+ });
41
+ assert.deepEqual(report.dormant_items, []);
42
+ assert.equal(report.counts.dormant_total, 0);
43
+ });
44
+ test("computeForeshadowVisibilityReport returns empty dormant list for empty input", () => {
45
+ const report = computeForeshadowVisibilityReport({
46
+ items: [],
47
+ asOfChapter: 10,
48
+ volume: 1,
49
+ platform: null,
50
+ genreDriveType: null
51
+ });
52
+ assert.deepEqual(report.dormant_items, []);
53
+ assert.equal(report.counts.dormant_total, 0);
54
+ assert.deepEqual(report.counts.dormant_by_scope, { short: 0, medium: 0, long: 0 });
55
+ });
56
+ test("loadForeshadowGlobalItems normalizes both list and object schemas", async () => {
57
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-foreshadow-test-"));
58
+ await mkdir(join(rootDir, "foreshadowing"), { recursive: true });
59
+ const globalAbs = join(rootDir, "foreshadowing", "global.json");
60
+ await writeFile(globalAbs, `${JSON.stringify([{ id: "x" }], null, 2)}\n`, "utf8");
61
+ const listItems = await loadForeshadowGlobalItems(rootDir);
62
+ assert.equal(listItems.length, 1);
63
+ assert.equal(listItems[0]?.id, "x");
64
+ await writeFile(globalAbs, `${JSON.stringify({ foreshadowing: [{ id: "y" }] }, null, 2)}\n`, "utf8");
65
+ const objItems = await loadForeshadowGlobalItems(rootDir);
66
+ assert.equal(objItems.length, 1);
67
+ assert.equal(objItems[0]?.id, "y");
68
+ });
69
+ test("writeForeshadowVisibilityLogs keeps latest.json monotonic by chapter (and generated_at tie-break)", async () => {
70
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-foreshadow-log-test-"));
71
+ const mkReport = (chapter, generated_at) => ({
72
+ schema_version: 1,
73
+ generated_at,
74
+ as_of: { chapter, volume: 1 },
75
+ platform: null,
76
+ genre_drive_type: null,
77
+ thresholds: { short: 6, medium: 12, long: 24 },
78
+ dormant_items: [],
79
+ counts: { dormant_total: 0, dormant_by_scope: { short: 0, medium: 0, long: 0 } }
80
+ });
81
+ await writeForeshadowVisibilityLogs({ rootDir, report: mkReport(5, "2026-01-01T00:00:00.000Z"), historyRange: null });
82
+ await writeForeshadowVisibilityLogs({ rootDir, report: mkReport(4, "2026-01-02T00:00:00.000Z"), historyRange: null });
83
+ const latestAbs = join(rootDir, "logs", "foreshadowing", "latest.json");
84
+ const raw = JSON.parse(await readFile(latestAbs, "utf8"));
85
+ assert.equal(raw.as_of.chapter, 5);
86
+ await writeForeshadowVisibilityLogs({ rootDir, report: mkReport(5, "2025-01-01T00:00:00.000Z"), historyRange: null });
87
+ const raw2 = JSON.parse(await readFile(latestAbs, "utf8"));
88
+ assert.equal(raw2.generated_at, "2026-01-01T00:00:00.000Z");
89
+ await writeForeshadowVisibilityLogs({ rootDir, report: mkReport(5, "2027-01-01T00:00:00.000Z"), historyRange: null });
90
+ const raw3 = JSON.parse(await readFile(latestAbs, "utf8"));
91
+ assert.equal(raw3.generated_at, "2027-01-01T00:00:00.000Z");
92
+ });
93
+ test("writeForeshadowVisibilityLogs treats invalid existing generated_at as incomparable", async () => {
94
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-foreshadow-log-bad-ts-test-"));
95
+ const mkReport = (chapter, generated_at) => ({
96
+ schema_version: 1,
97
+ generated_at,
98
+ as_of: { chapter, volume: 1 },
99
+ platform: null,
100
+ genre_drive_type: null,
101
+ thresholds: { short: 6, medium: 12, long: 24 },
102
+ dormant_items: [],
103
+ counts: { dormant_total: 0, dormant_by_scope: { short: 0, medium: 0, long: 0 } }
104
+ });
105
+ await writeForeshadowVisibilityLogs({ rootDir, report: mkReport(5, "2026-01-01T00:00:00.000Z"), historyRange: null });
106
+ const latestAbs = join(rootDir, "logs", "foreshadowing", "latest.json");
107
+ const raw = JSON.parse(await readFile(latestAbs, "utf8"));
108
+ raw.generated_at = "zzz";
109
+ await writeFile(latestAbs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
110
+ await writeForeshadowVisibilityLogs({ rootDir, report: mkReport(5, "2026-01-02T00:00:00.000Z"), historyRange: null });
111
+ const raw2 = JSON.parse(await readFile(latestAbs, "utf8"));
112
+ assert.equal(raw2.generated_at, "2026-01-02T00:00:00.000Z");
113
+ });
114
+ test("writeForeshadowVisibilityLogs writes visibility history under foreshadow-visibility-vol-*.json", async () => {
115
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-foreshadow-history-test-"));
116
+ const report = {
117
+ schema_version: 1,
118
+ generated_at: "2026-01-01T00:00:00.000Z",
119
+ as_of: { chapter: 10, volume: 1 },
120
+ platform: null,
121
+ genre_drive_type: null,
122
+ thresholds: { short: 6, medium: 12, long: 24 },
123
+ dormant_items: [],
124
+ counts: { dormant_total: 0, dormant_by_scope: { short: 0, medium: 0, long: 0 } }
125
+ };
126
+ const res = await writeForeshadowVisibilityLogs({ rootDir, report, historyRange: { start: 1, end: 10 } });
127
+ assert.equal(res.historyRel, "logs/foreshadowing/foreshadow-visibility-vol-01-ch001-ch010.json");
128
+ const raw = JSON.parse(await readFile(join(rootDir, res.historyRel), "utf8"));
129
+ assert.equal(raw.schema_version, 1);
130
+ assert.equal(raw.as_of.chapter, 10);
131
+ });