@actalk/inkos-core 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/agents/architect.d.ts +4 -2
  2. package/dist/agents/architect.d.ts.map +1 -1
  3. package/dist/agents/architect.js +59 -13
  4. package/dist/agents/architect.js.map +1 -1
  5. package/dist/agents/chapter-analyzer.js +2 -2
  6. package/dist/agents/composer.d.ts +8 -0
  7. package/dist/agents/composer.d.ts.map +1 -1
  8. package/dist/agents/composer.js +170 -5
  9. package/dist/agents/composer.js.map +1 -1
  10. package/dist/agents/continuity.js +8 -8
  11. package/dist/agents/continuity.js.map +1 -1
  12. package/dist/agents/fanfic-canon-importer.d.ts +1 -0
  13. package/dist/agents/fanfic-canon-importer.d.ts.map +1 -1
  14. package/dist/agents/fanfic-canon-importer.js +19 -1
  15. package/dist/agents/fanfic-canon-importer.js.map +1 -1
  16. package/dist/agents/foundation-reviewer.d.ts +29 -0
  17. package/dist/agents/foundation-reviewer.d.ts.map +1 -0
  18. package/dist/agents/foundation-reviewer.js +153 -0
  19. package/dist/agents/foundation-reviewer.js.map +1 -0
  20. package/dist/agents/planner.d.ts +15 -0
  21. package/dist/agents/planner.d.ts.map +1 -1
  22. package/dist/agents/planner.js +247 -36
  23. package/dist/agents/planner.js.map +1 -1
  24. package/dist/agents/post-write-validator.d.ts +3 -1
  25. package/dist/agents/post-write-validator.d.ts.map +1 -1
  26. package/dist/agents/post-write-validator.js +165 -12
  27. package/dist/agents/post-write-validator.js.map +1 -1
  28. package/dist/agents/settler-prompts.d.ts +1 -0
  29. package/dist/agents/settler-prompts.d.ts.map +1 -1
  30. package/dist/agents/settler-prompts.js +8 -1
  31. package/dist/agents/settler-prompts.js.map +1 -1
  32. package/dist/agents/writer-prompts.js +10 -4
  33. package/dist/agents/writer-prompts.js.map +1 -1
  34. package/dist/agents/writer.d.ts +15 -0
  35. package/dist/agents/writer.d.ts.map +1 -1
  36. package/dist/agents/writer.js +181 -15
  37. package/dist/agents/writer.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/llm/provider.d.ts.map +1 -1
  43. package/dist/llm/provider.js +25 -1
  44. package/dist/llm/provider.js.map +1 -1
  45. package/dist/models/chapter.d.ts +4 -4
  46. package/dist/models/chapter.d.ts.map +1 -1
  47. package/dist/models/chapter.js +1 -0
  48. package/dist/models/chapter.js.map +1 -1
  49. package/dist/models/input-governance.d.ts +165 -0
  50. package/dist/models/input-governance.d.ts.map +1 -1
  51. package/dist/models/input-governance.js +34 -0
  52. package/dist/models/input-governance.js.map +1 -1
  53. package/dist/models/project.d.ts +8 -0
  54. package/dist/models/project.d.ts.map +1 -1
  55. package/dist/models/project.js +1 -0
  56. package/dist/models/project.js.map +1 -1
  57. package/dist/models/runtime-state.d.ts +30 -0
  58. package/dist/models/runtime-state.d.ts.map +1 -1
  59. package/dist/models/runtime-state.js +9 -0
  60. package/dist/models/runtime-state.js.map +1 -1
  61. package/dist/pipeline/chapter-persistence.d.ts +33 -0
  62. package/dist/pipeline/chapter-persistence.d.ts.map +1 -0
  63. package/dist/pipeline/chapter-persistence.js +35 -0
  64. package/dist/pipeline/chapter-persistence.js.map +1 -0
  65. package/dist/pipeline/chapter-review-cycle.d.ts +79 -0
  66. package/dist/pipeline/chapter-review-cycle.d.ts.map +1 -0
  67. package/dist/pipeline/chapter-review-cycle.js +97 -0
  68. package/dist/pipeline/chapter-review-cycle.js.map +1 -0
  69. package/dist/pipeline/chapter-state-recovery.d.ts +59 -0
  70. package/dist/pipeline/chapter-state-recovery.d.ts.map +1 -0
  71. package/dist/pipeline/chapter-state-recovery.js +132 -0
  72. package/dist/pipeline/chapter-state-recovery.js.map +1 -0
  73. package/dist/pipeline/chapter-truth-validation.d.ts +41 -0
  74. package/dist/pipeline/chapter-truth-validation.d.ts.map +1 -0
  75. package/dist/pipeline/chapter-truth-validation.js +67 -0
  76. package/dist/pipeline/chapter-truth-validation.js.map +1 -0
  77. package/dist/pipeline/persisted-governed-plan.d.ts +4 -0
  78. package/dist/pipeline/persisted-governed-plan.d.ts.map +1 -0
  79. package/dist/pipeline/persisted-governed-plan.js +85 -0
  80. package/dist/pipeline/persisted-governed-plan.js.map +1 -0
  81. package/dist/pipeline/runner.d.ts +10 -7
  82. package/dist/pipeline/runner.d.ts.map +1 -1
  83. package/dist/pipeline/runner.js +459 -283
  84. package/dist/pipeline/runner.js.map +1 -1
  85. package/dist/state/manager.d.ts +8 -0
  86. package/dist/state/manager.d.ts.map +1 -1
  87. package/dist/state/manager.js +106 -9
  88. package/dist/state/manager.js.map +1 -1
  89. package/dist/state/memory-db.d.ts +2 -0
  90. package/dist/state/memory-db.d.ts.map +1 -1
  91. package/dist/state/memory-db.js +13 -2
  92. package/dist/state/memory-db.js.map +1 -1
  93. package/dist/state/runtime-state-store.d.ts.map +1 -1
  94. package/dist/state/runtime-state-store.js +1 -0
  95. package/dist/state/runtime-state-store.js.map +1 -1
  96. package/dist/state/state-bootstrap.d.ts +2 -5
  97. package/dist/state/state-bootstrap.d.ts.map +1 -1
  98. package/dist/state/state-bootstrap.js +61 -141
  99. package/dist/state/state-bootstrap.js.map +1 -1
  100. package/dist/state/state-projections.d.ts.map +1 -1
  101. package/dist/state/state-projections.js +6 -4
  102. package/dist/state/state-projections.js.map +1 -1
  103. package/dist/state/state-reducer.d.ts.map +1 -1
  104. package/dist/state/state-reducer.js +6 -0
  105. package/dist/state/state-reducer.js.map +1 -1
  106. package/dist/utils/cadence-policy.d.ts +36 -0
  107. package/dist/utils/cadence-policy.d.ts.map +1 -0
  108. package/dist/utils/cadence-policy.js +38 -0
  109. package/dist/utils/cadence-policy.js.map +1 -0
  110. package/dist/utils/chapter-cadence.d.ts +34 -0
  111. package/dist/utils/chapter-cadence.d.ts.map +1 -0
  112. package/dist/utils/chapter-cadence.js +142 -0
  113. package/dist/utils/chapter-cadence.js.map +1 -0
  114. package/dist/utils/context-filter.d.ts +2 -2
  115. package/dist/utils/context-filter.d.ts.map +1 -1
  116. package/dist/utils/context-filter.js +3 -2
  117. package/dist/utils/context-filter.js.map +1 -1
  118. package/dist/utils/governed-context.d.ts +4 -0
  119. package/dist/utils/governed-context.d.ts.map +1 -1
  120. package/dist/utils/governed-context.js +20 -0
  121. package/dist/utils/governed-context.js.map +1 -1
  122. package/dist/utils/governed-working-set.d.ts.map +1 -1
  123. package/dist/utils/governed-working-set.js +2 -1
  124. package/dist/utils/governed-working-set.js.map +1 -1
  125. package/dist/utils/hook-agenda.d.ts +21 -0
  126. package/dist/utils/hook-agenda.d.ts.map +1 -0
  127. package/dist/utils/hook-agenda.js +95 -0
  128. package/dist/utils/hook-agenda.js.map +1 -0
  129. package/dist/utils/hook-arbiter.d.ts.map +1 -1
  130. package/dist/utils/hook-arbiter.js +7 -0
  131. package/dist/utils/hook-arbiter.js.map +1 -1
  132. package/dist/utils/hook-governance.d.ts +2 -0
  133. package/dist/utils/hook-governance.d.ts.map +1 -1
  134. package/dist/utils/hook-governance.js +19 -3
  135. package/dist/utils/hook-governance.js.map +1 -1
  136. package/dist/utils/hook-health.d.ts +1 -0
  137. package/dist/utils/hook-health.d.ts.map +1 -1
  138. package/dist/utils/hook-health.js +85 -26
  139. package/dist/utils/hook-health.js.map +1 -1
  140. package/dist/utils/hook-lifecycle.d.ts +34 -0
  141. package/dist/utils/hook-lifecycle.d.ts.map +1 -0
  142. package/dist/utils/hook-lifecycle.js +125 -0
  143. package/dist/utils/hook-lifecycle.js.map +1 -0
  144. package/dist/utils/hook-policy.d.ts +74 -0
  145. package/dist/utils/hook-policy.d.ts.map +1 -0
  146. package/dist/utils/hook-policy.js +126 -0
  147. package/dist/utils/hook-policy.js.map +1 -0
  148. package/dist/utils/long-span-fatigue.d.ts.map +1 -1
  149. package/dist/utils/long-span-fatigue.js +75 -28
  150. package/dist/utils/long-span-fatigue.js.map +1 -1
  151. package/dist/utils/memory-retrieval.d.ts +2 -16
  152. package/dist/utils/memory-retrieval.d.ts.map +1 -1
  153. package/dist/utils/memory-retrieval.js +14 -269
  154. package/dist/utils/memory-retrieval.js.map +1 -1
  155. package/dist/utils/story-markdown.d.ts +13 -0
  156. package/dist/utils/story-markdown.d.ts.map +1 -0
  157. package/dist/utils/story-markdown.js +218 -0
  158. package/dist/utils/story-markdown.js.map +1 -0
  159. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { chatCompletion, createLLMClient } from "../llm/provider.js";
2
2
  import { ArchitectAgent } from "../agents/architect.js";
3
+ import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
3
4
  import { PlannerAgent } from "../agents/planner.js";
4
5
  import { ComposerAgent } from "../agents/composer.js";
5
6
  import { WriterAgent } from "../agents/writer.js";
@@ -15,13 +16,28 @@ import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
15
16
  import { StateManager } from "../state/manager.js";
16
17
  import { MemoryDB } from "../state/memory-db.js";
17
18
  import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
18
- import { ChapterIntentSchema } from "../models/input-governance.js";
19
19
  import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode } from "../utils/length-metrics.js";
20
20
  import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
21
21
  import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
22
22
  import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
23
23
  import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
24
- import { join, relative } from "node:path";
24
+ import { join } from "node:path";
25
+ import { parseStateDegradedReviewNote, resolveStateDegradedBaseStatus, retrySettlementAfterValidationFailure, } from "./chapter-state-recovery.js";
26
+ import { persistChapterArtifacts } from "./chapter-persistence.js";
27
+ import { runChapterReviewCycle } from "./chapter-review-cycle.js";
28
+ import { validateChapterTruthPersistence } from "./chapter-truth-validation.js";
29
+ import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
30
+ const SEQUENCE_LEVEL_CATEGORIES = new Set([
31
+ "Pacing Monotony", "节奏单调",
32
+ "Mood Monotony", "情绪单调",
33
+ "Title Collapse", "标题重复",
34
+ "Title Clustering", "标题聚集",
35
+ "Opening Pattern Repetition", "开头同构",
36
+ "Ending Pattern Repetition", "结尾同构",
37
+ ]);
38
+ function isSequenceLevelCategory(category) {
39
+ return SEQUENCE_LEVEL_CATEGORIES.has(category);
40
+ }
25
41
  export class PipelineRunner {
26
42
  state;
27
43
  config;
@@ -67,6 +83,80 @@ export class PipelineRunner {
67
83
  logWarn(language, message) {
68
84
  this.config.logger?.warn(this.localize(language, message));
69
85
  }
86
+ async tryGenerateStyleGuide(bookId, referenceText, sourceName, language) {
87
+ try {
88
+ await this.generateStyleGuide(bookId, referenceText, sourceName);
89
+ }
90
+ catch (error) {
91
+ const resolvedLanguage = language ?? await this.resolveBookLanguageById(bookId);
92
+ const detail = error instanceof Error ? error.message : String(error);
93
+ this.logWarn(resolvedLanguage, {
94
+ zh: `风格指纹提取失败,已跳过:${detail}`,
95
+ en: `Style fingerprint extraction failed and was skipped: ${detail}`,
96
+ });
97
+ }
98
+ }
99
+ async generateAndReviewFoundation(params) {
100
+ const maxRetries = params.maxRetries ?? 2;
101
+ let foundation = await params.generate();
102
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
103
+ this.logStage(params.stageLanguage, {
104
+ zh: `审核基础设定(第${attempt + 1}轮)`,
105
+ en: `reviewing foundation (round ${attempt + 1})`,
106
+ });
107
+ const review = await params.reviewer.review({
108
+ foundation,
109
+ mode: params.mode,
110
+ sourceCanon: params.sourceCanon,
111
+ styleGuide: params.styleGuide,
112
+ language: params.language,
113
+ });
114
+ this.config.logger?.info(`Foundation review: ${review.totalScore}/100 ${review.passed ? "PASSED" : "REJECTED"}`);
115
+ for (const dim of review.dimensions) {
116
+ this.config.logger?.info(` [${dim.score}] ${dim.name.slice(0, 40)}`);
117
+ }
118
+ if (review.passed) {
119
+ return foundation;
120
+ }
121
+ this.logWarn(params.stageLanguage, {
122
+ zh: `基础设定未通过审核(${review.totalScore}分),正在重新生成...`,
123
+ en: `Foundation rejected (${review.totalScore}/100), regenerating...`,
124
+ });
125
+ foundation = await params.generate(this.buildFoundationReviewFeedback(review, params.language));
126
+ }
127
+ // Final review
128
+ const finalReview = await params.reviewer.review({
129
+ foundation,
130
+ mode: params.mode,
131
+ sourceCanon: params.sourceCanon,
132
+ styleGuide: params.styleGuide,
133
+ language: params.language,
134
+ });
135
+ this.config.logger?.info(`Foundation final review: ${finalReview.totalScore}/100 ${finalReview.passed ? "PASSED" : "ACCEPTED (max retries)"}`);
136
+ return foundation;
137
+ }
138
+ buildFoundationReviewFeedback(review, language) {
139
+ const dimensionLines = review.dimensions
140
+ .map((dimension) => (language === "en"
141
+ ? `- ${dimension.name} [${dimension.score}]: ${dimension.feedback}`
142
+ : `- ${dimension.name}(${dimension.score}分):${dimension.feedback}`))
143
+ .join("\n");
144
+ return language === "en"
145
+ ? [
146
+ "## Overall Feedback",
147
+ review.overallFeedback,
148
+ "",
149
+ "## Dimension Notes",
150
+ dimensionLines || "- none",
151
+ ].join("\n")
152
+ : [
153
+ "## 总评",
154
+ review.overallFeedback,
155
+ "",
156
+ "## 分项问题",
157
+ dimensionLines || "- 无",
158
+ ].join("\n");
159
+ }
70
160
  agentCtx(bookId) {
71
161
  return {
72
162
  client: this.config.client,
@@ -161,7 +251,15 @@ export class PipelineRunner {
161
251
  const stageLanguage = await this.resolveBookLanguage(book);
162
252
  this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
163
253
  const { profile: gp } = await this.loadGenreProfile(book.genre);
164
- const foundation = await architect.generateFoundation(book, this.config.externalContext);
254
+ const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id));
255
+ const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" : "zh";
256
+ const foundation = await this.generateAndReviewFoundation({
257
+ generate: (reviewFeedback) => architect.generateFoundation(book, this.config.externalContext, reviewFeedback),
258
+ reviewer,
259
+ mode: "original",
260
+ language: resolvedLanguage,
261
+ stageLanguage,
262
+ });
165
263
  try {
166
264
  this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
167
265
  await this.state.saveBookConfigAt(stagingBookDir, book);
@@ -205,16 +303,30 @@ export class PipelineRunner {
205
303
  // Step 1: Import source material → fanfic_canon.md
206
304
  this.logStage(stageLanguage, { zh: "导入同人正典", en: "importing fanfic canon" });
207
305
  const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode);
208
- // Step 2: Generate foundation from fanfic canon (not from scratch)
306
+ // Step 2: Generate foundation with review loop
209
307
  const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
308
+ const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id));
210
309
  this.logStage(stageLanguage, { zh: "生成同人基础设定", en: "generating fanfic foundation" });
211
310
  const { profile: gp } = await this.loadGenreProfile(book.genre);
212
- const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
311
+ const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" : "zh";
312
+ const foundation = await this.generateAndReviewFoundation({
313
+ generate: (reviewFeedback) => architect.generateFanficFoundation(book, fanficCanon, fanficMode, reviewFeedback),
314
+ reviewer,
315
+ mode: "fanfic",
316
+ sourceCanon: fanficCanon,
317
+ language: resolvedLanguage,
318
+ stageLanguage,
319
+ });
213
320
  this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
214
321
  await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem, book.language ?? gp.language);
215
322
  this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
216
323
  await this.state.ensureControlDocuments(book.id, this.config.externalContext);
217
- // Step 3: Initialize chapters directory + snapshot
324
+ // Step 3: Generate style guide from source material
325
+ if (sourceText.length >= 500) {
326
+ this.logStage(stageLanguage, { zh: "提取原作风格指纹", en: "extracting source style fingerprint" });
327
+ await this.tryGenerateStyleGuide(book.id, sourceText, sourceName, stageLanguage);
328
+ }
329
+ // Step 4: Initialize chapters directory + snapshot
218
330
  this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
219
331
  await mkdir(join(bookDir, "chapters"), { recursive: true });
220
332
  await this.state.saveChapterIndex(book.id, []);
@@ -341,7 +453,7 @@ export class PipelineRunner {
341
453
  return {
342
454
  bookId,
343
455
  chapterNumber,
344
- intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
456
+ intentPath: relativeToBookDir(bookDir, plan.runtimePath),
345
457
  goal: plan.intent.goal,
346
458
  conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
347
459
  };
@@ -357,12 +469,12 @@ export class PipelineRunner {
357
469
  return {
358
470
  bookId,
359
471
  chapterNumber,
360
- intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
472
+ intentPath: relativeToBookDir(bookDir, plan.runtimePath),
361
473
  goal: plan.intent.goal,
362
474
  conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
363
- contextPath: this.relativeToBookDir(bookDir, composed.contextPath),
364
- ruleStackPath: this.relativeToBookDir(bookDir, composed.ruleStackPath),
365
- tracePath: this.relativeToBookDir(bookDir, composed.tracePath),
475
+ contextPath: relativeToBookDir(bookDir, composed.contextPath),
476
+ ruleStackPath: relativeToBookDir(bookDir, composed.ruleStackPath),
477
+ tracePath: relativeToBookDir(bookDir, composed.tracePath),
366
478
  };
367
479
  }
368
480
  /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
@@ -401,6 +513,15 @@ export class PipelineRunner {
401
513
  }
402
514
  : ch);
403
515
  await this.state.saveChapterIndex(bookId, updated);
516
+ const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
517
+ if (targetChapter === latestChapter) {
518
+ await this.persistAuditDriftGuidance({
519
+ bookDir,
520
+ chapterNumber: targetChapter,
521
+ issues: result.issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning"),
522
+ language,
523
+ }).catch(() => undefined);
524
+ }
404
525
  await this.emitWebhook(result.passed ? "audit-passed" : "audit-failed", bookId, targetChapter, { summary: result.summary, issueCount: result.issues.length });
405
526
  return { ...result, chapterNumber: targetChapter };
406
527
  }
@@ -588,6 +709,15 @@ export class PipelineRunner {
588
709
  }
589
710
  : ch);
590
711
  await this.state.saveChapterIndex(bookId, updatedIndex);
712
+ const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
713
+ if (targetChapter === latestChapter) {
714
+ await this.persistAuditDriftGuidance({
715
+ bookDir,
716
+ chapterNumber: targetChapter,
717
+ issues: effectivePostRevision.auditResult.issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning"),
718
+ language,
719
+ }).catch(() => undefined);
720
+ }
591
721
  // Re-snapshot
592
722
  this.logStage(stageLanguage, {
593
723
  zh: `更新第${targetChapter}章索引与快照`,
@@ -666,10 +796,20 @@ export class PipelineRunner {
666
796
  await releaseLock();
667
797
  }
668
798
  }
799
+ async repairChapterState(bookId, chapterNumber) {
800
+ const releaseLock = await this.state.acquireBookLock(bookId);
801
+ try {
802
+ return await this._repairChapterStateLocked(bookId, chapterNumber);
803
+ }
804
+ finally {
805
+ await releaseLock();
806
+ }
807
+ }
669
808
  async _writeNextChapterLocked(bookId, wordCount, temperatureOverride) {
670
809
  await this.state.ensureControlDocuments(bookId);
671
810
  const book = await this.state.loadBookConfig(bookId);
672
811
  const bookDir = this.state.bookDir(bookId);
812
+ await this.assertNoPendingStateRepair(bookId);
673
813
  const chapterNumber = await this.state.getNextChapterNumber(bookId);
674
814
  const stageLanguage = await this.resolveBookLanguage(book);
675
815
  this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
@@ -699,120 +839,49 @@ export class PipelineRunner {
699
839
  const writerCount = countChapterLength(output.content, lengthSpec.countingMode);
700
840
  // Token usage accumulator
701
841
  let totalUsage = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
702
- let postReviseCount = 0;
703
- let normalizeApplied = false;
704
- // 2a. Post-write error gate: if deterministic rules found errors, auto-fix before LLM audit
705
- let finalContent = output.content;
706
- let finalWordCount = output.wordCount;
707
- let revised = false;
708
- if (output.postWriteErrors.length > 0) {
709
- this.logWarn(pipelineLang, {
710
- zh: `检测到 ${output.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`,
711
- en: `${output.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`,
712
- });
713
- const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
714
- const spotFixIssues = output.postWriteErrors.map((v) => ({
715
- severity: "critical",
716
- category: v.rule,
717
- description: v.description,
718
- suggestion: v.suggestion,
719
- }));
720
- const fixResult = await reviser.reviseChapter(bookDir, finalContent, chapterNumber, spotFixIssues, "spot-fix", book.genre, {
721
- ...reducedControlInput,
722
- lengthSpec,
723
- });
724
- totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
725
- if (fixResult.revisedContent.length > 0) {
726
- finalContent = fixResult.revisedContent;
727
- finalWordCount = fixResult.wordCount;
728
- revised = true;
729
- }
730
- }
731
- const normalizedBeforeAudit = await this.normalizeDraftLengthIfNeeded({
732
- bookId,
842
+ const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
843
+ const reviewResult = await runChapterReviewCycle({
844
+ book: { genre: book.genre },
845
+ bookDir,
733
846
  chapterNumber,
734
- chapterContent: finalContent,
847
+ initialOutput: output,
848
+ reducedControlInput,
735
849
  lengthSpec,
736
- chapterIntent: writeInput.chapterIntent,
850
+ initialUsage: totalUsage,
851
+ createReviser: () => new ReviserAgent(this.agentCtxFor("reviser", bookId)),
852
+ auditor,
853
+ normalizeDraftLengthIfNeeded: (chapterContent) => this.normalizeDraftLengthIfNeeded({
854
+ bookId,
855
+ chapterNumber,
856
+ chapterContent,
857
+ lengthSpec,
858
+ chapterIntent: writeInput.chapterIntent,
859
+ }),
860
+ assertChapterContentNotEmpty: (content, stage) => this.assertChapterContentNotEmpty(content, chapterNumber, stage),
861
+ addUsage: PipelineRunner.addUsage,
862
+ restoreLostAuditIssues: (previous, next) => this.restoreLostAuditIssues(previous, next),
863
+ analyzeAITells,
864
+ analyzeSensitiveWords,
865
+ logWarn: (message) => this.logWarn(pipelineLang, message),
866
+ logStage: (message) => this.logStage(stageLanguage, message),
737
867
  });
738
- totalUsage = PipelineRunner.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
739
- finalContent = normalizedBeforeAudit.content;
740
- finalWordCount = normalizedBeforeAudit.wordCount;
741
- normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
742
- this.assertChapterContentNotEmpty(finalContent, chapterNumber, "draft generation");
743
- // 2b. LLM audit
744
- const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
745
- this.logStage(stageLanguage, { zh: "审计草稿", en: "auditing draft" });
746
- const llmAudit = await auditor.auditChapter(bookDir, finalContent, chapterNumber, book.genre, reducedControlInput);
747
- totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage);
748
- const aiTellsResult = analyzeAITells(finalContent);
749
- const sensitiveWriteResult = analyzeSensitiveWords(finalContent);
750
- const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block");
751
- let auditResult = {
752
- passed: hasBlockedWriteWords ? false : llmAudit.passed,
753
- issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
754
- summary: llmAudit.summary,
755
- };
756
- // 3. If audit fails, try auto-revise once
757
- if (!auditResult.passed) {
758
- const criticalIssues = auditResult.issues.filter((i) => i.severity === "critical");
759
- if (criticalIssues.length > 0) {
760
- const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
761
- this.logStage(stageLanguage, { zh: "自动修复关键问题", en: "auto-revising critical issues" });
762
- const reviseOutput = await reviser.reviseChapter(bookDir, finalContent, chapterNumber, auditResult.issues, "spot-fix", book.genre, {
763
- ...reducedControlInput,
764
- lengthSpec,
765
- });
766
- totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
767
- if (reviseOutput.revisedContent.length > 0) {
768
- const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
769
- bookId,
770
- chapterNumber,
771
- chapterContent: reviseOutput.revisedContent,
772
- lengthSpec,
773
- chapterIntent: writeInput.chapterIntent,
774
- });
775
- totalUsage = PipelineRunner.addUsage(totalUsage, normalizedRevision.tokenUsage);
776
- postReviseCount = normalizedRevision.wordCount;
777
- normalizeApplied = normalizeApplied || normalizedRevision.applied;
778
- // Guard: reject revision if AI markers increased
779
- const preMarkers = analyzeAITells(finalContent);
780
- const postMarkers = analyzeAITells(normalizedRevision.content);
781
- const preCount = preMarkers.issues.length;
782
- const postCount = postMarkers.issues.length;
783
- if (postCount > preCount) {
784
- // Revision made text MORE AI-like — discard it, keep original
785
- }
786
- else {
787
- finalContent = normalizedRevision.content;
788
- finalWordCount = normalizedRevision.wordCount;
789
- revised = true;
790
- this.assertChapterContentNotEmpty(finalContent, chapterNumber, "revision");
791
- }
792
- // Re-audit the (possibly revised) content
793
- const reAudit = await auditor.auditChapter(bookDir, finalContent, chapterNumber, book.genre, { ...reducedControlInput, temperature: 0 });
794
- totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage);
795
- const reAITells = analyzeAITells(finalContent);
796
- const reSensitive = analyzeSensitiveWords(finalContent);
797
- const reHasBlocked = reSensitive.found.some((f) => f.severity === "block");
798
- auditResult = this.restoreLostAuditIssues(auditResult, {
799
- passed: reHasBlocked ? false : reAudit.passed,
800
- issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
801
- summary: reAudit.summary,
802
- });
803
- }
804
- }
805
- }
868
+ totalUsage = reviewResult.totalUsage;
869
+ let finalContent = reviewResult.finalContent;
870
+ let finalWordCount = reviewResult.finalWordCount;
871
+ let revised = reviewResult.revised;
872
+ let auditResult = reviewResult.auditResult;
873
+ const postReviseCount = reviewResult.postReviseCount;
874
+ const normalizeApplied = reviewResult.normalizeApplied;
806
875
  // 4. Save the final chapter and truth files from a single persistence source
807
876
  this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
808
877
  this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" });
809
878
  const chapterIndexBeforePersist = await this.state.loadChapterIndex(bookId);
810
879
  const { resolveDuplicateTitle } = await import("../agents/post-write-validator.js");
811
- const initialTitleResolution = resolveDuplicateTitle(output.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang);
880
+ const initialTitleResolution = resolveDuplicateTitle(output.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang, { content: finalContent });
812
881
  let persistenceOutput = await this.buildPersistenceOutput(bookId, book, bookDir, chapterNumber, initialTitleResolution.title === output.title
813
882
  ? output
814
883
  : { ...output, title: initialTitleResolution.title }, finalContent, lengthSpec.countingMode, reducedControlInput);
815
- const finalTitleResolution = resolveDuplicateTitle(persistenceOutput.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang);
884
+ const finalTitleResolution = resolveDuplicateTitle(persistenceOutput.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang, { content: finalContent });
816
885
  if (finalTitleResolution.title !== persistenceOutput.title) {
817
886
  persistenceOutput = {
818
887
  ...persistenceOutput,
@@ -821,8 +890,8 @@ export class PipelineRunner {
821
890
  }
822
891
  if (persistenceOutput.title !== output.title) {
823
892
  const description = pipelineLang === "en"
824
- ? `Duplicate chapter title "${output.title}" was auto-renamed to "${persistenceOutput.title}".`
825
- : `章节标题"${output.title}"与已有标题重复,已自动改为"${persistenceOutput.title}"。`;
893
+ ? `Chapter title "${output.title}" was auto-adjusted to "${persistenceOutput.title}".`
894
+ : `章节标题"${output.title}"已自动调整为"${persistenceOutput.title}"。`;
826
895
  this.config.logger?.warn(`[title] ${description}`);
827
896
  auditResult = {
828
897
  ...auditResult,
@@ -856,7 +925,7 @@ export class PipelineRunner {
856
925
  const lengthTelemetry = this.buildLengthTelemetry({
857
926
  lengthSpec,
858
927
  writerCount,
859
- postWriterNormalizeCount: normalizedBeforeAudit.wordCount,
928
+ postWriterNormalizeCount: reviewResult.preAuditNormalizedWordCount,
860
929
  postReviseCount,
861
930
  finalCount: finalWordCount,
862
931
  normalizeApplied,
@@ -866,31 +935,36 @@ export class PipelineRunner {
866
935
  // 4.1 Validate settler output before writing
867
936
  this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
868
937
  const storyDir = join(bookDir, "story");
869
- const [oldState, oldHooks] = await Promise.all([
938
+ const [oldState, oldHooks, oldLedger] = await Promise.all([
870
939
  readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
871
940
  readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
941
+ readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
872
942
  ]);
873
943
  const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
874
- let validation;
875
- try {
876
- validation = await validator.validate(finalContent, chapterNumber, oldState, persistenceOutput.updatedState, oldHooks, persistenceOutput.updatedHooks, pipelineLang);
877
- }
878
- catch (error) {
879
- throw new Error(`State validation failed for chapter ${chapterNumber}: ${String(error)}`);
880
- }
881
- if (validation.warnings.length > 0) {
882
- this.logWarn(pipelineLang, {
883
- zh: `状态校验:第${chapterNumber}章发现 ${validation.warnings.length} 条警告`,
884
- en: `State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`,
885
- });
886
- for (const w of validation.warnings) {
887
- this.config.logger?.warn(` [${w.category}] ${w.description}`);
888
- }
889
- }
890
- if (!validation.passed) {
891
- const reason = validation.warnings[0]?.description ?? "validator reported contradictions";
892
- throw new Error(`State validation failed for chapter ${chapterNumber}: ${reason}`);
893
- }
944
+ const truthValidation = await validateChapterTruthPersistence({
945
+ writer,
946
+ validator,
947
+ book,
948
+ bookDir,
949
+ chapterNumber,
950
+ title: persistenceOutput.title,
951
+ content: finalContent,
952
+ persistenceOutput,
953
+ auditResult,
954
+ previousTruth: {
955
+ oldState,
956
+ oldHooks,
957
+ oldLedger,
958
+ },
959
+ reducedControlInput,
960
+ language: pipelineLang,
961
+ logWarn: (message) => this.logWarn(pipelineLang, message),
962
+ logger: this.config.logger,
963
+ });
964
+ let chapterStatus = truthValidation.chapterStatus;
965
+ let degradedIssues = truthValidation.degradedIssues;
966
+ persistenceOutput = truthValidation.persistenceOutput;
967
+ auditResult = truthValidation.auditResult;
894
968
  // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
895
969
  {
896
970
  const { detectParagraphLengthDrift, detectParagraphShapeWarnings, } = await import("../agents/post-write-validator.js");
@@ -919,75 +993,51 @@ export class PipelineRunner {
919
993
  };
920
994
  }
921
995
  }
922
- await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
923
- await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
924
- await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
925
- this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
926
- await this.syncNarrativeMemoryIndex(bookId);
927
- // 5. Update chapter index
928
- const existingIndex = await this.state.loadChapterIndex(bookId);
929
- const now = new Date().toISOString();
930
- const newEntry = {
931
- number: chapterNumber,
932
- title: persistenceOutput.title,
933
- status: auditResult.passed ? "ready-for-review" : "audit-failed",
934
- wordCount: finalWordCount,
935
- createdAt: now,
936
- updatedAt: now,
937
- auditIssues: auditResult.issues.map((i) => `[${i.severity}] ${i.description}`),
996
+ const resolvedStatus = chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed");
997
+ await persistChapterArtifacts({
998
+ chapterNumber,
999
+ chapterTitle: persistenceOutput.title,
1000
+ status: resolvedStatus,
1001
+ auditResult,
1002
+ finalWordCount,
938
1003
  lengthWarnings,
939
1004
  lengthTelemetry,
1005
+ degradedIssues,
940
1006
  tokenUsage: totalUsage,
941
- };
942
- await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
943
- await this.markBookActiveIfNeeded(bookId);
944
- // 5.5 Audit drift correction — feed audit findings back into state
945
- // This prevents the writer from repeating mistakes in the next chapter
946
- const driftIssues = auditResult.issues.filter((i) => i.severity === "critical" || i.severity === "warning");
947
- if (driftIssues.length > 0) {
948
- const storyDir = join(bookDir, "story");
949
- try {
950
- const statePath = join(storyDir, "current_state.md");
951
- const currentState = await readFile(statePath, "utf-8").catch(() => "");
952
- // Append drift correction section (or replace existing one)
953
- const correctionHeader = this.localize(stageLanguage, {
954
- zh: "## 审计纠偏(自动生成,下一章写作前参照)",
955
- en: "## Audit Drift Correction",
956
- });
957
- const correctionBlock = [
958
- correctionHeader,
959
- this.localize(stageLanguage, {
960
- zh: `> 第${chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
961
- en: `> Chapter ${chapterNumber} audit found the following issues to avoid in the next chapter:`,
962
- }),
963
- ...driftIssues.map((i) => `> - [${i.severity}] ${i.category}: ${i.description}`),
964
- "",
965
- ].join("\n");
966
- // Replace existing correction block or append
967
- const existingCorrectionIdx = currentState.indexOf(correctionHeader);
968
- const updatedState = existingCorrectionIdx >= 0
969
- ? currentState.slice(0, existingCorrectionIdx) + correctionBlock
970
- : currentState + "\n\n" + correctionBlock;
971
- await writeFile(statePath, updatedState, "utf-8");
972
- }
973
- catch {
974
- // Non-critical — don't block pipeline if drift correction fails
975
- }
976
- }
977
- // 5.6 Snapshot state for rollback support
978
- this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
979
- await this.state.snapshotState(bookId, chapterNumber);
980
- await this.syncCurrentStateFactHistory(bookId, chapterNumber);
1007
+ loadChapterIndex: () => this.state.loadChapterIndex(bookId),
1008
+ saveChapter: () => writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang),
1009
+ saveTruthFiles: async () => {
1010
+ await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
1011
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
1012
+ this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
1013
+ await this.syncNarrativeMemoryIndex(bookId);
1014
+ },
1015
+ saveChapterIndex: (index) => this.state.saveChapterIndex(bookId, index),
1016
+ markBookActiveIfNeeded: () => this.markBookActiveIfNeeded(bookId),
1017
+ persistAuditDriftGuidance: (issues) => this.persistAuditDriftGuidance({
1018
+ bookDir,
1019
+ chapterNumber,
1020
+ issues,
1021
+ language: stageLanguage,
1022
+ }).catch(() => undefined),
1023
+ snapshotState: () => this.state.snapshotState(bookId, chapterNumber),
1024
+ syncCurrentStateFactHistory: () => this.syncCurrentStateFactHistory(bookId, chapterNumber),
1025
+ logSnapshotStage: () => this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" }),
1026
+ });
981
1027
  // 6. Send notification
982
1028
  if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
983
- const statusEmoji = auditResult.passed ? "" : "⚠️";
1029
+ const statusEmoji = resolvedStatus === "state-degraded"
1030
+ ? "🧯"
1031
+ : auditResult.passed ? "✅" : "⚠️";
984
1032
  const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
985
1033
  await dispatchNotification(this.config.notifyChannels, {
986
1034
  title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
987
1035
  body: [
988
1036
  `**${persistenceOutput.title}** | ${chapterLength}`,
989
1037
  revised ? "📝 已自动修正" : "",
990
- `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
1038
+ resolvedStatus === "state-degraded"
1039
+ ? "状态结算: 已降级保存,需先修复 state 再继续"
1040
+ : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
991
1041
  ...auditResult.issues
992
1042
  .filter((i) => i.severity !== "info")
993
1043
  .map((i) => `- [${i.severity}] ${i.description}`),
@@ -1001,6 +1051,7 @@ export class PipelineRunner {
1001
1051
  wordCount: finalWordCount,
1002
1052
  passed: auditResult.passed,
1003
1053
  revised,
1054
+ status: resolvedStatus,
1004
1055
  });
1005
1056
  return {
1006
1057
  chapterNumber,
@@ -1008,12 +1059,112 @@ export class PipelineRunner {
1008
1059
  wordCount: finalWordCount,
1009
1060
  auditResult,
1010
1061
  revised,
1011
- status: auditResult.passed ? "ready-for-review" : "audit-failed",
1062
+ status: resolvedStatus,
1012
1063
  lengthWarnings,
1013
1064
  lengthTelemetry,
1014
1065
  tokenUsage: totalUsage,
1015
1066
  };
1016
1067
  }
1068
+ async _repairChapterStateLocked(bookId, chapterNumber) {
1069
+ const book = await this.state.loadBookConfig(bookId);
1070
+ const bookDir = this.state.bookDir(bookId);
1071
+ const stageLanguage = await this.resolveBookLanguage(book);
1072
+ const index = [...(await this.state.loadChapterIndex(bookId))];
1073
+ if (index.length === 0) {
1074
+ throw new Error(`Book "${bookId}" has no persisted chapters to repair.`);
1075
+ }
1076
+ const targetChapter = chapterNumber ?? index[index.length - 1].number;
1077
+ const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter);
1078
+ if (targetIndex < 0) {
1079
+ throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`);
1080
+ }
1081
+ const targetMeta = index[targetIndex];
1082
+ const latestChapter = Math.max(...index.map((chapter) => chapter.number));
1083
+ if (targetMeta.status !== "state-degraded") {
1084
+ throw new Error(`Chapter ${targetChapter} is not state-degraded.`);
1085
+ }
1086
+ if (targetChapter !== latestChapter) {
1087
+ throw new Error(`Only the latest state-degraded chapter can be repaired safely (latest is ${latestChapter}).`);
1088
+ }
1089
+ this.logStage(stageLanguage, { zh: "修复章节状态结算", en: "repairing chapter state settlement" });
1090
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
1091
+ const pipelineLang = book.language ?? gp.language;
1092
+ const content = await this.readChapterContent(bookDir, targetChapter);
1093
+ const storyDir = join(bookDir, "story");
1094
+ const [oldState, oldHooks] = await Promise.all([
1095
+ readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
1096
+ readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
1097
+ ]);
1098
+ const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
1099
+ let repairedOutput = await writer.settleChapterState({
1100
+ book,
1101
+ bookDir,
1102
+ chapterNumber: targetChapter,
1103
+ title: targetMeta.title,
1104
+ content,
1105
+ });
1106
+ const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
1107
+ let validation = await validator.validate(content, targetChapter, oldState, repairedOutput.updatedState, oldHooks, repairedOutput.updatedHooks, pipelineLang);
1108
+ if (!validation.passed) {
1109
+ const recovery = await retrySettlementAfterValidationFailure({
1110
+ writer,
1111
+ validator,
1112
+ book,
1113
+ bookDir,
1114
+ chapterNumber: targetChapter,
1115
+ title: targetMeta.title,
1116
+ content,
1117
+ oldState,
1118
+ oldHooks,
1119
+ originalValidation: validation,
1120
+ language: pipelineLang,
1121
+ logWarn: (message) => this.logWarn(pipelineLang, message),
1122
+ logger: this.config.logger,
1123
+ });
1124
+ if (recovery.kind !== "recovered") {
1125
+ throw new Error(recovery.issues[0]?.description
1126
+ ?? `State repair still failed for chapter ${targetChapter}.`);
1127
+ }
1128
+ repairedOutput = recovery.output;
1129
+ validation = recovery.validation;
1130
+ }
1131
+ if (!validation.passed) {
1132
+ throw new Error(`State repair still failed for chapter ${targetChapter}.`);
1133
+ }
1134
+ await writer.saveChapter(bookDir, repairedOutput, gp.numericalSystem, pipelineLang);
1135
+ await writer.saveNewTruthFiles(bookDir, repairedOutput, pipelineLang);
1136
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, repairedOutput);
1137
+ await this.syncNarrativeMemoryIndex(bookId);
1138
+ await this.state.snapshotState(bookId, targetChapter);
1139
+ await this.syncCurrentStateFactHistory(bookId, targetChapter);
1140
+ const baseStatus = resolveStateDegradedBaseStatus(targetMeta);
1141
+ const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote);
1142
+ const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
1143
+ index[targetIndex] = {
1144
+ ...targetMeta,
1145
+ status: baseStatus,
1146
+ updatedAt: new Date().toISOString(),
1147
+ auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)),
1148
+ reviewNote: undefined,
1149
+ };
1150
+ await this.state.saveChapterIndex(bookId, index);
1151
+ const repairedPassesAudit = baseStatus !== "audit-failed";
1152
+ return {
1153
+ chapterNumber: targetChapter,
1154
+ title: targetMeta.title,
1155
+ wordCount: targetMeta.wordCount,
1156
+ auditResult: {
1157
+ passed: repairedPassesAudit,
1158
+ issues: [],
1159
+ summary: repairedPassesAudit ? "state repaired" : "state repaired but chapter still needs review",
1160
+ },
1161
+ revised: false,
1162
+ status: baseStatus,
1163
+ lengthWarnings: targetMeta.lengthWarnings,
1164
+ lengthTelemetry: targetMeta.lengthTelemetry,
1165
+ tokenUsage: targetMeta.tokenUsage,
1166
+ };
1167
+ }
1017
1168
  // ---------------------------------------------------------------------------
1018
1169
  // Import operations (style imitation + canon for spinoff)
1019
1170
  // ---------------------------------------------------------------------------
@@ -1200,8 +1351,36 @@ ${matrix}`,
1200
1351
  ].join("\n");
1201
1352
  const canon = response.content + metaBlock;
1202
1353
  await writeFile(join(storyDir, "parent_canon.md"), canon, "utf-8");
1354
+ // Also generate style guide from parent's chapter text if available
1355
+ const parentChaptersDir = join(parentDir, "chapters");
1356
+ const parentChapterText = await this.readParentChapterSample(parentChaptersDir);
1357
+ if (parentChapterText.length >= 500) {
1358
+ await this.tryGenerateStyleGuide(targetBookId, parentChapterText, parentBook.title);
1359
+ }
1203
1360
  return canon;
1204
1361
  }
1362
+ async readParentChapterSample(chaptersDir) {
1363
+ try {
1364
+ const entries = await readdir(chaptersDir);
1365
+ const mdFiles = entries
1366
+ .filter((file) => file.endsWith(".md"))
1367
+ .sort()
1368
+ .slice(0, 5);
1369
+ const chunks = [];
1370
+ let totalLength = 0;
1371
+ for (const file of mdFiles) {
1372
+ if (totalLength >= 20000)
1373
+ break;
1374
+ const content = await readFile(join(chaptersDir, file), "utf-8");
1375
+ chunks.push(content);
1376
+ totalLength += content.length;
1377
+ }
1378
+ return chunks.join("\n\n---\n\n");
1379
+ }
1380
+ catch {
1381
+ return "";
1382
+ }
1383
+ }
1205
1384
  // ---------------------------------------------------------------------------
1206
1385
  // Chapter import (for continuation writing from existing chapters)
1207
1386
  // ---------------------------------------------------------------------------
@@ -1236,6 +1415,14 @@ ${matrix}`,
1236
1415
  await this.resetImportReplayTruthFiles(bookDir, resolvedLanguage);
1237
1416
  await this.state.saveChapterIndex(input.bookId, []);
1238
1417
  await this.state.snapshotState(input.bookId, 0);
1418
+ // Generate style guide from imported chapters
1419
+ if (allText.length >= 500) {
1420
+ log?.info(this.localize(resolvedLanguage, {
1421
+ zh: "提取原文风格指纹...",
1422
+ en: "Extracting source style fingerprint...",
1423
+ }));
1424
+ await this.tryGenerateStyleGuide(input.bookId, allText, book.title, resolvedLanguage);
1425
+ }
1239
1426
  log?.info(this.localize(resolvedLanguage, {
1240
1427
  zh: "基础设定已生成。",
1241
1428
  en: "Foundation generated.",
@@ -1363,6 +1550,14 @@ ${matrix}`,
1363
1550
  tokenUsage: output.tokenUsage,
1364
1551
  };
1365
1552
  }
1553
+ async assertNoPendingStateRepair(bookId) {
1554
+ const existingIndex = await this.state.loadChapterIndex(bookId);
1555
+ const latestChapter = [...existingIndex].sort((left, right) => right.number - left.number)[0];
1556
+ if (latestChapter?.status !== "state-degraded") {
1557
+ return;
1558
+ }
1559
+ throw new Error(`Latest chapter ${latestChapter.number} is state-degraded. Repair state or rewrite that chapter before continuing.`);
1560
+ }
1366
1561
  // ---------------------------------------------------------------------------
1367
1562
  // Helpers
1368
1563
  // ---------------------------------------------------------------------------
@@ -1742,6 +1937,58 @@ ${matrix}`,
1742
1937
  lengthWarning: params.lengthWarning,
1743
1938
  };
1744
1939
  }
1940
+ async persistAuditDriftGuidance(params) {
1941
+ const storyDir = join(params.bookDir, "story");
1942
+ const driftPath = join(storyDir, "audit_drift.md");
1943
+ const statePath = join(storyDir, "current_state.md");
1944
+ const currentState = await readFile(statePath, "utf-8").catch(() => "");
1945
+ const sanitizedState = this.stripAuditDriftCorrectionBlock(currentState).trimEnd();
1946
+ if (sanitizedState !== currentState) {
1947
+ await writeFile(statePath, sanitizedState, "utf-8");
1948
+ }
1949
+ if (params.issues.length === 0) {
1950
+ await rm(driftPath, { force: true }).catch(() => undefined);
1951
+ return;
1952
+ }
1953
+ const block = [
1954
+ this.localize(params.language, {
1955
+ zh: "# 审计纠偏",
1956
+ en: "# Audit Drift",
1957
+ }),
1958
+ "",
1959
+ this.localize(params.language, {
1960
+ zh: "## 审计纠偏(自动生成,下一章写作前参照)",
1961
+ en: "## Audit Drift Correction",
1962
+ }),
1963
+ "",
1964
+ this.localize(params.language, {
1965
+ zh: `> 第${params.chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
1966
+ en: `> Chapter ${params.chapterNumber} audit found the following issues to avoid in the next chapter:`,
1967
+ }),
1968
+ ...params.issues.map((issue) => `> - [${issue.severity}] ${issue.category}: ${issue.description}`),
1969
+ "",
1970
+ ].join("\n");
1971
+ await writeFile(driftPath, block, "utf-8");
1972
+ }
1973
+ stripAuditDriftCorrectionBlock(currentState) {
1974
+ const headers = [
1975
+ "## 审计纠偏(自动生成,下一章写作前参照)",
1976
+ "## Audit Drift Correction",
1977
+ "# 审计纠偏",
1978
+ "# Audit Drift",
1979
+ ];
1980
+ let cutIndex = -1;
1981
+ for (const header of headers) {
1982
+ const index = currentState.indexOf(header);
1983
+ if (index >= 0 && (cutIndex < 0 || index < cutIndex)) {
1984
+ cutIndex = index;
1985
+ }
1986
+ }
1987
+ if (cutIndex < 0) {
1988
+ return currentState;
1989
+ }
1990
+ return currentState.slice(0, cutIndex).trimEnd();
1991
+ }
1745
1992
  logLengthWarnings(lengthWarnings) {
1746
1993
  for (const warning of lengthWarnings) {
1747
1994
  this.config.logger?.warn(warning);
@@ -1765,8 +2012,9 @@ ${matrix}`,
1765
2012
  return {
1766
2013
  ...next,
1767
2014
  auditResult,
1768
- blockingCount: auditResult.issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
1769
- criticalCount: auditResult.issues.filter((issue) => issue.severity === "critical").length,
2015
+ revisionBlockingIssues: previous.revisionBlockingIssues,
2016
+ blockingCount: previous.blockingCount,
2017
+ criticalCount: previous.criticalCount,
1770
2018
  };
1771
2019
  }
1772
2020
  async evaluateMergedAudit(params) {
@@ -1786,6 +2034,14 @@ ${matrix}`,
1786
2034
  ...sensitiveResult.issues,
1787
2035
  ...longSpanFatigue.issues,
1788
2036
  ];
2037
+ // revisionBlockingIssues excludes long-span-fatigue issues by
2038
+ // construction (not by category name) so that an LLM-reported issue
2039
+ // sharing a category label with a long-span issue is still counted.
2040
+ const revisionBlockingIssues = [
2041
+ ...llmAudit.issues,
2042
+ ...aiTells.issues,
2043
+ ...sensitiveResult.issues,
2044
+ ];
1789
2045
  return {
1790
2046
  auditResult: {
1791
2047
  passed: hasBlockedWords ? false : llmAudit.passed,
@@ -1794,8 +2050,9 @@ ${matrix}`,
1794
2050
  tokenUsage: llmAudit.tokenUsage,
1795
2051
  },
1796
2052
  aiTellCount: aiTells.issues.length,
1797
- blockingCount: issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
1798
- criticalCount: issues.filter((issue) => issue.severity === "critical").length,
2053
+ blockingCount: revisionBlockingIssues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
2054
+ criticalCount: revisionBlockingIssues.filter((issue) => issue.severity === "critical").length,
2055
+ revisionBlockingIssues,
1799
2056
  };
1800
2057
  }
1801
2058
  async markBookActiveIfNeeded(bookId) {
@@ -1822,7 +2079,7 @@ ${matrix}`,
1822
2079
  async resolveGovernedPlan(book, bookDir, chapterNumber, externalContext, options) {
1823
2080
  if (options?.reuseExistingIntentWhenContextMissing &&
1824
2081
  (!externalContext || externalContext.trim().length === 0)) {
1825
- const persisted = await this.loadPersistedPlan(bookDir, chapterNumber);
2082
+ const persisted = await loadPersistedPlan(bookDir, chapterNumber);
1826
2083
  if (persisted)
1827
2084
  return persisted;
1828
2085
  }
@@ -1834,87 +2091,6 @@ ${matrix}`,
1834
2091
  externalContext,
1835
2092
  });
1836
2093
  }
1837
- async loadPersistedPlan(bookDir, chapterNumber) {
1838
- const runtimePath = join(bookDir, "story", "runtime", `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`);
1839
- try {
1840
- const intentMarkdown = await readFile(runtimePath, "utf-8");
1841
- const sections = this.parseIntentSections(intentMarkdown);
1842
- const goal = this.readIntentScalar(sections, "Goal");
1843
- if (!goal || this.isInvalidPersistedIntentScalar(goal))
1844
- return null;
1845
- const outlineNode = this.readIntentScalar(sections, "Outline Node");
1846
- if (outlineNode && outlineNode !== "(not found)" && this.isInvalidPersistedIntentScalar(outlineNode)) {
1847
- return null;
1848
- }
1849
- const conflicts = this.readIntentList(sections, "Conflicts")
1850
- .map((line) => {
1851
- const separator = line.indexOf(":");
1852
- if (separator < 0)
1853
- return null;
1854
- const type = line.slice(0, separator).trim();
1855
- const resolution = line.slice(separator + 1).trim();
1856
- if (!type || !resolution)
1857
- return null;
1858
- return { type, resolution };
1859
- })
1860
- .filter((conflict) => conflict !== null);
1861
- return {
1862
- intent: ChapterIntentSchema.parse({
1863
- chapter: chapterNumber,
1864
- goal,
1865
- outlineNode: outlineNode && outlineNode !== "(not found)" ? outlineNode : undefined,
1866
- mustKeep: this.readIntentList(sections, "Must Keep"),
1867
- mustAvoid: this.readIntentList(sections, "Must Avoid"),
1868
- styleEmphasis: this.readIntentList(sections, "Style Emphasis"),
1869
- conflicts,
1870
- }),
1871
- intentMarkdown,
1872
- plannerInputs: [runtimePath],
1873
- runtimePath,
1874
- };
1875
- }
1876
- catch {
1877
- return null;
1878
- }
1879
- }
1880
- parseIntentSections(markdown) {
1881
- const sections = new Map();
1882
- let current = null;
1883
- for (const line of markdown.split("\n")) {
1884
- if (line.startsWith("## ")) {
1885
- current = line.slice(3).trim();
1886
- sections.set(current, []);
1887
- continue;
1888
- }
1889
- if (!current)
1890
- continue;
1891
- sections.get(current)?.push(line);
1892
- }
1893
- return sections;
1894
- }
1895
- readIntentScalar(sections, name) {
1896
- const lines = sections.get(name) ?? [];
1897
- const value = lines.map((line) => line.trim()).find((line) => line.length > 0);
1898
- return value && value !== "- none" ? value : undefined;
1899
- }
1900
- readIntentList(sections, name) {
1901
- return (sections.get(name) ?? [])
1902
- .map((line) => line.trim())
1903
- .filter((line) => line.startsWith("-") && line !== "- none")
1904
- .map((line) => line.replace(/^-\s*/, ""));
1905
- }
1906
- isInvalidPersistedIntentScalar(value) {
1907
- const normalized = value.trim();
1908
- if (!normalized)
1909
- return true;
1910
- if (/^[*_`~::|.-]+$/.test(normalized))
1911
- return true;
1912
- return (/^\((describe|briefly describe|write)\b[\s\S]*\)$/i.test(normalized)
1913
- || /^((?:在这里描述|描述|填写|写下)[\s\S]*)$/u.test(normalized));
1914
- }
1915
- relativeToBookDir(bookDir, absolutePath) {
1916
- return relative(bookDir, absolutePath).replaceAll("\\", "/");
1917
- }
1918
2094
  async emitWebhook(event, bookId, chapterNumber, data) {
1919
2095
  if (!this.config.notifyChannels || this.config.notifyChannels.length === 0)
1920
2096
  return;