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,687 @@
1
+ import { appendFile, rename, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile } from "./fs-utils.js";
4
+ import { loadLatestJsonSummary } from "./latest-summary-loader.js";
5
+ import { resolveProjectRelativePath } from "./safe-path.js";
6
+ import { pad2, pad3 } from "./steps.js";
7
+ import { truncateWithEllipsis } from "./text-utils.js";
8
+ import { isPlainObject } from "./type-guards.js";
9
+ import { parseSummaryIssues, safeNonNegativeFiniteOrNull, safeNonNegativeIntOrNull, safePositiveIntOrNull } from "./safe-parse.js";
10
+ const DEFAULT_METRICS_REL = "engagement-metrics.jsonl";
11
+ function safeInt(v) {
12
+ if (typeof v !== "number" || !Number.isInteger(v))
13
+ return null;
14
+ return v;
15
+ }
16
+ function safeFiniteNumber(v) {
17
+ if (typeof v !== "number" || !Number.isFinite(v))
18
+ return null;
19
+ return v;
20
+ }
21
+ function safeString(v) {
22
+ if (typeof v !== "string")
23
+ return null;
24
+ const t = v.trim();
25
+ return t.length > 0 ? t : null;
26
+ }
27
+ const RFC3339_DATE_TIME = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.(\d{1,9}))?(?:Z|([+-])([01]\d|2[0-3]):([0-5]\d))$/;
28
+ function isLeapYear(year) {
29
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
30
+ }
31
+ function daysInMonth(year, month) {
32
+ switch (month) {
33
+ case 1:
34
+ case 3:
35
+ case 5:
36
+ case 7:
37
+ case 8:
38
+ case 10:
39
+ case 12:
40
+ return 31;
41
+ case 4:
42
+ case 6:
43
+ case 9:
44
+ case 11:
45
+ return 30;
46
+ case 2:
47
+ return isLeapYear(year) ? 29 : 28;
48
+ default:
49
+ return 0;
50
+ }
51
+ }
52
+ function safeIso(v) {
53
+ if (typeof v !== "string")
54
+ return null;
55
+ const t = v.trim();
56
+ const m = RFC3339_DATE_TIME.exec(t);
57
+ if (!m)
58
+ return null;
59
+ const year = Number.parseInt(m[1] ?? "", 10);
60
+ const month = Number.parseInt(m[2] ?? "", 10);
61
+ const day = Number.parseInt(m[3] ?? "", 10);
62
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day))
63
+ return null;
64
+ if (day > daysInMonth(year, month))
65
+ return null;
66
+ if (!Number.isFinite(Date.parse(t)))
67
+ return null;
68
+ return t;
69
+ }
70
+ function clampScore(n) {
71
+ const clamped = Math.max(1, Math.min(5, Math.round(n)));
72
+ return clamped;
73
+ }
74
+ function countNonWhitespaceChars(text) {
75
+ const compact = text.replace(/\s+/gu, "");
76
+ return Array.from(compact).length;
77
+ }
78
+ function normalizeEventText(text) {
79
+ return text.trim().replace(/\s+/gu, " ").replace(/[。!?;,、]+$/gu, "");
80
+ }
81
+ function extractSummaryKeyEvents(markdown) {
82
+ const lines = markdown.split(/\r?\n/gu);
83
+ const out = [];
84
+ let inKeyEvents = false;
85
+ for (const line of lines) {
86
+ const heading = /^(?:\uFEFF)?\s{0,3}#{1,6}\s+(.+?)\s*$/u.exec(line);
87
+ if (heading) {
88
+ const title = (heading[1] ?? "").trim();
89
+ inKeyEvents = title.includes("关键事件") || title.toLowerCase().includes("key events") || title.toLowerCase().includes("key beats");
90
+ continue;
91
+ }
92
+ if (!inKeyEvents)
93
+ continue;
94
+ const bullet = /^\s*(?:[-*]|\d+\.)\s+(.+?)\s*$/u.exec(line);
95
+ if (!bullet)
96
+ continue;
97
+ const cleaned = normalizeEventText(bullet[1] ?? "");
98
+ if (cleaned.length < 2)
99
+ continue;
100
+ out.push(truncateWithEllipsis(cleaned, 200));
101
+ }
102
+ return out;
103
+ }
104
+ function extractSummaryBullets(markdown) {
105
+ const lines = markdown.split(/\r?\n/gu);
106
+ const out = [];
107
+ for (const line of lines) {
108
+ const bullet = /^\s*(?:[-*]|\d+\.)\s+(.+?)\s*$/u.exec(line);
109
+ if (!bullet)
110
+ continue;
111
+ const cleaned = normalizeEventText(bullet[1] ?? "");
112
+ if (cleaned.length < 2)
113
+ continue;
114
+ out.push(truncateWithEllipsis(cleaned, 200));
115
+ }
116
+ return out;
117
+ }
118
+ function scoreConflictIntensity(events) {
119
+ const conflictKeywords = [
120
+ "战",
121
+ "打",
122
+ "杀",
123
+ "追",
124
+ "逃",
125
+ "对峙",
126
+ "对抗",
127
+ "冲突",
128
+ "威胁",
129
+ "危机",
130
+ "围",
131
+ "拦",
132
+ "袭",
133
+ "爆发",
134
+ "生死",
135
+ "决战",
136
+ "大战",
137
+ "强敌",
138
+ "背叛"
139
+ ];
140
+ const peakKeywords = ["决战", "终局", "生死", "灭", "覆灭", "崩盘", "大劫", "同归于尽"];
141
+ let conflictHits = 0;
142
+ let hasPeak = false;
143
+ for (const ev of events) {
144
+ for (const k of peakKeywords) {
145
+ if (ev.includes(k))
146
+ hasPeak = true;
147
+ }
148
+ for (const k of conflictKeywords) {
149
+ if (ev.includes(k)) {
150
+ conflictHits += 1;
151
+ break;
152
+ }
153
+ }
154
+ }
155
+ if (hasPeak)
156
+ return { score: 5, evidence: `peak_keyword=true; conflict_hits=${conflictHits}` };
157
+ if (conflictHits >= 3)
158
+ return { score: 4, evidence: `conflict_hits=${conflictHits}` };
159
+ if (conflictHits === 2)
160
+ return { score: 3, evidence: "conflict_hits=2" };
161
+ if (conflictHits === 1)
162
+ return { score: 2, evidence: "conflict_hits=1" };
163
+ return { score: 1, evidence: "conflict_hits=0" };
164
+ }
165
+ function scorePayoff(events) {
166
+ const payoffKeywords = ["突破", "晋级", "升级", "获得", "奖励", "胜", "赢", "击败", "反转", "揭示", "真相", "身份", "和解", "告白", "兑现", "解决"];
167
+ const bigPayoffKeywords = ["真相", "身份", "大反转", "重大", "终结", "解决", "兑现", "击杀", "覆灭", "告白", "和解"];
168
+ let payoffHits = 0;
169
+ let hasBig = false;
170
+ for (const ev of events) {
171
+ for (const k of bigPayoffKeywords)
172
+ if (ev.includes(k))
173
+ hasBig = true;
174
+ for (const k of payoffKeywords) {
175
+ if (ev.includes(k)) {
176
+ payoffHits += 1;
177
+ break;
178
+ }
179
+ }
180
+ }
181
+ if (hasBig && payoffHits >= 2)
182
+ return { score: 5, evidence: `big_payoff=true; payoff_hits=${payoffHits}` };
183
+ if (hasBig)
184
+ return { score: 4, evidence: `big_payoff=true; payoff_hits=${payoffHits}` };
185
+ if (payoffHits >= 3)
186
+ return { score: 4, evidence: `payoff_hits=${payoffHits}` };
187
+ if (payoffHits === 2)
188
+ return { score: 3, evidence: "payoff_hits=2" };
189
+ if (payoffHits === 1)
190
+ return { score: 2, evidence: "payoff_hits=1" };
191
+ return { score: 1, evidence: "payoff_hits=0" };
192
+ }
193
+ function scoreNewInfoLoad(args) {
194
+ if (args.infoLoadNewTermsPer1k !== null) {
195
+ const v = args.infoLoadNewTermsPer1k;
196
+ const score = v < 0.5 ? 1 : v < 1.0 ? 2 : v < 2.0 ? 3 : v < 3.5 ? 4 : 5;
197
+ return { score, evidence: `info_load.new_terms_per_1k_words=${v}` };
198
+ }
199
+ const newEntities = args.infoLoadNewEntities;
200
+ const unknownEntities = args.infoLoadUnknownEntities;
201
+ if (newEntities !== null || unknownEntities !== null) {
202
+ const total = (newEntities ?? 0) + (unknownEntities ?? 0);
203
+ const score = total < 2 ? 1 : total < 4 ? 2 : total < 7 ? 3 : total < 10 ? 4 : 5;
204
+ return { score, evidence: `info_load.entities_total=${total} (new=${newEntities ?? "null"}, unknown=${unknownEntities ?? "null"})` };
205
+ }
206
+ // Fallback heuristic (summary-only): count rule/setting introduction phrases.
207
+ const infoKeywords = ["系统", "规则", "设定", "机制", "首次", "发现", "揭示", "介绍", "解释", "新"];
208
+ let hits = 0;
209
+ for (const ev of args.events) {
210
+ if (infoKeywords.some((k) => ev.includes(k)))
211
+ hits += 1;
212
+ }
213
+ const score = hits === 0 ? 1 : hits === 1 ? 2 : hits === 2 ? 3 : hits === 3 ? 4 : 5;
214
+ return { score, evidence: `fallback.summary_info_hits=${hits}` };
215
+ }
216
+ function extractPlatformConstraintsSignals(evalRaw) {
217
+ if (!isPlainObject(evalRaw)) {
218
+ return { wordCountChars: null, newTermsPer1k: null, newEntitiesCount: null, unknownEntitiesCount: null };
219
+ }
220
+ const obj = evalRaw;
221
+ const pcRaw = obj.platform_constraints;
222
+ if (!isPlainObject(pcRaw))
223
+ return { wordCountChars: null, newTermsPer1k: null, newEntitiesCount: null, unknownEntitiesCount: null };
224
+ const pc = pcRaw;
225
+ const wcRaw = pc.word_count;
226
+ const infoRaw = pc.info_load;
227
+ let wordCountChars = null;
228
+ if (isPlainObject(wcRaw)) {
229
+ const chars = safeInt(wcRaw.chars);
230
+ if (chars !== null && chars >= 0)
231
+ wordCountChars = chars;
232
+ }
233
+ let newTermsPer1k = null;
234
+ let newEntitiesCount = null;
235
+ let unknownEntitiesCount = null;
236
+ if (isPlainObject(infoRaw)) {
237
+ const info = infoRaw;
238
+ const terms = safeFiniteNumber(info.new_terms_per_1k_words);
239
+ if (terms !== null && terms >= 0)
240
+ newTermsPer1k = terms;
241
+ const newE = safeInt(info.new_entities_count);
242
+ if (newE !== null && newE >= 0)
243
+ newEntitiesCount = newE;
244
+ const unkE = safeInt(info.unknown_entities_count);
245
+ if (unkE !== null && unkE >= 0)
246
+ unknownEntitiesCount = unkE;
247
+ }
248
+ return { wordCountChars, newTermsPer1k, newEntitiesCount, unknownEntitiesCount };
249
+ }
250
+ export async function computeEngagementMetricRecord(args) {
251
+ const warnings = [];
252
+ if (!Number.isInteger(args.chapter) || args.chapter < 1)
253
+ throw new Error(`Invalid chapter: ${String(args.chapter)} (expected int >= 1).`);
254
+ if (!Number.isInteger(args.volume) || args.volume < 0)
255
+ throw new Error(`Invalid volume: ${String(args.volume)} (expected int >= 0).`);
256
+ const chapterAbs = resolveProjectRelativePath(args.rootDir, args.chapterRel, "chapterRel");
257
+ const summaryAbs = resolveProjectRelativePath(args.rootDir, args.summaryRel, "summaryRel");
258
+ const evalAbs = resolveProjectRelativePath(args.rootDir, args.evalRel, "evalRel");
259
+ const chapterText = await readTextFile(chapterAbs);
260
+ const summaryText = await readTextFile(summaryAbs);
261
+ let evalRaw = null;
262
+ if (await pathExists(evalAbs)) {
263
+ try {
264
+ evalRaw = await readJsonFile(evalAbs);
265
+ }
266
+ catch (err) {
267
+ const message = err instanceof Error ? err.message : String(err);
268
+ warnings.push(`Engagement metrics: failed to read eval JSON (${args.evalRel}): ${message}`);
269
+ evalRaw = null;
270
+ }
271
+ }
272
+ const pc = extractPlatformConstraintsSignals(evalRaw);
273
+ const keyEvents = extractSummaryKeyEvents(summaryText);
274
+ const fallbackBullets = keyEvents.length > 0 ? [] : extractSummaryBullets(summaryText);
275
+ const events = keyEvents.length > 0 ? keyEvents : fallbackBullets;
276
+ const plotBeats = keyEvents.length > 0 ? keyEvents.length : fallbackBullets.length;
277
+ const beatsSource = keyEvents.length > 0 ? "key_events" : "summary_bullets";
278
+ if (events.length === 0)
279
+ warnings.push("Engagement metrics: no summary bullet events detected; conflict/payoff scoring may be degraded.");
280
+ const wordCount = pc.wordCountChars ?? countNonWhitespaceChars(chapterText);
281
+ const conflict = scoreConflictIntensity(events);
282
+ const payoff = scorePayoff(events);
283
+ const infoLoad = scoreNewInfoLoad({
284
+ infoLoadNewTermsPer1k: pc.newTermsPer1k,
285
+ infoLoadNewEntities: pc.newEntitiesCount,
286
+ infoLoadUnknownEntities: pc.unknownEntitiesCount,
287
+ events
288
+ });
289
+ const notesParts = [];
290
+ notesParts.push(`word_count=${wordCount}${pc.wordCountChars !== null ? "(platform_constraints)" : ""}`);
291
+ notesParts.push(`beats=${plotBeats}(${beatsSource})`);
292
+ notesParts.push(`conflict=${conflict.score}(${conflict.evidence})`);
293
+ notesParts.push(`payoff=${payoff.score}(${payoff.evidence})`);
294
+ notesParts.push(`info_load=${infoLoad.score}(${infoLoad.evidence})`);
295
+ const notes = notesParts.join("; ");
296
+ const now = new Date().toISOString();
297
+ const record = {
298
+ schema_version: 1,
299
+ generated_at: now,
300
+ chapter: args.chapter,
301
+ volume: args.volume,
302
+ word_count: wordCount,
303
+ plot_progression_beats: plotBeats,
304
+ conflict_intensity: conflict.score,
305
+ payoff_score: payoff.score,
306
+ new_info_load_score: infoLoad.score,
307
+ notes: truncateWithEllipsis(notes, 320)
308
+ };
309
+ return { record, warnings };
310
+ }
311
+ export async function appendEngagementMetricRecord(args) {
312
+ const rel = args.relPath ?? DEFAULT_METRICS_REL;
313
+ const abs = resolveProjectRelativePath(args.rootDir, rel, "relPath");
314
+ await ensureDir(dirname(abs));
315
+ await appendFile(abs, `${JSON.stringify(args.record)}\n`, "utf8");
316
+ return { rel };
317
+ }
318
+ function normalizeLoadedMetric(raw) {
319
+ if (!isPlainObject(raw))
320
+ return null;
321
+ const obj = raw;
322
+ if (obj.schema_version !== 1)
323
+ return null;
324
+ const chapter = safeInt(obj.chapter);
325
+ if (chapter === null || chapter < 1)
326
+ return null;
327
+ const volume = safeInt(obj.volume);
328
+ if (volume === null || volume < 0)
329
+ return null;
330
+ const word_count = safeInt(obj.word_count);
331
+ const plot_progression_beats = safeInt(obj.plot_progression_beats);
332
+ const conflict_intensity = safeInt(obj.conflict_intensity);
333
+ const payoff_score = safeInt(obj.payoff_score);
334
+ const new_info_load_score = safeInt(obj.new_info_load_score);
335
+ if (word_count === null || word_count < 0)
336
+ return null;
337
+ if (plot_progression_beats === null || plot_progression_beats < 0)
338
+ return null;
339
+ if (conflict_intensity === null || conflict_intensity < 1 || conflict_intensity > 5)
340
+ return null;
341
+ if (payoff_score === null || payoff_score < 1 || payoff_score > 5)
342
+ return null;
343
+ if (new_info_load_score === null || new_info_load_score < 1 || new_info_load_score > 5)
344
+ return null;
345
+ const generated_at = safeIso(obj.generated_at);
346
+ if (!generated_at)
347
+ return null;
348
+ const notes = safeString(obj.notes);
349
+ if (!notes)
350
+ return null;
351
+ return {
352
+ schema_version: 1,
353
+ generated_at,
354
+ chapter,
355
+ volume,
356
+ word_count,
357
+ plot_progression_beats,
358
+ conflict_intensity: clampScore(conflict_intensity),
359
+ payoff_score: clampScore(payoff_score),
360
+ new_info_load_score: clampScore(new_info_load_score),
361
+ notes
362
+ };
363
+ }
364
+ export async function loadEngagementMetricsStream(args) {
365
+ const rel = args.relPath ?? DEFAULT_METRICS_REL;
366
+ const abs = resolveProjectRelativePath(args.rootDir, rel, "relPath");
367
+ if (!(await pathExists(abs)))
368
+ return { records: [], warnings: [], rel };
369
+ const rawText = await readTextFile(abs);
370
+ const warnings = [];
371
+ const records = [];
372
+ const lines = rawText.split(/\r?\n/gu);
373
+ for (const [i, lineRaw] of lines.entries()) {
374
+ const line = lineRaw.trim();
375
+ if (line.length === 0)
376
+ continue;
377
+ let parsed;
378
+ try {
379
+ parsed = JSON.parse(line);
380
+ }
381
+ catch {
382
+ warnings.push(`Engagement metrics: invalid JSONL at line ${i + 1}; skipping.`);
383
+ continue;
384
+ }
385
+ const normalized = normalizeLoadedMetric(parsed);
386
+ if (!normalized) {
387
+ warnings.push(`Engagement metrics: invalid record at line ${i + 1}; skipping.`);
388
+ continue;
389
+ }
390
+ records.push(normalized);
391
+ if (typeof args.maxRecords === "number" && args.maxRecords > 0 && records.length > args.maxRecords) {
392
+ records.shift();
393
+ }
394
+ }
395
+ return { records, warnings, rel };
396
+ }
397
+ function average(nums) {
398
+ if (nums.length === 0)
399
+ return null;
400
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
401
+ }
402
+ function pickLatestPerChapter(records) {
403
+ // If duplicates exist, keep the newest per chapter (generated_at tie-break); fall back to last-seen order.
404
+ const byChapter = new Map();
405
+ for (const r of records) {
406
+ const prev = byChapter.get(r.chapter);
407
+ if (!prev) {
408
+ byChapter.set(r.chapter, r);
409
+ continue;
410
+ }
411
+ const a = Date.parse(prev.generated_at);
412
+ const b = Date.parse(r.generated_at);
413
+ if (Number.isFinite(a) && Number.isFinite(b)) {
414
+ if (b >= a)
415
+ byChapter.set(r.chapter, r);
416
+ }
417
+ else {
418
+ byChapter.set(r.chapter, r);
419
+ }
420
+ }
421
+ return Array.from(byChapter.values()).sort((a, b) => a.chapter - b.chapter || a.generated_at.localeCompare(b.generated_at, "en"));
422
+ }
423
+ export function computeEngagementReport(args) {
424
+ if (!Number.isInteger(args.asOfChapter) || args.asOfChapter < 1)
425
+ throw new Error(`Invalid asOfChapter: ${String(args.asOfChapter)} (expected int >= 1).`);
426
+ if (!Number.isInteger(args.volume) || args.volume < 0)
427
+ throw new Error(`Invalid volume: ${String(args.volume)} (expected int >= 0).`);
428
+ if (!Number.isInteger(args.chapterRange.start) || args.chapterRange.start < 1) {
429
+ throw new Error(`Invalid chapterRange.start: ${String(args.chapterRange.start)} (expected int >= 1).`);
430
+ }
431
+ if (!Number.isInteger(args.chapterRange.end) || args.chapterRange.end < args.chapterRange.start) {
432
+ throw new Error(`Invalid chapterRange.end: ${String(args.chapterRange.end)} (expected int >= start=${args.chapterRange.start}).`);
433
+ }
434
+ if (args.asOfChapter < args.chapterRange.end) {
435
+ throw new Error(`Invalid asOfChapter: ${String(args.asOfChapter)} (expected int >= chapterRange.end=${args.chapterRange.end}).`);
436
+ }
437
+ const selected = pickLatestPerChapter(args.records.filter((r) => r.chapter >= args.chapterRange.start && r.chapter <= args.chapterRange.end));
438
+ const metricsByChapter = new Map();
439
+ for (const r of selected)
440
+ metricsByChapter.set(r.chapter, r);
441
+ const issues = [];
442
+ // Low plot beats stretches (consecutive beats <= 1).
443
+ const lowBeatThreshold = 1;
444
+ const minStretch = 3;
445
+ const pushLowBeatsStretch = (start, end, stretchLen) => {
446
+ issues.push({
447
+ id: "engagement.low_density.low_plot_beats_stretch",
448
+ severity: "warn",
449
+ summary: `Low plot progression beats for ${stretchLen} consecutive chapters (<=${lowBeatThreshold}).`,
450
+ evidence: `range=ch${pad3(start)}-ch${pad3(end)}`,
451
+ suggestion: "Add 1-2 clear progression beats per chapter (goal→obstacle→decision), and surface consequences."
452
+ });
453
+ };
454
+ let stretchStart = null;
455
+ let stretchEnd = null;
456
+ let stretchLen = 0;
457
+ let lastChapter = null;
458
+ for (const r of selected) {
459
+ const isConsecutive = lastChapter !== null && r.chapter === lastChapter + 1;
460
+ if (r.plot_progression_beats <= lowBeatThreshold) {
461
+ if (stretchStart === null || stretchEnd === null || !isConsecutive) {
462
+ if (stretchStart !== null && stretchEnd !== null && stretchLen >= minStretch) {
463
+ pushLowBeatsStretch(stretchStart, stretchEnd, stretchLen);
464
+ }
465
+ stretchStart = r.chapter;
466
+ stretchEnd = r.chapter;
467
+ stretchLen = 1;
468
+ }
469
+ else {
470
+ stretchLen += 1;
471
+ stretchEnd = r.chapter;
472
+ }
473
+ }
474
+ else {
475
+ if (stretchStart !== null && stretchEnd !== null && stretchLen >= minStretch) {
476
+ pushLowBeatsStretch(stretchStart, stretchEnd, stretchLen);
477
+ }
478
+ stretchStart = null;
479
+ stretchEnd = null;
480
+ stretchLen = 0;
481
+ }
482
+ lastChapter = r.chapter;
483
+ }
484
+ if (stretchStart !== null && stretchEnd !== null && stretchLen >= minStretch) {
485
+ pushLowBeatsStretch(stretchStart, stretchEnd, stretchLen);
486
+ }
487
+ const tail5 = (() => {
488
+ const tailEnd = args.chapterRange.end;
489
+ const tailStart = tailEnd - 4;
490
+ if (tailStart < args.chapterRange.start)
491
+ return null;
492
+ const tail = [];
493
+ for (let ch = tailStart; ch <= tailEnd; ch += 1) {
494
+ const r = metricsByChapter.get(ch);
495
+ if (!r)
496
+ return null;
497
+ tail.push(r);
498
+ }
499
+ return tail;
500
+ })();
501
+ // Low payoff trend in last 5 chapters (requires a complete consecutive tail).
502
+ if (tail5) {
503
+ const avgPayoff = average(tail5.map((r) => r.payoff_score));
504
+ if (avgPayoff !== null && avgPayoff <= 2.0) {
505
+ issues.push({
506
+ id: "engagement.low_density.low_payoff_trend",
507
+ severity: "warn",
508
+ summary: `Low payoff trend in last 5 chapters (avg_payoff=${avgPayoff.toFixed(2)}).`,
509
+ evidence: `range=ch${pad3(tail5[0].chapter)}-ch${pad3(tail5[tail5.length - 1].chapter)}`,
510
+ suggestion: "Schedule small but frequent rewards/reveals (wins, reveals, emotional beats) to avoid perceived stalling."
511
+ });
512
+ }
513
+ }
514
+ // Conflict plateau in last 5 chapters (requires a complete consecutive tail; all <= 2).
515
+ if (tail5) {
516
+ const maxConflict = Math.max(...tail5.map((r) => r.conflict_intensity));
517
+ if (maxConflict <= 2) {
518
+ issues.push({
519
+ id: "engagement.low_density.conflict_plateau",
520
+ severity: "warn",
521
+ summary: "Conflict plateau in last 5 chapters (conflict_intensity stays low).",
522
+ evidence: `range=ch${pad3(tail5[0].chapter)}-ch${pad3(tail5[tail5.length - 1].chapter)}`,
523
+ suggestion: "Introduce explicit opposition, time pressure, or meaningful cost to raise tension without forcing a full climax."
524
+ });
525
+ }
526
+ }
527
+ const wordCounts = selected.map((r) => r.word_count);
528
+ const beats = selected.map((r) => r.plot_progression_beats);
529
+ const conflicts = selected.map((r) => r.conflict_intensity);
530
+ const payoffs = selected.map((r) => r.payoff_score);
531
+ const infos = selected.map((r) => r.new_info_load_score);
532
+ const hasBlocking = issues.some((i) => i.severity === "hard");
533
+ return {
534
+ schema_version: 1,
535
+ generated_at: new Date().toISOString(),
536
+ as_of: { chapter: args.asOfChapter, volume: args.volume },
537
+ scope: { volume: args.volume, chapter_start: args.chapterRange.start, chapter_end: args.chapterRange.end },
538
+ metrics_stream_path: args.metricsRelPath ?? DEFAULT_METRICS_REL,
539
+ metrics: selected,
540
+ stats: {
541
+ chapters: selected.length,
542
+ avg_word_count: average(wordCounts),
543
+ avg_plot_progression_beats: average(beats),
544
+ avg_conflict_intensity: average(conflicts),
545
+ avg_payoff_score: average(payoffs),
546
+ avg_new_info_load_score: average(infos)
547
+ },
548
+ issues,
549
+ has_blocking_issues: hasBlocking
550
+ };
551
+ }
552
+ export async function writeEngagementLogs(args) {
553
+ const dirRel = "logs/engagement";
554
+ const dirAbs = join(args.rootDir, dirRel);
555
+ await ensureDir(dirAbs);
556
+ const latestRel = `${dirRel}/latest.json`;
557
+ const latestAbs = join(args.rootDir, latestRel);
558
+ const result = { latestRel };
559
+ if (args.historyRange) {
560
+ const historyRel = `${dirRel}/engagement-report-vol-${pad2(args.report.scope.volume)}-ch${pad3(args.historyRange.start)}-ch${pad3(args.historyRange.end)}.json`;
561
+ await writeJsonFile(join(args.rootDir, historyRel), args.report);
562
+ result.historyRel = historyRel;
563
+ }
564
+ const parseLatest = (raw) => {
565
+ if (!isPlainObject(raw))
566
+ return null;
567
+ const obj = raw;
568
+ if (obj.schema_version !== 1)
569
+ return null;
570
+ const asOf = obj.as_of;
571
+ if (!isPlainObject(asOf))
572
+ return null;
573
+ const chapter = safeInt(asOf.chapter);
574
+ if (chapter === null || chapter < 0)
575
+ return null;
576
+ const rawTs = typeof obj.generated_at === "string" ? obj.generated_at : null;
577
+ const generated_at = rawTs && Number.isFinite(Date.parse(rawTs)) ? rawTs : null;
578
+ return { chapter, generated_at };
579
+ };
580
+ const next = { chapter: args.report.as_of.chapter, generated_at: args.report.generated_at };
581
+ let shouldWriteLatest = true;
582
+ if (await pathExists(latestAbs)) {
583
+ try {
584
+ const existing = parseLatest(await readJsonFile(latestAbs));
585
+ if (existing) {
586
+ if (existing.chapter > next.chapter) {
587
+ shouldWriteLatest = false;
588
+ }
589
+ else if (existing.chapter === next.chapter) {
590
+ if (existing.generated_at) {
591
+ const a = Date.parse(existing.generated_at);
592
+ const b = Date.parse(next.generated_at);
593
+ if (Number.isFinite(a) && Number.isFinite(b) && a >= b)
594
+ shouldWriteLatest = false;
595
+ }
596
+ }
597
+ }
598
+ }
599
+ catch {
600
+ shouldWriteLatest = true;
601
+ }
602
+ }
603
+ if (shouldWriteLatest) {
604
+ const tmpAbs = join(dirAbs, `.tmp-engagement-latest-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
605
+ await writeJsonFile(tmpAbs, args.report);
606
+ try {
607
+ let stillWrite = true;
608
+ if (await pathExists(latestAbs)) {
609
+ try {
610
+ const existing2 = parseLatest(await readJsonFile(latestAbs));
611
+ if (existing2) {
612
+ if (existing2.chapter > next.chapter) {
613
+ stillWrite = false;
614
+ }
615
+ else if (existing2.chapter === next.chapter && existing2.generated_at) {
616
+ const a = Date.parse(existing2.generated_at);
617
+ const b = Date.parse(next.generated_at);
618
+ if (Number.isFinite(a) && Number.isFinite(b) && a >= b)
619
+ stillWrite = false;
620
+ }
621
+ }
622
+ }
623
+ catch {
624
+ stillWrite = true;
625
+ }
626
+ }
627
+ if (stillWrite)
628
+ await rename(tmpAbs, latestAbs);
629
+ }
630
+ finally {
631
+ await rm(tmpAbs, { force: true }).catch(() => { });
632
+ }
633
+ }
634
+ return result;
635
+ }
636
+ export async function loadEngagementLatestSummary(rootDir) {
637
+ return loadLatestJsonSummary({
638
+ rootDir,
639
+ relPath: "logs/engagement/latest.json",
640
+ summarize: summarizeEngagementReport
641
+ });
642
+ }
643
+ export function summarizeEngagementReport(raw) {
644
+ if (!isPlainObject(raw))
645
+ return null;
646
+ const obj = raw;
647
+ if (obj.schema_version !== 1)
648
+ return null;
649
+ const asOfRaw = isPlainObject(obj.as_of) ? obj.as_of : null;
650
+ const scopeRaw = isPlainObject(obj.scope) ? obj.scope : null;
651
+ const statsRaw = isPlainObject(obj.stats) ? obj.stats : null;
652
+ const issuesRaw = Array.isArray(obj.issues) ? obj.issues : [];
653
+ const as_of = asOfRaw
654
+ ? {
655
+ chapter: safePositiveIntOrNull(asOfRaw.chapter),
656
+ volume: safeNonNegativeIntOrNull(asOfRaw.volume)
657
+ }
658
+ : null;
659
+ let scope = scopeRaw
660
+ ? {
661
+ volume: safeNonNegativeIntOrNull(scopeRaw.volume),
662
+ chapter_start: safePositiveIntOrNull(scopeRaw.chapter_start),
663
+ chapter_end: safePositiveIntOrNull(scopeRaw.chapter_end)
664
+ }
665
+ : null;
666
+ if (scope && scope.chapter_start !== null && scope.chapter_end !== null && scope.chapter_start > scope.chapter_end)
667
+ scope = null;
668
+ const stats = statsRaw
669
+ ? {
670
+ chapters: safeNonNegativeIntOrNull(statsRaw.chapters),
671
+ avg_word_count: safeNonNegativeFiniteOrNull(statsRaw.avg_word_count),
672
+ avg_plot_progression_beats: safeNonNegativeFiniteOrNull(statsRaw.avg_plot_progression_beats),
673
+ avg_conflict_intensity: safeNonNegativeFiniteOrNull(statsRaw.avg_conflict_intensity),
674
+ avg_payoff_score: safeNonNegativeFiniteOrNull(statsRaw.avg_payoff_score),
675
+ avg_new_info_load_score: safeNonNegativeFiniteOrNull(statsRaw.avg_new_info_load_score)
676
+ }
677
+ : null;
678
+ const issues = parseSummaryIssues(issuesRaw);
679
+ const has_blocking_issues = typeof obj.has_blocking_issues === "boolean" ? obj.has_blocking_issues : null;
680
+ return {
681
+ as_of,
682
+ scope,
683
+ stats,
684
+ issues,
685
+ has_blocking_issues
686
+ };
687
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,7 @@
1
+ export class NovelCliError extends Error {
2
+ exitCode;
3
+ constructor(message, exitCode = 1) {
4
+ super(message);
5
+ this.exitCode = exitCode;
6
+ }
7
+ }