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,1028 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, stat, 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 { attachHookLedgerToEval, computeHookLedgerUpdate, loadHookLedger, writeHookLedgerFile, writeRetentionLogs } from "../hook-ledger.js";
7
+ function makePolicy(overrides = {}) {
8
+ return {
9
+ enabled: true,
10
+ fulfillment_window_chapters: 4,
11
+ diversity_window_chapters: 5,
12
+ max_same_type_streak: 2,
13
+ min_distinct_types_in_window: 2,
14
+ overdue_policy: "warn",
15
+ ...overrides
16
+ };
17
+ }
18
+ function makeEval(args) {
19
+ return {
20
+ chapter: 1,
21
+ hook: {
22
+ present: args.present ?? true,
23
+ type: args.hookType,
24
+ evidence: args.evidence ?? "章末证据片段"
25
+ },
26
+ scores: {
27
+ hook_strength: {
28
+ score: args.strength,
29
+ evidence: args.evidence ?? "章末证据片段"
30
+ }
31
+ }
32
+ };
33
+ }
34
+ test("computeHookLedgerUpdate creates an entry with window and evidence snippet", () => {
35
+ const ledger = { schema_version: 1, entries: [] };
36
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence: "最后一段:他停在门口,迟迟没有推开门……" });
37
+ const policy = makePolicy();
38
+ const res = computeHookLedgerUpdate({
39
+ ledger,
40
+ evalRaw,
41
+ chapter: 10,
42
+ volume: 1,
43
+ evalRelPath: "evaluations/chapter-010-eval.json",
44
+ policy,
45
+ reportRange: { start: 1, end: 10 }
46
+ });
47
+ assert.ok(res.entry);
48
+ assert.equal(res.entry.id, "hook:ch010");
49
+ assert.equal(res.entry.chapter, 10);
50
+ assert.equal(res.entry.hook_type, "question");
51
+ assert.equal(res.entry.hook_strength, 4);
52
+ assert.deepEqual(res.entry.fulfillment_window, [11, 14]);
53
+ assert.equal(res.entry.status, "open");
54
+ assert.ok(typeof res.entry.created_at === "string" && res.entry.created_at.length > 0);
55
+ assert.ok(typeof res.entry.updated_at === "string" && res.entry.updated_at.length > 0);
56
+ assert.ok(typeof res.entry.evidence_snippet === "string" && res.entry.evidence_snippet.length > 0);
57
+ assert.equal(res.entry.sources?.eval_path, "evaluations/chapter-010-eval.json");
58
+ assert.equal(res.updatedLedger.entries.length, 1);
59
+ assert.equal(res.report.debt.open.length, 1);
60
+ assert.equal(res.report.debt.lapsed.length, 0);
61
+ });
62
+ test("computeHookLedgerUpdate reads hook signals from eval_used wrapper", () => {
63
+ const ledger = { schema_version: 1, entries: [] };
64
+ const evalRaw = { eval_used: makeEval({ hookType: "question", strength: 4, evidence: "章末证据片段" }) };
65
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
66
+ const res = computeHookLedgerUpdate({
67
+ ledger,
68
+ evalRaw,
69
+ chapter: 1,
70
+ volume: 1,
71
+ evalRelPath: "evaluations/chapter-001-eval.json",
72
+ policy,
73
+ reportRange: { start: 1, end: 1 }
74
+ });
75
+ assert.ok(res.entry);
76
+ assert.equal(res.entry.hook_type, "question");
77
+ assert.equal(res.entry.hook_strength, 4);
78
+ });
79
+ test("computeHookLedgerUpdate uses legacy hook_strength fallback fields when scores are missing", () => {
80
+ const ledger = { schema_version: 1, entries: [] };
81
+ const evalRaw = {
82
+ chapter: 1,
83
+ hook: { present: true, type: "QUESTION", evidence: "章末证据片段" },
84
+ hook_strength: 5
85
+ };
86
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
87
+ const res = computeHookLedgerUpdate({
88
+ ledger,
89
+ evalRaw,
90
+ chapter: 1,
91
+ volume: 1,
92
+ evalRelPath: "evaluations/chapter-001-eval.json",
93
+ policy,
94
+ reportRange: { start: 1, end: 1 }
95
+ });
96
+ assert.ok(res.entry);
97
+ assert.equal(res.entry.hook_type, "question");
98
+ assert.equal(res.entry.hook_strength, 5);
99
+ });
100
+ test("computeHookLedgerUpdate uses hook.strength fallback when scores are missing", () => {
101
+ const ledger = { schema_version: 1, entries: [] };
102
+ const evalRaw = {
103
+ chapter: 1,
104
+ hook: { present: true, type: "question", strength: 2, evidence: "章末证据片段" }
105
+ };
106
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
107
+ const res = computeHookLedgerUpdate({
108
+ ledger,
109
+ evalRaw,
110
+ chapter: 1,
111
+ volume: 1,
112
+ evalRelPath: "evaluations/chapter-001-eval.json",
113
+ policy,
114
+ reportRange: { start: 1, end: 1 }
115
+ });
116
+ assert.ok(res.entry);
117
+ assert.equal(res.entry.hook_strength, 2);
118
+ });
119
+ test("computeHookLedgerUpdate evidence_snippet truncation does not split surrogate pairs", () => {
120
+ const ledger = { schema_version: 1, entries: [] };
121
+ const evidence = `${"a".repeat(118)}😀b`;
122
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence });
123
+ const policy = makePolicy();
124
+ const res = computeHookLedgerUpdate({
125
+ ledger,
126
+ evalRaw,
127
+ chapter: 10,
128
+ volume: 1,
129
+ evalRelPath: "evaluations/chapter-010-eval.json",
130
+ policy,
131
+ reportRange: { start: 1, end: 10 }
132
+ });
133
+ assert.ok(res.entry?.evidence_snippet);
134
+ assert.equal(res.entry.evidence_snippet, `${"a".repeat(118)}…`);
135
+ });
136
+ test("computeHookLedgerUpdate marks overdue open promises as lapsed and reports hook debt", () => {
137
+ const ledger = {
138
+ schema_version: 1,
139
+ entries: [
140
+ {
141
+ id: "hook:ch020",
142
+ chapter: 20,
143
+ hook_type: "question",
144
+ hook_strength: 4,
145
+ promise_text: "留悬念:未解之问",
146
+ status: "open",
147
+ fulfillment_window: [21, 24],
148
+ fulfilled_chapter: null,
149
+ created_at: "2026-01-01T00:00:00Z",
150
+ updated_at: "2026-01-01T00:00:00Z"
151
+ }
152
+ ]
153
+ };
154
+ const evalRaw = makeEval({ hookType: "threat_reveal", strength: 3, evidence: "章末证据片段:危险更近了一步。" });
155
+ const policy = makePolicy({ overdue_policy: "warn" });
156
+ const res = computeHookLedgerUpdate({
157
+ ledger,
158
+ evalRaw,
159
+ chapter: 25,
160
+ volume: 1,
161
+ evalRelPath: "evaluations/chapter-025-eval.json",
162
+ policy,
163
+ reportRange: { start: 16, end: 25 }
164
+ });
165
+ const old = res.updatedLedger.entries.find((e) => e.chapter === 20);
166
+ assert.ok(old);
167
+ assert.equal(old.status, "lapsed");
168
+ assert.ok(Array.isArray(old.history) && old.history.some((h) => h.action === "lapsed"));
169
+ assert.ok(res.report.issues.some((i) => i.id === "retention.hook_ledger.hook_debt"));
170
+ assert.equal(res.report.debt.newly_lapsed_total, 1);
171
+ });
172
+ test("computeHookLedgerUpdate still reports hook debt when debt already exists (even if nothing newly lapses)", () => {
173
+ const ledger = {
174
+ schema_version: 1,
175
+ entries: [
176
+ {
177
+ id: "hook:ch020",
178
+ chapter: 20,
179
+ hook_type: "question",
180
+ hook_strength: 4,
181
+ promise_text: "留悬念:未解之问",
182
+ status: "lapsed",
183
+ fulfillment_window: [21, 24],
184
+ fulfilled_chapter: null,
185
+ created_at: "2026-01-01T00:00:00Z",
186
+ updated_at: "2026-01-02T00:00:00Z"
187
+ }
188
+ ]
189
+ };
190
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
191
+ const policy = makePolicy({ overdue_policy: "hard", diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
192
+ const res = computeHookLedgerUpdate({
193
+ ledger,
194
+ evalRaw,
195
+ chapter: 30,
196
+ volume: 1,
197
+ evalRelPath: "evaluations/chapter-030-eval.json",
198
+ policy,
199
+ reportRange: { start: 21, end: 30 }
200
+ });
201
+ assert.equal(res.report.debt.newly_lapsed_total, 0);
202
+ assert.ok(res.report.issues.some((i) => i.id === "retention.hook_ledger.hook_debt" && i.severity === "hard"));
203
+ assert.equal(res.report.has_blocking_issues, true);
204
+ });
205
+ test("computeHookLedgerUpdate blocks when overdue_policy is hard and hook debt is detected", () => {
206
+ const ledger = {
207
+ schema_version: 1,
208
+ entries: [
209
+ {
210
+ id: "hook:ch020",
211
+ chapter: 20,
212
+ hook_type: "question",
213
+ hook_strength: 4,
214
+ promise_text: "留悬念:未解之问",
215
+ status: "open",
216
+ fulfillment_window: [21, 24],
217
+ fulfilled_chapter: null,
218
+ created_at: "2026-01-01T00:00:00Z",
219
+ updated_at: "2026-01-01T00:00:00Z"
220
+ }
221
+ ]
222
+ };
223
+ const evalRaw = makeEval({ hookType: "threat_reveal", strength: 3, evidence: "章末证据片段:危险更近了一步。" });
224
+ const policy = makePolicy({ overdue_policy: "hard" });
225
+ const res = computeHookLedgerUpdate({
226
+ ledger,
227
+ evalRaw,
228
+ chapter: 25,
229
+ volume: 1,
230
+ evalRelPath: "evaluations/chapter-025-eval.json",
231
+ policy,
232
+ reportRange: { start: 16, end: 25 }
233
+ });
234
+ assert.equal(res.report.has_blocking_issues, true);
235
+ assert.ok(res.report.issues.some((i) => i.id === "retention.hook_ledger.hook_debt" && i.severity === "hard"));
236
+ });
237
+ test("computeHookLedgerUpdate does not hard-block on diversity issues (even when overdue_policy is hard)", () => {
238
+ const ledger = { schema_version: 1, entries: [] };
239
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence: "章末证据片段" });
240
+ const policy = makePolicy({ overdue_policy: "hard", diversity_window_chapters: 5, min_distinct_types_in_window: 2 });
241
+ const res = computeHookLedgerUpdate({
242
+ ledger,
243
+ evalRaw,
244
+ chapter: 1,
245
+ volume: 1,
246
+ evalRelPath: "evaluations/chapter-001-eval.json",
247
+ policy,
248
+ reportRange: { start: 1, end: 1 }
249
+ });
250
+ assert.ok(res.report.issues.some((i) => i.id === "retention.hook_ledger.diversity.low_distinct_types" && i.severity === "warn"));
251
+ assert.equal(res.report.has_blocking_issues, false);
252
+ });
253
+ test("computeHookLedgerUpdate flags diversity streak and low distinct types in window", () => {
254
+ const ledger = {
255
+ schema_version: 1,
256
+ entries: [
257
+ {
258
+ id: "hook:ch001",
259
+ chapter: 1,
260
+ hook_type: "question",
261
+ hook_strength: 4,
262
+ promise_text: "留悬念:未解之问",
263
+ status: "open",
264
+ fulfillment_window: [2, 5],
265
+ fulfilled_chapter: null,
266
+ created_at: "2026-01-01T00:00:00Z",
267
+ updated_at: "2026-01-01T00:00:00Z"
268
+ },
269
+ {
270
+ id: "hook:ch002",
271
+ chapter: 2,
272
+ hook_type: "question",
273
+ hook_strength: 4,
274
+ promise_text: "留悬念:未解之问",
275
+ status: "open",
276
+ fulfillment_window: [3, 6],
277
+ fulfilled_chapter: null,
278
+ created_at: "2026-01-01T00:00:00Z",
279
+ updated_at: "2026-01-01T00:00:00Z"
280
+ }
281
+ ]
282
+ };
283
+ const policy = makePolicy({ diversity_window_chapters: 3, max_same_type_streak: 2, min_distinct_types_in_window: 2, overdue_policy: "warn" });
284
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence: "章末证据片段:疑问仍在。" });
285
+ const res = computeHookLedgerUpdate({
286
+ ledger,
287
+ evalRaw,
288
+ chapter: 3,
289
+ volume: 1,
290
+ evalRelPath: "evaluations/chapter-003-eval.json",
291
+ policy,
292
+ reportRange: { start: 1, end: 3 }
293
+ });
294
+ assert.ok(res.report.issues.some((i) => i.id === "retention.hook_ledger.diversity.streak_exceeded"));
295
+ assert.ok(res.report.issues.some((i) => i.id === "retention.hook_ledger.diversity.low_distinct_types"));
296
+ assert.equal(res.report.stats.entries_total, 3);
297
+ assert.equal(res.report.stats.open_total, 3);
298
+ assert.equal(res.report.stats.fulfilled_total, 0);
299
+ assert.equal(res.report.stats.lapsed_total, 0);
300
+ assert.equal(res.report.diversity.window_chapters, 3);
301
+ assert.deepEqual(res.report.diversity.range, { start: 1, end: 3 });
302
+ assert.equal(res.report.diversity.distinct_types_in_window, 1);
303
+ assert.equal(res.report.diversity.max_same_type_streak_in_window, 3);
304
+ });
305
+ test("computeHookLedgerUpdate does not overwrite fulfilled entries on re-commit", () => {
306
+ const ledger = {
307
+ schema_version: 1,
308
+ entries: [
309
+ {
310
+ id: "hook:ch010",
311
+ chapter: 10,
312
+ hook_type: "question",
313
+ hook_strength: 5,
314
+ promise_text: "自定义承诺点",
315
+ status: "fulfilled",
316
+ fulfillment_window: [11, 20],
317
+ fulfilled_chapter: 12,
318
+ created_at: "2026-01-01T00:00:00Z",
319
+ updated_at: "2026-01-02T00:00:00Z",
320
+ evidence_snippet: "旧证据"
321
+ }
322
+ ]
323
+ };
324
+ const evalRaw = makeEval({ hookType: "twist_reveal", strength: 1, evidence: "新证据" });
325
+ const policy = makePolicy({ overdue_policy: "warn", diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
326
+ const res = computeHookLedgerUpdate({
327
+ ledger,
328
+ evalRaw,
329
+ chapter: 10,
330
+ volume: 1,
331
+ evalRelPath: "evaluations/chapter-010-eval.json",
332
+ policy,
333
+ reportRange: { start: 1, end: 10 }
334
+ });
335
+ const ch10 = res.updatedLedger.entries.find((e) => e.chapter === 10);
336
+ assert.ok(ch10);
337
+ assert.equal(ch10.status, "fulfilled");
338
+ assert.equal(ch10.hook_type, "question");
339
+ assert.equal(ch10.hook_strength, 5);
340
+ assert.equal(ch10.promise_text, "自定义承诺点");
341
+ assert.deepEqual(ch10.fulfillment_window, [11, 20]);
342
+ assert.equal(ch10.evidence_snippet, "旧证据");
343
+ });
344
+ test("computeHookLedgerUpdate dedupes same-status duplicates by newest timestamps", () => {
345
+ const ledger = {
346
+ schema_version: 1,
347
+ entries: [
348
+ {
349
+ id: "hook:ch005-old",
350
+ chapter: 5,
351
+ hook_type: "question",
352
+ hook_strength: 3,
353
+ promise_text: "旧",
354
+ status: "open",
355
+ fulfillment_window: [6, 9],
356
+ fulfilled_chapter: null,
357
+ created_at: "2026-01-01T00:00:00Z",
358
+ updated_at: "2026-01-01T00:00:00Z"
359
+ },
360
+ {
361
+ id: "hook:ch005-new",
362
+ chapter: 5,
363
+ hook_type: "question",
364
+ hook_strength: 4,
365
+ promise_text: "新",
366
+ status: "open",
367
+ fulfillment_window: [6, 9],
368
+ fulfilled_chapter: null,
369
+ created_at: "2026-01-01T00:00:00Z",
370
+ updated_at: "2026-01-03T00:00:00Z"
371
+ }
372
+ ]
373
+ };
374
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
375
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
376
+ const res = computeHookLedgerUpdate({
377
+ ledger,
378
+ evalRaw,
379
+ chapter: 6,
380
+ volume: 1,
381
+ evalRelPath: "evaluations/chapter-006-eval.json",
382
+ policy,
383
+ reportRange: { start: 1, end: 6 }
384
+ });
385
+ assert.ok(res.warnings.some((w) => w.includes("Dropped") && w.includes("hook:ch005-old")));
386
+ const ch5 = res.updatedLedger.entries.find((e) => e.chapter === 5);
387
+ assert.ok(ch5);
388
+ assert.equal(ch5.id, "hook:ch005-new");
389
+ });
390
+ test("computeHookLedgerUpdate dedupes cross-status duplicates by timestamp (keeps newest)", () => {
391
+ const ledger = {
392
+ schema_version: 1,
393
+ entries: [
394
+ {
395
+ id: "hook:ch020-open-old",
396
+ chapter: 20,
397
+ hook_type: "question",
398
+ hook_strength: 4,
399
+ promise_text: "留悬念:未解之问",
400
+ status: "open",
401
+ fulfillment_window: [21, 24],
402
+ fulfilled_chapter: null,
403
+ created_at: "2026-01-01T00:00:00Z",
404
+ updated_at: "2026-01-01T00:00:00Z"
405
+ },
406
+ {
407
+ id: "hook:ch020-lapsed-new",
408
+ chapter: 20,
409
+ hook_type: "question",
410
+ hook_strength: 4,
411
+ promise_text: "留悬念:未解之问",
412
+ status: "lapsed",
413
+ fulfillment_window: [21, 24],
414
+ fulfilled_chapter: null,
415
+ created_at: "2026-01-01T00:00:00Z",
416
+ updated_at: "2026-01-03T00:00:00Z"
417
+ }
418
+ ]
419
+ };
420
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
421
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
422
+ const res = computeHookLedgerUpdate({
423
+ ledger,
424
+ evalRaw,
425
+ chapter: 22,
426
+ volume: 1,
427
+ evalRelPath: "evaluations/chapter-022-eval.json",
428
+ policy,
429
+ reportRange: { start: 13, end: 22 }
430
+ });
431
+ assert.ok(res.warnings.some((w) => w.includes("Dropped") && w.includes("hook:ch020")));
432
+ assert.equal(res.updatedLedger.entries.filter((e) => e.chapter === 20).length, 1);
433
+ const ch20 = res.updatedLedger.entries.find((e) => e.chapter === 20);
434
+ assert.ok(ch20);
435
+ assert.equal(ch20.id, "hook:ch020-lapsed-new");
436
+ assert.equal(ch20.status, "lapsed");
437
+ });
438
+ test("computeHookLedgerUpdate dedupes cross-status duplicates when one is missing updated_at (keeps lapsed)", () => {
439
+ const ledger = {
440
+ schema_version: 1,
441
+ entries: [
442
+ {
443
+ id: "hook:ch020-lapsed",
444
+ chapter: 20,
445
+ hook_type: "question",
446
+ hook_strength: 4,
447
+ promise_text: "留悬念:未解之问",
448
+ status: "lapsed",
449
+ fulfillment_window: [21, 24],
450
+ fulfilled_chapter: null,
451
+ created_at: "2026-01-01T00:00:00Z",
452
+ updated_at: "2026-01-03T00:00:00Z"
453
+ },
454
+ {
455
+ id: "hook:ch020-open-missing-updated",
456
+ chapter: 20,
457
+ hook_type: "question",
458
+ hook_strength: 4,
459
+ promise_text: "留悬念:未解之问",
460
+ status: "open",
461
+ fulfillment_window: [21, 24],
462
+ fulfilled_chapter: null,
463
+ created_at: "2026-01-02T00:00:00Z"
464
+ }
465
+ ]
466
+ };
467
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
468
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
469
+ const res = computeHookLedgerUpdate({
470
+ ledger,
471
+ evalRaw,
472
+ chapter: 22,
473
+ volume: 1,
474
+ evalRelPath: "evaluations/chapter-022-eval.json",
475
+ policy,
476
+ reportRange: { start: 13, end: 22 }
477
+ });
478
+ assert.ok(res.warnings.some((w) => w.includes("missing updated_at")));
479
+ const ch20 = res.updatedLedger.entries.find((e) => e.chapter === 20);
480
+ assert.ok(ch20);
481
+ assert.equal(ch20.id, "hook:ch020-lapsed");
482
+ assert.equal(ch20.status, "lapsed");
483
+ });
484
+ test("computeHookLedgerUpdate preserves fulfilled status when deduping", () => {
485
+ const ledger = {
486
+ schema_version: 1,
487
+ entries: [
488
+ {
489
+ id: "hook:ch020-fulfilled-old",
490
+ chapter: 20,
491
+ hook_type: "question",
492
+ hook_strength: 4,
493
+ promise_text: "留悬念:未解之问",
494
+ status: "fulfilled",
495
+ fulfillment_window: [21, 24],
496
+ fulfilled_chapter: 22,
497
+ created_at: "2026-01-01T00:00:00Z",
498
+ updated_at: "2026-01-01T00:00:00Z"
499
+ },
500
+ {
501
+ id: "hook:ch020-open-new",
502
+ chapter: 20,
503
+ hook_type: "question",
504
+ hook_strength: 4,
505
+ promise_text: "留悬念:未解之问",
506
+ status: "open",
507
+ fulfillment_window: [21, 24],
508
+ fulfilled_chapter: null,
509
+ created_at: "2026-01-01T00:00:00Z",
510
+ updated_at: "2026-01-03T00:00:00Z"
511
+ }
512
+ ]
513
+ };
514
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
515
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
516
+ const res = computeHookLedgerUpdate({
517
+ ledger,
518
+ evalRaw,
519
+ chapter: 22,
520
+ volume: 1,
521
+ evalRelPath: "evaluations/chapter-022-eval.json",
522
+ policy,
523
+ reportRange: { start: 13, end: 22 }
524
+ });
525
+ assert.equal(res.updatedLedger.entries.filter((e) => e.chapter === 20).length, 1);
526
+ const ch20 = res.updatedLedger.entries.find((e) => e.chapter === 20);
527
+ assert.ok(ch20);
528
+ assert.equal(ch20.id, "hook:ch020-fulfilled-old");
529
+ assert.equal(ch20.status, "fulfilled");
530
+ });
531
+ test("computeHookLedgerUpdate backfills missing fulfillment_window and clears _needs_window_backfill", () => {
532
+ const ledger = {
533
+ schema_version: 1,
534
+ entries: [
535
+ {
536
+ id: "hook:ch010",
537
+ chapter: 10,
538
+ hook_type: "question",
539
+ hook_strength: 4,
540
+ promise_text: "留悬念:未解之问",
541
+ status: "open",
542
+ // fulfillment_window intentionally missing
543
+ fulfilled_chapter: null,
544
+ created_at: "2026-01-01T00:00:00Z",
545
+ updated_at: "2026-01-02T00:00:00Z"
546
+ }
547
+ ]
548
+ };
549
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
550
+ const policy = makePolicy({ fulfillment_window_chapters: 4, diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
551
+ const res = computeHookLedgerUpdate({
552
+ ledger,
553
+ evalRaw,
554
+ chapter: 12,
555
+ volume: 1,
556
+ evalRelPath: "evaluations/chapter-012-eval.json",
557
+ policy,
558
+ reportRange: { start: 1, end: 12 }
559
+ });
560
+ const ch10 = res.updatedLedger.entries.find((e) => e.chapter === 10);
561
+ assert.ok(ch10);
562
+ assert.deepEqual(ch10.fulfillment_window, [11, 14]);
563
+ assert.equal(ch10._needs_window_backfill, undefined);
564
+ assert.ok(Array.isArray(ch10.history) && ch10.history.some((h) => h.action === "window_backfilled"));
565
+ });
566
+ test("computeHookLedgerUpdate backfills fulfillment_window when it is behind the entry chapter", () => {
567
+ const ledger = {
568
+ schema_version: 1,
569
+ entries: [
570
+ {
571
+ id: "hook:ch010",
572
+ chapter: 10,
573
+ hook_type: "question",
574
+ hook_strength: 4,
575
+ promise_text: "留悬念:未解之问",
576
+ status: "open",
577
+ fulfillment_window: [1, 2],
578
+ fulfilled_chapter: null,
579
+ created_at: "2026-01-01T00:00:00Z",
580
+ updated_at: "2026-01-02T00:00:00Z"
581
+ }
582
+ ]
583
+ };
584
+ const evalRaw = makeEval({ hookType: "none", strength: 3, present: false });
585
+ const policy = makePolicy({ fulfillment_window_chapters: 4, diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
586
+ const res = computeHookLedgerUpdate({
587
+ ledger,
588
+ evalRaw,
589
+ chapter: 12,
590
+ volume: 1,
591
+ evalRelPath: "evaluations/chapter-012-eval.json",
592
+ policy,
593
+ reportRange: { start: 1, end: 12 }
594
+ });
595
+ const ch10 = res.updatedLedger.entries.find((e) => e.chapter === 10);
596
+ assert.ok(ch10);
597
+ assert.deepEqual(ch10.fulfillment_window, [11, 14]);
598
+ assert.equal(ch10.status, "open");
599
+ assert.ok(res.warnings.some((w) => w.includes("invalid fulfillment_window")));
600
+ });
601
+ test("loadHookLedger returns an empty, schema-pointed ledger when hook-ledger.json is missing", async () => {
602
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-missing-file-test-"));
603
+ const loaded = await loadHookLedger(rootDir);
604
+ assert.equal(loaded.ledger.$schema, "schemas/hook-ledger.schema.json");
605
+ assert.equal(loaded.ledger.schema_version, 1);
606
+ assert.deepEqual(loaded.ledger.entries, []);
607
+ assert.deepEqual(loaded.warnings, []);
608
+ });
609
+ test("loadHookLedger rejects non-object JSON to avoid silent data loss", async () => {
610
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-non-object-test-"));
611
+ const abs = join(rootDir, "hook-ledger.json");
612
+ await writeFile(abs, `[]\n`, "utf8");
613
+ await assert.rejects(() => loadHookLedger(rootDir), /expected a JSON object/);
614
+ });
615
+ test("loadHookLedger rejects unsupported schema_version values", async () => {
616
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-bad-sv-test-"));
617
+ const abs = join(rootDir, "hook-ledger.json");
618
+ const raw = { schema_version: 2, entries: [] };
619
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
620
+ await assert.rejects(() => loadHookLedger(rootDir), /schema_version.*must be 1/);
621
+ });
622
+ test("loadHookLedger drops entries missing id/chapter and returns warnings", async () => {
623
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-missing-fields-test-"));
624
+ const abs = join(rootDir, "hook-ledger.json");
625
+ const raw = {
626
+ schema_version: 1,
627
+ entries: [{ id: "hook:ch001" }, { chapter: 1 }]
628
+ };
629
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
630
+ const loaded = await loadHookLedger(rootDir);
631
+ assert.equal(loaded.ledger.entries.length, 0);
632
+ assert.ok(loaded.warnings.some((w) => w.includes("missing id/chapter")));
633
+ });
634
+ test("loadHookLedger preserves user comment fields and normalizes links", async () => {
635
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-comments-test-"));
636
+ const abs = join(rootDir, "hook-ledger.json");
637
+ const raw = {
638
+ schema_version: 1,
639
+ _comment: "root comment",
640
+ entries: [
641
+ {
642
+ id: "hook:ch001",
643
+ chapter: 1,
644
+ hook_type: "Question",
645
+ hook_strength: 4,
646
+ promise_text: "留悬念:未解之问",
647
+ status: "open",
648
+ fulfillment_window: [2, 5],
649
+ fulfilled_chapter: null,
650
+ created_at: "2026-01-01T00:00:00Z",
651
+ updated_at: "2026-01-01T00:00:00Z",
652
+ _note: "entry note",
653
+ links: { promise_ids: [" a ", "a", ""], foreshadowing_ids: ["b", " b "] }
654
+ }
655
+ ]
656
+ };
657
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
658
+ const loaded = await loadHookLedger(rootDir);
659
+ assert.equal(loaded.ledger.$schema, "schemas/hook-ledger.schema.json");
660
+ assert.equal(loaded.ledger._comment, "root comment");
661
+ assert.equal(loaded.ledger.entries.length, 1);
662
+ const e = loaded.ledger.entries[0];
663
+ assert.equal(e.hook_type, "question");
664
+ assert.equal(e._note, "entry note");
665
+ assert.deepEqual(e.links.promise_ids, ["a"]);
666
+ assert.deepEqual(e.links.foreshadowing_ids, ["b"]);
667
+ });
668
+ test("loadHookLedger normalizes unknown fields, invalid strengths, and missing windows", async () => {
669
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-test-"));
670
+ const abs = join(rootDir, "hook-ledger.json");
671
+ const raw = {
672
+ schema_version: 1,
673
+ foo: "bar",
674
+ entries: [
675
+ {
676
+ id: "hook:ch002",
677
+ chapter: 2,
678
+ hook_type: "question",
679
+ hook_strength: 6,
680
+ promise_text: "留悬念:未解之问",
681
+ status: "open",
682
+ created_at: "2026-01-01",
683
+ updated_at: " 2026-01-01T00:00:00Z ",
684
+ // fulfillment_window missing
685
+ fulfilled_chapter: 3,
686
+ extra_field: "drop-me"
687
+ }
688
+ ]
689
+ };
690
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
691
+ const loaded = await loadHookLedger(rootDir);
692
+ assert.equal(loaded.ledger.foo, undefined);
693
+ assert.equal(loaded.ledger.entries.length, 1);
694
+ const e = loaded.ledger.entries[0];
695
+ assert.equal(e.extra_field, undefined);
696
+ assert.equal(e.hook_strength, 3);
697
+ assert.ok(e._invalid_hook_strength !== undefined);
698
+ assert.equal(e._invalid_created_at, "2026-01-01");
699
+ assert.equal(e._invalid_updated_at, undefined);
700
+ assert.equal(e.updated_at, "2026-01-01T00:00:00Z");
701
+ assert.ok(typeof e.created_at === "string" && e.created_at.endsWith("Z"));
702
+ assert.equal(e.status, "fulfilled");
703
+ assert.ok(Array.isArray(e.history) && e.history.some((h) => h.action === "status_auto_fixed"));
704
+ assert.ok(Array.isArray(e.fulfillment_window) && e.fulfillment_window.length === 2);
705
+ assert.equal(e._needs_window_backfill, true);
706
+ assert.ok(Array.isArray(loaded.warnings) && loaded.warnings.length > 0);
707
+ });
708
+ test("loadHookLedger rejects invalid entries type to avoid silent data loss", async () => {
709
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-invalid-test-"));
710
+ const abs = join(rootDir, "hook-ledger.json");
711
+ const raw = { schema_version: 1, entries: { bad: true } };
712
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
713
+ await assert.rejects(() => loadHookLedger(rootDir), /entries.*array/);
714
+ });
715
+ test("loadHookLedger rejects missing schema_version (schema SSOT)", async () => {
716
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-missing-sv-test-"));
717
+ const abs = join(rootDir, "hook-ledger.json");
718
+ const raw = { entries: [] };
719
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
720
+ await assert.rejects(() => loadHookLedger(rootDir), /schema_version/);
721
+ });
722
+ test("loadHookLedger rejects missing entries (schema SSOT)", async () => {
723
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-missing-entries-test-"));
724
+ const abs = join(rootDir, "hook-ledger.json");
725
+ const raw = { schema_version: 1 };
726
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
727
+ await assert.rejects(() => loadHookLedger(rootDir), /entries/);
728
+ });
729
+ test("computeHookLedgerUpdate refreshes auto promise_text when hook_type changes", () => {
730
+ const ledger = {
731
+ schema_version: 1,
732
+ entries: [
733
+ {
734
+ id: "hook:ch010",
735
+ chapter: 10,
736
+ hook_type: "question",
737
+ hook_strength: 4,
738
+ promise_text: "留悬念:未解之问",
739
+ status: "open",
740
+ fulfillment_window: [11, 14],
741
+ fulfilled_chapter: null,
742
+ created_at: "2026-01-01T00:00:00Z",
743
+ updated_at: "2026-01-02T00:00:00Z"
744
+ }
745
+ ]
746
+ };
747
+ const evalRaw = makeEval({ hookType: "twist_reveal", strength: 4, evidence: "新证据" });
748
+ const policy = makePolicy({ overdue_policy: "warn", diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
749
+ const res = computeHookLedgerUpdate({
750
+ ledger,
751
+ evalRaw,
752
+ chapter: 10,
753
+ volume: 1,
754
+ evalRelPath: "evaluations/chapter-010-eval.json",
755
+ policy,
756
+ reportRange: { start: 1, end: 10 }
757
+ });
758
+ const ch10 = res.updatedLedger.entries.find((e) => e.chapter === 10);
759
+ assert.ok(ch10);
760
+ assert.equal(ch10.hook_type, "twist_reveal");
761
+ assert.equal(ch10.promise_text, "留悬念:反转揭示");
762
+ });
763
+ test("computeHookLedgerUpdate does not overwrite custom promise_text when hook_type changes", () => {
764
+ const ledger = {
765
+ schema_version: 1,
766
+ entries: [
767
+ {
768
+ id: "hook:ch010",
769
+ chapter: 10,
770
+ hook_type: "question",
771
+ hook_strength: 4,
772
+ promise_text: "自定义承诺点",
773
+ status: "open",
774
+ fulfillment_window: [11, 14],
775
+ fulfilled_chapter: null,
776
+ created_at: "2026-01-01T00:00:00Z",
777
+ updated_at: "2026-01-02T00:00:00Z"
778
+ }
779
+ ]
780
+ };
781
+ const evalRaw = makeEval({ hookType: "twist_reveal", strength: 4, evidence: "新证据" });
782
+ const policy = makePolicy({ overdue_policy: "warn", diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
783
+ const res = computeHookLedgerUpdate({
784
+ ledger,
785
+ evalRaw,
786
+ chapter: 10,
787
+ volume: 1,
788
+ evalRelPath: "evaluations/chapter-010-eval.json",
789
+ policy,
790
+ reportRange: { start: 1, end: 10 }
791
+ });
792
+ const ch10 = res.updatedLedger.entries.find((e) => e.chapter === 10);
793
+ assert.ok(ch10);
794
+ assert.equal(ch10.hook_type, "twist_reveal");
795
+ assert.equal(ch10.promise_text, "自定义承诺点");
796
+ });
797
+ test("computeHookLedgerUpdate refreshes evidence_snippet on re-commit for open entries", () => {
798
+ const ledger = {
799
+ schema_version: 1,
800
+ entries: [
801
+ {
802
+ id: "hook:ch010",
803
+ chapter: 10,
804
+ hook_type: "question",
805
+ hook_strength: 4,
806
+ promise_text: "留悬念:未解之问",
807
+ status: "open",
808
+ fulfillment_window: [11, 14],
809
+ fulfilled_chapter: null,
810
+ created_at: "2026-01-01T00:00:00Z",
811
+ updated_at: "2026-01-02T00:00:00Z",
812
+ evidence_snippet: "旧证据"
813
+ }
814
+ ]
815
+ };
816
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence: "新证据" });
817
+ const policy = makePolicy({ overdue_policy: "warn", diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
818
+ const res = computeHookLedgerUpdate({
819
+ ledger,
820
+ evalRaw,
821
+ chapter: 10,
822
+ volume: 1,
823
+ evalRelPath: "evaluations/chapter-010-eval.json",
824
+ policy,
825
+ reportRange: { start: 1, end: 10 }
826
+ });
827
+ const ch10 = res.updatedLedger.entries.find((e) => e.chapter === 10);
828
+ assert.ok(ch10);
829
+ assert.equal(ch10.evidence_snippet, "新证据");
830
+ });
831
+ test("computeHookLedgerUpdate does not lapse on window end (inclusive)", () => {
832
+ const ledger = {
833
+ schema_version: 1,
834
+ entries: [
835
+ {
836
+ id: "hook:ch020",
837
+ chapter: 20,
838
+ hook_type: "question",
839
+ hook_strength: 4,
840
+ promise_text: "留悬念:未解之问",
841
+ status: "open",
842
+ fulfillment_window: [21, 24],
843
+ fulfilled_chapter: null,
844
+ created_at: "2026-01-01T00:00:00Z",
845
+ updated_at: "2026-01-01T00:00:00Z"
846
+ }
847
+ ]
848
+ };
849
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
850
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
851
+ const res = computeHookLedgerUpdate({
852
+ ledger,
853
+ evalRaw,
854
+ chapter: 24,
855
+ volume: 1,
856
+ evalRelPath: "evaluations/chapter-024-eval.json",
857
+ policy,
858
+ reportRange: { start: 20, end: 24 }
859
+ });
860
+ const e = res.updatedLedger.entries.find((x) => x.chapter === 20);
861
+ assert.ok(e);
862
+ assert.equal(e.status, "open");
863
+ assert.equal(res.report.debt.newly_lapsed_total, 0);
864
+ assert.ok(!res.report.issues.some((i) => i.id === "retention.hook_ledger.hook_debt"));
865
+ });
866
+ test("computeHookLedgerUpdate warns when eval hook.present=true but hook.type is missing", () => {
867
+ const ledger = { schema_version: 1, entries: [] };
868
+ const evalRaw = {
869
+ chapter: 1,
870
+ hook: { present: true },
871
+ scores: { hook_strength: { score: 4 } }
872
+ };
873
+ const policy = makePolicy({ diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
874
+ const res = computeHookLedgerUpdate({
875
+ ledger,
876
+ evalRaw,
877
+ chapter: 10,
878
+ volume: 1,
879
+ evalRelPath: "evaluations/chapter-010-eval.json",
880
+ policy,
881
+ reportRange: { start: 1, end: 10 }
882
+ });
883
+ assert.equal(res.entry, null);
884
+ assert.ok(res.warnings.some((w) => w.includes("hook.present=true")));
885
+ });
886
+ test("loadHookLedger drops __proto__ comment field to avoid prototype pollution", async () => {
887
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-load-proto-test-"));
888
+ const abs = join(rootDir, "hook-ledger.json");
889
+ const raw = {
890
+ schema_version: 1,
891
+ __proto__: { polluted: true },
892
+ entries: []
893
+ };
894
+ await writeFile(abs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
895
+ const loaded = await loadHookLedger(rootDir);
896
+ assert.equal(loaded.ledger.polluted, undefined);
897
+ assert.equal({}.polluted, undefined);
898
+ });
899
+ test("computeHookLedgerUpdate skips when hook is not present", () => {
900
+ const ledger = { schema_version: 1, entries: [] };
901
+ const evalRaw = makeEval({ hookType: "none", strength: 3, evidence: "章末证据片段", present: false });
902
+ const policy = makePolicy({ diversity_window_chapters: 5, min_distinct_types_in_window: 2, overdue_policy: "warn" });
903
+ const res = computeHookLedgerUpdate({
904
+ ledger,
905
+ evalRaw,
906
+ chapter: 1,
907
+ volume: 1,
908
+ evalRelPath: "evaluations/chapter-001-eval.json",
909
+ policy,
910
+ reportRange: { start: 1, end: 1 }
911
+ });
912
+ assert.equal(res.entry, null);
913
+ assert.equal(res.updatedLedger.entries.length, 0);
914
+ assert.equal(res.report.issues.length, 0);
915
+ assert.equal(res.report.has_blocking_issues, false);
916
+ });
917
+ test("computeHookLedgerUpdate strips unknown fields to keep hook-ledger.json schema-valid and returns warnings", () => {
918
+ const ledger = {
919
+ schema_version: 1,
920
+ foo: "bar",
921
+ entries: [
922
+ {
923
+ id: "hook:ch020",
924
+ chapter: 20,
925
+ hook_type: "question",
926
+ hook_strength: 4,
927
+ promise_text: "留悬念:未解之问",
928
+ status: "open",
929
+ fulfillment_window: [21, 24],
930
+ fulfilled_chapter: null,
931
+ created_at: "2026-01-01T00:00:00Z",
932
+ updated_at: "2026-01-01T00:00:00Z",
933
+ extra_field: "should_be_dropped"
934
+ },
935
+ {
936
+ id: "hook:ch020-dup",
937
+ chapter: 20,
938
+ hook_type: "question",
939
+ hook_strength: 4,
940
+ promise_text: "留悬念:未解之问",
941
+ status: "fulfilled",
942
+ fulfillment_window: [21, 24],
943
+ fulfilled_chapter: 21,
944
+ created_at: "2026-01-01T00:00:00Z",
945
+ updated_at: "2026-01-01T00:00:00Z",
946
+ extra_field: "should_be_dropped"
947
+ }
948
+ ]
949
+ };
950
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence: "章末证据片段" });
951
+ const policy = makePolicy({ overdue_policy: "warn", diversity_window_chapters: 1, min_distinct_types_in_window: 1 });
952
+ const res = computeHookLedgerUpdate({
953
+ ledger,
954
+ evalRaw,
955
+ chapter: 22,
956
+ volume: 1,
957
+ evalRelPath: "evaluations/chapter-022-eval.json",
958
+ policy,
959
+ reportRange: { start: 13, end: 22 }
960
+ });
961
+ assert.ok(Array.isArray(res.warnings) && res.warnings.some((w) => w.includes("Dropped") && w.includes("duplicate")));
962
+ assert.equal(res.updatedLedger.foo, undefined);
963
+ assert.equal(res.updatedLedger.entries.filter((e) => e.chapter === 20).length, 1);
964
+ assert.equal(res.updatedLedger.entries.length, 2);
965
+ const ch20 = res.updatedLedger.entries.find((e) => e.chapter === 20);
966
+ assert.ok(ch20);
967
+ assert.equal(ch20.extra_field, undefined);
968
+ });
969
+ test("writeHookLedgerFile + writeRetentionLogs write expected paths", async () => {
970
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-write-test-"));
971
+ await mkdir(join(rootDir, "logs"), { recursive: true });
972
+ const ledger = { schema_version: 1, entries: [] };
973
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence: "章末证据片段" });
974
+ const policy = makePolicy();
975
+ const res = computeHookLedgerUpdate({
976
+ ledger,
977
+ evalRaw,
978
+ chapter: 10,
979
+ volume: 2,
980
+ evalRelPath: "evaluations/chapter-010-eval.json",
981
+ policy,
982
+ reportRange: { start: 1, end: 10 }
983
+ });
984
+ const { rel: ledgerRel } = await writeHookLedgerFile({ rootDir, ledger: res.updatedLedger });
985
+ assert.equal(ledgerRel, "hook-ledger.json");
986
+ assert.ok((await stat(join(rootDir, ledgerRel))).isFile());
987
+ const { latestRel, historyRel } = await writeRetentionLogs({ rootDir, report: res.report, writeHistory: true });
988
+ assert.equal(latestRel, "logs/retention/latest.json");
989
+ assert.ok((await stat(join(rootDir, latestRel))).isFile());
990
+ assert.equal(historyRel, "logs/retention/retention-report-vol-02-ch001-ch010.json");
991
+ assert.ok((await stat(join(rootDir, historyRel ?? ""))).isFile());
992
+ });
993
+ test("attachHookLedgerToEval writes hook_ledger metadata with paths and issue counts", async () => {
994
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-hook-ledger-attach-test-"));
995
+ await mkdir(join(rootDir, "evaluations"), { recursive: true });
996
+ const ledger = { schema_version: 1, entries: [] };
997
+ const evalRaw = makeEval({ hookType: "question", strength: 4, evidence: "章末证据片段" });
998
+ const policy = makePolicy({ diversity_window_chapters: 1, max_same_type_streak: 99, min_distinct_types_in_window: 1, overdue_policy: "warn" });
999
+ const res = computeHookLedgerUpdate({
1000
+ ledger,
1001
+ evalRaw,
1002
+ chapter: 10,
1003
+ volume: 2,
1004
+ evalRelPath: "evaluations/chapter-010-eval.json",
1005
+ policy,
1006
+ reportRange: { start: 1, end: 10 }
1007
+ });
1008
+ assert.ok(res.entry);
1009
+ assert.equal(res.report.issues.length, 0);
1010
+ const evalRel = "evaluations/chapter-010-eval.json";
1011
+ const evalAbs = join(rootDir, evalRel);
1012
+ await writeFile(evalAbs, `${JSON.stringify({ chapter: 10 }, null, 2)}\n`, "utf8");
1013
+ await attachHookLedgerToEval({
1014
+ evalAbsPath: evalAbs,
1015
+ evalRelPath: evalRel,
1016
+ ledgerRelPath: "hook-ledger.json",
1017
+ reportLatestRelPath: "logs/retention/latest.json",
1018
+ entry: res.entry,
1019
+ report: res.report
1020
+ });
1021
+ const written = JSON.parse(await readFile(evalAbs, "utf8"));
1022
+ assert.ok(written.hook_ledger);
1023
+ assert.equal(written.hook_ledger.ledger_path, "hook-ledger.json");
1024
+ assert.equal(written.hook_ledger.report_latest_path, "logs/retention/latest.json");
1025
+ assert.equal(written.hook_ledger.entry.id, "hook:ch010");
1026
+ assert.equal(written.hook_ledger.issues_total, 0);
1027
+ assert.deepEqual(written.hook_ledger.issues_by_severity, { warn: 0, soft: 0, hard: 0 });
1028
+ });