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
package/dist/commit.js ADDED
@@ -0,0 +1,1460 @@
1
+ import { appendFile, readdir, rename, stat, truncate, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { clearCharacterVoiceDriftFile, computeCharacterVoiceDrift, loadActiveCharacterVoiceDriftIds, loadCharacterVoiceProfiles, writeCharacterVoiceDriftFile } from "./character-voice.js";
4
+ import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
5
+ import { attachClicheLintToEval, computeClicheLintReport, loadWebNovelClicheLintConfig, precomputeClicheLintReport, writeClicheLintLogs } from "./cliche-lint.js";
6
+ import { NovelCliError } from "./errors.js";
7
+ import { fingerprintsMatch, hashText } from "./fingerprint.js";
8
+ import { ensureDir, pathExists, readJsonFile, readTextFile, removePath, writeJsonFile } from "./fs-utils.js";
9
+ import { appendEngagementMetricRecord, computeEngagementMetricRecord, computeEngagementReport, loadEngagementMetricsStream, writeEngagementLogs } from "./engagement.js";
10
+ import { computeForeshadowVisibilityReport, loadForeshadowGlobalItems, writeForeshadowVisibilityLogs } from "./foreshadow-visibility.js";
11
+ import { attachHookLedgerToEval, computeHookLedgerUpdate, loadHookLedger, writeHookLedgerFile, writeRetentionLogs } from "./hook-ledger.js";
12
+ import { checkHookPolicy } from "./hook-policy.js";
13
+ import { withWriteLock } from "./lock.js";
14
+ import { computeContinuityReport, tryResolveVolumeChapterRange, writeContinuityLogs, writeVolumeContinuityReport } from "./consistency-auditor.js";
15
+ import { attachPlatformConstraintsToEval, computePlatformConstraints, precomputeInfoLoadNer, writePlatformConstraintsLogs } from "./platform-constraints.js";
16
+ import { loadPlatformProfile } from "./platform-profile.js";
17
+ import { computePromiseLedgerReport, loadPromiseLedger, writePromiseLedgerLogs } from "./promise-ledger.js";
18
+ import { attachNamingLintToEval, computeNamingReport, precomputeNamingReport, summarizeNamingIssues, writeNamingLintLogs } from "./naming-lint.js";
19
+ import { attachReadabilityLintToEval, computeReadabilityReport, precomputeReadabilityReport, summarizeReadabilityIssues, writeReadabilityLogs } from "./readability-lint.js";
20
+ import { attachScoringWeightsToEval, loadGenreWeightProfiles } from "./scoring-weights.js";
21
+ import { rejectPathTraversalInput } from "./safe-path.js";
22
+ import { chapterRelPaths, pad2, pad3 } from "./steps.js";
23
+ import { computeTitlePolicyReport, writeTitlePolicyLogs } from "./title-policy.js";
24
+ import { isPlainObject } from "./type-guards.js";
25
+ function requireInt(field, value, file) {
26
+ if (typeof value !== "number" || !Number.isInteger(value))
27
+ throw new NovelCliError(`Invalid ${file}: '${field}' must be an int.`, 2);
28
+ return value;
29
+ }
30
+ function requireString(field, value, file) {
31
+ if (typeof value !== "string" || value.length === 0)
32
+ throw new NovelCliError(`Invalid ${file}: '${field}' must be a non-empty string.`, 2);
33
+ return value;
34
+ }
35
+ function loadStateInit() {
36
+ return {
37
+ schema_version: 1,
38
+ state_version: 0,
39
+ last_updated_chapter: 0,
40
+ characters: {},
41
+ world_state: {},
42
+ active_foreshadowing: []
43
+ };
44
+ }
45
+ async function readState(rootDir, relPath) {
46
+ const abs = join(rootDir, relPath);
47
+ if (!(await pathExists(abs)))
48
+ return loadStateInit();
49
+ const raw = await readJsonFile(abs);
50
+ if (!isPlainObject(raw))
51
+ throw new NovelCliError(`Invalid state file: ${relPath} must be an object.`, 2);
52
+ const obj = raw;
53
+ const schemaVersion = requireInt("schema_version", obj.schema_version, relPath);
54
+ const stateVersion = requireInt("state_version", obj.state_version, relPath);
55
+ const lastUpdated = requireInt("last_updated_chapter", obj.last_updated_chapter, relPath);
56
+ return { ...obj, schema_version: schemaVersion, state_version: stateVersion, last_updated_chapter: lastUpdated };
57
+ }
58
+ async function appendJsonl(rootDir, relPath, payload) {
59
+ const abs = join(rootDir, relPath);
60
+ await ensureDir(dirname(abs));
61
+ await appendFile(abs, `${JSON.stringify(payload)}\n`, "utf8");
62
+ }
63
+ const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
64
+ function isForbiddenPathSegment(key) {
65
+ return FORBIDDEN_PATH_SEGMENTS.has(key);
66
+ }
67
+ function validateOps(ops, warnings) {
68
+ const allowedTop = new Set(["characters", "items", "locations", "factions", "world_state", "active_foreshadowing"]);
69
+ const out = [];
70
+ for (const opRaw of ops) {
71
+ if (!isPlainObject(opRaw)) {
72
+ warnings.push("Dropped non-object op entry.");
73
+ continue;
74
+ }
75
+ const op = opRaw;
76
+ const opType = op.op;
77
+ if (opType === "foreshadow") {
78
+ out.push(op);
79
+ continue;
80
+ }
81
+ if (opType !== "set" && opType !== "inc" && opType !== "add" && opType !== "remove") {
82
+ warnings.push(`Dropped invalid op type: ${String(opType)}`);
83
+ continue;
84
+ }
85
+ const path = op.path;
86
+ if (typeof path !== "string" || path.length === 0) {
87
+ warnings.push(`Dropped op with invalid path: ${JSON.stringify(op)}`);
88
+ continue;
89
+ }
90
+ const parts = path.split(".");
91
+ if (parts.length < 2 || parts.length > 4) {
92
+ warnings.push(`Dropped op with invalid path depth: ${path}`);
93
+ continue;
94
+ }
95
+ if (!allowedTop.has(parts[0] ?? "")) {
96
+ warnings.push(`Dropped op with invalid top-level path: ${path}`);
97
+ continue;
98
+ }
99
+ const forbidden = parts.find(isForbiddenPathSegment);
100
+ if (forbidden) {
101
+ warnings.push(`Dropped op with forbidden path segment: ${forbidden}`);
102
+ continue;
103
+ }
104
+ out.push(op);
105
+ }
106
+ return out;
107
+ }
108
+ function ensureObjectAtPath(root, pathParts, warnings) {
109
+ let cursor = root;
110
+ for (const key of pathParts) {
111
+ if (isForbiddenPathSegment(key)) {
112
+ warnings.push(`Dropped op with forbidden path segment: ${key}`);
113
+ return null;
114
+ }
115
+ const current = cursor[key];
116
+ if (current === undefined) {
117
+ cursor[key] = {};
118
+ cursor = cursor[key];
119
+ continue;
120
+ }
121
+ if (!isPlainObject(current)) {
122
+ warnings.push(`Path collision: '${key}' is not an object; skipping op.`);
123
+ return null;
124
+ }
125
+ cursor = current;
126
+ }
127
+ return cursor;
128
+ }
129
+ function applyStateOps(state, ops, warnings) {
130
+ let applied = 0;
131
+ const foreshadowOps = [];
132
+ for (const op of ops) {
133
+ const opType = op.op;
134
+ if (opType === "foreshadow") {
135
+ foreshadowOps.push(op);
136
+ continue;
137
+ }
138
+ const path = String(op.path ?? "");
139
+ const parts = path.split(".");
140
+ const leaf = parts.pop();
141
+ if (!leaf) {
142
+ warnings.push(`Dropped op with empty leaf path: ${path}`);
143
+ continue;
144
+ }
145
+ const forbidden = parts.find(isForbiddenPathSegment);
146
+ if (forbidden || isForbiddenPathSegment(leaf)) {
147
+ warnings.push(`Dropped op with forbidden path segment: ${forbidden ?? leaf}`);
148
+ continue;
149
+ }
150
+ const parent = ensureObjectAtPath(state, parts, warnings);
151
+ if (!parent)
152
+ continue;
153
+ if (opType === "set") {
154
+ parent[leaf] = op.value;
155
+ applied += 1;
156
+ continue;
157
+ }
158
+ if (opType === "inc") {
159
+ const delta = op.value;
160
+ if (typeof delta !== "number" || !Number.isFinite(delta)) {
161
+ warnings.push(`Dropped inc op with non-number value: ${path}`);
162
+ continue;
163
+ }
164
+ const prev = parent[leaf];
165
+ const prevNum = typeof prev === "number" && Number.isFinite(prev) ? prev : 0;
166
+ parent[leaf] = prevNum + delta;
167
+ applied += 1;
168
+ continue;
169
+ }
170
+ if (opType === "add") {
171
+ const prev = parent[leaf];
172
+ if (prev === undefined) {
173
+ parent[leaf] = [op.value];
174
+ applied += 1;
175
+ continue;
176
+ }
177
+ if (!Array.isArray(prev)) {
178
+ warnings.push(`Dropped add op: target is not an array: ${path}`);
179
+ continue;
180
+ }
181
+ prev.push(op.value);
182
+ applied += 1;
183
+ continue;
184
+ }
185
+ if (opType === "remove") {
186
+ const prev = parent[leaf];
187
+ if (!Array.isArray(prev)) {
188
+ warnings.push(`Dropped remove op: target is not an array: ${path}`);
189
+ continue;
190
+ }
191
+ const idx = prev.findIndex((v) => v === op.value);
192
+ if (idx >= 0)
193
+ prev.splice(idx, 1);
194
+ applied += 1;
195
+ continue;
196
+ }
197
+ }
198
+ return { applied, foreshadowOps };
199
+ }
200
+ function statusRank(status) {
201
+ switch (status) {
202
+ case "planted":
203
+ return 1;
204
+ case "advanced":
205
+ return 2;
206
+ case "resolved":
207
+ return 3;
208
+ default:
209
+ return 0;
210
+ }
211
+ }
212
+ function normalizeForeshadowFile(raw) {
213
+ if (!isPlainObject(raw))
214
+ return { foreshadowing: [] };
215
+ const obj = raw;
216
+ const list = Array.isArray(obj.foreshadowing) ? obj.foreshadowing : [];
217
+ const items = [];
218
+ for (const it of list) {
219
+ if (!isPlainObject(it))
220
+ continue;
221
+ const id = typeof it.id === "string" ? it.id : null;
222
+ if (!id)
223
+ continue;
224
+ items.push({ ...it, id });
225
+ }
226
+ return { foreshadowing: items };
227
+ }
228
+ async function updateForeshadowing(args) {
229
+ if (args.foreshadowOps.length === 0)
230
+ return;
231
+ const globalRel = "foreshadowing/global.json";
232
+ const globalAbs = join(args.rootDir, globalRel);
233
+ let globalRaw = { foreshadowing: [] };
234
+ if (await pathExists(globalAbs)) {
235
+ try {
236
+ globalRaw = await readJsonFile(globalAbs);
237
+ }
238
+ catch (err) {
239
+ const message = err instanceof Error ? err.message : String(err);
240
+ args.warnings.push(`Failed to read ${globalRel}: ${message}. Skipping foreshadow merge for this commit.`);
241
+ return;
242
+ }
243
+ }
244
+ if (Array.isArray(globalRaw))
245
+ globalRaw = { foreshadowing: globalRaw };
246
+ if (!(isPlainObject(globalRaw) && Array.isArray(globalRaw.foreshadowing))) {
247
+ args.warnings.push(`Invalid ${globalRel}: expected a list or {foreshadowing:[...]}. Skipping foreshadow merge for this commit.`);
248
+ return;
249
+ }
250
+ const global = normalizeForeshadowFile(globalRaw);
251
+ const volumeRel = `volumes/vol-${pad2(args.checkpoint.current_volume)}/foreshadowing.json`;
252
+ const volumeAbs = join(args.rootDir, volumeRel);
253
+ let volumeRaw = null;
254
+ if (await pathExists(volumeAbs)) {
255
+ try {
256
+ volumeRaw = await readJsonFile(volumeAbs);
257
+ }
258
+ catch (err) {
259
+ const message = err instanceof Error ? err.message : String(err);
260
+ args.warnings.push(`Failed to read ${volumeRel}: ${message}. Proceeding without volume foreshadow metadata.`);
261
+ volumeRaw = null;
262
+ }
263
+ }
264
+ if (Array.isArray(volumeRaw))
265
+ volumeRaw = { foreshadowing: volumeRaw };
266
+ if (volumeRaw !== null && !(isPlainObject(volumeRaw) && Array.isArray(volumeRaw.foreshadowing))) {
267
+ args.warnings.push(`Ignoring invalid ${volumeRel}: expected a list or {foreshadowing:[...]}.`);
268
+ volumeRaw = null;
269
+ }
270
+ const volume = normalizeForeshadowFile(volumeRaw);
271
+ const volumeIndex = new Map(volume.foreshadowing.map((it) => [it.id, it]));
272
+ const globalIndex = new Map(global.foreshadowing.map((it) => [it.id, it]));
273
+ for (const op of args.foreshadowOps) {
274
+ const id = typeof op.path === "string" ? op.path : null;
275
+ const value = typeof op.value === "string" ? op.value : null;
276
+ if (!id || !value) {
277
+ args.warnings.push(`Dropped invalid foreshadow op: ${JSON.stringify(op)}`);
278
+ continue;
279
+ }
280
+ if (value !== "planted" && value !== "advanced" && value !== "resolved") {
281
+ args.warnings.push(`Dropped foreshadow op with invalid value: ${id}=${value}`);
282
+ continue;
283
+ }
284
+ const detail = typeof op.detail === "string" ? op.detail : undefined;
285
+ let item = globalIndex.get(id);
286
+ if (!item) {
287
+ const seed = volumeIndex.get(id);
288
+ item = { id };
289
+ if (seed) {
290
+ for (const k of ["description", "scope", "target_resolve_range"]) {
291
+ if (seed[k] !== undefined)
292
+ item[k] = seed[k];
293
+ }
294
+ }
295
+ global.foreshadowing.push(item);
296
+ globalIndex.set(id, item);
297
+ }
298
+ // Status monotonic.
299
+ const prevStatus = typeof item.status === "string" ? item.status : "";
300
+ const nextStatus = statusRank(value) >= statusRank(prevStatus) ? value : prevStatus;
301
+ item.status = nextStatus;
302
+ if (value === "planted") {
303
+ if (typeof item.planted_chapter !== "number")
304
+ item.planted_chapter = args.delta.chapter;
305
+ if (typeof item.planted_storyline !== "string")
306
+ item.planted_storyline = args.delta.storyline_id;
307
+ }
308
+ const lastUpdated = typeof item.last_updated_chapter === "number" ? item.last_updated_chapter : 0;
309
+ item.last_updated_chapter = Math.max(lastUpdated, args.delta.chapter);
310
+ // Backfill metadata when missing.
311
+ const seed = volumeIndex.get(id);
312
+ if (seed) {
313
+ for (const k of ["description", "scope", "target_resolve_range"]) {
314
+ if (item[k] === undefined && seed[k] !== undefined)
315
+ item[k] = seed[k];
316
+ }
317
+ }
318
+ // History.
319
+ const history = Array.isArray(item.history) ? item.history : [];
320
+ const key = `${args.delta.chapter}:${value}`;
321
+ const existingKeys = new Set(history
322
+ .filter((h) => isPlainObject(h))
323
+ .map((h) => `${String(h.chapter ?? "")}:${String(h.action ?? "")}`));
324
+ if (!existingKeys.has(key)) {
325
+ history.push({ chapter: args.delta.chapter, action: value, ...(detail ? { detail } : {}) });
326
+ item.history = history;
327
+ }
328
+ }
329
+ if (!args.dryRun) {
330
+ await ensureDir(dirname(globalAbs));
331
+ await writeJsonFile(globalAbs, { foreshadowing: global.foreshadowing });
332
+ }
333
+ }
334
+ async function doRename(rootDir, fromRel, toRel) {
335
+ const fromAbs = join(rootDir, fromRel);
336
+ const toAbs = join(rootDir, toRel);
337
+ if (await pathExists(toAbs)) {
338
+ throw new NovelCliError(`Refusing to overwrite existing destination: ${toRel}`, 2);
339
+ }
340
+ await ensureDir(dirname(toAbs));
341
+ try {
342
+ await rename(fromAbs, toAbs);
343
+ }
344
+ catch (err) {
345
+ const message = err instanceof Error ? err.message : String(err);
346
+ throw new NovelCliError(`Failed to move '${fromRel}' to '${toRel}': ${message}`, 2);
347
+ }
348
+ }
349
+ async function rollbackRename(rootDir, fromRel, toRel) {
350
+ const fromAbs = join(rootDir, fromRel);
351
+ const toAbs = join(rootDir, toRel);
352
+ await ensureDir(dirname(toAbs));
353
+ await rename(fromAbs, toAbs);
354
+ }
355
+ async function ensureFilePresent(rootDir, relPath) {
356
+ const abs = join(rootDir, relPath);
357
+ if (!(await pathExists(abs)))
358
+ throw new NovelCliError(`Missing required file: ${relPath}`, 2);
359
+ }
360
+ function pendingVolumeEndMarkerRel(volume) {
361
+ return `logs/continuity/pending-volume-end-vol-${pad2(volume)}.json`;
362
+ }
363
+ function resolveForeshadowVisibilityHistoryRange(args) {
364
+ if (args.isVolumeEnd && args.volumeRange)
365
+ return { start: args.volumeRange.start, end: args.volumeRange.end };
366
+ if (args.chapter % 10 === 0)
367
+ return { start: Math.max(1, args.chapter - 9), end: args.chapter };
368
+ return null;
369
+ }
370
+ function resolvePromiseLedgerHistoryRange(args) {
371
+ if (args.isVolumeEnd && args.volumeRange)
372
+ return { start: args.volumeRange.start, end: args.volumeRange.end };
373
+ if (args.chapter % 10 === 0)
374
+ return { start: Math.max(1, args.chapter - 9), end: args.chapter };
375
+ return null;
376
+ }
377
+ function resolveEngagementHistoryRange(args) {
378
+ if (args.isVolumeEnd && args.volumeRange)
379
+ return { start: args.volumeRange.start, end: args.volumeRange.end };
380
+ if (args.chapter % 10 === 0)
381
+ return { start: Math.max(1, args.chapter - 9), end: args.chapter };
382
+ return null;
383
+ }
384
+ function parsePendingVolumeEndAuditMarker(raw) {
385
+ if (!isPlainObject(raw))
386
+ return null;
387
+ const obj = raw;
388
+ if (obj.schema_version !== 1)
389
+ return null;
390
+ const created_at = typeof obj.created_at === "string" ? obj.created_at : null;
391
+ const volume = typeof obj.volume === "number" && Number.isInteger(obj.volume) && obj.volume >= 0 ? obj.volume : null;
392
+ const range = obj.chapter_range;
393
+ if (!created_at || volume === null)
394
+ return null;
395
+ if (!Array.isArray(range) || range.length !== 2)
396
+ return null;
397
+ const start = range[0];
398
+ const end = range[1];
399
+ if (typeof start !== "number" || typeof end !== "number")
400
+ return null;
401
+ if (!Number.isInteger(start) || !Number.isInteger(end))
402
+ return null;
403
+ if (start < 1 || end < start)
404
+ return null;
405
+ return { schema_version: 1, created_at, volume, chapter_range: [start, end] };
406
+ }
407
+ async function listPendingVolumeEndAuditMarkers(rootDir, warnings) {
408
+ const dirRel = "logs/continuity";
409
+ const dirAbs = join(rootDir, dirRel);
410
+ if (!(await pathExists(dirAbs)))
411
+ return [];
412
+ const entries = await readdir(dirAbs, { withFileTypes: true });
413
+ const out = [];
414
+ for (const e of entries) {
415
+ if (!e.isFile())
416
+ continue;
417
+ const m = /^pending-volume-end-vol-(\d{2})\.json$/u.exec(e.name);
418
+ if (!m)
419
+ continue;
420
+ const rel = `${dirRel}/${e.name}`;
421
+ let raw;
422
+ try {
423
+ raw = await readJsonFile(join(rootDir, rel));
424
+ }
425
+ catch (err) {
426
+ const message = err instanceof Error ? err.message : String(err);
427
+ warnings.push(`Failed to read pending volume-end audit marker: ${rel}. ${message}`);
428
+ continue;
429
+ }
430
+ const parsed = parsePendingVolumeEndAuditMarker(raw);
431
+ if (!parsed) {
432
+ warnings.push(`Ignoring invalid pending volume-end audit marker: ${rel}`);
433
+ continue;
434
+ }
435
+ out.push({ rel, marker: parsed });
436
+ }
437
+ out.sort((a, b) => a.marker.volume - b.marker.volume || a.marker.chapter_range[0] - b.marker.chapter_range[0]);
438
+ return out;
439
+ }
440
+ export async function commitChapter(args) {
441
+ if (!Number.isInteger(args.chapter) || args.chapter <= 0) {
442
+ throw new NovelCliError(`--chapter must be an int >= 1`, 2);
443
+ }
444
+ const checkpoint = await readCheckpoint(args.rootDir);
445
+ const volume = checkpoint.current_volume;
446
+ const warnings = [];
447
+ const plan = [];
448
+ // Best-effort volume range resolution (for plan + optional volume-end continuity audits).
449
+ // Never block commit on missing outline/contracts.
450
+ let volumeRange = null;
451
+ try {
452
+ volumeRange = await tryResolveVolumeChapterRange({ rootDir: args.rootDir, volume });
453
+ }
454
+ catch {
455
+ volumeRange = null;
456
+ }
457
+ let isVolumeEnd = volumeRange !== null && args.chapter === volumeRange.end;
458
+ let shouldPeriodicContinuityAudit = args.chapter % 5 === 0 && !isVolumeEnd;
459
+ const loadedProfile = await loadPlatformProfile(args.rootDir);
460
+ if (!loadedProfile)
461
+ warnings.push("Missing platform-profile.json; platform constraints will be skipped.");
462
+ const hookLedgerPolicy = loadedProfile?.profile.retention?.hook_ledger ?? null;
463
+ const hookLedgerEnabled = Boolean(hookLedgerPolicy && hookLedgerPolicy.enabled);
464
+ const retentionReportRange = { start: Math.max(1, args.chapter - 9), end: args.chapter };
465
+ const shouldWriteRetentionHistory = hookLedgerEnabled && args.chapter % 10 === 0;
466
+ const loadedCliche = await loadWebNovelClicheLintConfig(args.rootDir);
467
+ if (!loadedCliche)
468
+ warnings.push("Missing web-novel-cliche-lint.json; cliché lint will be skipped.");
469
+ const loadedGenreWeights = loadedProfile?.profile.scoring ? await loadGenreWeightProfiles(args.rootDir) : null;
470
+ if (loadedProfile?.profile.scoring && !loadedGenreWeights) {
471
+ throw new NovelCliError("Missing required file: genre-weight-profiles.json (required when platform-profile.json.scoring is present). Copy it from templates/genre-weight-profiles.json.", 2);
472
+ }
473
+ const promiseLedgerExists = await pathExists(join(args.rootDir, "promise-ledger.json"));
474
+ let promiseLedgerHistoryRange = promiseLedgerExists ? resolvePromiseLedgerHistoryRange({ chapter: args.chapter, isVolumeEnd, volumeRange }) : null;
475
+ let engagementHistoryRange = resolveEngagementHistoryRange({ chapter: args.chapter, isVolumeEnd, volumeRange });
476
+ const characterVoiceProfilesExists = await pathExists(join(args.rootDir, "character-voice-profiles.json"));
477
+ const rel = chapterRelPaths(args.chapter);
478
+ await ensureFilePresent(args.rootDir, rel.staging.chapterMd);
479
+ await ensureFilePresent(args.rootDir, rel.staging.summaryMd);
480
+ await ensureFilePresent(args.rootDir, rel.staging.deltaJson);
481
+ await ensureFilePresent(args.rootDir, rel.staging.crossrefJson);
482
+ await ensureFilePresent(args.rootDir, rel.staging.evalJson);
483
+ // Parse delta early to resolve storyline memory paths and state merge.
484
+ const deltaRaw = await readJsonFile(join(args.rootDir, rel.staging.deltaJson));
485
+ if (!isPlainObject(deltaRaw))
486
+ throw new NovelCliError(`Invalid delta file: ${rel.staging.deltaJson} must be an object.`, 2);
487
+ const deltaObj = deltaRaw;
488
+ const delta = {
489
+ ...deltaObj,
490
+ chapter: requireInt("chapter", deltaObj.chapter, rel.staging.deltaJson),
491
+ base_state_version: requireInt("base_state_version", deltaObj.base_state_version, rel.staging.deltaJson),
492
+ storyline_id: requireString("storyline_id", deltaObj.storyline_id, rel.staging.deltaJson),
493
+ ops: Array.isArray(deltaObj.ops) ? deltaObj.ops : (() => {
494
+ throw new NovelCliError(`Invalid ${rel.staging.deltaJson}: 'ops' must be an array.`, 2);
495
+ })()
496
+ };
497
+ if (delta.chapter !== args.chapter) {
498
+ warnings.push(`Delta.chapter is ${delta.chapter}, expected ${args.chapter}.`);
499
+ }
500
+ rejectPathTraversalInput(delta.storyline_id, "delta.storyline_id");
501
+ const memoryRel = chapterRelPaths(args.chapter, delta.storyline_id).staging.storylineMemoryMd;
502
+ if (!memoryRel)
503
+ throw new NovelCliError(`Internal error: storyline memory path is null`, 2);
504
+ await ensureFilePresent(args.rootDir, memoryRel);
505
+ const finalMemoryRel = chapterRelPaths(args.chapter, delta.storyline_id).final.storylineMemoryMd;
506
+ if (!finalMemoryRel)
507
+ throw new NovelCliError(`Internal error: final storyline memory path is null`, 2);
508
+ // Plan moves.
509
+ plan.push(`MOVE ${rel.staging.chapterMd} -> ${rel.final.chapterMd}`);
510
+ plan.push(`MOVE ${rel.staging.summaryMd} -> ${rel.final.summaryMd}`);
511
+ plan.push(`MOVE ${rel.staging.evalJson} -> ${rel.final.evalJson}`);
512
+ plan.push(`MOVE ${rel.staging.crossrefJson} -> ${rel.final.crossrefJson}`);
513
+ plan.push(`MOVE ${memoryRel} -> ${finalMemoryRel}`);
514
+ // Merge state delta.
515
+ plan.push(`MERGE ${rel.staging.deltaJson} -> ${rel.final.stateCurrentJson} (+ append ${rel.final.stateChangelogJsonl})`);
516
+ // Update foreshadowing/global.json
517
+ plan.push(`UPDATE ${rel.final.foreshadowGlobalJson} (from foreshadow ops)`);
518
+ // Cleanup staging delta.
519
+ plan.push(`REMOVE ${rel.staging.deltaJson}`);
520
+ if (loadedProfile) {
521
+ plan.push(`WRITE logs/platform-constraints/platform-constraints-chapter-${pad3(args.chapter)}.json (+ latest.json)`);
522
+ plan.push(`WRITE logs/retention/title-policy/title-policy-chapter-${pad3(args.chapter)}.json (+ latest.json)`);
523
+ plan.push(`WRITE logs/readability/readability-report-chapter-${pad3(args.chapter)}.json (+ latest.json)`);
524
+ plan.push(`WRITE logs/naming/naming-report-chapter-${pad3(args.chapter)}.json (+ latest.json)`);
525
+ plan.push(`PATCH ${rel.final.evalJson} (attach platform_constraints metadata)`);
526
+ plan.push(`PATCH ${rel.final.evalJson} (attach readability_lint metadata)`);
527
+ plan.push(`PATCH ${rel.final.evalJson} (attach naming_lint metadata)`);
528
+ if (hookLedgerEnabled) {
529
+ plan.push(`UPDATE hook-ledger.json (chapter-end promises + windows/diversity)`);
530
+ plan.push(`WRITE logs/retention/latest.json`);
531
+ if (shouldWriteRetentionHistory) {
532
+ plan.push(`WRITE logs/retention/retention-report-vol-${pad2(volume)}-ch${pad3(retentionReportRange.start)}-ch${pad3(retentionReportRange.end)}.json`);
533
+ }
534
+ plan.push(`PATCH ${rel.final.evalJson} (attach hook_ledger metadata if hook present)`);
535
+ }
536
+ }
537
+ if (loadedCliche) {
538
+ plan.push(`WRITE logs/cliche-lint/cliche-lint-chapter-${pad3(args.chapter)}.json (+ latest.json)`);
539
+ plan.push(`PATCH ${rel.final.evalJson} (attach cliche_lint metadata)`);
540
+ }
541
+ if (loadedGenreWeights) {
542
+ plan.push(`PATCH ${rel.final.evalJson} (attach scoring_weights metadata + per-dimension weights)`);
543
+ }
544
+ // Optional: periodic continuity audits (non-blocking) on a fixed cadence.
545
+ if (shouldPeriodicContinuityAudit) {
546
+ const start = Math.max(1, args.chapter - 9);
547
+ const end = args.chapter;
548
+ plan.push(`WRITE logs/continuity/continuity-report-vol-${pad2(volume)}-ch${pad3(start)}-ch${pad3(end)}.json (+ latest.json)`);
549
+ }
550
+ // Optional: volume-end full continuity audit (non-blocking) when this is the last planned chapter of the volume.
551
+ if (isVolumeEnd && volumeRange) {
552
+ plan.push(`WRITE volumes/vol-${pad2(volume)}/continuity-report.json`);
553
+ plan.push(`WRITE logs/continuity/continuity-report-vol-${pad2(volume)}-ch${pad3(volumeRange.start)}-ch${pad3(volumeRange.end)}.json (+ latest.json)`);
554
+ }
555
+ // Optional: foreshadow visibility maintenance (non-blocking).
556
+ // This generates a dormancy view + non-spoiler light-touch reminder tasks.
557
+ plan.push(`WRITE logs/foreshadowing/latest.json (monotonic)`);
558
+ const foreshadowHistoryRange = resolveForeshadowVisibilityHistoryRange({ chapter: args.chapter, isVolumeEnd, volumeRange });
559
+ if (foreshadowHistoryRange) {
560
+ plan.push(`WRITE logs/foreshadowing/foreshadow-visibility-vol-${pad2(volume)}-ch${pad3(foreshadowHistoryRange.start)}-ch${pad3(foreshadowHistoryRange.end)}.json`);
561
+ }
562
+ // Optional: engagement density maintenance (non-blocking).
563
+ // This appends a per-chapter metrics record and periodically maintains a rolling engagement window report.
564
+ plan.push(`APPEND engagement-metrics.jsonl (chapter metrics)`);
565
+ if (engagementHistoryRange) {
566
+ plan.push(`WRITE logs/engagement/latest.json (monotonic)`);
567
+ plan.push(`WRITE logs/engagement/engagement-report-vol-${pad2(volume)}-ch${pad3(engagementHistoryRange.start)}-ch${pad3(engagementHistoryRange.end)}.json`);
568
+ }
569
+ if (characterVoiceProfilesExists) {
570
+ plan.push(`WRITE character-voice-drift.json (voice drift directives; cleared on recovery)`);
571
+ }
572
+ // Optional: periodic promise ledger report maintenance (non-blocking) on a fixed cadence when promise-ledger.json exists.
573
+ if (promiseLedgerExists && promiseLedgerHistoryRange) {
574
+ plan.push(`WRITE logs/promises/latest.json (monotonic)`);
575
+ plan.push(`WRITE logs/promises/promise-ledger-report-vol-${pad2(volume)}-ch${pad3(promiseLedgerHistoryRange.start)}-ch${pad3(promiseLedgerHistoryRange.end)}.json`);
576
+ }
577
+ // Update checkpoint.
578
+ plan.push(`UPDATE .checkpoint.json (commit chapter ${args.chapter})`);
579
+ if (args.dryRun) {
580
+ return { plan, warnings };
581
+ }
582
+ const chapterAbs = join(args.rootDir, rel.staging.chapterMd);
583
+ const precomputedNer = loadedProfile
584
+ ? await precomputeInfoLoadNer({ rootDir: args.rootDir, chapter: args.chapter, chapterAbsPath: chapterAbs })
585
+ : null;
586
+ const precomputedClicheLint = loadedCliche
587
+ ? await precomputeClicheLintReport({
588
+ rootDir: args.rootDir,
589
+ chapter: args.chapter,
590
+ chapterAbsPath: chapterAbs,
591
+ config: loadedCliche.config,
592
+ configRelPath: loadedCliche.relPath,
593
+ platformProfile: loadedProfile?.profile ?? null
594
+ })
595
+ : null;
596
+ if (precomputedClicheLint?.error)
597
+ warnings.push(precomputedClicheLint.error);
598
+ const precomputedReadabilityLint = loadedProfile
599
+ ? await precomputeReadabilityReport({
600
+ rootDir: args.rootDir,
601
+ chapter: args.chapter,
602
+ chapterAbsPath: chapterAbs,
603
+ platformProfile: loadedProfile.profile
604
+ })
605
+ : null;
606
+ if (precomputedReadabilityLint?.error)
607
+ warnings.push(precomputedReadabilityLint.error);
608
+ const precomputedNamingLint = loadedProfile
609
+ ? await precomputeNamingReport({
610
+ rootDir: args.rootDir,
611
+ chapter: args.chapter,
612
+ chapterAbsPath: chapterAbs,
613
+ platformProfile: loadedProfile.profile,
614
+ ...(precomputedNer ? { infoLoadNer: precomputedNer } : {})
615
+ })
616
+ : null;
617
+ if (precomputedNamingLint?.error)
618
+ warnings.push(precomputedNamingLint.error);
619
+ await withWriteLock(args.rootDir, { chapter: args.chapter }, async () => {
620
+ const checkpointAbs = join(args.rootDir, ".checkpoint.json");
621
+ const stateAbs = join(args.rootDir, rel.final.stateCurrentJson);
622
+ const globalAbs = join(args.rootDir, rel.final.foreshadowGlobalJson);
623
+ const changelogAbs = join(args.rootDir, rel.final.stateChangelogJsonl);
624
+ const deltaAbs = join(args.rootDir, rel.staging.deltaJson);
625
+ const evalStagingAbs = join(args.rootDir, rel.staging.evalJson);
626
+ const originalCheckpoint = await readTextFile(checkpointAbs);
627
+ const originalStateExists = await pathExists(stateAbs);
628
+ const originalState = originalStateExists ? await readTextFile(stateAbs) : null;
629
+ const originalGlobalExists = await pathExists(globalAbs);
630
+ const originalGlobal = originalGlobalExists ? await readTextFile(globalAbs) : null;
631
+ const originalChangelogExists = await pathExists(changelogAbs);
632
+ const originalChangelogSize = originalChangelogExists ? (await stat(changelogAbs)).size : 0;
633
+ const originalDelta = await readTextFile(deltaAbs);
634
+ const originalEval = loadedProfile || loadedCliche ? await readTextFile(evalStagingAbs) : null;
635
+ const hookLedgerAbs = join(args.rootDir, "hook-ledger.json");
636
+ const originalHookLedgerExists = hookLedgerEnabled ? await pathExists(hookLedgerAbs) : false;
637
+ const originalHookLedger = originalHookLedgerExists ? await readTextFile(hookLedgerAbs) : null;
638
+ const retentionLatestAbs = join(args.rootDir, "logs/retention/latest.json");
639
+ const originalRetentionLatestExists = hookLedgerEnabled ? await pathExists(retentionLatestAbs) : false;
640
+ const originalRetentionLatest = originalRetentionLatestExists ? await readTextFile(retentionLatestAbs) : null;
641
+ const retentionHistoryAbs = join(args.rootDir, `logs/retention/retention-report-vol-${pad2(volume)}-ch${pad3(retentionReportRange.start)}-ch${pad3(retentionReportRange.end)}.json`);
642
+ const originalRetentionHistoryExists = shouldWriteRetentionHistory ? await pathExists(retentionHistoryAbs) : false;
643
+ const originalRetentionHistory = originalRetentionHistoryExists ? await readTextFile(retentionHistoryAbs) : null;
644
+ const platformConstraintsLatestAbs = join(args.rootDir, "logs/platform-constraints/latest.json");
645
+ const platformConstraintsHistoryAbs = join(args.rootDir, `logs/platform-constraints/platform-constraints-chapter-${pad3(args.chapter)}.json`);
646
+ const originalPlatformConstraintsLatestExists = loadedProfile ? await pathExists(platformConstraintsLatestAbs) : false;
647
+ const originalPlatformConstraintsLatest = originalPlatformConstraintsLatestExists ? await readTextFile(platformConstraintsLatestAbs) : null;
648
+ const originalPlatformConstraintsHistoryExists = loadedProfile ? await pathExists(platformConstraintsHistoryAbs) : false;
649
+ const originalPlatformConstraintsHistory = originalPlatformConstraintsHistoryExists ? await readTextFile(platformConstraintsHistoryAbs) : null;
650
+ const titlePolicyLatestAbs = join(args.rootDir, "logs/retention/title-policy/latest.json");
651
+ const titlePolicyHistoryAbs = join(args.rootDir, `logs/retention/title-policy/title-policy-chapter-${pad3(args.chapter)}.json`);
652
+ const originalTitlePolicyLatestExists = loadedProfile ? await pathExists(titlePolicyLatestAbs) : false;
653
+ const originalTitlePolicyLatest = originalTitlePolicyLatestExists ? await readTextFile(titlePolicyLatestAbs) : null;
654
+ const originalTitlePolicyHistoryExists = loadedProfile ? await pathExists(titlePolicyHistoryAbs) : false;
655
+ const originalTitlePolicyHistory = originalTitlePolicyHistoryExists ? await readTextFile(titlePolicyHistoryAbs) : null;
656
+ const readabilityLintLatestAbs = join(args.rootDir, "logs/readability/latest.json");
657
+ const readabilityLintHistoryAbs = join(args.rootDir, `logs/readability/readability-report-chapter-${pad3(args.chapter)}.json`);
658
+ const originalReadabilityLintLatestExists = loadedProfile ? await pathExists(readabilityLintLatestAbs) : false;
659
+ const originalReadabilityLintLatest = originalReadabilityLintLatestExists ? await readTextFile(readabilityLintLatestAbs) : null;
660
+ const originalReadabilityLintHistoryExists = loadedProfile ? await pathExists(readabilityLintHistoryAbs) : false;
661
+ const originalReadabilityLintHistory = originalReadabilityLintHistoryExists ? await readTextFile(readabilityLintHistoryAbs) : null;
662
+ const namingLintLatestAbs = join(args.rootDir, "logs/naming/latest.json");
663
+ const namingLintHistoryAbs = join(args.rootDir, `logs/naming/naming-report-chapter-${pad3(args.chapter)}.json`);
664
+ const originalNamingLintLatestExists = loadedProfile ? await pathExists(namingLintLatestAbs) : false;
665
+ const originalNamingLintLatest = originalNamingLintLatestExists ? await readTextFile(namingLintLatestAbs) : null;
666
+ const originalNamingLintHistoryExists = loadedProfile ? await pathExists(namingLintHistoryAbs) : false;
667
+ const originalNamingLintHistory = originalNamingLintHistoryExists ? await readTextFile(namingLintHistoryAbs) : null;
668
+ const clicheLintLatestAbs = join(args.rootDir, "logs/cliche-lint/latest.json");
669
+ const clicheLintHistoryAbs = join(args.rootDir, `logs/cliche-lint/cliche-lint-chapter-${pad3(args.chapter)}.json`);
670
+ const originalClicheLintLatestExists = loadedCliche ? await pathExists(clicheLintLatestAbs) : false;
671
+ const originalClicheLintLatest = originalClicheLintLatestExists ? await readTextFile(clicheLintLatestAbs) : null;
672
+ const originalClicheLintHistoryExists = loadedCliche ? await pathExists(clicheLintHistoryAbs) : false;
673
+ const originalClicheLintHistory = originalClicheLintHistoryExists ? await readTextFile(clicheLintHistoryAbs) : null;
674
+ const moved = [];
675
+ let platformConstraintsWritten = false;
676
+ let titlePolicyWritten = false;
677
+ let readabilityLintWritten = false;
678
+ let namingLintWritten = false;
679
+ let clicheLintWritten = false;
680
+ let hookLedgerWritten = false;
681
+ let retentionWritten = false;
682
+ const rollback = async () => {
683
+ // Roll back moved files (best-effort).
684
+ for (const m of moved.slice().reverse()) {
685
+ try {
686
+ await rollbackRename(args.rootDir, m.to, m.from);
687
+ }
688
+ catch {
689
+ // ignore
690
+ }
691
+ }
692
+ // Roll back checkpoint/state/global.
693
+ try {
694
+ await writeFile(checkpointAbs, originalCheckpoint, "utf8");
695
+ }
696
+ catch {
697
+ // ignore
698
+ }
699
+ try {
700
+ if (originalStateExists && originalState !== null) {
701
+ await ensureDir(dirname(stateAbs));
702
+ await writeFile(stateAbs, originalState, "utf8");
703
+ }
704
+ else {
705
+ await removePath(stateAbs);
706
+ }
707
+ }
708
+ catch {
709
+ // ignore
710
+ }
711
+ try {
712
+ if (originalGlobalExists && originalGlobal !== null) {
713
+ await ensureDir(dirname(globalAbs));
714
+ await writeFile(globalAbs, originalGlobal, "utf8");
715
+ }
716
+ else {
717
+ await removePath(globalAbs);
718
+ }
719
+ }
720
+ catch {
721
+ // ignore
722
+ }
723
+ try {
724
+ if (originalChangelogExists) {
725
+ await truncate(changelogAbs, originalChangelogSize);
726
+ }
727
+ else {
728
+ await removePath(changelogAbs);
729
+ }
730
+ }
731
+ catch {
732
+ // ignore
733
+ }
734
+ try {
735
+ if (!(await pathExists(deltaAbs))) {
736
+ await ensureDir(dirname(deltaAbs));
737
+ await writeFile(deltaAbs, originalDelta, "utf8");
738
+ }
739
+ }
740
+ catch {
741
+ // ignore
742
+ }
743
+ try {
744
+ if (originalEval !== null) {
745
+ await ensureDir(dirname(evalStagingAbs));
746
+ await writeFile(evalStagingAbs, originalEval, "utf8");
747
+ }
748
+ }
749
+ catch {
750
+ // ignore
751
+ }
752
+ if (platformConstraintsWritten) {
753
+ try {
754
+ if (originalPlatformConstraintsLatestExists && originalPlatformConstraintsLatest !== null) {
755
+ await ensureDir(dirname(platformConstraintsLatestAbs));
756
+ await writeFile(platformConstraintsLatestAbs, originalPlatformConstraintsLatest, "utf8");
757
+ }
758
+ else {
759
+ await removePath(platformConstraintsLatestAbs);
760
+ }
761
+ }
762
+ catch {
763
+ // ignore
764
+ }
765
+ try {
766
+ if (originalPlatformConstraintsHistoryExists && originalPlatformConstraintsHistory !== null) {
767
+ await ensureDir(dirname(platformConstraintsHistoryAbs));
768
+ await writeFile(platformConstraintsHistoryAbs, originalPlatformConstraintsHistory, "utf8");
769
+ }
770
+ else {
771
+ await removePath(platformConstraintsHistoryAbs);
772
+ }
773
+ }
774
+ catch {
775
+ // ignore
776
+ }
777
+ }
778
+ if (titlePolicyWritten) {
779
+ try {
780
+ if (originalTitlePolicyLatestExists && originalTitlePolicyLatest !== null) {
781
+ await ensureDir(dirname(titlePolicyLatestAbs));
782
+ await writeFile(titlePolicyLatestAbs, originalTitlePolicyLatest, "utf8");
783
+ }
784
+ else {
785
+ await removePath(titlePolicyLatestAbs);
786
+ }
787
+ }
788
+ catch {
789
+ // ignore
790
+ }
791
+ try {
792
+ if (originalTitlePolicyHistoryExists && originalTitlePolicyHistory !== null) {
793
+ await ensureDir(dirname(titlePolicyHistoryAbs));
794
+ await writeFile(titlePolicyHistoryAbs, originalTitlePolicyHistory, "utf8");
795
+ }
796
+ else {
797
+ await removePath(titlePolicyHistoryAbs);
798
+ }
799
+ }
800
+ catch {
801
+ // ignore
802
+ }
803
+ }
804
+ if (readabilityLintWritten) {
805
+ try {
806
+ if (originalReadabilityLintLatestExists && originalReadabilityLintLatest !== null) {
807
+ await ensureDir(dirname(readabilityLintLatestAbs));
808
+ await writeFile(readabilityLintLatestAbs, originalReadabilityLintLatest, "utf8");
809
+ }
810
+ else {
811
+ await removePath(readabilityLintLatestAbs);
812
+ }
813
+ }
814
+ catch {
815
+ // ignore
816
+ }
817
+ try {
818
+ if (originalReadabilityLintHistoryExists && originalReadabilityLintHistory !== null) {
819
+ await ensureDir(dirname(readabilityLintHistoryAbs));
820
+ await writeFile(readabilityLintHistoryAbs, originalReadabilityLintHistory, "utf8");
821
+ }
822
+ else {
823
+ await removePath(readabilityLintHistoryAbs);
824
+ }
825
+ }
826
+ catch {
827
+ // ignore
828
+ }
829
+ }
830
+ if (namingLintWritten) {
831
+ try {
832
+ if (originalNamingLintLatestExists && originalNamingLintLatest !== null) {
833
+ await ensureDir(dirname(namingLintLatestAbs));
834
+ await writeFile(namingLintLatestAbs, originalNamingLintLatest, "utf8");
835
+ }
836
+ else {
837
+ await removePath(namingLintLatestAbs);
838
+ }
839
+ }
840
+ catch {
841
+ // ignore
842
+ }
843
+ try {
844
+ if (originalNamingLintHistoryExists && originalNamingLintHistory !== null) {
845
+ await ensureDir(dirname(namingLintHistoryAbs));
846
+ await writeFile(namingLintHistoryAbs, originalNamingLintHistory, "utf8");
847
+ }
848
+ else {
849
+ await removePath(namingLintHistoryAbs);
850
+ }
851
+ }
852
+ catch {
853
+ // ignore
854
+ }
855
+ }
856
+ if (clicheLintWritten) {
857
+ try {
858
+ if (originalClicheLintLatestExists && originalClicheLintLatest !== null) {
859
+ await ensureDir(dirname(clicheLintLatestAbs));
860
+ await writeFile(clicheLintLatestAbs, originalClicheLintLatest, "utf8");
861
+ }
862
+ else {
863
+ await removePath(clicheLintLatestAbs);
864
+ }
865
+ }
866
+ catch {
867
+ // ignore
868
+ }
869
+ try {
870
+ if (originalClicheLintHistoryExists && originalClicheLintHistory !== null) {
871
+ await ensureDir(dirname(clicheLintHistoryAbs));
872
+ await writeFile(clicheLintHistoryAbs, originalClicheLintHistory, "utf8");
873
+ }
874
+ else {
875
+ await removePath(clicheLintHistoryAbs);
876
+ }
877
+ }
878
+ catch {
879
+ // ignore
880
+ }
881
+ }
882
+ if (hookLedgerWritten) {
883
+ try {
884
+ if (originalHookLedgerExists && originalHookLedger !== null) {
885
+ await writeFile(hookLedgerAbs, originalHookLedger, "utf8");
886
+ }
887
+ else {
888
+ await removePath(hookLedgerAbs);
889
+ }
890
+ }
891
+ catch {
892
+ // ignore
893
+ }
894
+ }
895
+ if (retentionWritten) {
896
+ try {
897
+ if (originalRetentionLatestExists && originalRetentionLatest !== null) {
898
+ await ensureDir(dirname(retentionLatestAbs));
899
+ await writeFile(retentionLatestAbs, originalRetentionLatest, "utf8");
900
+ }
901
+ else {
902
+ await removePath(retentionLatestAbs);
903
+ }
904
+ }
905
+ catch {
906
+ // ignore
907
+ }
908
+ if (shouldWriteRetentionHistory) {
909
+ try {
910
+ if (originalRetentionHistoryExists && originalRetentionHistory !== null) {
911
+ await ensureDir(dirname(retentionHistoryAbs));
912
+ await writeFile(retentionHistoryAbs, originalRetentionHistory, "utf8");
913
+ }
914
+ else {
915
+ await removePath(retentionHistoryAbs);
916
+ }
917
+ }
918
+ catch {
919
+ // ignore
920
+ }
921
+ }
922
+ }
923
+ };
924
+ try {
925
+ if (loadedProfile?.profile.hook_policy?.required) {
926
+ const hookPolicy = loadedProfile.profile.hook_policy;
927
+ const evalRaw = await readJsonFile(evalStagingAbs);
928
+ if (isPlainObject(evalRaw)) {
929
+ const evalChapter = evalRaw.chapter;
930
+ if (typeof evalChapter === "number" && Number.isFinite(evalChapter) && evalChapter !== args.chapter) {
931
+ warnings.push(`Eval.chapter is ${evalChapter}, expected ${args.chapter}.`);
932
+ }
933
+ }
934
+ const hookCheck = checkHookPolicy({ hookPolicy, evalRaw });
935
+ if (hookCheck.status === "invalid_eval") {
936
+ throw new NovelCliError(`Hook policy enabled but eval is missing required hook fields: ${hookCheck.reason}`, 2);
937
+ }
938
+ if (hookCheck.status === "fail") {
939
+ throw new NovelCliError(`Hook policy violation: ${hookCheck.reason}`, 2);
940
+ }
941
+ }
942
+ // Pre-validate state merge (in-memory).
943
+ const state = await readState(args.rootDir, rel.final.stateCurrentJson);
944
+ if (state.state_version !== delta.base_state_version) {
945
+ throw new NovelCliError(`State version mismatch: state.state_version=${state.state_version} delta.base_state_version=${delta.base_state_version}`, 2);
946
+ }
947
+ const normalizedOps = validateOps(delta.ops, warnings);
948
+ const { applied: appliedOps, foreshadowOps } = applyStateOps(state, normalizedOps, warnings);
949
+ state.state_version = state.state_version + 1;
950
+ state.last_updated_chapter = args.chapter;
951
+ const chapterText = loadedProfile || loadedCliche ? await readTextFile(chapterAbs) : null;
952
+ const chapterFingerprintNow = chapterText !== null
953
+ ? await (async () => {
954
+ const s = await stat(chapterAbs);
955
+ return { size: s.size, mtime_ms: s.mtimeMs, content_hash: hashText(chapterText) };
956
+ })()
957
+ : null;
958
+ let infoLoadNer = precomputedNer;
959
+ if (precomputedNer?.status === "pass" && precomputedNer.chapter_fingerprint && chapterFingerprintNow) {
960
+ const fpNow = chapterFingerprintNow;
961
+ const fpPrev = precomputedNer.chapter_fingerprint;
962
+ if (!fingerprintsMatch(fpNow, fpPrev)) {
963
+ infoLoadNer = {
964
+ status: "skipped",
965
+ error: "Chapter changed during commit; skipping info-load NER.",
966
+ chapter_fingerprint: null,
967
+ current_index: null,
968
+ recent_texts: null
969
+ };
970
+ }
971
+ }
972
+ let readabilityLintReport = null;
973
+ if (loadedProfile && chapterText !== null) {
974
+ const pre = precomputedReadabilityLint;
975
+ if (pre &&
976
+ pre.status === "pass" &&
977
+ pre.report &&
978
+ pre.chapter_fingerprint &&
979
+ chapterFingerprintNow &&
980
+ fingerprintsMatch(pre.chapter_fingerprint, chapterFingerprintNow)) {
981
+ readabilityLintReport = pre.report;
982
+ }
983
+ else {
984
+ readabilityLintReport = await computeReadabilityReport({
985
+ rootDir: args.rootDir,
986
+ chapter: args.chapter,
987
+ chapterAbsPath: chapterAbs,
988
+ chapterText,
989
+ platformProfile: loadedProfile.profile,
990
+ preferDeterministicScript: true
991
+ });
992
+ }
993
+ if (readabilityLintReport.mode === "fallback" && readabilityLintReport.script_error) {
994
+ const detail = readabilityLintReport.script_error;
995
+ const msg = `Readability lint degraded: ${detail}`;
996
+ if (!warnings.some((w) => w.includes(detail)))
997
+ warnings.push(msg);
998
+ }
999
+ if (readabilityLintReport.has_blocking_issues) {
1000
+ const blocking = readabilityLintReport.policy?.blocking_severity ?? "hard_only";
1001
+ const blockingIssues = blocking === "soft_and_hard"
1002
+ ? readabilityLintReport.issues.filter((i) => i.severity === "soft" || i.severity === "hard")
1003
+ : readabilityLintReport.issues.filter((i) => i.severity === "hard");
1004
+ const limit = 3;
1005
+ const detailsBase = summarizeReadabilityIssues(blockingIssues, limit);
1006
+ const suffix = blockingIssues.length > limit ? " …" : "";
1007
+ const details = detailsBase.length > 0 ? `${detailsBase}${suffix}` : "(details in readability lint report)";
1008
+ const scriptRel = readabilityLintReport.script?.rel_path ?? "scripts/lint-readability.sh";
1009
+ const inspect = `bash "${scriptRel}" "${rel.staging.chapterMd}" "platform-profile.json" ${args.chapter}`;
1010
+ throw new NovelCliError(`Mobile readability blocking issue: ${details}. Inspect: ${inspect}`, 2);
1011
+ }
1012
+ }
1013
+ let namingLintReport = null;
1014
+ if (loadedProfile && chapterText !== null) {
1015
+ const pre = precomputedNamingLint;
1016
+ if (pre &&
1017
+ pre.status === "pass" &&
1018
+ pre.report &&
1019
+ pre.chapter_fingerprint &&
1020
+ chapterFingerprintNow &&
1021
+ fingerprintsMatch(pre.chapter_fingerprint, chapterFingerprintNow)) {
1022
+ namingLintReport = pre.report;
1023
+ }
1024
+ else {
1025
+ namingLintReport = await computeNamingReport({
1026
+ rootDir: args.rootDir,
1027
+ chapter: args.chapter,
1028
+ chapterText,
1029
+ platformProfile: loadedProfile.profile,
1030
+ ...(infoLoadNer ? { infoLoadNer } : {})
1031
+ });
1032
+ }
1033
+ if (namingLintReport.has_blocking_issues) {
1034
+ const blockingIssues = namingLintReport.issues.filter((i) => i.severity === "hard");
1035
+ const limit = 3;
1036
+ const detailsBase = summarizeNamingIssues(blockingIssues, limit);
1037
+ const suffix = blockingIssues.length > limit ? " …" : "";
1038
+ const details = detailsBase.length > 0 ? `${detailsBase}${suffix}` : "(details in naming lint report)";
1039
+ throw new NovelCliError(`Naming conflict blocking issue: ${details}`, 2);
1040
+ }
1041
+ }
1042
+ let platformConstraintsReport = null;
1043
+ if (loadedProfile && chapterText !== null) {
1044
+ platformConstraintsReport = await computePlatformConstraints({
1045
+ rootDir: args.rootDir,
1046
+ chapter: args.chapter,
1047
+ chapterAbsPath: chapterAbs,
1048
+ chapterText,
1049
+ platformProfile: loadedProfile.profile,
1050
+ state,
1051
+ ...(infoLoadNer ? { infoLoadNer } : {})
1052
+ });
1053
+ if (platformConstraintsReport.has_hard_violations) {
1054
+ const hardIssues = platformConstraintsReport.issues.filter((i) => i.severity === "hard");
1055
+ const hardSummaries = hardIssues.map((i) => i.summary).slice(0, 3);
1056
+ const suffix = hardIssues.length > 3 ? " …" : "";
1057
+ throw new NovelCliError(`Platform constraints hard violation: ${hardSummaries.join(" | ")}${suffix}`, 2);
1058
+ }
1059
+ }
1060
+ let clicheLintReport = null;
1061
+ if (loadedCliche && chapterText !== null) {
1062
+ const pre = precomputedClicheLint;
1063
+ if (pre &&
1064
+ pre.status === "pass" &&
1065
+ pre.report &&
1066
+ pre.chapter_fingerprint &&
1067
+ chapterFingerprintNow &&
1068
+ fingerprintsMatch(pre.chapter_fingerprint, chapterFingerprintNow)) {
1069
+ clicheLintReport = pre.report;
1070
+ }
1071
+ else {
1072
+ clicheLintReport = await computeClicheLintReport({
1073
+ rootDir: args.rootDir,
1074
+ chapter: args.chapter,
1075
+ chapterAbsPath: chapterAbs,
1076
+ chapterText,
1077
+ config: loadedCliche.config,
1078
+ configRelPath: loadedCliche.relPath,
1079
+ platformProfile: loadedProfile?.profile ?? null,
1080
+ preferDeterministicScript: false
1081
+ });
1082
+ }
1083
+ if (clicheLintReport.has_hard_hits) {
1084
+ const hardHits = clicheLintReport.hits.filter((h) => h.severity === "hard");
1085
+ const hardSummaries = hardHits
1086
+ .map((h) => `${h.word} x${h.count}`)
1087
+ .slice(0, 3);
1088
+ const suffix = hardHits.length > 3 ? " …" : "";
1089
+ const details = hardSummaries.length > 0 ? `${hardSummaries.join(" | ")}${suffix}` : "(details in cliché lint report)";
1090
+ throw new NovelCliError(`Cliché lint hard violation: ${details}`, 2);
1091
+ }
1092
+ }
1093
+ let hookLedgerUpdate = null;
1094
+ if (loadedProfile && hookLedgerPolicy && hookLedgerPolicy.enabled) {
1095
+ const ledgerLoaded = await loadHookLedger(args.rootDir);
1096
+ const evalRaw = await readJsonFile(evalStagingAbs);
1097
+ hookLedgerUpdate = computeHookLedgerUpdate({
1098
+ ledger: ledgerLoaded.ledger,
1099
+ evalRaw,
1100
+ chapter: args.chapter,
1101
+ volume,
1102
+ evalRelPath: rel.final.evalJson,
1103
+ policy: hookLedgerPolicy,
1104
+ reportRange: retentionReportRange
1105
+ });
1106
+ const seenWarnings = new Set();
1107
+ for (const w of ledgerLoaded.warnings) {
1108
+ if (seenWarnings.has(w))
1109
+ continue;
1110
+ seenWarnings.add(w);
1111
+ warnings.push(`Hook ledger (load): ${w}`);
1112
+ }
1113
+ for (const w of hookLedgerUpdate.warnings) {
1114
+ if (seenWarnings.has(w))
1115
+ continue;
1116
+ seenWarnings.add(w);
1117
+ warnings.push(`Hook ledger (update): ${w}`);
1118
+ }
1119
+ if (hookLedgerUpdate.report.has_blocking_issues) {
1120
+ const blockingIssues = hookLedgerUpdate.report.issues.filter((i) => i.severity === "hard");
1121
+ const limit = 2;
1122
+ const details = blockingIssues
1123
+ .map((i) => (i.evidence ? `${i.summary} (${i.evidence})` : i.summary))
1124
+ .slice(0, limit)
1125
+ .join(" | ");
1126
+ const suffix = blockingIssues.length > limit ? " …" : "";
1127
+ const msg = details.length > 0 ? `${details}${suffix}` : "(details in logs/retention/latest.json)";
1128
+ let logHint = " See logs/retention/latest.json.";
1129
+ try {
1130
+ await writeRetentionLogs({
1131
+ rootDir: args.rootDir,
1132
+ report: hookLedgerUpdate.report,
1133
+ writeHistory: shouldWriteRetentionHistory
1134
+ });
1135
+ }
1136
+ catch (err) {
1137
+ const message = err instanceof Error ? err.message : String(err);
1138
+ logHint = ` (also failed to write retention logs: ${message})`;
1139
+ }
1140
+ throw new NovelCliError(`Retention hook ledger violation: ${msg}.${logHint}`, 2);
1141
+ }
1142
+ if (hookLedgerUpdate.report.issues.length > 0) {
1143
+ const details = hookLedgerUpdate.report.issues
1144
+ .map((i) => (i.evidence ? `${i.summary} (${i.evidence})` : i.summary))
1145
+ .slice(0, 2)
1146
+ .join(" | ");
1147
+ const suffix = hookLedgerUpdate.report.issues.length > 2 ? " …" : "";
1148
+ warnings.push(`Retention hook ledger: ${details}${suffix}. See logs/retention/latest.json.`);
1149
+ }
1150
+ }
1151
+ // Moves first (rollbackable).
1152
+ await doRename(args.rootDir, rel.staging.chapterMd, rel.final.chapterMd);
1153
+ moved.push({ from: rel.staging.chapterMd, to: rel.final.chapterMd });
1154
+ await doRename(args.rootDir, rel.staging.summaryMd, rel.final.summaryMd);
1155
+ moved.push({ from: rel.staging.summaryMd, to: rel.final.summaryMd });
1156
+ await doRename(args.rootDir, rel.staging.evalJson, rel.final.evalJson);
1157
+ moved.push({ from: rel.staging.evalJson, to: rel.final.evalJson });
1158
+ await doRename(args.rootDir, rel.staging.crossrefJson, rel.final.crossrefJson);
1159
+ moved.push({ from: rel.staging.crossrefJson, to: rel.final.crossrefJson });
1160
+ await doRename(args.rootDir, memoryRel, finalMemoryRel);
1161
+ moved.push({ from: memoryRel, to: finalMemoryRel });
1162
+ // Now write state + changelog + foreshadowing + checkpoint.
1163
+ await writeJsonFile(stateAbs, state);
1164
+ await appendJsonl(args.rootDir, rel.final.stateChangelogJsonl, deltaObj);
1165
+ warnings.push(`Applied ${appliedOps} state ops.`);
1166
+ await updateForeshadowing({ rootDir: args.rootDir, checkpoint, delta, foreshadowOps, warnings, dryRun: false });
1167
+ await removePath(join(args.rootDir, rel.staging.deltaJson));
1168
+ if (loadedProfile && platformConstraintsReport) {
1169
+ platformConstraintsWritten = true;
1170
+ const { historyRel } = await writePlatformConstraintsLogs({ rootDir: args.rootDir, chapter: args.chapter, report: platformConstraintsReport });
1171
+ await attachPlatformConstraintsToEval({
1172
+ evalAbsPath: join(args.rootDir, rel.final.evalJson),
1173
+ evalRelPath: rel.final.evalJson,
1174
+ platform: loadedProfile.profile.platform,
1175
+ reportRelPath: historyRel,
1176
+ report: platformConstraintsReport
1177
+ });
1178
+ if (chapterText !== null) {
1179
+ const titleReport = computeTitlePolicyReport({ chapter: args.chapter, chapterText, platformProfile: loadedProfile.profile });
1180
+ titlePolicyWritten = true;
1181
+ await writeTitlePolicyLogs({ rootDir: args.rootDir, chapter: args.chapter, report: titleReport });
1182
+ }
1183
+ }
1184
+ if (loadedProfile && readabilityLintReport) {
1185
+ readabilityLintWritten = true;
1186
+ const { historyRel: readabilityHistoryRel } = await writeReadabilityLogs({ rootDir: args.rootDir, chapter: args.chapter, report: readabilityLintReport });
1187
+ await attachReadabilityLintToEval({
1188
+ evalAbsPath: join(args.rootDir, rel.final.evalJson),
1189
+ evalRelPath: rel.final.evalJson,
1190
+ reportRelPath: readabilityHistoryRel,
1191
+ report: readabilityLintReport
1192
+ });
1193
+ }
1194
+ if (loadedProfile && namingLintReport) {
1195
+ namingLintWritten = true;
1196
+ const { historyRel: namingHistoryRel } = await writeNamingLintLogs({ rootDir: args.rootDir, chapter: args.chapter, report: namingLintReport });
1197
+ await attachNamingLintToEval({
1198
+ evalAbsPath: join(args.rootDir, rel.final.evalJson),
1199
+ evalRelPath: rel.final.evalJson,
1200
+ reportRelPath: namingHistoryRel,
1201
+ report: namingLintReport
1202
+ });
1203
+ }
1204
+ if (loadedCliche && clicheLintReport) {
1205
+ clicheLintWritten = true;
1206
+ const { historyRel } = await writeClicheLintLogs({ rootDir: args.rootDir, chapter: args.chapter, report: clicheLintReport });
1207
+ await attachClicheLintToEval({
1208
+ evalAbsPath: join(args.rootDir, rel.final.evalJson),
1209
+ evalRelPath: rel.final.evalJson,
1210
+ reportRelPath: historyRel,
1211
+ report: clicheLintReport
1212
+ });
1213
+ }
1214
+ if (loadedProfile && loadedProfile.profile.scoring && loadedGenreWeights) {
1215
+ await attachScoringWeightsToEval({
1216
+ evalAbsPath: join(args.rootDir, rel.final.evalJson),
1217
+ evalRelPath: rel.final.evalJson,
1218
+ platformProfile: loadedProfile.profile,
1219
+ genreWeightProfiles: loadedGenreWeights
1220
+ });
1221
+ }
1222
+ if (loadedProfile && hookLedgerPolicy && hookLedgerPolicy.enabled && hookLedgerUpdate) {
1223
+ hookLedgerWritten = true;
1224
+ retentionWritten = true;
1225
+ const { rel: hookLedgerRel } = await writeHookLedgerFile({ rootDir: args.rootDir, ledger: hookLedgerUpdate.updatedLedger });
1226
+ const { latestRel: retentionLatestRel, historyRel: retentionHistoryRel } = await writeRetentionLogs({
1227
+ rootDir: args.rootDir,
1228
+ report: hookLedgerUpdate.report,
1229
+ writeHistory: shouldWriteRetentionHistory
1230
+ });
1231
+ if (hookLedgerUpdate.entry) {
1232
+ await attachHookLedgerToEval({
1233
+ evalAbsPath: join(args.rootDir, rel.final.evalJson),
1234
+ evalRelPath: rel.final.evalJson,
1235
+ ledgerRelPath: hookLedgerRel,
1236
+ reportLatestRelPath: retentionLatestRel,
1237
+ ...(retentionHistoryRel ? { reportHistoryRelPath: retentionHistoryRel } : {}),
1238
+ entry: hookLedgerUpdate.entry,
1239
+ report: hookLedgerUpdate.report
1240
+ });
1241
+ }
1242
+ }
1243
+ const updatedCheckpoint = { ...checkpoint };
1244
+ if (updatedCheckpoint.last_completed_chapter >= args.chapter) {
1245
+ warnings.push(`Checkpoint last_completed_chapter is already ${updatedCheckpoint.last_completed_chapter}; leaving as-is.`);
1246
+ }
1247
+ else {
1248
+ updatedCheckpoint.last_completed_chapter = args.chapter;
1249
+ }
1250
+ updatedCheckpoint.pipeline_stage = "committed";
1251
+ updatedCheckpoint.inflight_chapter = null;
1252
+ updatedCheckpoint.revision_count = 0;
1253
+ updatedCheckpoint.hook_fix_count = 0;
1254
+ updatedCheckpoint.title_fix_count = 0;
1255
+ updatedCheckpoint.last_checkpoint_time = new Date().toISOString();
1256
+ await writeCheckpoint(args.rootDir, updatedCheckpoint);
1257
+ }
1258
+ catch (err) {
1259
+ await rollback();
1260
+ throw err;
1261
+ }
1262
+ });
1263
+ // Post-commit (outside write-lock): optional sliding-window and volume-end continuity audits.
1264
+ // These audits are non-blocking (best-effort): failures only add warnings.
1265
+ const runContinuityAudit = async (scope, volume, start, end) => {
1266
+ const report = await computeContinuityReport({
1267
+ rootDir: args.rootDir,
1268
+ volume,
1269
+ scope,
1270
+ chapterRange: { start, end }
1271
+ });
1272
+ await writeContinuityLogs({ rootDir: args.rootDir, report });
1273
+ if (scope === "volume_end") {
1274
+ await writeVolumeContinuityReport({ rootDir: args.rootDir, report });
1275
+ }
1276
+ return report;
1277
+ };
1278
+ const warnIfNerFullyDegraded = (scope, report) => {
1279
+ const nerOk = typeof report.stats.ner_ok === "number" ? report.stats.ner_ok : null;
1280
+ const nerFailed = typeof report.stats.ner_failed === "number" ? report.stats.ner_failed : null;
1281
+ if (nerOk === 0 && typeof nerFailed === "number" && nerFailed > 0) {
1282
+ const sample = typeof report.stats.ner_failed_sample === "string" ? report.stats.ner_failed_sample : null;
1283
+ const suffix = sample ? ` (sample: ${sample})` : "";
1284
+ warnings.push(`Continuity audit degraded (${scope}): NER failed for ${nerFailed} chapters; report may be empty.${suffix}`);
1285
+ }
1286
+ };
1287
+ // Re-resolve volume range for audits if we couldn't resolve it during planning.
1288
+ if (!volumeRange) {
1289
+ try {
1290
+ volumeRange = await tryResolveVolumeChapterRange({ rootDir: args.rootDir, volume });
1291
+ }
1292
+ catch {
1293
+ volumeRange = null;
1294
+ }
1295
+ isVolumeEnd = volumeRange !== null && args.chapter === volumeRange.end;
1296
+ shouldPeriodicContinuityAudit = args.chapter % 5 === 0 && !isVolumeEnd;
1297
+ promiseLedgerHistoryRange = promiseLedgerExists ? resolvePromiseLedgerHistoryRange({ chapter: args.chapter, isVolumeEnd, volumeRange }) : null;
1298
+ engagementHistoryRange = resolveEngagementHistoryRange({ chapter: args.chapter, isVolumeEnd, volumeRange });
1299
+ }
1300
+ // Crash compensation for volume-end audits:
1301
+ // - create a pending marker before running volume_end (so a crash leaves a durable "rerun needed" flag)
1302
+ // - remove marker after successful report write
1303
+ const volumeEndTasks = new Map();
1304
+ const pendingMarkers = await listPendingVolumeEndAuditMarkers(args.rootDir, warnings);
1305
+ for (const it of pendingMarkers) {
1306
+ const [start, end] = it.marker.chapter_range;
1307
+ const volumeReportAbs = join(args.rootDir, `volumes/vol-${pad2(it.marker.volume)}/continuity-report.json`);
1308
+ if (await pathExists(volumeReportAbs)) {
1309
+ // Marker was likely left behind; clear it.
1310
+ try {
1311
+ await removePath(join(args.rootDir, it.rel));
1312
+ }
1313
+ catch {
1314
+ // ignore
1315
+ }
1316
+ continue;
1317
+ }
1318
+ volumeEndTasks.set(it.marker.volume, { start, end, markerRel: it.rel });
1319
+ }
1320
+ if (isVolumeEnd && volumeRange) {
1321
+ volumeEndTasks.set(volume, { start: volumeRange.start, end: volumeRange.end, markerRel: pendingVolumeEndMarkerRel(volume) });
1322
+ }
1323
+ for (const [taskVolume, task] of Array.from(volumeEndTasks.entries()).sort((a, b) => a[0] - b[0])) {
1324
+ const markerAbs = join(args.rootDir, task.markerRel);
1325
+ if (!(await pathExists(markerAbs))) {
1326
+ try {
1327
+ await writeJsonFile(markerAbs, {
1328
+ schema_version: 1,
1329
+ created_at: new Date().toISOString(),
1330
+ volume: taskVolume,
1331
+ chapter_range: [task.start, task.end]
1332
+ });
1333
+ }
1334
+ catch (err) {
1335
+ const message = err instanceof Error ? err.message : String(err);
1336
+ warnings.push(`Failed to write pending volume-end audit marker: ${task.markerRel}. ${message}`);
1337
+ }
1338
+ }
1339
+ try {
1340
+ const report = await runContinuityAudit("volume_end", taskVolume, task.start, task.end);
1341
+ warnIfNerFullyDegraded("volume_end", report);
1342
+ await removePath(markerAbs);
1343
+ }
1344
+ catch (err) {
1345
+ const message = err instanceof Error ? err.message : String(err);
1346
+ warnings.push(`Continuity audit skipped (volume_end): ${message}`);
1347
+ }
1348
+ }
1349
+ if (shouldPeriodicContinuityAudit) {
1350
+ try {
1351
+ const start = Math.max(1, args.chapter - 9);
1352
+ const end = args.chapter;
1353
+ const report = await runContinuityAudit("periodic", volume, start, end);
1354
+ warnIfNerFullyDegraded("periodic", report);
1355
+ }
1356
+ catch (err) {
1357
+ const message = err instanceof Error ? err.message : String(err);
1358
+ warnings.push(`Continuity audit skipped (periodic): ${message}`);
1359
+ }
1360
+ }
1361
+ // Post-commit (outside write-lock): foreshadow visibility maintenance (non-blocking).
1362
+ try {
1363
+ const platform = loadedProfile?.profile.platform ?? null;
1364
+ const genreDriveType = typeof loadedProfile?.profile.scoring?.genre_drive_type === "string" ? loadedProfile.profile.scoring.genre_drive_type : null;
1365
+ const items = await loadForeshadowGlobalItems(args.rootDir);
1366
+ const report = computeForeshadowVisibilityReport({
1367
+ items,
1368
+ asOfChapter: args.chapter,
1369
+ volume,
1370
+ platform,
1371
+ genreDriveType
1372
+ });
1373
+ const historyRange = resolveForeshadowVisibilityHistoryRange({ chapter: args.chapter, isVolumeEnd, volumeRange });
1374
+ await writeForeshadowVisibilityLogs({ rootDir: args.rootDir, report, historyRange });
1375
+ }
1376
+ catch (err) {
1377
+ const message = err instanceof Error ? err.message : String(err);
1378
+ warnings.push(`Foreshadow visibility maintenance skipped: ${message}`);
1379
+ }
1380
+ // Post-commit (outside write-lock): promise ledger report maintenance (non-blocking).
1381
+ if (promiseLedgerHistoryRange) {
1382
+ try {
1383
+ const loaded = await loadPromiseLedger(args.rootDir);
1384
+ for (const w of loaded.warnings)
1385
+ warnings.push(`Promise ledger (load): ${w}`);
1386
+ const report = computePromiseLedgerReport({
1387
+ ledger: loaded.ledger,
1388
+ asOfChapter: args.chapter,
1389
+ volume,
1390
+ chapterRange: promiseLedgerHistoryRange
1391
+ });
1392
+ await writePromiseLedgerLogs({ rootDir: args.rootDir, report, historyRange: promiseLedgerHistoryRange });
1393
+ }
1394
+ catch (err) {
1395
+ const message = err instanceof Error ? err.message : String(err);
1396
+ warnings.push(`Promise ledger report maintenance skipped: ${message}`);
1397
+ }
1398
+ }
1399
+ // Post-commit (outside write-lock): engagement density metrics + rolling window report (non-blocking).
1400
+ try {
1401
+ const metric = await computeEngagementMetricRecord({
1402
+ rootDir: args.rootDir,
1403
+ chapter: args.chapter,
1404
+ volume,
1405
+ chapterRel: rel.final.chapterMd,
1406
+ summaryRel: rel.final.summaryMd,
1407
+ evalRel: rel.final.evalJson
1408
+ });
1409
+ for (const w of metric.warnings)
1410
+ warnings.push(w);
1411
+ const stream = await appendEngagementMetricRecord({ rootDir: args.rootDir, record: metric.record });
1412
+ if (engagementHistoryRange) {
1413
+ const loaded = await loadEngagementMetricsStream({ rootDir: args.rootDir, relPath: stream.rel });
1414
+ for (const w of loaded.warnings)
1415
+ warnings.push(w);
1416
+ const report = computeEngagementReport({
1417
+ records: loaded.records,
1418
+ asOfChapter: args.chapter,
1419
+ volume,
1420
+ chapterRange: engagementHistoryRange,
1421
+ metricsRelPath: loaded.rel
1422
+ });
1423
+ await writeEngagementLogs({ rootDir: args.rootDir, report, historyRange: engagementHistoryRange });
1424
+ }
1425
+ }
1426
+ catch (err) {
1427
+ const message = err instanceof Error ? err.message : String(err);
1428
+ warnings.push(`Engagement density maintenance skipped: ${message}`);
1429
+ }
1430
+ // Post-commit (outside write-lock): character voice drift directives (non-blocking).
1431
+ try {
1432
+ await withWriteLock(args.rootDir, { chapter: args.chapter }, async () => {
1433
+ const loaded = await loadCharacterVoiceProfiles(args.rootDir);
1434
+ for (const w of loaded.warnings)
1435
+ warnings.push(w);
1436
+ if (!loaded.profiles)
1437
+ return;
1438
+ const previousActiveCharacterIds = await loadActiveCharacterVoiceDriftIds(args.rootDir);
1439
+ const computed = await computeCharacterVoiceDrift({
1440
+ rootDir: args.rootDir,
1441
+ profiles: loaded.profiles,
1442
+ asOfChapter: args.chapter,
1443
+ volume,
1444
+ previousActiveCharacterIds
1445
+ });
1446
+ for (const w of computed.warnings)
1447
+ warnings.push(w);
1448
+ if (computed.drift) {
1449
+ await writeCharacterVoiceDriftFile({ rootDir: args.rootDir, drift: computed.drift });
1450
+ return;
1451
+ }
1452
+ await clearCharacterVoiceDriftFile(args.rootDir);
1453
+ });
1454
+ }
1455
+ catch (err) {
1456
+ const message = err instanceof Error ? err.message : String(err);
1457
+ warnings.push(`Character voice drift maintenance skipped: ${message}`);
1458
+ }
1459
+ return { plan, warnings };
1460
+ }