dsh-novel-write-engine 0.2.0 → 0.2.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.
package/lib/index.js CHANGED
@@ -23,6 +23,9 @@ const REVIEW_DIR = "reviews";
23
23
  const CHECK_DIR = "checks";
24
24
  const EXPORT_DIR = "exports";
25
25
  const BACKUP_DIR = "backups";
26
+ const HISTORY_DIR = "history";
27
+ /** Brief receipts are append-only evidence, separate from content transactions. */
28
+ const BRIEF_RECEIPTS_FILE = "state/briefs.yaml";
26
29
  const LOCK_FILE = ".engine.lock";
27
30
  const STAGING_DIR = ".staging";
28
31
  const REGISTRY_FILE = "projects.yaml";
@@ -47,6 +50,21 @@ const planWorldFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3,
47
50
  const planChapterFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3, "0")}.chapter.yaml`;
48
51
  const reviewFile = (chapter) => `${REVIEW_DIR}/ch${String(chapter).padStart(3, "0")}.review.yaml`;
49
52
  const checkFile = (chapter) => `${CHECK_DIR}/ch${String(chapter).padStart(3, "0")}.check.yaml`;
53
+ /** Immutable-ish prior versions; the primary paths always hold the current one. */
54
+ const historyChapter = (chapter) => `ch${String(chapter).padStart(3, "0")}`;
55
+ const historyRevision = (revision) => `r${String(Math.max(0, revision)).padStart(4, "0")}`;
56
+ const reviewHistoryFile = (chapter, revision) => `${HISTORY_DIR}/reviews/${historyChapter(chapter)}.${historyRevision(revision)}.review.yaml`;
57
+ const checkHistoryFile = (chapter, revision) => `${HISTORY_DIR}/checks/${historyChapter(chapter)}.${historyRevision(revision)}.check.yaml`;
58
+ const chapterHistoryFile = (chapter, revision) => `${HISTORY_DIR}/chapters/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
59
+ const deprecatedHistoryFile = (chapter, revision) => `${HISTORY_DIR}/deprecated/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
60
+ const planHistoryFile = (chapter, revision) => `${HISTORY_DIR}/plans/${historyChapter(chapter)}.${historyRevision(revision)}.chapter.yaml`;
61
+ const HISTORY_KINDS = [
62
+ "reviews",
63
+ "checks",
64
+ "chapters",
65
+ "deprecated",
66
+ "plans"
67
+ ];
50
68
  /** Limits that are structural, not taste. */
51
69
  const LIMITS = {
52
70
  /** P4 <-> P5/P6 retries before a forced rollback. */
@@ -166,7 +184,8 @@ const OPERATIONS = [
166
184
  "panelSummary",
167
185
  "panelChapters",
168
186
  "panelGates",
169
- "stats"
187
+ "stats",
188
+ "restoreBackup"
170
189
  ];
171
190
  /** Error families map one-to-one to the three repair actions a caller can take. */
172
191
  const ERROR_CODES = {
@@ -676,8 +695,10 @@ const STAGE_NEXT = {
676
695
  const CJK = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g;
677
696
  const ENGLISH_WORD = /[A-Za-z]+/g;
678
697
  const NUMBER = /(?<![A-Za-z0-9])[0-9]+(?![A-Za-z0-9])/g;
679
- const CHINESE_PUNCT = /[\u3000-\u303f\uff00-\uffef]/g;
680
- const ENGLISH_PUNCT = /[^\p{L}\p{N}_\s\u3000-\u303f\uff00-\uffef]/gu;
698
+ const CHINESE_PUNCT = /[\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/g;
699
+ const ENGLISH_PUNCT = /[^\p{L}\p{N}_\s\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/gu;
700
+ /** A line participates in dialogue if it contains any of the four common quote pairs. */
701
+ const DIALOGUE_MARKS = /["“”「」『』]/;
681
702
  const matchCount = (text, pattern) => text.match(pattern)?.length ?? 0;
682
703
  /**
683
704
  * The one word-count rule of the engine.
@@ -694,15 +715,17 @@ function countWordMetrics(text) {
694
715
  const numbers = matchCount(text, NUMBER);
695
716
  const chinesePunct = matchCount(text, CHINESE_PUNCT);
696
717
  const englishPunct = matchCount(text, ENGLISH_PUNCT);
697
- const dialogueLines = nonEmptyLines.filter((line) => line.includes("\"")).length;
718
+ const dialogueLines = nonEmptyLines.filter((line) => DIALOGUE_MARKS.test(line)).length;
698
719
  const paragraphs = splitParagraphs(text);
720
+ const punctuation = chinesePunct + englishPunct;
699
721
  return {
700
- total_word_count: cjkChars + englishWords + numbers + chinesePunct + englishPunct,
722
+ total_word_count: cjkChars + englishWords + numbers + punctuation,
701
723
  cjk_chars: cjkChars,
702
724
  english_words: englishWords,
703
725
  numbers,
704
726
  chinese_punct: chinesePunct,
705
727
  english_punct: englishPunct,
728
+ punctuation_chars: punctuation,
706
729
  non_ws_chars: matchCount(text, /\S/g),
707
730
  total_lines: lines.length,
708
731
  non_empty_lines: nonEmptyLines.length,
@@ -768,7 +791,10 @@ const SEMANTIC_RULES = {
768
791
  ADJECTIVE_DENSITY: "ADJECTIVE_DENSITY",
769
792
  PLEASURE_DENSITY: "PLEASURE_DENSITY",
770
793
  SENSORY_DENSITY: "SENSORY_DENSITY",
771
- WATER_WORDS: "WATER_WORDS"
794
+ WATER_WORDS: "WATER_WORDS",
795
+ DIALOGUE_RATIO: "DIALOGUE_RATIO",
796
+ NUMERIC_CONTRADICTION: "NUMERIC_CONTRADICTION",
797
+ NUMERIC_FACT_MISMATCH: "NUMERIC_FACT_MISMATCH"
772
798
  };
773
799
  /** Cross-chapter families. */
774
800
  const CROSS_RULES = {
@@ -1040,6 +1066,142 @@ const slidingWindows = (text, size) => {
1040
1066
  if (windows[windows.length - 1] !== tail) windows.push(tail);
1041
1067
  return windows;
1042
1068
  };
1069
+ const dialogueRatio = (ctx) => {
1070
+ const target = ctx.targets?.dialogue_ratio;
1071
+ if (target === void 0) return [];
1072
+ const ratio = countWordMetrics(ctx.text).dialogue_ratio;
1073
+ if (ratio >= target[0] && ratio <= target[1]) return [];
1074
+ return [v(SEMANTIC_RULES.DIALOGUE_RATIO, "advisory", 1, `dialogue ratio ${Math.round(ratio * 1e3) / 10}% is outside target ${Math.round(target[0] * 1e3) / 10}%..${Math.round(target[1] * 1e3) / 10}% (quoted lines / non-empty lines)`)];
1075
+ };
1076
+ const CN_DIGITS = {
1077
+ 零: 0,
1078
+ 一: 1,
1079
+ 二: 2,
1080
+ 两: 2,
1081
+ 三: 3,
1082
+ 四: 4,
1083
+ 五: 5,
1084
+ 六: 6,
1085
+ 七: 7,
1086
+ 八: 8,
1087
+ 九: 9
1088
+ };
1089
+ const CN_UNITS = {
1090
+ 十: 10,
1091
+ 百: 100,
1092
+ 千: 1e3,
1093
+ 万: 1e4
1094
+ };
1095
+ /** Parse a small Chinese/Arabic number; null means the token was not a number. */
1096
+ function parseSmallNumber(raw) {
1097
+ if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
1098
+ let total = 0;
1099
+ let section = 0;
1100
+ let digit = null;
1101
+ for (const char of raw) {
1102
+ if (char in CN_DIGITS) {
1103
+ digit = CN_DIGITS[char];
1104
+ continue;
1105
+ }
1106
+ const unit = CN_UNITS[char];
1107
+ if (unit === void 0) return null;
1108
+ if (unit === 1e4) {
1109
+ section = (section + (digit ?? 0)) * unit;
1110
+ total += section;
1111
+ section = 0;
1112
+ digit = null;
1113
+ } else {
1114
+ section += (digit ?? 1) * unit;
1115
+ digit = null;
1116
+ }
1117
+ }
1118
+ return total + section + (digit ?? 0);
1119
+ }
1120
+ const NUMBER_TOKEN = "[零一二两三四五六七八九十百千万]+|\\d+(?:\\.\\d+)?";
1121
+ const DISTANCE_RE = new RegExp(`(${NUMBER_TOKEN})\\s*(海里|公里|千米|米|里)`, "g");
1122
+ const DURATION_RE = new RegExp(`(${NUMBER_TOKEN})\\s*个?\\s*(小时|钟头|分钟)`, "g");
1123
+ const SPEED_RE = new RegExp(`(?:时速|每小时|速度(?:是|为)?)\\s*(${NUMBER_TOKEN})\\s*(海里|公里|千米|米)`, "g");
1124
+ const METRES = {
1125
+ 米: 1,
1126
+ 里: 500,
1127
+ 公里: 1e3,
1128
+ 千米: 1e3,
1129
+ 海里: 1852
1130
+ };
1131
+ const HOURS = {
1132
+ 分钟: 1 / 60,
1133
+ 小时: 1,
1134
+ 钟头: 1
1135
+ };
1136
+ const collect = (text, pattern, units) => {
1137
+ const result = [];
1138
+ for (const match of text.matchAll(pattern)) {
1139
+ const value = parseSmallNumber(match[1] ?? "");
1140
+ if (value === null || units[match[2] ?? ""] === void 0) continue;
1141
+ result.push({
1142
+ value,
1143
+ unit: match[2],
1144
+ index: match.index ?? 0,
1145
+ raw: match[0]
1146
+ });
1147
+ }
1148
+ return result;
1149
+ };
1150
+ const numericContradiction = (ctx) => {
1151
+ const explicit = collect(ctx.text, SPEED_RE, METRES).map((quantity) => ({
1152
+ at: quantity.index,
1153
+ kmh: quantity.value * METRES[quantity.unit] / 1e3,
1154
+ raw: quantity.raw
1155
+ }));
1156
+ const distances = collect(ctx.text, DISTANCE_RE, METRES);
1157
+ const durations = collect(ctx.text, DURATION_RE, HOURS);
1158
+ const speeds = [];
1159
+ if (distances.length > 0 && durations.length > 0) for (const distance of distances) {
1160
+ const nearest = durations.map((duration) => ({
1161
+ duration,
1162
+ distance: Math.abs(duration.index - distance.index)
1163
+ })).sort((left, right) => left.distance - right.distance)[0];
1164
+ if (nearest === void 0 || nearest.distance > 60) continue;
1165
+ const metres = distance.value * METRES[distance.unit];
1166
+ const hours = nearest.duration.value * HOURS[nearest.duration.unit];
1167
+ if (hours <= 0) continue;
1168
+ speeds.push({
1169
+ at: Math.min(distance.index, nearest.duration.index),
1170
+ kmh: metres / 1e3 / hours,
1171
+ raw: `${distance.raw} / ${nearest.duration.raw}`
1172
+ });
1173
+ }
1174
+ const anchors = explicit.length > 0 ? explicit : speeds;
1175
+ if (anchors.length < 2) return [];
1176
+ const positive = anchors.filter((anchor) => anchor.kmh > 0);
1177
+ if (positive.length < 2) return [];
1178
+ const min = Math.min(...positive.map((anchor) => anchor.kmh));
1179
+ const max = Math.max(...positive.map((anchor) => anchor.kmh));
1180
+ if (max / Math.max(1e-4, min) < 1.5) return [];
1181
+ const firstNeedle = positive[0].raw.split(" / ")[0];
1182
+ const paragraphIndex = splitParagraphs(ctx.text).findIndex((paragraph) => paragraph.includes(firstNeedle));
1183
+ return [v(SEMANTIC_RULES.NUMERIC_CONTRADICTION, "advisory", Math.max(1, paragraphIndex + 1), `numeric statements imply different speeds (${min.toFixed(1)}–${max.toFixed(1)} km/h): ${positive.slice(0, 3).map((anchor) => anchor.raw).join("; ")}`)];
1184
+ };
1185
+ const escapePattern = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1186
+ const numericFactMismatch = (ctx) => {
1187
+ if (ctx.facts === void 0 || ctx.facts.length === 0) return [];
1188
+ const results = [];
1189
+ const paragraphs = splitParagraphs(ctx.text);
1190
+ for (const fact of ctx.facts) {
1191
+ if (fact.alias.trim() === "" || !Number.isFinite(fact.value)) continue;
1192
+ const tolerance = fact.tolerance ?? 0;
1193
+ const pattern = new RegExp(`${escapePattern(fact.alias)}\\s*(?:是|为|约|有|:|:)?\\s*(${NUMBER_TOKEN})\\s*${escapePattern(fact.unit)}`, "g");
1194
+ for (const match of ctx.text.matchAll(pattern)) {
1195
+ const value = parseSmallNumber(match[1] ?? "");
1196
+ if (value === null) continue;
1197
+ if (Math.abs(value - fact.value) <= tolerance) continue;
1198
+ const paragraph = paragraphs.findIndex((candidate) => candidate.includes(match[0]));
1199
+ results.push(v(SEMANTIC_RULES.NUMERIC_FACT_MISMATCH, "advisory", Math.max(1, paragraph + 1), `"${fact.alias}" declares ${fact.value}${fact.unit}, text shows ${value}${fact.unit}`));
1200
+ break;
1201
+ }
1202
+ }
1203
+ return results;
1204
+ };
1043
1205
  const pleasureDensity = (ctx) => {
1044
1206
  const { min, keywords, window } = ctx.rulepack.semantics.pleasure_density;
1045
1207
  if (keywords.length === 0) return [];
@@ -1238,6 +1400,9 @@ const BUILTIN_RULES = {
1238
1400
  [SEMANTIC_RULES.PLEASURE_DENSITY]: pleasureDensity,
1239
1401
  [SEMANTIC_RULES.SENSORY_DENSITY]: sensoryDensity,
1240
1402
  [SEMANTIC_RULES.WATER_WORDS]: waterWords,
1403
+ [SEMANTIC_RULES.DIALOGUE_RATIO]: dialogueRatio,
1404
+ [SEMANTIC_RULES.NUMERIC_CONTRADICTION]: numericContradiction,
1405
+ [SEMANTIC_RULES.NUMERIC_FACT_MISMATCH]: numericFactMismatch,
1241
1406
  [CROSS_RULES.DUPLICATE_PARAGRAPH]: duplicateParagraph,
1242
1407
  [CROSS_RULES.FORESHADOW_DORMANT]: foreshadowDormant,
1243
1408
  [CROSS_RULES.FS_REVISITED_TOO_SOON]: revisitedTooSoon,
@@ -1455,6 +1620,7 @@ const SCHEMAS = {
1455
1620
  rounds: req("object"),
1456
1621
  briefs: req("object"),
1457
1622
  blocking: req("array"),
1623
+ draft: f("object"),
1458
1624
  review_digest: f("string"),
1459
1625
  last_action: f("any")
1460
1626
  },
@@ -1510,6 +1676,7 @@ const SCHEMAS = {
1510
1676
  content_hash: req("string"),
1511
1677
  machine: req("object"),
1512
1678
  semantic: req("object"),
1679
+ acknowledged: f("array"),
1513
1680
  verdict: req("string", { enum: ["pass", "fail"] })
1514
1681
  },
1515
1682
  worldPlan: {
@@ -1546,6 +1713,7 @@ const SCHEMAS = {
1546
1713
  characters: req("array"),
1547
1714
  glossary: req("array"),
1548
1715
  backstory: req("array"),
1716
+ facts: f("array"),
1549
1717
  themes: req("array")
1550
1718
  }
1551
1719
  };
@@ -1625,6 +1793,26 @@ const validateNested = (kind, record) => {
1625
1793
  if (targets.total_chapters !== null) integerAt(kind, targets.total_chapters, "targets.total_chapters");
1626
1794
  return;
1627
1795
  }
1796
+ case "pipeline": {
1797
+ const rounds = objectAt(kind, record.rounds, "rounds");
1798
+ for (const key of [
1799
+ "review",
1800
+ "revise",
1801
+ "check"
1802
+ ]) integerAt(kind, rounds[key], `rounds.${key}`);
1803
+ if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, "rounds.rollbacks");
1804
+ if (record.draft !== void 0) {
1805
+ const draft = objectAt(kind, record.draft, "draft");
1806
+ integerAt(kind, draft.chapter, "draft.chapter");
1807
+ stringAt(kind, draft.title, "draft.title");
1808
+ stringAt(kind, draft.scene_type, "draft.scene_type");
1809
+ stringAt(kind, draft.narrator, "draft.narrator");
1810
+ stringAt(kind, draft.content_hash, "draft.content_hash");
1811
+ stringAt(kind, draft.updated_at, "draft.updated_at");
1812
+ validateSummary(kind, draft.summary, "draft.summary");
1813
+ }
1814
+ return;
1815
+ }
1628
1816
  case "chapters":
1629
1817
  validateRecords(kind, record.chapters, "chapters", (row, path) => {
1630
1818
  integerAt(kind, row.num, `${path}.num`);
@@ -1641,6 +1829,17 @@ const validateNested = (kind, record) => {
1641
1829
  validateSummary(kind, row.summary, `${path}.summary`);
1642
1830
  if (row.assigned_ids !== void 0) objectAt(kind, row.assigned_ids, `${path}.assigned_ids`);
1643
1831
  optionalString(kind, row.archive_digest, `${path}.archive_digest`);
1832
+ optionalString(kind, row.final_check, `${path}.final_check`);
1833
+ optionalString(kind, row.archived_at, `${path}.archived_at`);
1834
+ if (row.rounds !== void 0) {
1835
+ const rounds = objectAt(kind, row.rounds, `${path}.rounds`);
1836
+ for (const key of [
1837
+ "review",
1838
+ "revise",
1839
+ "check"
1840
+ ]) integerAt(kind, rounds[key], `${path}.rounds.${key}`);
1841
+ if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, `${path}.rounds.rollbacks`);
1842
+ }
1644
1843
  });
1645
1844
  return;
1646
1845
  case "characters":
@@ -1699,6 +1898,7 @@ const validateNested = (kind, record) => {
1699
1898
  integerAt(kind, row.last_touched, `${path}.last_touched`);
1700
1899
  stringAt(kind, row.status, `${path}.status`);
1701
1900
  stringAt(kind, row.description, `${path}.description`);
1901
+ optionalString(kind, row.anchor, `${path}.anchor`);
1702
1902
  listAt(kind, row.advance_log, `${path}.advance_log`);
1703
1903
  });
1704
1904
  return;
@@ -2153,17 +2353,29 @@ async function readJournal(store) {
2153
2353
  status: raw.status
2154
2354
  };
2155
2355
  if (typeof raw.reason === "string") record.reason = raw.reason;
2356
+ if (typeof raw.code === "string") record.code = raw.code;
2357
+ if (typeof raw.hint === "string") record.hint = raw.hint;
2358
+ if (typeof raw.stage === "string") record.stage = raw.stage;
2359
+ if (raw.attempt === true) record.attempt = true;
2360
+ if (Array.isArray(raw.blocking)) record.blocking = raw.blocking.map((item) => typeof item === "object" && item !== null ? item : null).filter((item) => item !== null);
2156
2361
  if (typeof raw.hashes === "object" && raw.hashes !== null && !Array.isArray(raw.hashes)) record.hashes = Object.fromEntries(Object.entries(raw.hashes).filter((entry) => typeof entry[1] === "string"));
2157
2362
  if (typeof raw.before === "object" && raw.before !== null && !Array.isArray(raw.before)) record.before = Object.fromEntries(Object.entries(raw.before).filter((entry) => entry[1] === null || typeof entry[1] === "string"));
2158
2363
  records.push(record);
2159
2364
  if (record.status !== "pending") rows.push({
2365
+ seq: rows.length + 1,
2160
2366
  at: record.at,
2161
2367
  op: record.op,
2162
2368
  chapter: record.chapter,
2163
2369
  revision: record.revision,
2164
2370
  txid: record.txid,
2165
2371
  status: record.status,
2166
- files: record.files
2372
+ files: record.files,
2373
+ ...record.code === void 0 ? {} : { code: record.code },
2374
+ ...record.reason === void 0 ? {} : { reason: record.reason },
2375
+ ...record.hint === void 0 ? {} : { hint: record.hint },
2376
+ ...record.stage === void 0 ? {} : { stage: record.stage },
2377
+ ...record.attempt === true ? { attempt: true } : {},
2378
+ ...record.blocking === void 0 ? {} : { blocking: record.blocking }
2167
2379
  });
2168
2380
  } catch {
2169
2381
  malformed += 1;
@@ -2187,7 +2399,7 @@ function assertJournalHealthy(malformed) {
2187
2399
  async function reconcileJournal(store) {
2188
2400
  const { records, malformed } = await readJournal(store);
2189
2401
  const expected = /* @__PURE__ */ new Map();
2190
- const tracked = (path) => (path === "novel.yaml" || path.startsWith("state/") || path === "outline/outline.yaml" || path === "outline/worldbook.yaml") && path !== "state/archive-digests.json";
2402
+ const tracked = (path) => (path === "novel.yaml" || path.startsWith("state/") || path === "outline/outline.yaml" || path === "outline/worldbook.yaml") && path !== "state/archive-digests.json" && path !== "state/briefs.yaml";
2191
2403
  for (const record of records) {
2192
2404
  if (record.status !== "committed" || record.hashes === void 0) continue;
2193
2405
  for (const [path, hash] of Object.entries(record.hashes)) {
@@ -2254,6 +2466,7 @@ async function recoverPending(store) {
2254
2466
  await appendJournal(store, {
2255
2467
  ...last,
2256
2468
  status: "aborted",
2469
+ code: ERROR_CODES.TXN_ABORTED,
2257
2470
  reason: "recovered: the process stopped before the transaction renamed a target"
2258
2471
  });
2259
2472
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
@@ -2480,6 +2693,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2480
2693
  // src/store/backup.ts
2481
2694
  /** Marks a directory this engine owns; user files under backups/ are never pruned. */
2482
2695
  const BACKUP_MARKER = ".engine-backup";
2696
+ const BACKUP_MANIFEST = "manifest.json";
2483
2697
  /** Backup transaction directories, newest first by filesystem mtime. */
2484
2698
  async function listBackups(store) {
2485
2699
  const entries = [];
@@ -2487,13 +2701,51 @@ async function listBackups(store) {
2487
2701
  const info = await store.stat(joinRel(BACKUP_DIR, name));
2488
2702
  if (info === null || !info.isDirectory) continue;
2489
2703
  if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
2704
+ const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
2705
+ let manifest = null;
2706
+ if (raw !== null) try {
2707
+ manifest = JSON.parse(raw);
2708
+ } catch {
2709
+ manifest = null;
2710
+ }
2711
+ const fileNames = Array.isArray(manifest?.files) ? manifest.files.filter((file) => typeof file === "string") : [];
2490
2712
  entries.push({
2491
2713
  name,
2492
- mtimeMs: info.mtimeMs
2714
+ mtimeMs: info.mtimeMs,
2715
+ txid: typeof manifest?.txid === "string" ? manifest.txid : name,
2716
+ op: typeof manifest?.op === "string" ? manifest.op : null,
2717
+ chapter: typeof manifest?.chapter === "number" ? manifest.chapter : null,
2718
+ revision: typeof manifest?.revision === "number" ? manifest.revision : null,
2719
+ at: typeof manifest?.at === "string" ? manifest.at : null,
2720
+ reason: typeof manifest?.reason === "string" ? manifest.reason : null,
2721
+ files: fileNames
2493
2722
  });
2494
2723
  }
2495
2724
  return entries.sort((left, right) => right.mtimeMs - left.mtimeMs);
2496
2725
  }
2726
+ /** Read one manifest, refusing a directory this engine did not create. */
2727
+ async function readBackupManifest(store, name) {
2728
+ if (!/^[0-9a-f-]{36}$/i.test(name)) return null;
2729
+ if (!await store.exists(joinRel("backups", name, ".engine-backup"))) return null;
2730
+ const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
2731
+ if (raw === null) return null;
2732
+ try {
2733
+ const value = JSON.parse(raw);
2734
+ if (value.schema_version !== 1 || typeof value.txid !== "string" || typeof value.op !== "string" || typeof value.chapter !== "number" || typeof value.revision !== "number" || typeof value.at !== "string" || !Array.isArray(value.files)) return null;
2735
+ return {
2736
+ schema_version: 1,
2737
+ txid: value.txid,
2738
+ op: value.op,
2739
+ chapter: value.chapter,
2740
+ revision: value.revision,
2741
+ at: value.at,
2742
+ ...typeof value.reason === "string" ? { reason: value.reason } : {},
2743
+ files: value.files.filter((file) => typeof file === "string")
2744
+ };
2745
+ } catch {
2746
+ return null;
2747
+ }
2748
+ }
2497
2749
  /**
2498
2750
  * Keep the newest `keep` backup directories. Only directories directly under
2499
2751
  * `backups/` are considered, and only directories this engine created are
@@ -2585,6 +2837,16 @@ async function runTransaction(store, input) {
2585
2837
  await store.writeBytes(joinRel(stagingBackup, file.path), existing);
2586
2838
  }
2587
2839
  await store.writeText(joinRel(stagingBackup, BACKUP_MARKER), "1");
2840
+ await store.writeText(joinRel(stagingBackup, BACKUP_MANIFEST), `${JSON.stringify({
2841
+ schema_version: 1,
2842
+ txid: input.txid,
2843
+ op: input.op,
2844
+ chapter: input.chapter,
2845
+ revision: input.revision,
2846
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2847
+ ...input.reason === void 0 ? {} : { reason: input.reason },
2848
+ files: input.files.filter((file) => before.get(file.path) !== null && before.get(file.path) !== void 0).map((file) => file.path)
2849
+ }, null, 2)}\n`);
2588
2850
  const targetBackup = store.path(backupDir(input.txid));
2589
2851
  await promises.mkdir(dirname(targetBackup), { recursive: true });
2590
2852
  await promises.rename(store.path(stagingBackup), targetBackup);
@@ -2652,6 +2914,7 @@ async function runTransaction(store, input) {
2652
2914
  revision: input.revision,
2653
2915
  files: input.files.map((file) => file.path),
2654
2916
  status: "aborted",
2917
+ code: error instanceof EngineError ? error.code : ERROR_CODES.TXN_ABORTED,
2655
2918
  reason: error instanceof Error ? error.message : String(error)
2656
2919
  }, { repairTrailing: true }).catch(() => void 0);
2657
2920
  const suffix = rollbackProblem === null ? "" : `; compensation was incomplete: ${rollbackProblem}; do not retry until the files are restored`;
@@ -3124,6 +3387,7 @@ function emptyWorldbook(now) {
3124
3387
  characters: [],
3125
3388
  glossary: [],
3126
3389
  backstory: [],
3390
+ facts: [],
3127
3391
  themes: []
3128
3392
  };
3129
3393
  }
@@ -3132,15 +3396,28 @@ function outlineCard(document, chapter) {
3132
3396
  return document.chapters.find((card) => card.num === chapter) ?? null;
3133
3397
  }
3134
3398
  /** A compact progress view used by status, the panel and the report. */
3135
- function outlineSummary(document, archivedCount) {
3399
+ function outlineSummary(document, archivedCount, target = null, archivedNumbers) {
3136
3400
  const planned = document.chapters.filter((card) => card.status === "planned").length;
3137
3401
  const archived = document.chapters.filter((card) => card.status === "archived").length;
3402
+ const total = document.chapters.length;
3403
+ const missingCards = target === null ? 0 : Math.max(0, target - total);
3404
+ const plannedCoverage = target === null || target === 0 ? null : Math.min(1, total / target);
3405
+ const archivedCoverage = target === null || target === 0 ? null : Math.min(1, archived / target);
3406
+ const archivedSet = archivedNumbers === void 0 ? null : new Set(archivedNumbers);
3407
+ const matchedCards = archivedSet === null ? archived : document.chapters.filter((card) => card.status === "archived" && archivedSet.has(card.num)).length;
3408
+ const archivedDenominator = archivedSet === null ? archivedCount : archivedSet.size;
3409
+ const cardCoverage = archivedDenominator === 0 ? 1 : Math.min(1, matchedCards / archivedDenominator);
3138
3410
  return {
3139
- present: bookPlanPresent(document.book) || document.volumes.length > 0 || document.chapters.length > 0,
3140
- total: document.chapters.length,
3411
+ present: bookPlanPresent(document.book) || document.volumes.length > 0 || total > 0,
3412
+ target,
3413
+ total,
3141
3414
  planned,
3142
3415
  archived,
3143
- coverage: archivedCount === 0 ? 0 : Math.min(1, archived / archivedCount),
3416
+ missingCards,
3417
+ plannedCoverage,
3418
+ archivedCoverage,
3419
+ coverage: plannedCoverage ?? (archivedDenominator === 0 ? 0 : cardCoverage),
3420
+ cardCoverage,
3144
3421
  volumes: document.volumes.length
3145
3422
  };
3146
3423
  }
@@ -3469,6 +3746,7 @@ function mergeWorldbook(existing, patch, now) {
3469
3746
  characters: [...existing.characters],
3470
3747
  glossary: [...existing.glossary],
3471
3748
  backstory: [...existing.backstory],
3749
+ facts: [...existing.facts ?? []],
3472
3750
  themes: [...existing.themes]
3473
3751
  };
3474
3752
  if (patch.premise !== void 0) result.premise = optionalText(patch.premise, "premise", LIMITS.maxWorldbookTextChars);
@@ -3534,6 +3812,26 @@ function mergeWorldbook(existing, patch, now) {
3534
3812
  time: text$1(raw.time, `backstory[${index}].time`, 120),
3535
3813
  event: text$1(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
3536
3814
  }));
3815
+ if (patch.facts !== void 0) {
3816
+ result.facts = records(patch.facts, "facts").map((raw, index) => {
3817
+ const value = raw.value;
3818
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].value must be a finite number`);
3819
+ const tolerance = raw.tolerance ?? 0;
3820
+ if (typeof tolerance !== "number" || !Number.isFinite(tolerance) || tolerance < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].tolerance must be a non-negative number`);
3821
+ return {
3822
+ alias: text$1(raw.alias, `facts[${index}].alias`, 120),
3823
+ value,
3824
+ unit: text$1(raw.unit, `facts[${index}].unit`, 40),
3825
+ tolerance,
3826
+ ...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
3827
+ };
3828
+ });
3829
+ const seenFacts = /* @__PURE__ */ new Set();
3830
+ for (const fact of result.facts) {
3831
+ if (seenFacts.has(fact.alias)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts contains duplicate "${fact.alias}"`);
3832
+ seenFacts.add(fact.alias);
3833
+ }
3834
+ }
3537
3835
  return result;
3538
3836
  }
3539
3837
  /** Validate a worldbook loaded from disk. */
@@ -3552,6 +3850,7 @@ function validateWorldbookDocument(value, now) {
3552
3850
  "characters",
3553
3851
  "glossary",
3554
3852
  "backstory",
3853
+ "facts",
3555
3854
  "themes"
3556
3855
  ]);
3557
3856
  for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml has unknown key "${key}"`);
@@ -3672,14 +3971,16 @@ function outlineDrift(document, records) {
3672
3971
  }
3673
3972
  /** Counts for status/panel/report; no IO. */
3674
3973
  function worldbookCounts(document) {
3974
+ const facts = document.facts?.length ?? 0;
3675
3975
  return {
3676
- present: document.premise !== "" || document.setting !== "" || document.themes.length > 0 || document.characters.length > 0 || document.locations.length > 0 || document.factions.length > 0 || document.rules.length > 0 || document.glossary.length > 0 || document.backstory.length > 0,
3976
+ present: document.premise !== "" || document.setting !== "" || document.themes.length > 0 || document.characters.length > 0 || document.locations.length > 0 || document.factions.length > 0 || document.rules.length > 0 || document.glossary.length > 0 || document.backstory.length > 0 || facts > 0,
3677
3977
  characters: document.characters.length,
3678
3978
  locations: document.locations.length,
3679
3979
  factions: document.factions.length,
3680
3980
  rules: document.rules.length,
3681
3981
  glossary: document.glossary.length,
3682
- backstory: document.backstory.length
3982
+ backstory: document.backstory.length,
3983
+ facts
3683
3984
  };
3684
3985
  }
3685
3986
  const pick = (values, mentioned, key, limit) => {
@@ -3728,6 +4029,13 @@ function worldbookBrief(document, mentioned, names) {
3728
4029
  backstory: document.backstory.slice(0, 3).map((entry) => ({
3729
4030
  time: entry.time,
3730
4031
  event: clip(entry.event, 160)
4032
+ })),
4033
+ facts: (document.facts ?? []).slice(0, 20).map((fact) => ({
4034
+ alias: clip(fact.alias, 120),
4035
+ value: fact.value,
4036
+ unit: clip(fact.unit, 40),
4037
+ tolerance: fact.tolerance ?? 0,
4038
+ ...fact.note === void 0 ? {} : { note: clip(fact.note, 160) }
3731
4039
  }))
3732
4040
  };
3733
4041
  }
@@ -4095,14 +4403,18 @@ function buildBookReport(input) {
4095
4403
  role: character.role,
4096
4404
  appearances: appearances.get(character.name) ?? 0,
4097
4405
  last_seen: Math.max(character.updated_at, lastAppearance.get(character.name) ?? 0)
4098
- })).sort((left, right) => right.appearances - left.appearances || right.last_seen - left.last_seen || left.name.localeCompare(right.name, "zh-CN")).slice(0, 10);
4406
+ })).sort((left, right) => right.appearances - left.appearances || right.last_seen - left.last_seen || compareText(left.name, right.name)).slice(0, 10);
4099
4407
  const latestChapter = records.length === 0 ? 0 : records[records.length - 1].num;
4100
4408
  const open = input.foreshadows.items.filter((item) => item.status !== "resolved");
4101
4409
  const dormant = open.filter((item) => item.status === "dormant");
4102
4410
  const overdue = open.filter((item) => latestChapter - item.last_touched >= input.foreshadowDormantAfter);
4103
4411
  const relationshipTypes = {};
4104
4412
  for (const relationship of input.relationships.items) relationshipTypes[relationship.type] = (relationshipTypes[relationship.type] ?? 0) + 1;
4105
- const outline = outlineSummary(input.outline, records.length);
4413
+ const outline = outlineSummary(input.outline, records.length, input.targetChapters, records.map((record) => record.num));
4414
+ const averageWords = records.length === 0 ? 0 : Math.round(words / records.length);
4415
+ const remainingChapters = input.targetChapters === null ? null : Math.max(0, input.targetChapters - records.length);
4416
+ const minimumRemaining = remainingChapters === null ? null : remainingChapters * input.project.targets.words_per_chapter[0];
4417
+ const averageRemaining = remainingChapters === null ? null : remainingChapters * (records.length === 0 ? input.project.targets.words_per_chapter[0] : averageWords);
4106
4418
  const drift = outlineDrift(input.outline, records);
4107
4419
  const volumeCounts = input.outline.volumes.map((volume) => {
4108
4420
  const cards = input.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
@@ -4128,16 +4440,25 @@ function buildBookReport(input) {
4128
4440
  archived: records.length,
4129
4441
  target: input.targetChapters,
4130
4442
  words,
4131
- average_words: records.length === 0 ? 0 : Math.round(words / records.length),
4132
- percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters * 100)),
4133
- remaining_words: input.targetChapters === null ? null : Math.max(0, (input.targetChapters - records.length) * input.project.targets.words_per_chapter[0])
4443
+ average_words: averageWords,
4444
+ percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters * 1e3) / 10),
4445
+ remaining_words: averageRemaining,
4446
+ remaining_words_min: minimumRemaining,
4447
+ remaining_words_average: averageRemaining,
4448
+ remaining_words_basis: input.targetChapters === null ? "none" : records.length === 0 ? "minimum_target" : "actual_average"
4134
4449
  },
4135
4450
  outline: {
4136
4451
  present: outline.present,
4137
4452
  strict: input.outline.book.strict,
4453
+ target: outline.target,
4454
+ total: outline.total,
4138
4455
  planned: outline.planned,
4139
4456
  archived: outline.archived,
4457
+ missing_cards: outline.missingCards,
4458
+ planned_coverage: outline.plannedCoverage,
4459
+ archived_coverage: outline.archivedCoverage,
4140
4460
  coverage: outline.coverage,
4461
+ card_coverage: outline.cardCoverage,
4141
4462
  volumes: volumeCounts,
4142
4463
  drift
4143
4464
  },
@@ -4153,7 +4474,22 @@ function buildBookReport(input) {
4153
4474
  id,
4154
4475
  average: Math.round(value.total / value.samples * 10) / 10,
4155
4476
  samples: value.samples
4156
- })).sort((left, right) => left.id.localeCompare(right.id, "zh-CN"))
4477
+ })).sort((left, right) => compareText(left.id, right.id))
4478
+ },
4479
+ effort: {
4480
+ reviews: records.reduce((total, record) => total + (record.rounds?.review ?? 0), 0),
4481
+ revisions: records.reduce((total, record) => total + (record.rounds?.revise ?? 0), 0),
4482
+ checks: records.reduce((total, record) => total + (record.rounds?.check ?? 0), 0),
4483
+ rollbacks: records.reduce((total, record) => total + (record.rounds?.rollbacks ?? 0), 0),
4484
+ chapters: records.map((record) => ({
4485
+ num: record.num,
4486
+ reviews: record.rounds?.review ?? 0,
4487
+ revisions: record.rounds?.revise ?? 0,
4488
+ checks: record.rounds?.check ?? 0,
4489
+ rollbacks: record.rounds?.rollbacks ?? 0,
4490
+ final_check: record.final_check ?? null,
4491
+ archived_at: record.archived_at ?? null
4492
+ }))
4157
4493
  },
4158
4494
  pacing: {
4159
4495
  scene_types: sceneTypes,
@@ -4171,7 +4507,7 @@ function buildBookReport(input) {
4171
4507
  dormant: dormant.length,
4172
4508
  overdue: overdue.length,
4173
4509
  resolved: input.foreshadows.items.length - open.length,
4174
- oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || left.id.localeCompare(right.id)).slice(0, 5).map((item) => ({
4510
+ oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || compareText(left.id, right.id)).slice(0, 5).map((item) => ({
4175
4511
  id: item.id,
4176
4512
  title: item.title,
4177
4513
  last_touched: item.last_touched,
@@ -4455,6 +4791,10 @@ function searchSnapshot(input) {
4455
4791
  ...input.worldbook.backstory.map((entry) => ({
4456
4792
  title: entry.time,
4457
4793
  source: `${entry.time}\n${entry.event}`
4794
+ })),
4795
+ ...(input.worldbook.facts ?? []).map((fact) => ({
4796
+ title: fact.alias,
4797
+ source: `${fact.alias}\n${fact.value} ${fact.unit}${fact.tolerance === void 0 ? "" : ` ±${fact.tolerance}`}\n${fact.note ?? ""}`
4458
4798
  }))
4459
4799
  ];
4460
4800
  for (const entry of entries) {
@@ -4529,6 +4869,58 @@ function searchSnapshot(input) {
4529
4869
  results
4530
4870
  };
4531
4871
  }
4872
+ // src/engine/history.ts
4873
+ const PATTERN = /^ch(?<chapter>\d{3,})\.r(?<revision>\d+)\.(?<suffix>review\.yaml|check\.yaml|chapter\.yaml|md)$/;
4874
+ const kindOf = (directory, suffix) => {
4875
+ if (directory === "reviews") return suffix === "review.yaml";
4876
+ if (directory === "checks") return suffix === "check.yaml";
4877
+ if (directory === "plans") return suffix === "chapter.yaml";
4878
+ return suffix === "md";
4879
+ };
4880
+ /**
4881
+ * List prior versions, newest revision first. Files beyond the visible cap are
4882
+ * still on disk: history is evidence, not a cache, and this function never
4883
+ * deletes it.
4884
+ */
4885
+ async function listHistory(store, chapter) {
4886
+ const entries = [];
4887
+ for (const kind of HISTORY_KINDS) {
4888
+ const names = await store.list(joinRel(HISTORY_DIR, kind));
4889
+ for (const name of names) {
4890
+ const match = PATTERN.exec(name);
4891
+ if (match === null) continue;
4892
+ const suffix = match.groups.suffix;
4893
+ if (!kindOf(kind, suffix)) continue;
4894
+ const num = Number(match.groups.chapter);
4895
+ const revision = Number(match.groups.revision);
4896
+ if (chapter !== void 0 && num !== chapter) continue;
4897
+ const path = joinRel(HISTORY_DIR, kind, name);
4898
+ const text = await store.readText(path);
4899
+ entries.push({
4900
+ kind,
4901
+ chapter: num,
4902
+ revision,
4903
+ path,
4904
+ chars: text?.length ?? 0
4905
+ });
4906
+ }
4907
+ }
4908
+ return entries.sort((left, right) => left.chapter - right.chapter || right.revision - left.revision || compareText(left.kind, right.kind));
4909
+ }
4910
+ /** Read every prior version of one kind for a chapter, oldest first. */
4911
+ async function historyTexts(store, kind, chapter, entries) {
4912
+ const entriesForChapter = (entries ?? await listHistory(store, chapter)).filter((entry) => entry.kind === kind && entry.chapter === chapter);
4913
+ const result = [];
4914
+ for (const entry of entriesForChapter) {
4915
+ const text = await store.readText(entry.path);
4916
+ if (text !== null) result.push({
4917
+ path: entry.path,
4918
+ revision: entry.revision,
4919
+ text
4920
+ });
4921
+ }
4922
+ return result.sort((left, right) => left.revision - right.revision);
4923
+ }
4532
4924
  // src/engine/brief.ts
4533
4925
  const TITLES = {
4534
4926
  P1: "P1 世界观约束",
@@ -5285,8 +5677,12 @@ const requireReason = (value, where) => {
5285
5677
  if (value.action !== "no_change") return;
5286
5678
  if (typeof value.reason !== "string" || value.reason.trim() === "") fail$1(`${where} is no_change but has no reason`);
5287
5679
  };
5680
+ /** A one-character anchor is indistinguishable from a substring accident. */
5681
+ const ANCHOR_MIN_CHARS = 8;
5682
+ const anchorIsStrong = (value) => typeof value === "string" && Array.from(value.trim()).length >= ANCHOR_MIN_CHARS;
5288
5683
  const requireForeshadowFields = (value, where) => {
5289
5684
  if (value.title !== void 0 && typeof value.title !== "string") fail$1(`${where}.title must be a string`);
5685
+ if (value.anchor !== void 0 && !anchorIsStrong(value.anchor)) fail$1(`${where}.anchor must be a verbatim quote of at least ${ANCHOR_MIN_CHARS} characters`);
5290
5686
  if (value.description !== void 0 && typeof value.description !== "string") fail$1(`${where}.description must be a string`);
5291
5687
  if (value.note !== void 0 && typeof value.note !== "string") fail$1(`${where}.note must be a string`);
5292
5688
  if (value.new_info !== void 0 && typeof value.new_info !== "boolean") fail$1(`${where}.new_info must be a boolean`);
@@ -5297,6 +5693,32 @@ const appearanceOf = (text, name, aliases = []) => {
5297
5693
  return Array.from(source).slice(0, 120).join("");
5298
5694
  };
5299
5695
  const named = (value) => isRecord(value) && typeof value.name === "string";
5696
+ const ROLE_ALIASES = {
5697
+ 主角: "protagonist",
5698
+ 男主角: "protagonist",
5699
+ 女主: "protagonist",
5700
+ 女主角: "protagonist",
5701
+ 男主: "protagonist",
5702
+ 主人公: "protagonist",
5703
+ protagonist: "protagonist",
5704
+ 配角: "supporting",
5705
+ 重要配角: "supporting",
5706
+ 男二: "supporting",
5707
+ 女二: "supporting",
5708
+ supporting: "supporting",
5709
+ 反派: "antagonist",
5710
+ antagonist: "antagonist",
5711
+ 龙套: "extra",
5712
+ 路人: "extra",
5713
+ extra: "extra"
5714
+ };
5715
+ /** Keep one canonical role vocabulary instead of letting free text reach reports. */
5716
+ const normalizeRole = (value, fallback) => {
5717
+ const canonicalFallback = ROLE_ALIASES[fallback] ?? "extra";
5718
+ const trimmed = value?.trim() ?? "";
5719
+ if (trimmed === "") return canonicalFallback;
5720
+ return ROLE_ALIASES[trimmed] ?? canonicalFallback;
5721
+ };
5300
5722
  /**
5301
5723
  * Apply the archive reconciliation sheet. Every ledger entity the summary
5302
5724
  * mentions must be declared; unknown keys are refused instead of being
@@ -5358,9 +5780,10 @@ function applyStateDelta(input) {
5358
5780
  if (declared.action === "created") {
5359
5781
  if (existing !== void 0) fail$1(`character "${name}" already exists; use updated or no_change`);
5360
5782
  if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
5783
+ const hinted = normalizeRole(input.roleHints?.[name], name === narratorName || name === narrator ? "protagonist" : "extra");
5361
5784
  const created = {
5362
5785
  name,
5363
- role: declared.role ?? "extra",
5786
+ role: normalizeRole(declared.role, hinted),
5364
5787
  first_appearance: chapter,
5365
5788
  profile: declared.profile ?? {},
5366
5789
  current_state: declared.current_state ?? {},
@@ -5373,7 +5796,7 @@ function applyStateDelta(input) {
5373
5796
  }
5374
5797
  if (existing === void 0) fail$1(`character "${name}" does not exist; created must be used first`);
5375
5798
  if (declared.action === "updated") {
5376
- if (declared.role !== void 0) existing.role = declared.role;
5799
+ if (declared.role !== void 0) existing.role = normalizeRole(declared.role, existing.role);
5377
5800
  if (declared.profile !== void 0) existing.profile = {
5378
5801
  ...existing.profile,
5379
5802
  ...declared.profile
@@ -5542,11 +5965,15 @@ function applyStateDelta(input) {
5542
5965
  requireForeshadowFields(declared, `foreshadows.${plant.key}`);
5543
5966
  if (declared.action !== "created") fail$1(`foreshadows.${plant.key} is planted; its action must be created`);
5544
5967
  if (typeof plant.title !== "string" || plant.title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty summary title`);
5545
- if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" does not appear in the manuscript`);
5968
+ if (plant.anchor !== void 0 && !anchorIsStrong(plant.anchor)) fail$1(`foreshadow "${plant.key}" anchor must be a verbatim quote of at least ${ANCHOR_MIN_CHARS} characters`);
5546
5969
  if (declared.title !== void 0 && typeof declared.title !== "string") fail$1(`foreshadows.${plant.key}.title must be a string`);
5547
5970
  const title = declared.title ?? plant.title;
5548
5971
  if (title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty title`);
5549
- if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" does not appear in the manuscript`);
5972
+ const anchor = declared.anchor ?? plant.anchor;
5973
+ if (anchor === void 0) {
5974
+ if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" has no anchor and does not appear verbatim in the manuscript`);
5975
+ if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" has no anchor and does not appear verbatim in the manuscript; add a verbatim anchor or keep the title as text`);
5976
+ } else if (!input.text.includes(anchor)) fail$1(`planted foreshadow "${title}" anchor "${anchor}" does not appear verbatim in the manuscript`);
5550
5977
  const allocated = allocateIds("FS", foreshadows.next_seq, 1);
5551
5978
  const id = allocated.ids[0];
5552
5979
  if (byId.has(id)) fail$1(`foreshadows.next_seq is behind the ledger; ${id} already exists`);
@@ -5559,6 +5986,7 @@ function applyStateDelta(input) {
5559
5986
  status: "planted",
5560
5987
  resolved_at: null,
5561
5988
  description: declared.description ?? plant.description,
5989
+ ...anchor === void 0 ? {} : { anchor },
5562
5990
  advance_log: []
5563
5991
  };
5564
5992
  foreshadows.items.push(item);
@@ -5884,7 +6312,7 @@ const wordCount = (input) => {
5884
6312
  if (min <= 0) return [];
5885
6313
  const metrics = countWordMetrics(input.text);
5886
6314
  if (metrics.total_word_count >= min) return [];
5887
- return [block("PG-03", `text has ${metrics.total_word_count} words, minimum is ${min}`, `write at least ${min - metrics.total_word_count} more words, then call novel_word_count to verify`)];
6315
+ return [block("PG-03", `text has ${metrics.total_word_count} counted words (${metrics.cjk_chars} 汉字 + ${metrics.punctuation_chars} 标点 + ${metrics.english_words} 英文词 + ${metrics.numbers} 数字), minimum is ${min}`, `the engine counts punctuation as words; write at least ${min - metrics.total_word_count} more counted words, then call novel_word_count to verify the same metric`)];
5888
6316
  };
5889
6317
  /** PG-04: format blockers must be zero before length is judged. */
5890
6318
  const format = (input, options) => {
@@ -6332,7 +6760,7 @@ function projectChapter(record) {
6332
6760
  }
6333
6761
  function projectSummary(snapshot) {
6334
6762
  return {
6335
- schemaVersion: 2,
6763
+ schemaVersion: 3,
6336
6764
  panelId: PANEL_ID,
6337
6765
  title: snapshot.title,
6338
6766
  genre: snapshot.genre,
@@ -6375,14 +6803,14 @@ function projectChapters(snapshot) {
6375
6803
  details: draft.details
6376
6804
  });
6377
6805
  return {
6378
- schemaVersion: 2,
6806
+ schemaVersion: 3,
6379
6807
  panelId: PANEL_ID,
6380
6808
  rows
6381
6809
  };
6382
6810
  }
6383
6811
  function projectGates(snapshot) {
6384
6812
  return {
6385
- schemaVersion: 2,
6813
+ schemaVersion: 3,
6386
6814
  panelId: PANEL_ID,
6387
6815
  stage: snapshot.stage,
6388
6816
  blocking: snapshot.blocking,
@@ -6675,6 +7103,13 @@ var EngineService = class {
6675
7103
  path: PROJECT_FILE,
6676
7104
  text: yaml(project)
6677
7105
  },
7106
+ {
7107
+ path: BRIEF_RECEIPTS_FILE,
7108
+ text: yaml({
7109
+ schema_version: 1,
7110
+ receipts: []
7111
+ })
7112
+ },
6678
7113
  {
6679
7114
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
6680
7115
  text: yaml(pipeline)
@@ -6698,7 +7133,14 @@ var EngineService = class {
6698
7133
  title: project.title,
6699
7134
  chapter: 1,
6700
7135
  stage: "idle"
6701
- }
7136
+ },
7137
+ warnings: [...project.author.trim() === "" ? [{
7138
+ code: "PROJECT_AUTHOR_EMPTY",
7139
+ message: "作者为空;proposal 与导出会缺作者,可用 novel_amend 补 novel.yaml.author"
7140
+ }] : [], ...project.description.trim() === "" ? [{
7141
+ code: "PROJECT_DESCRIPTION_EMPTY",
7142
+ message: "简介为空;proposal 与导出会缺简介,可用 novel_amend 补 novel.yaml.description"
7143
+ }] : []]
6702
7144
  };
6703
7145
  },
6704
7146
  emptyAllowed: true
@@ -7023,6 +7465,7 @@ var EngineService = class {
7023
7465
  "characters",
7024
7466
  "glossary",
7025
7467
  "backstory",
7468
+ "facts",
7026
7469
  "themes"
7027
7470
  ]);
7028
7471
  const keys = Object.entries(input).filter(([key, value]) => key !== "root" && value !== void 0).map(([key]) => key);
@@ -7050,9 +7493,10 @@ var EngineService = class {
7050
7493
  locations: worldbook.locations.length,
7051
7494
  characters: worldbook.characters.length,
7052
7495
  glossary: worldbook.glossary.length,
7053
- backstory: worldbook.backstory.length
7496
+ backstory: worldbook.backstory.length,
7497
+ facts: (worldbook.facts ?? []).length
7054
7498
  };
7055
- const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory;
7499
+ const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts;
7056
7500
  const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_worldbook", this.now(), shortDigest(stableJson(input))) });
7057
7501
  return {
7058
7502
  files: [{
@@ -7092,6 +7536,7 @@ var EngineService = class {
7092
7536
  return this.mutate({
7093
7537
  root: input.root,
7094
7538
  op: "reopenChapter",
7539
+ chapter: input.chapter,
7095
7540
  reason,
7096
7541
  key: () => `reopen:${input.chapter}:${shortDigest(reason)}`,
7097
7542
  run: async (snapshot, context) => {
@@ -7116,7 +7561,7 @@ var EngineService = class {
7116
7561
  if (changedAfterArchive.length > 0) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `refusing to reopen: ${changedAfterArchive.join(", ")} changed after chapter ${input.chapter} was archived`, "move the amended files aside or restore the archived versions first; reopening would overwrite them silently");
7117
7562
  const files = [];
7118
7563
  for (const path of archive.files) {
7119
- if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml") continue;
7564
+ if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml" || path === planChapterFile(input.chapter)) continue;
7120
7565
  const hasBeforeEntry = beforeHashes !== void 0 && Object.hasOwn(beforeHashes, path);
7121
7566
  const expectedBefore = hasBeforeEntry ? beforeHashes[path] : void 0;
7122
7567
  if (hasBeforeEntry && expectedBefore === null) {
@@ -7180,6 +7625,15 @@ var EngineService = class {
7180
7625
  briefs: {},
7181
7626
  blocking: [],
7182
7627
  review_digest: void 0,
7628
+ draft: {
7629
+ chapter: input.chapter,
7630
+ title: stripChapterPrefix(plan.title),
7631
+ scene_type: plan.scene_type,
7632
+ narrator: plan.narrator,
7633
+ summary: record.summary,
7634
+ content_hash: record.content_hash,
7635
+ updated_at: reopenedAt
7636
+ },
7183
7637
  last_action: action("novel_reopen", this.now(), shortDigest(reason))
7184
7638
  });
7185
7639
  delete nextPipeline.review_digest;
@@ -7213,6 +7667,126 @@ var EngineService = class {
7213
7667
  });
7214
7668
  }
7215
7669
  /**
7670
+ * Restore one active chapter from a backup transaction. Archived chapters
7671
+ * are refused: their manuscript hash is bound by the index, so restoring
7672
+ * behind the index would corrupt the project. Reopen first, then restore.
7673
+ */
7674
+ async restoreBackup(input) {
7675
+ if (typeof input.txid !== "string" || !/^[0-9a-f-]{36}$/i.test(input.txid)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore txid must be a transaction UUID");
7676
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
7677
+ if (input.paths !== void 0 && (!Array.isArray(input.paths) || input.paths.length === 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore paths must be a non-empty array");
7678
+ return this.mutate({
7679
+ root: input.root,
7680
+ op: "restoreBackup",
7681
+ chapter: input.chapter,
7682
+ reason: `restore backup ${input.txid}`,
7683
+ run: async (snapshot, context) => {
7684
+ const manifest = await readBackupManifest(context.store, input.txid);
7685
+ if (manifest === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} has no readable manifest`, "list backups with novel_state {kind:\"backups\"}; only marked engine backups can be restored");
7686
+ const chapter = input.chapter ?? snapshot.pipeline.chapter;
7687
+ if (snapshot.chapters.chapters.some((record) => record.num === chapter)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is archived; restoring it behind the index would break its content hash`, "call novel_reopen for the chapter, then restore the backup");
7688
+ if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not the active chapter ${snapshot.pipeline.chapter}; restore only the active chapter`, "switch the active chapter first, or restore files from the active chapter");
7689
+ if (manifest.chapter !== chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} belongs to chapter ${manifest.chapter}, not chapter ${chapter}`, "choose a backup whose manifest chapter matches the active chapter");
7690
+ const fileFor = (path) => `backups/${input.txid}/${path}`;
7691
+ const allowed = [
7692
+ chapterFile(chapter),
7693
+ planChapterFile(chapter),
7694
+ planWorldFile(chapter),
7695
+ reviewFile(chapter),
7696
+ checkFile(chapter)
7697
+ ];
7698
+ const requested = input.paths ?? allowed.filter((path) => manifest.files.includes(path));
7699
+ for (const path of requested) if (!allowed.includes(path)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `restore path ${path} is not a chapter file for chapter ${chapter}`, `allowed: ${allowed.join(", ")}`);
7700
+ if (requested.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains no restorable file for chapter ${chapter}`, "choose a backup whose files include the chapter you are restoring");
7701
+ const files = [];
7702
+ let restoredReview = null;
7703
+ let restoredCheck = null;
7704
+ for (const path of requested) {
7705
+ if (!manifest.files.includes(path)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} manifest does not record ${path}`, "restore only files that the transaction captured as pre-images");
7706
+ const text = await context.store.readText(fileFor(path));
7707
+ if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${path}`, "the file was created by that transaction, so it has no pre-image to restore");
7708
+ const kind = path === planChapterFile(chapter) ? "chapterPlan" : path === planWorldFile(chapter) ? "worldPlan" : path === reviewFile(chapter) ? "review" : path === checkFile(chapter) ? "check" : null;
7709
+ if (kind !== null) try {
7710
+ const parsed = parse(text);
7711
+ const row = recordOf(parsed);
7712
+ if (row === null) throw new Error("document is not a mapping");
7713
+ if (kind === "chapterPlan") {
7714
+ validateStoredChapterPlan(row, path);
7715
+ if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
7716
+ } else if (kind === "worldPlan") {
7717
+ validateWorldPlanShape(narrow("worldPlan", parsed), path);
7718
+ if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
7719
+ } else {
7720
+ const restored = narrow(kind, parsed);
7721
+ if (restored.chapter !== chapter) throw new Error(`document belongs to chapter ${String(restored.chapter)}, not ${chapter}`);
7722
+ if (kind === "review") restoredReview = restored;
7723
+ if (kind === "check") restoredCheck = restored;
7724
+ }
7725
+ } catch (error) {
7726
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid ${kind} document: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
7727
+ }
7728
+ files.push({
7729
+ path,
7730
+ text
7731
+ });
7732
+ }
7733
+ const clearDraft = requested.includes(chapterFile(chapter)) || requested.includes(planChapterFile(chapter));
7734
+ const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
7735
+ if (clearDraft) delete nextPipeline.draft;
7736
+ if (restoredReview !== null) nextPipeline.review_digest = sha256(stableJson(restoredReview));
7737
+ if (restoredReview !== null || restoredCheck !== null) {
7738
+ const effectiveReview = restoredReview ?? snapshot.review;
7739
+ const effectiveCheck = restoredCheck ?? snapshot.check;
7740
+ const reviewBlocking = effectiveReview === null ? [] : [...effectiveReview.issues.filter((issue) => issue.severity === "blocker" && issue.status !== "fixed").map((issue) => ({
7741
+ code: `ISSUE-${issue.id}`,
7742
+ scope: issue.dimension,
7743
+ detail: `${issue.problem} → ${issue.fix}`,
7744
+ hint: issue.location
7745
+ })), ...effectiveReview.total < this.config.quality.minScore ? [{
7746
+ code: "SCORE-BELOW-MIN",
7747
+ scope: "quality",
7748
+ detail: `score ${effectiveReview.total} is below the minimum ${this.config.quality.minScore}`,
7749
+ hint: "revise the chapter and submit a new P4 review"
7750
+ }] : []];
7751
+ const checkMatches = effectiveCheck !== null && effectiveCheck.content_hash === sha256(snapshot.text ?? "");
7752
+ const checkBlocking = effectiveCheck === null || !checkMatches || effectiveCheck.verdict === "pass" ? [] : [...effectiveCheck.machine.violations.map((violation) => ({
7753
+ code: violation.code,
7754
+ scope: "check",
7755
+ detail: violation.detail
7756
+ })), ...evaluateSemantic(snapshot.text ?? "", effectiveCheck.semantic.items).blocking];
7757
+ nextPipeline.blocking = [...reviewBlocking, ...checkBlocking];
7758
+ }
7759
+ files.push({
7760
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
7761
+ text: yaml(nextPipeline)
7762
+ });
7763
+ return {
7764
+ files,
7765
+ backup: true,
7766
+ data: {
7767
+ txid: input.txid,
7768
+ chapter,
7769
+ restored: requested
7770
+ },
7771
+ warnings: clearDraft ? [{
7772
+ code: "RESTORE_DRAFT_CLEARED",
7773
+ message: "the restored manuscript/plan invalidated the live draft summary; call novel_rollback to P3, then submit novel_write_draft again"
7774
+ }] : [],
7775
+ event: {
7776
+ name: "novel/restored",
7777
+ payload: {
7778
+ schemaVersion: 1,
7779
+ projectRoot: snapshot.root,
7780
+ txid: input.txid,
7781
+ chapter,
7782
+ paths: requested
7783
+ }
7784
+ }
7785
+ };
7786
+ }
7787
+ });
7788
+ }
7789
+ /**
7216
7790
  * Read-only paths must surface a committed-journal mismatch. The write path
7217
7791
  * already fails closed; without this, status recommends the next write and
7218
7792
  * the model only learns about the mismatch when that write aborts.
@@ -7256,6 +7830,21 @@ var EngineService = class {
7256
7830
  }];
7257
7831
  }
7258
7832
  }
7833
+ /**
7834
+ * Count every committed attempt for one chapter, not just the loop since the
7835
+ * last rollback. `pipeline.rounds` is deliberately reset by rollback to keep
7836
+ * the retry budget fair; it is not the chapter's lifetime effort.
7837
+ */
7838
+ async lifetimeRounds(snapshot, chapter) {
7839
+ const { rows } = await readJournal(this.storeFor(snapshot.root));
7840
+ const committed = (op) => rows.filter((row) => row.status === "committed" && row.chapter === chapter && row.op === op).length;
7841
+ return {
7842
+ review: Math.max(snapshot.pipeline.rounds.review, committed("submitReview")),
7843
+ revise: Math.max(snapshot.pipeline.rounds.revise, committed("submitRevision")),
7844
+ check: Math.max(snapshot.pipeline.rounds.check, committed("runCheck")),
7845
+ rollbacks: committed("rollback")
7846
+ };
7847
+ }
7259
7848
  async getStatus(input = {}) {
7260
7849
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
7261
7850
  const snapshot = await this.load(input.root);
@@ -7316,6 +7905,7 @@ var EngineService = class {
7316
7905
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
7317
7906
  const snapshot = await this.load(input.root);
7318
7907
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
7908
+ if (input.revision !== void 0 && input.kind !== "history") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision is only meaningful for kind:\"history\"", "request history with chapter+revision, or omit revision for current documents");
7319
7909
  const store = this.storeFor(snapshot.root);
7320
7910
  const ledgerPath = LEDGER_PATHS[input.kind];
7321
7911
  if (ledgerPath !== void 0) {
@@ -7333,7 +7923,10 @@ var EngineService = class {
7333
7923
  }
7334
7924
  switch (input.kind) {
7335
7925
  case "pipeline": return {
7336
- data: snapshot.pipeline,
7926
+ data: {
7927
+ ...snapshot.pipeline,
7928
+ briefs: snapshot.briefs
7929
+ },
7337
7930
  revision: snapshot.pipeline.revision,
7338
7931
  warnings: []
7339
7932
  };
@@ -7428,6 +8021,66 @@ var EngineService = class {
7428
8021
  revision: snapshot.pipeline.revision,
7429
8022
  warnings: []
7430
8023
  };
8024
+ case "briefs": {
8025
+ const receipts = await this.readBriefReceipts(store);
8026
+ if (receipts.receipts.length > 0) return {
8027
+ data: receipts,
8028
+ revision: snapshot.pipeline.revision,
8029
+ warnings: []
8030
+ };
8031
+ return {
8032
+ data: {
8033
+ schema_version: 1,
8034
+ receipts: Object.entries(snapshot.briefs).map(([stage, receipt]) => ({
8035
+ chapter: snapshot.pipeline.chapter,
8036
+ stage,
8037
+ digest: receipt.digest,
8038
+ issued_at: receipt.issued_at
8039
+ }))
8040
+ },
8041
+ revision: snapshot.pipeline.revision,
8042
+ warnings: []
8043
+ };
8044
+ }
8045
+ case "history": {
8046
+ const entries = await listHistory(store, input.chapter);
8047
+ if (input.revision !== void 0) {
8048
+ if (!Number.isInteger(input.revision) || input.revision < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "history revision must be a positive integer");
8049
+ const matching = entries.filter((entry) => entry.chapter === chapter && entry.revision === input.revision);
8050
+ const contents = [];
8051
+ for (const entry of matching) contents.push({
8052
+ ...entry,
8053
+ text: await store.readText(entry.path)
8054
+ });
8055
+ return {
8056
+ data: {
8057
+ schema_version: 1,
8058
+ chapter,
8059
+ revision: input.revision,
8060
+ files: contents
8061
+ },
8062
+ revision: snapshot.pipeline.revision,
8063
+ warnings: []
8064
+ };
8065
+ }
8066
+ return {
8067
+ data: {
8068
+ schema_version: 1,
8069
+ entries,
8070
+ total: entries.length
8071
+ },
8072
+ revision: snapshot.pipeline.revision,
8073
+ warnings: []
8074
+ };
8075
+ }
8076
+ case "backups": return {
8077
+ data: {
8078
+ schema_version: 1,
8079
+ backups: await listBackups(store)
8080
+ },
8081
+ revision: snapshot.pipeline.revision,
8082
+ warnings: []
8083
+ };
7431
8084
  case "review":
7432
8085
  if (chapter === snapshot.pipeline.chapter && snapshot.reviewError !== null) return {
7433
8086
  data: {
@@ -7774,13 +8427,14 @@ var EngineService = class {
7774
8427
  const archivedCards = new Set(snapshot.outline.chapters.filter((card) => card.status === "archived").map((card) => card.num));
7775
8428
  const missingCards = archivedRecords.filter((record) => !archivedCards.has(record.num));
7776
8429
  const extraCards = [...archivedCards].filter((num) => !archivedRecords.some((record) => record.num === num));
7777
- const coverage = archivedRecords.length === 0 ? 0 : (archivedRecords.length - missingCards.length) / archivedRecords.length;
8430
+ const outlineProgress = outlineSummary(snapshot.outline, archivedRecords.length, snapshot.project.targets.total_chapters, archivedRecords.map((record) => record.num));
8431
+ const percentText = (value) => value === null ? "—" : `${Math.round(value * 1e3) / 10}%`;
7778
8432
  add({
7779
8433
  id: "outline-coverage",
7780
- severity: missingCards.length === 0 && extraCards.length === 0 ? "ok" : "warning",
8434
+ severity: missingCards.length === 0 && extraCards.length === 0 && (outlineProgress.target === null || outlineProgress.missingCards === 0) ? "ok" : "warning",
7781
8435
  title: "大纲覆盖率",
7782
- detail: archivedRecords.length === 0 ? "0%(暂无已归档章节)" : `${Math.round(coverage * 100)}% · ${missingCards.length} 章缺卡 · ${extraCards.length} 张卡无正文`,
7783
- ...missingCards.length === 0 && extraCards.length === 0 ? {} : { hint: `missing cards: ${missingCards.map((record) => record.num).join(", ") || "none"}; extra cards: ${extraCards.join(", ") || "none"}` }
8436
+ detail: `规划覆盖 ${percentText(outlineProgress.plannedCoverage)}(${outlineProgress.total}/${outlineProgress.target ?? "不限"} 张卡)· 归档覆盖 ${percentText(outlineProgress.archivedCoverage)}(${archivedRecords.length}/${outlineProgress.target ?? "不限"} 章)· ${outlineProgress.missingCards} 章缺卡 · ${missingCards.length} 章有正文无卡 · ${extraCards.length} 张卡无正文`,
8437
+ ...missingCards.length === 0 && extraCards.length === 0 && (outlineProgress.target === null || outlineProgress.missingCards === 0) ? {} : { hint: `缺卡章节数 ${outlineProgress.missingCards}; archived chapters without a card: ${missingCards.map((record) => record.num).join(", ") || "none"}; cards without a manuscript: ${extraCards.join(", ") || "none"}` }
7784
8438
  });
7785
8439
  const stageMissing = [];
7786
8440
  switch (snapshot.pipeline.stage) {
@@ -7815,11 +8469,20 @@ var EngineService = class {
7815
8469
  detail: stageMissing.length === 0 ? `${snapshot.pipeline.stage} 阶段所需产物齐全` : `缺少:${stageMissing.join("、")}`,
7816
8470
  ...stageMissing.length === 0 ? {} : { hint: "use novel_brief/novel_status to rebuild the missing stage product" }
7817
8471
  });
8472
+ const restoreCandidates = /* @__PURE__ */ new Set([
8473
+ chapterFile(snapshot.pipeline.chapter),
8474
+ planChapterFile(snapshot.pipeline.chapter),
8475
+ planWorldFile(snapshot.pipeline.chapter),
8476
+ reviewFile(snapshot.pipeline.chapter),
8477
+ checkFile(snapshot.pipeline.chapter)
8478
+ ]);
8479
+ const activeRestorable = backups.find((entry) => entry.op !== null && entry.chapter === snapshot.pipeline.chapter && entry.files.some((path) => restoreCandidates.has(path)));
7818
8480
  add({
7819
8481
  id: "backups",
7820
8482
  severity: "info",
7821
8483
  title: "备份",
7822
- detail: `${backups.length} backup snapshot(s)`
8484
+ detail: backups.length === 0 ? "0 个备份快照" : `${backups.length} 个备份快照;最新:${backups[0].op ?? "旧版备份"}${backups[0].chapter === null ? "" : ` · 第 ${backups[0].chapter} 章`} · ${backups[0].files.length} 个文件`,
8485
+ ...backups.length === 0 ? {} : activeRestorable !== void 0 ? { hint: `用 novel_state {kind:"backups"} 列出;用 novel_restore {txid:"${activeRestorable.name}"} 恢复活动章` } : backups[0].op === null || backups[0].files.length === 0 ? { hint: "最新备份没有 manifest 或没有可恢复的 pre-image,只能列出;它不是 novel_restore 的候选" } : { hint: "最新备份不属于活动章或没有本章可恢复的 pre-image;用 novel_state {kind:\"backups\"} 列出,归档章先 novel_reopen" }
7823
8486
  });
7824
8487
  const locked = await store.exists(LOCK_FILE);
7825
8488
  add({
@@ -7875,13 +8538,25 @@ var EngineService = class {
7875
8538
  const store = this.storeFor(snapshot.root);
7876
8539
  const reviews = [];
7877
8540
  const reviewIssues = [];
8541
+ const historicalEntries = await listHistory(store);
7878
8542
  for (const record of snapshot.chapters.chapters) try {
7879
8543
  const review = await store.readDoc(reviewFile(record.num), "review");
7880
8544
  if (review === null) reviewIssues.push(`chapter ${record.num}: review is missing`);
7881
8545
  else reviews.push(review);
8546
+ for (const historical of await historyTexts(store, "reviews", record.num, historicalEntries)) try {
8547
+ const parsed = narrow("review", parse(historical.text));
8548
+ reviews.push(parsed);
8549
+ } catch (error) {
8550
+ reviewIssues.push(`chapter ${record.num} ${historical.path}: ${errorMessage(error)}`);
8551
+ }
7882
8552
  } catch (error) {
7883
8553
  reviewIssues.push(`chapter ${record.num}: ${errorMessage(error)}`);
7884
8554
  }
8555
+ const uniqueReviews = /* @__PURE__ */ new Map();
8556
+ for (const review of reviews) {
8557
+ const key = stableJson(review);
8558
+ if (!uniqueReviews.has(key)) uniqueReviews.set(key, review);
8559
+ }
7885
8560
  const journal = await readJournal(store);
7886
8561
  const report = buildBookReport({
7887
8562
  project: snapshot.project,
@@ -7896,7 +8571,7 @@ var EngineService = class {
7896
8571
  timeline: snapshot.timeline,
7897
8572
  foreshadows: snapshot.foreshadows,
7898
8573
  relationships: snapshot.relationships,
7899
- reviews,
8574
+ reviews: [...uniqueReviews.values()],
7900
8575
  activity: journal.rows.slice(-8),
7901
8576
  targetChapters: snapshot.project.targets.total_chapters,
7902
8577
  minScore: this.config.quality.minScore,
@@ -8007,6 +8682,8 @@ var EngineService = class {
8007
8682
  return this.mutate({
8008
8683
  root: input.root,
8009
8684
  op: "getBrief",
8685
+ chapter: input.chapter,
8686
+ advanceRevision: false,
8010
8687
  run: async (snapshot, context) => {
8011
8688
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
8012
8689
  if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `brief ${input.stage} belongs to active chapter ${snapshot.pipeline.chapter}, not ${chapter}`, `omit chapter or pass ${snapshot.pipeline.chapter}`);
@@ -8019,6 +8696,20 @@ var EngineService = class {
8019
8696
  chapter,
8020
8697
  digest,
8021
8698
  min_words: snapshot.project.targets.words_per_chapter[0],
8699
+ max_words: snapshot.project.targets.words_per_chapter[1],
8700
+ word_count_policy: {
8701
+ metric: "total_word_count",
8702
+ includes_punctuation: true,
8703
+ formula: "cjk_chars + english_words + numbers + punctuation_chars",
8704
+ note: "引擎与商业平台一致把标点计入字数;brief/status/check 的 min_words 都是这个口径"
8705
+ },
8706
+ dialogue_ratio_target: snapshot.project.targets.dialogue_ratio,
8707
+ dialogue_ratio_metric: "含引号行数 / 非空行数",
8708
+ delta_rules: {
8709
+ title_is_a_label: true,
8710
+ anchor_required_for_verification: true,
8711
+ rule: "伏笔 title 可以写成可读短语;要逐字校验请在 anchor 里放正文原句(至少 8 个字符)。缺 anchor 时 title 必须逐字出现在正文。"
8712
+ },
8022
8713
  min_score: this.config.quality.minScore,
8023
8714
  scene_types: [
8024
8715
  "战斗/动作章",
@@ -8051,26 +8742,28 @@ var EngineService = class {
8051
8742
  fields,
8052
8743
  context: contextPack
8053
8744
  };
8054
- const existing = snapshot.pipeline.briefs[input.stage];
8745
+ const existing = snapshot.briefs[input.stage];
8055
8746
  if (existing !== void 0 && existing.digest === digest) return {
8056
8747
  files: [],
8057
8748
  data,
8058
8749
  replay: true
8059
8750
  };
8060
- const nextPipeline = pipelineWith(snapshot, context.revision, {
8061
- briefs: {
8062
- ...snapshot.pipeline.briefs,
8063
- [input.stage]: {
8064
- issued_at: this.now().toISOString(),
8065
- digest
8066
- }
8067
- },
8068
- last_action: action("novel_brief", this.now(), digest)
8751
+ const issuedAt = this.now().toISOString();
8752
+ const receiptDocument = await this.readBriefReceipts(context.store);
8753
+ receiptDocument.receipts.push({
8754
+ chapter,
8755
+ stage: input.stage,
8756
+ digest,
8757
+ issued_at: issuedAt
8069
8758
  });
8759
+ const receipts = receiptDocument.receipts;
8070
8760
  return {
8071
8761
  files: [{
8072
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8073
- text: yaml(nextPipeline)
8762
+ path: BRIEF_RECEIPTS_FILE,
8763
+ text: yaml({
8764
+ schema_version: 1,
8765
+ receipts
8766
+ })
8074
8767
  }],
8075
8768
  backup: false,
8076
8769
  data
@@ -8139,6 +8832,7 @@ var EngineService = class {
8139
8832
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8140
8833
  text: yaml(nextPipeline)
8141
8834
  }],
8835
+ backup: snapshot.worldPlan !== null,
8142
8836
  data: {
8143
8837
  chapter,
8144
8838
  stage: "P2",
@@ -8193,13 +8887,21 @@ var EngineService = class {
8193
8887
  last_action: action("novel_plan_chapter", this.now(), shortDigest(stableJson(plan)))
8194
8888
  });
8195
8889
  return {
8196
- files: [{
8197
- path,
8198
- text: yaml(plan)
8199
- }, {
8200
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8201
- text: yaml(nextPipeline)
8202
- }],
8890
+ files: [
8891
+ ...snapshot.plan === null ? [] : [{
8892
+ path: planHistoryFile(chapter, snapshot.pipeline.revision),
8893
+ text: yaml(snapshot.plan)
8894
+ }],
8895
+ {
8896
+ path,
8897
+ text: yaml(plan)
8898
+ },
8899
+ {
8900
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8901
+ text: yaml(nextPipeline)
8902
+ }
8903
+ ],
8904
+ backup: snapshot.plan !== null,
8203
8905
  data: {
8204
8906
  chapter,
8205
8907
  stage: "P3",
@@ -8217,6 +8919,7 @@ var EngineService = class {
8217
8919
  return this.mutate({
8218
8920
  root: input.root,
8219
8921
  op: "submitDraft",
8922
+ chapter: input.chapter,
8220
8923
  key: () => `draft:${input.chapter}:${sha256(stableJson({
8221
8924
  content: input.content,
8222
8925
  chapter_title: input.chapter_title,
@@ -8242,31 +8945,32 @@ var EngineService = class {
8242
8945
  summary: input.summary
8243
8946
  });
8244
8947
  const chapter = input.chapter;
8245
- const plan = snapshot.plan;
8246
- if (plan === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no usable plan`, "rollback to P2 and submit novel_plan_chapter; the draft was not written");
8247
- const mergedPlan = {
8248
- ...plan,
8948
+ if (snapshot.plan === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no usable plan`, "rollback to P2 and submit novel_plan_chapter; the draft was not written");
8949
+ const draft = {
8950
+ chapter,
8249
8951
  title: input.chapter_title,
8250
8952
  scene_type: input.scene_type,
8251
8953
  narrator: input.narrator,
8252
- summary: input.summary
8954
+ summary: input.summary,
8955
+ content_hash: sha256(input.content),
8956
+ updated_at: this.now().toISOString()
8253
8957
  };
8254
- const planPath = planChapterFile(chapter);
8255
8958
  const nextPipeline = pipelineWith(snapshot, context.revision, {
8256
8959
  stage: "P4",
8960
+ draft,
8257
8961
  blocking: [],
8258
8962
  last_action: action("novel_write_draft", this.now(), sha256(input.content))
8259
8963
  });
8260
8964
  return {
8261
8965
  files: [
8966
+ ...snapshot.text === null ? [] : [{
8967
+ path: chapterHistoryFile(chapter, snapshot.pipeline.revision),
8968
+ text: snapshot.text
8969
+ }],
8262
8970
  {
8263
8971
  path: chapterFile(chapter),
8264
8972
  text: input.content
8265
8973
  },
8266
- {
8267
- path: planPath,
8268
- text: yaml(mergedPlan)
8269
- },
8270
8974
  {
8271
8975
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8272
8976
  text: yaml(nextPipeline)
@@ -8287,6 +8991,7 @@ var EngineService = class {
8287
8991
  return this.mutate({
8288
8992
  root: input.root,
8289
8993
  op: "submitReview",
8994
+ chapter: input.chapter,
8290
8995
  key: (snapshot) => `review:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
8291
8996
  dimensions: input.dimensions,
8292
8997
  issues: input.issues,
@@ -8368,13 +9073,21 @@ var EngineService = class {
8368
9073
  last_action: action("novel_review", this.now(), reviewDigest)
8369
9074
  });
8370
9075
  return {
8371
- files: [{
8372
- path: reviewFile(input.chapter),
8373
- text: yaml(review)
8374
- }, {
8375
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8376
- text: yaml(nextPipeline)
8377
- }],
9076
+ files: [
9077
+ ...snapshot.review === null ? [] : [{
9078
+ path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
9079
+ text: yaml(snapshot.review)
9080
+ }],
9081
+ {
9082
+ path: reviewFile(input.chapter),
9083
+ text: yaml(review)
9084
+ },
9085
+ {
9086
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9087
+ text: yaml(nextPipeline)
9088
+ }
9089
+ ],
9090
+ backup: snapshot.review !== null,
8378
9091
  data: {
8379
9092
  chapter: input.chapter,
8380
9093
  round: review.round,
@@ -8391,16 +9104,31 @@ var EngineService = class {
8391
9104
  });
8392
9105
  }
8393
9106
  async submitRevision(input) {
9107
+ const hasContent = typeof input.content === "string" && input.content.trim() !== "";
9108
+ const hasPatch = input.patch !== void 0;
9109
+ if (hasContent === hasPatch) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires exactly one of content or patch", "send the full revised text, or a patch with expected_hash and unique edits");
9110
+ if (hasPatch) {
9111
+ const patch = input.patch;
9112
+ if (patch === null || typeof patch !== "object" || !Array.isArray(patch.edits) || patch.edits.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "patch.edits must be a non-empty array");
9113
+ if (typeof patch.expected_hash !== "string" || patch.expected_hash.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "patch.expected_hash must be the sha256 of the current manuscript", "read novel_status or the current manuscript, then hash the exact text");
9114
+ for (const [index, edit] of patch.edits.entries()) {
9115
+ if (typeof edit !== "object" || edit === null || typeof edit.old_text !== "string" || edit.old_text === "" || typeof edit.new_text !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] needs non-empty old_text and a string new_text`);
9116
+ if (edit.occurrence !== void 0 && (!Number.isInteger(edit.occurrence) || edit.occurrence < 1)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}].occurrence must be a positive integer`);
9117
+ }
9118
+ }
8394
9119
  return this.mutate({
8395
9120
  root: input.root,
8396
9121
  op: "submitRevision",
8397
- key: () => `revision:${input.chapter}:${sha256(input.content)}:${sha256(stableJson({
9122
+ chapter: input.chapter,
9123
+ key: () => `revision:${input.chapter}:${sha256(input.content ?? stableJson(input.patch))}:${sha256(stableJson({
8398
9124
  summary: input.summary,
8399
9125
  issues: input.issues,
8400
9126
  proof: input.proof
8401
9127
  }))}`,
8402
9128
  run: async (snapshot, context) => {
8403
9129
  this.assertChapter(snapshot, input.chapter);
9130
+ const revisedContent = input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
9131
+ if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
8404
9132
  let patchedReview = null;
8405
9133
  if (snapshot.review !== null) {
8406
9134
  const original = snapshot.review.issues;
@@ -8450,6 +9178,7 @@ var EngineService = class {
8450
9178
  issues
8451
9179
  };
8452
9180
  }
9181
+ const reviewChanged = patchedReview !== null && (snapshot.review === null || stableJson(patchedReview) !== stableJson(snapshot.review));
8453
9182
  if (patchedReview !== null) try {
8454
9183
  narrow("review", patchedReview);
8455
9184
  } catch (error) {
@@ -8457,46 +9186,65 @@ var EngineService = class {
8457
9186
  }
8458
9187
  await this.assertGates(snapshot, "submitRevision", {
8459
9188
  briefStage: briefStageFor("submitRevision", snapshot.pipeline.stage),
8460
- text: input.content,
9189
+ text: revisedContent,
8461
9190
  summary: input.summary,
8462
9191
  summaryProvided: input.summary !== void 0,
8463
9192
  review: patchedReview,
8464
9193
  proof: input.proof
8465
9194
  });
8466
- if (input.summary === void 0 && snapshot.plan?.summary !== void 0) {
8467
- const opening = snapshot.plan.summary.opening;
8468
- if (!openingInFirstTwoParagraphs(input.content, opening)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
9195
+ const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
9196
+ if (input.summary === void 0 && inheritedSummary !== void 0) {
9197
+ const opening = inheritedSummary.opening;
9198
+ if (!openingInFirstTwoParagraphs(revisedContent, opening)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
8469
9199
  }
8470
- const machine = this.machine(snapshot, input.content, { summary: input.summary ?? snapshot.plan?.summary ?? null });
9200
+ const effectiveSummary = input.summary ?? inheritedSummary;
9201
+ if (effectiveSummary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the revised draft has no summary", "resubmit novel_revise with the ten-field summary");
9202
+ const machine = this.machine(snapshot, revisedContent, { summary: effectiveSummary });
9203
+ const baseDraft = snapshot.pipeline.draft ?? {
9204
+ chapter: input.chapter,
9205
+ title: snapshot.plan?.title ?? `第${input.chapter}章`,
9206
+ scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
9207
+ narrator: snapshot.plan?.narrator ?? "",
9208
+ summary: effectiveSummary,
9209
+ content_hash: sha256(snapshot.text ?? ""),
9210
+ updated_at: this.now().toISOString()
9211
+ };
8471
9212
  const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
8472
9213
  const nextPipeline = pipelineWith(snapshot, context.revision, {
8473
9214
  stage: nextStage$1,
9215
+ draft: {
9216
+ ...baseDraft,
9217
+ summary: effectiveSummary,
9218
+ content_hash: sha256(revisedContent),
9219
+ updated_at: this.now().toISOString()
9220
+ },
8474
9221
  rounds: {
8475
9222
  ...snapshot.pipeline.rounds,
8476
9223
  revise: snapshot.pipeline.rounds.revise + 1
8477
9224
  },
8478
9225
  blocking: [],
8479
- ...patchedReview === null ? {} : { review_digest: sha256(stableJson(patchedReview)) },
8480
- last_action: action("novel_revise", this.now(), sha256(input.content))
8481
- });
8482
- const planPath = planChapterFile(input.chapter);
8483
- const planText = await context.store.readText(planPath);
8484
- const plan = planText === null ? null : parsePlanYaml(planText, planPath);
8485
- const files = [{
8486
- path: chapterFile(input.chapter),
8487
- text: input.content
8488
- }, {
8489
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8490
- text: yaml(nextPipeline)
8491
- }];
8492
- if (plan !== null && input.summary !== void 0) files.push({
8493
- path: planPath,
8494
- text: yaml({
8495
- ...plan,
8496
- summary: input.summary
8497
- })
9226
+ ...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
9227
+ last_action: action("novel_revise", this.now(), sha256(revisedContent))
8498
9228
  });
8499
- if (patchedReview !== null && patchedReview !== snapshot.review) files.push({
9229
+ const files = [
9230
+ ...snapshot.text === null ? [] : [{
9231
+ path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
9232
+ text: snapshot.text
9233
+ }],
9234
+ ...snapshot.review === null || !reviewChanged ? [] : [{
9235
+ path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
9236
+ text: yaml(snapshot.review)
9237
+ }],
9238
+ {
9239
+ path: chapterFile(input.chapter),
9240
+ text: revisedContent
9241
+ },
9242
+ {
9243
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9244
+ text: yaml(nextPipeline)
9245
+ }
9246
+ ];
9247
+ if (reviewChanged && patchedReview !== null) files.push({
8500
9248
  path: reviewFile(input.chapter),
8501
9249
  text: yaml(patchedReview)
8502
9250
  });
@@ -8518,9 +9266,11 @@ var EngineService = class {
8518
9266
  return this.mutate({
8519
9267
  root: input.root,
8520
9268
  op: "runCheck",
9269
+ chapter: input.chapter,
8521
9270
  key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
8522
9271
  semantic: input.semantic ?? [],
8523
- scope
9272
+ scope,
9273
+ acknowledge: input.acknowledge ?? []
8524
9274
  }))}`,
8525
9275
  run: async (snapshot, context) => {
8526
9276
  this.assertChapter(snapshot, input.chapter);
@@ -8539,6 +9289,13 @@ var EngineService = class {
8539
9289
  violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
8540
9290
  };
8541
9291
  const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
9292
+ const machineAdvisories = scopedMachine.violations.filter((violation) => violation.severity === "advisory");
9293
+ const advisoryCodes = [...new Set(machineAdvisories.map((violation) => violation.code))];
9294
+ const requestedAck = Array.isArray(input.acknowledge) ? input.acknowledge : [];
9295
+ const unknownAck = requestedAck.filter((code) => !advisoryCodes.includes(code));
9296
+ if (unknownAck.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot acknowledge non-advisory finding(s): ${unknownAck.join(", ")}`, `advisory codes in this run: ${advisoryCodes.join(", ") || "none"}; blockers and unknown codes cannot be acknowledged`);
9297
+ const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
9298
+ const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
8542
9299
  const semanticResult = evaluateSemantic(text, semantic);
8543
9300
  const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
8544
9301
  const check = {
@@ -8547,10 +9304,12 @@ var EngineService = class {
8547
9304
  scope,
8548
9305
  content_hash: sha256(text),
8549
9306
  machine: {
8550
- violations: scopedMachine.violations,
9307
+ violations: machineBlocking,
9308
+ advisories: machineAdvisories,
8551
9309
  metrics: scopedMachine.metrics
8552
9310
  },
8553
9311
  semantic: { items: semantic },
9312
+ ...acknowledged.length === 0 ? {} : { acknowledged },
8554
9313
  verdict
8555
9314
  };
8556
9315
  try {
@@ -8575,24 +9334,42 @@ var EngineService = class {
8575
9334
  last_action: action("novel_check", this.now(), sha256(text))
8576
9335
  });
8577
9336
  return {
8578
- files: [{
8579
- path: checkFile(input.chapter),
8580
- text: yaml(check)
8581
- }, {
8582
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8583
- text: yaml(nextPipeline)
8584
- }],
9337
+ files: [
9338
+ ...snapshot.check === null ? [] : [{
9339
+ path: checkHistoryFile(input.chapter, snapshot.pipeline.revision),
9340
+ text: yaml(snapshot.check)
9341
+ }],
9342
+ {
9343
+ path: checkFile(input.chapter),
9344
+ text: yaml(check)
9345
+ },
9346
+ {
9347
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9348
+ text: yaml(nextPipeline)
9349
+ }
9350
+ ],
9351
+ backup: snapshot.check !== null,
8585
9352
  data: {
8586
9353
  chapter: input.chapter,
8587
9354
  verdict,
8588
- violations: scopedMachine.violations.length,
9355
+ violations: machineBlocking.length,
9356
+ advisories: machineAdvisories.filter((violation) => !acknowledged.includes(violation.code)).length,
9357
+ advisory_codes: advisoryCodes,
9358
+ acknowledged,
8589
9359
  metrics: scopedMachine.metrics,
8590
9360
  stage: nextStage$2
8591
9361
  },
8592
- warnings: [...machineWarnings(scopedMachine), ...verdict === "pass" && !fullScope ? [{
8593
- code: "CHECK_SCOPE_PARTIAL",
8594
- message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
8595
- }] : []]
9362
+ warnings: [
9363
+ ...machineWarnings(scopedMachine, acknowledged),
9364
+ ...acknowledged.length === 0 ? [] : [{
9365
+ code: "CHECK_ADVISORY_ACKNOWLEDGED",
9366
+ message: `已记录知悉:${acknowledged.join(", ")};advisory 不阻塞归档`
9367
+ }],
9368
+ ...verdict === "pass" && !fullScope ? [{
9369
+ code: "CHECK_SCOPE_PARTIAL",
9370
+ message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
9371
+ }] : []
9372
+ ]
8596
9373
  };
8597
9374
  }
8598
9375
  });
@@ -8607,6 +9384,7 @@ var EngineService = class {
8607
9384
  return this.mutate({
8608
9385
  root: input.root,
8609
9386
  op: "archiveChapter",
9387
+ chapter: input.chapter,
8610
9388
  key: (snapshot) => {
8611
9389
  const archived = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
8612
9390
  return `archive:${input.chapter}:${archived?.content_hash ?? sha256(snapshot.text ?? "")}:${archiveDigest}`;
@@ -8633,7 +9411,8 @@ var EngineService = class {
8633
9411
  this.assertChapter(snapshot, input.chapter);
8634
9412
  this.assertChapterWithinTarget(snapshot, input.chapter, "novel_archive");
8635
9413
  const text = snapshot.text ?? "";
8636
- const summary = snapshot.plan?.summary;
9414
+ const draft = snapshot.pipeline.draft;
9415
+ const summary = draft?.summary ?? snapshot.plan?.summary;
8637
9416
  if (summary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the draft has no summary; resubmit the draft with a summary", "call novel_write_draft with the full summary block");
8638
9417
  if (snapshot.review !== null && snapshot.pipeline.review_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review has no review_digest; it predates digest-bound reviews and cannot be archived blindly", "re-run novel_review for this chapter, then re-run the archive");
8639
9418
  if (snapshot.review !== null && snapshot.pipeline.review_digest !== void 0 && snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review changed after submission; its digest no longer matches", "re-run novel_review instead of editing reviews/ directly");
@@ -8644,6 +9423,10 @@ var EngineService = class {
8644
9423
  review: snapshot.review,
8645
9424
  proof: snapshot.review?.proof ?? null
8646
9425
  });
9426
+ if (draft !== void 0) {
9427
+ if (draft.chapter !== input.chapter) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `pipeline draft belongs to chapter ${draft.chapter}, but chapter ${input.chapter} is being archived`, "re-open the intended chapter or resubmit the draft before archiving");
9428
+ if (draft.content_hash !== sha256(text)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the manuscript changed after its draft summary was written; the summary and title cannot be sealed against this text", "restore the manuscript to the draft version, or call novel_write_draft / novel_revise again before archiving");
9429
+ }
8647
9430
  if (text === "") throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${input.chapter} has no draft`);
8648
9431
  const archiveBlockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
8649
9432
  if (archiveBlockers.length > 0) throw new EngineBlockedError(ERROR_CODES.GATE_BLOCKED, `archiveChapter blocked by ${archiveBlockers.length} machine finding(s), first: ${archiveBlockers[0].code}`, archiveBlockers.map((violation) => ({
@@ -8659,7 +9442,7 @@ var EngineService = class {
8659
9442
  const metrics = countWordMetrics(text);
8660
9443
  const score = snapshot.review?.total ?? 0;
8661
9444
  const chapter = input.chapter;
8662
- const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
9445
+ const storedTitle = stripTitlePrefix(snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? "");
8663
9446
  const record = {
8664
9447
  num: chapter,
8665
9448
  title: storedTitle === "" ? `第${chapter}章` : storedTitle,
@@ -8667,13 +9450,16 @@ var EngineService = class {
8667
9450
  word_count: metrics.total_word_count,
8668
9451
  dialogue_lines: metrics.dialogue_lines,
8669
9452
  dialogue_ratio: metrics.dialogue_ratio,
8670
- scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
8671
- narrator: snapshot.plan?.narrator ?? "",
9453
+ scene_type: snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? "日常过渡章",
9454
+ narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? "",
8672
9455
  score,
8673
9456
  status: "reviewed",
8674
9457
  content_hash: sha256(text),
8675
9458
  summary
8676
9459
  };
9460
+ const roleHints = {};
9461
+ for (const character of snapshot.worldbook.characters) if (character.role !== "") roleHints[character.name] = character.role;
9462
+ for (const character of snapshot.worldPlan?.characters ?? []) if (character.role !== "") roleHints[character.name] = character.role;
8677
9463
  const ledger = applyStateDelta({
8678
9464
  chapter,
8679
9465
  text,
@@ -8685,11 +9471,15 @@ var EngineService = class {
8685
9471
  foreshadows: snapshot.foreshadows,
8686
9472
  timeline: snapshot.timeline,
8687
9473
  relationships: snapshot.relationships,
8688
- narrator: snapshot.plan?.narrator ?? null
9474
+ narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? null,
9475
+ roleHints
8689
9476
  });
8690
9477
  record.assigned_ids = ledger.assigned;
8691
9478
  record.archive_digest = archiveDigest;
8692
9479
  const archivedAt = this.now().toISOString();
9480
+ record.rounds = await this.lifetimeRounds(snapshot, chapter);
9481
+ record.final_check = snapshot.check?.verdict ?? "fail";
9482
+ record.archived_at = archivedAt;
8693
9483
  const outline = markOutlineArchived(snapshot.outline, record, archivedAt, snapshot.project.targets.words_per_chapter);
8694
9484
  this.assertOutlineWritable(snapshot, outline, "novel_archive");
8695
9485
  const foreshadows = ledger.foreshadows;
@@ -8730,6 +9520,7 @@ var EngineService = class {
8730
9520
  blocking: [],
8731
9521
  last_action: action("novel_archive", this.now(), sha256(text))
8732
9522
  });
9523
+ delete nextPipeline.draft;
8733
9524
  assertArchiveDocuments([
8734
9525
  ["chapters", chapters],
8735
9526
  ["characters", ledger.characters],
@@ -8742,12 +9533,18 @@ var EngineService = class {
8742
9533
  ]);
8743
9534
  const archiveDigests = await this.readArchiveDigests(context.store, snapshot);
8744
9535
  archiveDigests[String(chapter)] = archiveDigest;
9536
+ const planText = await context.store.readText(planChapterFile(chapter));
9537
+ if (planText === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no stored plan; archiving would not be able to prove it later`, "restore the plan from backups/ before archiving");
8745
9538
  return {
8746
9539
  files: [
8747
9540
  {
8748
9541
  path: chapterFile(chapter),
8749
9542
  text
8750
9543
  },
9544
+ {
9545
+ path: planChapterFile(chapter),
9546
+ text: planText
9547
+ },
8751
9548
  {
8752
9549
  path: joinRel(STATE_DIR, STATE_FILES.chapters),
8753
9550
  text: yaml(chapters)
@@ -8833,7 +9630,6 @@ var EngineService = class {
8833
9630
  op: "amend",
8834
9631
  reason: input.reason,
8835
9632
  ...repairPaths.length === 0 ? {} : { repairPaths },
8836
- key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
8837
9633
  run: async (snapshot, context) => {
8838
9634
  if (input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason", "state the intent so the journal explains the change");
8839
9635
  if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
@@ -8966,8 +9762,9 @@ var EngineService = class {
8966
9762
  return this.mutate({
8967
9763
  root: input.root,
8968
9764
  op: "rollback",
9765
+ chapter: input.chapter,
8969
9766
  reason: input.reason,
8970
- key: () => `rollback:${input.chapter}:${input.toStage}`,
9767
+ key: (snapshot) => `rollback:${input.chapter}:${input.toStage}:${sha256(snapshot.text ?? "")}`,
8971
9768
  run: async (snapshot, context) => {
8972
9769
  this.assertChapter(snapshot, input.chapter);
8973
9770
  if (!Object.prototype.hasOwnProperty.call(ROUND_TRANSITIONS, input.toStage)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown rollback target "${String(input.toStage)}"`, `allowed targets: ${Object.keys(ROUND_TRANSITIONS).join(", ")}`);
@@ -8985,15 +9782,31 @@ var EngineService = class {
8985
9782
  blocking: [],
8986
9783
  last_action: action("novel_rollback", this.now(), shortDigest(input.reason ?? ""))
8987
9784
  });
9785
+ delete nextPipeline.draft;
9786
+ if (stage !== "idle" && stage !== "P2" && snapshot.pipeline.draft !== void 0) nextPipeline.draft = snapshot.pipeline.draft;
8988
9787
  const files = [{
8989
9788
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8990
9789
  text: yaml(nextPipeline)
8991
9790
  }];
8992
9791
  const current = await context.store.readText(chapterFile(input.chapter));
8993
- if (current !== null) files.push({
8994
- path: deprecatedChapterFile(input.chapter),
8995
- text: current
8996
- });
9792
+ if (current !== null) {
9793
+ files.push({
9794
+ path: chapterFile(input.chapter),
9795
+ text: current
9796
+ });
9797
+ files.push({
9798
+ path: deprecatedChapterFile(input.chapter),
9799
+ text: current
9800
+ });
9801
+ files.push({
9802
+ path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
9803
+ text: current
9804
+ });
9805
+ files.push({
9806
+ path: deprecatedHistoryFile(input.chapter, snapshot.pipeline.revision),
9807
+ text: current
9808
+ });
9809
+ }
8997
9810
  return {
8998
9811
  files,
8999
9812
  backup: true,
@@ -9074,8 +9887,21 @@ var EngineService = class {
9074
9887
  });
9075
9888
  const requested = input.limit ?? 50;
9076
9889
  const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
9890
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
9891
+ if (input.cursor !== void 0 && (!Number.isInteger(input.cursor) || input.cursor < 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit cursor must be a non-negative integer");
9892
+ if (input.since !== void 0 && Number.isNaN(Date.parse(input.since))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit since must be an ISO timestamp");
9893
+ if (input.until !== void 0 && Number.isNaN(Date.parse(input.until))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit until must be an ISO timestamp");
9894
+ const filtered = rows.filter((row) => {
9895
+ if (input.chapter !== void 0 && row.chapter !== input.chapter) return false;
9896
+ if (input.op !== void 0 && row.op !== input.op) return false;
9897
+ if (input.status !== void 0 && row.status !== input.status) return false;
9898
+ if (input.since !== void 0 && Date.parse(row.at) < Date.parse(input.since)) return false;
9899
+ if (input.until !== void 0 && Date.parse(row.at) > Date.parse(input.until)) return false;
9900
+ if (input.cursor !== void 0 && (row.seq ?? 0) <= input.cursor) return false;
9901
+ return true;
9902
+ });
9077
9903
  return {
9078
- data: limit === 0 ? [] : rows.slice(-limit),
9904
+ data: limit === 0 ? [] : input.cursor === void 0 ? filtered.slice(-limit) : filtered.slice(0, limit),
9079
9905
  revision: snapshot.pipeline.revision,
9080
9906
  warnings
9081
9907
  };
@@ -9275,7 +10101,7 @@ var EngineService = class {
9275
10101
  const snapshot = await this.load(input.root);
9276
10102
  const totalWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
9277
10103
  const scores = snapshot.chapters.chapters.map((chapter) => chapter.score);
9278
- const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
10104
+ const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
9279
10105
  const counts = worldbookCounts(snapshot.worldbook);
9280
10106
  return {
9281
10107
  totalWords,
@@ -9286,9 +10112,12 @@ var EngineService = class {
9286
10112
  lastScore: scores.length === 0 ? 0 : scores[scores.length - 1],
9287
10113
  openForeshadows: snapshot.foreshadows.items.filter((item) => item.status !== "resolved").length,
9288
10114
  plannedChapters: outline.planned,
9289
- outlineCoverage: outline.coverage,
10115
+ totalCards: outline.total,
10116
+ outlineCoverage: outline.plannedCoverage,
10117
+ archivedCoverage: outline.archivedCoverage,
10118
+ missingCards: outline.missingCards,
9290
10119
  blueprintReady: bookPlanPresent(snapshot.outline.book),
9291
- worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory,
10120
+ worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts,
9292
10121
  strictOutline: snapshot.outline.book.strict,
9293
10122
  targetChapters: snapshot.project.targets.total_chapters
9294
10123
  };
@@ -9448,7 +10277,7 @@ var EngineService = class {
9448
10277
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `state/${STATE_FILES.chapters} is unreadable; ${operation} cannot preserve archived outline cards`, `repair state/${STATE_FILES.chapters} with novel_amend before ${operation}`);
9449
10278
  }
9450
10279
  bookStatus(snapshot) {
9451
- const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
10280
+ const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
9452
10281
  const counts = worldbookCounts(snapshot.worldbook);
9453
10282
  return {
9454
10283
  premise: snapshot.outline.book.premise,
@@ -9456,10 +10285,15 @@ var EngineService = class {
9456
10285
  strict: snapshot.outline.book.strict,
9457
10286
  outline: {
9458
10287
  present: snapshot.outlinePresent && summary.present,
10288
+ target: summary.target,
9459
10289
  total: summary.total,
9460
10290
  planned: summary.planned,
9461
10291
  archived: summary.archived,
10292
+ missing_cards: summary.missingCards,
10293
+ planned_coverage: summary.plannedCoverage,
10294
+ archived_coverage: summary.archivedCoverage,
9462
10295
  coverage: summary.coverage,
10296
+ card_coverage: summary.cardCoverage,
9463
10297
  volumes: summary.volumes,
9464
10298
  current: outlineCard(snapshot.outline, snapshot.pipeline.chapter)
9465
10299
  },
@@ -9470,7 +10304,8 @@ var EngineService = class {
9470
10304
  factions: counts.factions,
9471
10305
  rules: counts.rules,
9472
10306
  glossary: counts.glossary,
9473
- backstory: counts.backstory
10307
+ backstory: counts.backstory,
10308
+ facts: counts.facts
9474
10309
  }
9475
10310
  };
9476
10311
  }
@@ -9483,7 +10318,7 @@ var EngineService = class {
9483
10318
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
9484
10319
  const journal = await readJournal(this.storeFor(snapshot.root));
9485
10320
  const journalBlocking = await this.journalBlocking(snapshot);
9486
- const outline = outlineSummary(snapshot.outline, archived.length);
10321
+ const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
9487
10322
  const counts = worldbookCounts(snapshot.worldbook);
9488
10323
  const scores = archived.slice(-10).map((chapter) => chapter.score);
9489
10324
  const blocking = [...this.effectiveBlocking(snapshot), ...journalBlocking];
@@ -9496,10 +10331,15 @@ var EngineService = class {
9496
10331
  target,
9497
10332
  outline: {
9498
10333
  present: snapshot.outlinePresent && outline.present,
10334
+ target: outline.target,
9499
10335
  total: outline.total,
9500
10336
  planned: outline.planned,
9501
10337
  archived: outline.archived,
10338
+ missing_cards: outline.missingCards,
10339
+ planned_coverage: outline.plannedCoverage,
10340
+ archived_coverage: outline.archivedCoverage,
9502
10341
  coverage: outline.coverage,
10342
+ card_coverage: outline.cardCoverage,
9503
10343
  volumes: snapshot.outline.volumes.map((volume) => {
9504
10344
  const cards = snapshot.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
9505
10345
  return {
@@ -9518,7 +10358,8 @@ var EngineService = class {
9518
10358
  factions: counts.factions,
9519
10359
  rules: counts.rules,
9520
10360
  glossary: counts.glossary,
9521
- backstory: counts.backstory
10361
+ backstory: counts.backstory,
10362
+ facts: counts.facts
9522
10363
  }
9523
10364
  };
9524
10365
  const qualityScores = scores.slice(-5);
@@ -9609,6 +10450,16 @@ var EngineService = class {
9609
10450
  to_stage: target
9610
10451
  };
9611
10452
  }
10453
+ const draft = snapshot.pipeline.draft;
10454
+ if (draft !== void 0 && (draft.chapter !== snapshot.pipeline.chapter || draft.content_hash !== sha256(text))) return {
10455
+ item: {
10456
+ code: "DRAFT_STALE",
10457
+ scope: "archive",
10458
+ detail: "the manuscript or active chapter no longer matches its draft summary",
10459
+ hint: "call novel_rollback to P3 and submit novel_write_draft again"
10460
+ },
10461
+ to_stage: "P3"
10462
+ };
9612
10463
  const summary = snapshot.plan?.summary ?? null;
9613
10464
  const blockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
9614
10465
  if (blockers.length > 0) return {
@@ -10030,8 +10881,31 @@ var EngineService = class {
10030
10881
  }));
10031
10882
  }
10032
10883
  /** Whether the stored brief still signs the current snapshot. */
10884
+ async readBriefReceipts(store) {
10885
+ const raw = await store.readText(BRIEF_RECEIPTS_FILE);
10886
+ if (raw === null) return {
10887
+ schema_version: 1,
10888
+ receipts: []
10889
+ };
10890
+ try {
10891
+ const parsed = parse(raw);
10892
+ if (parsed === null || !Array.isArray(parsed.receipts)) return {
10893
+ schema_version: 1,
10894
+ receipts: []
10895
+ };
10896
+ return {
10897
+ schema_version: 1,
10898
+ receipts: parsed.receipts.filter((receipt) => typeof receipt === "object" && receipt !== null && Number.isInteger(receipt.chapter) && typeof receipt.stage === "string" && typeof receipt.digest === "string" && typeof receipt.issued_at === "string")
10899
+ };
10900
+ } catch {
10901
+ return {
10902
+ schema_version: 1,
10903
+ receipts: []
10904
+ };
10905
+ }
10906
+ }
10033
10907
  briefIsCurrent(snapshot, stage) {
10034
- const record = snapshot.pipeline.briefs[stage];
10908
+ const record = snapshot.briefs[stage];
10035
10909
  if (record === void 0) return false;
10036
10910
  const context = this.briefContextFor(stage, snapshot, snapshot.pipeline.chapter);
10037
10911
  return record.digest === this.briefDigest(stage, snapshot, context);
@@ -10117,6 +10991,8 @@ var EngineService = class {
10117
10991
  genre: snapshot.project.genre,
10118
10992
  sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
10119
10993
  rulepack: this.rulepack,
10994
+ targets: snapshot.project.targets,
10995
+ facts: snapshot.worldbook.facts ?? [],
10120
10996
  summary: extra.summary ?? snapshot.plan?.summary ?? null,
10121
10997
  evidence: {
10122
10998
  characters: snapshot.characters.characters.length > 0 ? snapshot.characters : null,
@@ -10222,13 +11098,16 @@ var EngineService = class {
10222
11098
  text,
10223
11099
  chapter: snapshot.pipeline.chapter,
10224
11100
  project: snapshot.project,
10225
- pipeline: snapshot.pipeline,
11101
+ pipeline: {
11102
+ ...snapshot.pipeline,
11103
+ briefs: snapshot.briefs
11104
+ },
10226
11105
  chapters: snapshot.chapters,
10227
11106
  briefDigest: extras.briefDigest ?? (extras.briefStage === void 0 || extras.briefStage === null ? null : this.briefDigest(extras.briefStage, snapshot, this.briefContextFor(extras.briefStage, snapshot, snapshot.pipeline.chapter))),
10228
11107
  briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
10229
- draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
10230
- sceneType: extras.sceneType ?? snapshot.plan?.scene_type ?? null,
10231
- summary: extras.summary ?? snapshot.plan?.summary ?? null,
11108
+ draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
11109
+ sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
11110
+ summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
10232
11111
  summaryProvided: extras.summaryProvided,
10233
11112
  review: extras.review ?? snapshot.review,
10234
11113
  check: extras.check ?? snapshot.check,
@@ -10295,7 +11174,7 @@ var EngineService = class {
10295
11174
  STATE_FILES.relationships,
10296
11175
  STATE_FILES.handoff
10297
11176
  ].map((file) => joinRel(STATE_DIR, file));
10298
- stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
11177
+ stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
10299
11178
  return (await Promise.all(stateFiles.map((path) => store.exists(path)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0 || await store.exists("outline/outline.yaml") || await store.exists("outline/worldbook.yaml");
10300
11179
  }
10301
11180
  /** A live writer lock makes a read-only snapshot potentially torn. */
@@ -10334,6 +11213,19 @@ var EngineService = class {
10334
11213
  }
10335
11214
  const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
10336
11215
  if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
11216
+ let briefs = pipeline.briefs;
11217
+ const briefsRaw = await store.readText(BRIEF_RECEIPTS_FILE);
11218
+ if (briefsRaw !== null) try {
11219
+ const parsed = parse(briefsRaw);
11220
+ if (parsed !== null && Array.isArray(parsed.receipts)) {
11221
+ const next = {};
11222
+ for (const receipt of parsed.receipts) if (typeof receipt === "object" && receipt !== null && Number.isInteger(receipt.chapter) && typeof receipt.stage === "string" && typeof receipt.digest === "string" && typeof receipt.issued_at === "string") next[receipt.stage] = {
11223
+ digest: receipt.digest,
11224
+ issued_at: receipt.issued_at
11225
+ };
11226
+ briefs = next;
11227
+ }
11228
+ } catch {}
10337
11229
  const ledgerErrors = [];
10338
11230
  const ledger = async (path, kind, code, fallback) => {
10339
11231
  const read = await this.recoverableDoc(store, path, kind);
@@ -10422,6 +11314,10 @@ var EngineService = class {
10422
11314
  const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
10423
11315
  validateStoredChapterPlan(parsed, planChapterFile(chapter));
10424
11316
  plan = parsed;
11317
+ if (pipeline.draft !== void 0 && pipeline.draft.chapter === chapter) plan = {
11318
+ ...plan,
11319
+ summary: pipeline.draft.summary
11320
+ };
10425
11321
  } catch (error) {
10426
11322
  if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
10427
11323
  planError = errorMessage(error);
@@ -10464,6 +11360,7 @@ var EngineService = class {
10464
11360
  project,
10465
11361
  projectError,
10466
11362
  pipeline,
11363
+ briefs,
10467
11364
  chapters,
10468
11365
  characters,
10469
11366
  world,
@@ -10565,7 +11462,7 @@ var EngineService = class {
10565
11462
  STATE_FILES.relationships,
10566
11463
  STATE_FILES.handoff
10567
11464
  ].map((file) => joinRel(STATE_DIR, file));
10568
- stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
11465
+ stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
10569
11466
  const stateCandidates = journalWasPresent ? stateFiles : stateFiles.filter((path) => path !== joinRel(STATE_DIR, STATE_FILES.journal));
10570
11467
  if ((await Promise.all(stateCandidates.map((file) => store.exists(file)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0 || await store.exists("outline/outline.yaml") || await store.exists("outline/worldbook.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, "state documents exist but novel.yaml is missing; refusing to overwrite the project", "restore novel.yaml, or initialize a new empty directory");
10571
11468
  await assertJournalConsistent(store);
@@ -10603,12 +11500,32 @@ var EngineService = class {
10603
11500
  replayed: true
10604
11501
  };
10605
11502
  }
10606
- const revision = snapshot.pipeline.revision + 1;
11503
+ const revision = options.advanceRevision === false ? snapshot.pipeline.revision : snapshot.pipeline.revision + 1;
10607
11504
  abortIfNeeded();
10608
- const outcome = await options.run(snapshot, {
10609
- store,
10610
- revision
10611
- });
11505
+ let outcome;
11506
+ try {
11507
+ outcome = await options.run(snapshot, {
11508
+ store,
11509
+ revision
11510
+ });
11511
+ } catch (error) {
11512
+ await appendJournal(store, {
11513
+ txid: randomUUID(),
11514
+ at: this.now().toISOString(),
11515
+ op: options.op,
11516
+ chapter: options.chapter ?? snapshot.pipeline.chapter,
11517
+ revision: snapshot.pipeline.revision,
11518
+ files: [],
11519
+ status: "aborted",
11520
+ reason: errorMessage(error),
11521
+ attempt: true,
11522
+ stage: snapshot.pipeline.stage,
11523
+ ...error instanceof EngineError ? { code: error.code } : {},
11524
+ ...error instanceof EngineError && error.hint !== void 0 ? { hint: error.hint } : {},
11525
+ ...error instanceof EngineBlockedError ? { blocking: error.blocking } : {}
11526
+ }).catch(() => void 0);
11527
+ throw error;
11528
+ }
10612
11529
  if (outcome.replay === true) return {
10613
11530
  data: outcome.data,
10614
11531
  revision: snapshot.pipeline.revision,
@@ -10619,7 +11536,7 @@ var EngineService = class {
10619
11536
  await runTransaction(store, {
10620
11537
  txid: randomUUID(),
10621
11538
  op: options.op,
10622
- chapter: snapshot.pipeline.chapter,
11539
+ chapter: options.chapter ?? snapshot.pipeline.chapter,
10623
11540
  revision,
10624
11541
  files: outcome.files,
10625
11542
  backup: outcome.backup ?? false,
@@ -10661,6 +11578,7 @@ var EngineService = class {
10661
11578
  chapter: 1,
10662
11579
  stage: "idle"
10663
11580
  }),
11581
+ briefs: {},
10664
11582
  chapters: emptyChapters(),
10665
11583
  characters: emptyCharacters(),
10666
11584
  world: emptyWorld(),
@@ -10728,6 +11646,38 @@ const STATUS_BRIEF = {
10728
11646
  done: "P1"
10729
11647
  };
10730
11648
  const yaml = (value) => stringify(value, { lineWidth: 0 });
11649
+ /** Apply a patch only to the exact manuscript the caller hashed. */
11650
+ function applyRevisionPatch(text, patch) {
11651
+ const actual = sha256(text);
11652
+ if (actual !== patch.expected_hash) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch expected_hash ${patch.expected_hash} does not match the current manuscript ${actual}`, "read the current text and compute its sha256 again; nothing was written");
11653
+ const planned = [];
11654
+ for (const [index, edit] of patch.edits.entries()) {
11655
+ const positions = [];
11656
+ let cursor = 0;
11657
+ while (cursor <= text.length) {
11658
+ const found = text.indexOf(edit.old_text, cursor);
11659
+ if (found === -1) break;
11660
+ positions.push(found);
11661
+ cursor = found + 1;
11662
+ }
11663
+ const expectedOccurrence = edit.occurrence ?? 1;
11664
+ if (edit.occurrence === void 0 && positions.length !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] matches ${positions.length} place(s); a unique old_text is required`, "include more surrounding text, or set occurrence explicitly");
11665
+ if (positions.length < expectedOccurrence) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}].occurrence ${expectedOccurrence} exceeds ${positions.length} match(es)`);
11666
+ const start = positions[expectedOccurrence - 1];
11667
+ const current = {
11668
+ index,
11669
+ start,
11670
+ end: start + edit.old_text.length,
11671
+ newText: edit.new_text
11672
+ };
11673
+ if (planned.some((other) => current.start < other.end && other.start < current.end)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] overlaps another edit; overlapping edits cannot be applied atomically`, "merge overlapping edits into one old_text/new_text pair; nothing was written");
11674
+ planned.push(current);
11675
+ }
11676
+ planned.sort((left, right) => right.start - left.start);
11677
+ let next = text;
11678
+ for (const edit of planned) next = `${next.slice(0, edit.start)}${edit.newText}${next.slice(edit.end)}`;
11679
+ return next;
11680
+ }
10731
11681
  const emptyChapters = () => ({
10732
11682
  schema_version: 1,
10733
11683
  chapters: []
@@ -10752,6 +11702,7 @@ const pipelineWith = (snapshot, revision, patch) => ({
10752
11702
  rounds: snapshot.pipeline.rounds,
10753
11703
  briefs: snapshot.pipeline.briefs,
10754
11704
  blocking: snapshot.pipeline.blocking,
11705
+ ...snapshot.pipeline.draft === void 0 ? {} : { draft: snapshot.pipeline.draft },
10755
11706
  review_digest: snapshot.pipeline.review_digest,
10756
11707
  last_action: snapshot.pipeline.last_action,
10757
11708
  ...patch
@@ -10775,7 +11726,7 @@ const action = (op, at, digest) => ({
10775
11726
  at: at.toISOString(),
10776
11727
  digest
10777
11728
  });
10778
- const machineWarnings = (machine) => machine.violations.filter((violation) => violation.severity === "advisory").slice(0, 5).map((violation) => ({
11729
+ const machineWarnings = (machine, acknowledged = []) => machine.violations.filter((violation) => violation.severity === "advisory" && !acknowledged.includes(violation.code)).slice(0, 5).map((violation) => ({
10779
11730
  code: violation.code,
10780
11731
  message: `${violation.detail} (paragraph ${violation.location.paragraph})`
10781
11732
  }));
@@ -10940,7 +11891,10 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
10940
11891
  "narrator",
10941
11892
  "score",
10942
11893
  "status",
10943
- "summary"
11894
+ "summary",
11895
+ "rounds",
11896
+ "final_check",
11897
+ "archived_at"
10944
11898
  ]) if (stableJson(before[field]) !== stableJson(after[field])) problems.push(`archived chapter ${String(before.num)} cannot change its ${field}`);
10945
11899
  }
10946
11900
  return problems;
@@ -11362,6 +12316,7 @@ const makeGuarded = (engine) => (body) => async (args, exec) => {
11362
12316
  return fail(error);
11363
12317
  }
11364
12318
  };
12319
+ const percent = (value) => `${Math.round(value * 1e3) / 10}%`;
11365
12320
  const text = (value) => [{
11366
12321
  type: "text",
11367
12322
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -11408,7 +12363,7 @@ const root = {
11408
12363
  description: "Project root. When omitted, resolves to the last active project, then the registry active root, then the configured projectRoot; pass it explicitly when several projects exist."
11409
12364
  };
11410
12365
  /**
11411
- * The twenty-seven tools are thin adapters: narrow parameters, call one service
12366
+ * The twenty-eight tools are thin adapters: narrow parameters, call one service
11412
12367
  * operation, render the canonical value. They never read a file.
11413
12368
  */
11414
12369
  function createNovelTools(engine) {
@@ -11492,7 +12447,8 @@ function createNovelTools(engine) {
11492
12447
  ...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
11493
12448
  value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
11494
12449
  ...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
11495
- ...value.book === void 0 ? [] : [`全书:大纲 ${value.book.outline.total} 张卡(已归档 ${value.book.outline.archived},覆盖率 ${Math.round(value.book.outline.coverage * 100)}%)· 世界观 ${value.book.worldbook.characters} 角色 / ${value.book.worldbook.locations} 地点 / ${value.book.worldbook.factions} 势力 / ${value.book.worldbook.rules} 规则${value.book.strict ? " · 严格大纲" : ""}`, `当前章卡:${value.book.outline.current === null ? "(无)" : `第${value.book.outline.current.num}章 ${value.book.outline.current.title} · ${value.book.outline.current.scene_type} · ${value.book.outline.current.target_words[0]}-${value.book.outline.current.target_words[1]} 字`}`],
12450
+ ...value.metrics === null || value.metrics === void 0 ? [] : [`字数:${value.metrics.total_word_count}(汉字 ${value.metrics.cjk_chars} + 标点 ${value.metrics.punctuation_chars} + 英文词/数字,含标点)`],
12451
+ ...value.book === void 0 ? [] : [`全书:大纲 ${value.book.outline.total} 张卡(规划覆盖 ${value.book.outline.planned_coverage === null || value.book.outline.planned_coverage === void 0 ? "—" : percent(value.book.outline.planned_coverage)} · 归档覆盖 ${value.book.outline.archived_coverage === null || value.book.outline.archived_coverage === void 0 ? "—" : percent(value.book.outline.archived_coverage)}${(value.book.outline.missing_cards ?? 0) > 0 ? ` · 缺卡 ${value.book.outline.missing_cards}` : ""})· 世界观 ${value.book.worldbook.characters} 角色 / ${value.book.worldbook.locations} 地点 / ${value.book.worldbook.factions} 势力 / ${value.book.worldbook.rules} 规则${value.book.strict ? " · 严格大纲" : ""}`, `当前章卡:${value.book.outline.current === null ? "(无)" : `第${value.book.outline.current.num}章 ${value.book.outline.current.title} · ${value.book.outline.current.scene_type} · ${value.book.outline.current.target_words[0]}-${value.book.outline.current.target_words[1]} 字`}`],
11496
12452
  `下一步:${value.next.tool} — ${value.next.reason}`
11497
12453
  ].join("\n"));
11498
12454
  }
@@ -11701,7 +12657,11 @@ function createNovelTools(engine) {
11701
12657
  },
11702
12658
  content: {
11703
12659
  type: "string",
11704
- required: true
12660
+ description: "Full revised text; mutually exclusive with patch."
12661
+ },
12662
+ patch: {
12663
+ type: "json",
12664
+ description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
11705
12665
  },
11706
12666
  summary: { type: "json" },
11707
12667
  issues: {
@@ -11718,7 +12678,8 @@ function createNovelTools(engine) {
11718
12678
  output: jsonOutput,
11719
12679
  execute: guarded(async (args) => engine.submitRevision({
11720
12680
  chapter: args.chapter,
11721
- content: args.content,
12681
+ ...args.content === void 0 ? {} : { content: args.content },
12682
+ ...args.patch === void 0 ? {} : { patch: asJson(args.patch, "patch") },
11722
12683
  summary: asJson(args.summary, "summary"),
11723
12684
  issues: asJson(args.issues, "issues"),
11724
12685
  proof: asJson(args.proof, "proof"),
@@ -11739,6 +12700,11 @@ function createNovelTools(engine) {
11739
12700
  required: true,
11740
12701
  description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
11741
12702
  },
12703
+ acknowledge: {
12704
+ type: "array",
12705
+ items: { type: "string" },
12706
+ description: "Advisory codes read and accepted; blockers cannot be acknowledged."
12707
+ },
11742
12708
  project_root: root
11743
12709
  },
11744
12710
  output: jsonOutput,
@@ -11746,12 +12712,13 @@ function createNovelTools(engine) {
11746
12712
  chapter: args.chapter,
11747
12713
  scope: args.scope,
11748
12714
  semantic: asJson(args.semantic, "semantic"),
12715
+ ...args.acknowledge === void 0 ? {} : { acknowledge: args.acknowledge },
11749
12716
  root: args.project_root
11750
12717
  }))
11751
12718
  }),
11752
12719
  defineTool({
11753
12720
  name: "novel_archive",
11754
- description: "Archive the active chapter: all gates run, then one transaction writes the index, five ledgers and handoff.",
12721
+ description: "Archive the active chapter: all gates run, then one transaction writes the index, eight ledgers and handoff.",
11755
12722
  parameters: {
11756
12723
  chapter: {
11757
12724
  type: "integer",
@@ -11799,7 +12766,7 @@ function createNovelTools(engine) {
11799
12766
  }),
11800
12767
  defineTool({
11801
12768
  name: "novel_state",
11802
- description: "Read a state document (ledgers, plans, review, check, outline, worldbook) without touching the manuscript.",
12769
+ description: "Read a state document (ledgers, plans, review, check, outline, worldbook), list brief receipts, revision history or backup manifests without touching the manuscript.",
11803
12770
  parameters: {
11804
12771
  kind: {
11805
12772
  type: "string",
@@ -11820,10 +12787,14 @@ function createNovelTools(engine) {
11820
12787
  "planWorld",
11821
12788
  "planChapter",
11822
12789
  "outline",
11823
- "worldbook"
12790
+ "worldbook",
12791
+ "history",
12792
+ "backups",
12793
+ "briefs"
11824
12794
  ]
11825
12795
  },
11826
12796
  chapter: { type: "integer" },
12797
+ revision: { type: "integer" },
11827
12798
  include_summary: {
11828
12799
  type: "boolean",
11829
12800
  description: "For kind=chapters: include the full ten-field summary (default compact)."
@@ -11839,6 +12810,7 @@ function createNovelTools(engine) {
11839
12810
  execute: guarded(async (args) => engine.queryState({
11840
12811
  kind: args.kind,
11841
12812
  chapter: args.chapter,
12813
+ ...args.revision === void 0 ? {} : { revision: args.revision },
11842
12814
  ...args.include_summary === void 0 ? {} : { include_summary: args.include_summary },
11843
12815
  ...args.chapter_range === void 0 ? {} : { chapter_range: args.chapter_range },
11844
12816
  root: args.project_root
@@ -11959,14 +12931,29 @@ function createNovelTools(engine) {
11959
12931
  }),
11960
12932
  defineTool({
11961
12933
  name: "novel_audit",
11962
- description: "Read the append-only journal as an audit trail of every committed and aborted transaction.",
12934
+ description: "Read the append-only journal, including refused gate attempts, with chapter/op/status/time filters and a revision cursor.",
11963
12935
  parameters: {
11964
12936
  limit: { type: "integer" },
12937
+ cursor: { type: "integer" },
12938
+ chapter: { type: "integer" },
12939
+ op: { type: "string" },
12940
+ status: {
12941
+ type: "string",
12942
+ enum: ["committed", "aborted"]
12943
+ },
12944
+ since: { type: "string" },
12945
+ until: { type: "string" },
11965
12946
  project_root: root
11966
12947
  },
11967
12948
  output: jsonOutput,
11968
12949
  execute: guarded(async (args) => engine.auditHistory({
11969
12950
  limit: args.limit,
12951
+ cursor: args.cursor,
12952
+ chapter: args.chapter,
12953
+ op: args.op,
12954
+ status: args.status,
12955
+ since: args.since,
12956
+ until: args.until,
11970
12957
  root: args.project_root
11971
12958
  }))
11972
12959
  }),
@@ -12083,6 +13070,10 @@ function createNovelTools(engine) {
12083
13070
  characters: { type: "json" },
12084
13071
  glossary: { type: "json" },
12085
13072
  backstory: { type: "json" },
13073
+ facts: {
13074
+ type: "json",
13075
+ description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
13076
+ },
12086
13077
  themes: stringArray,
12087
13078
  project_root: root
12088
13079
  },
@@ -12096,6 +13087,7 @@ function createNovelTools(engine) {
12096
13087
  characters: asJson(args.characters, "characters"),
12097
13088
  glossary: asJson(args.glossary, "glossary"),
12098
13089
  backstory: asJson(args.backstory, "backstory"),
13090
+ ...args.facts === void 0 ? {} : { facts: asJson(args.facts, "facts") },
12099
13091
  themes: args.themes,
12100
13092
  root: args.project_root
12101
13093
  }))
@@ -12171,6 +13163,30 @@ function createNovelTools(engine) {
12171
13163
  reason: args.reason,
12172
13164
  root: args.project_root
12173
13165
  }))
13166
+ }),
13167
+ defineTool({
13168
+ name: "novel_restore",
13169
+ description: "Restore an active chapter file from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
13170
+ parameters: {
13171
+ txid: {
13172
+ type: "string",
13173
+ required: true
13174
+ },
13175
+ chapter: { type: "integer" },
13176
+ paths: {
13177
+ type: "array",
13178
+ items: { type: "string" },
13179
+ description: "Project-relative chapter files; omitted restores every restorable chapter file that the backup manifest records."
13180
+ },
13181
+ project_root: root
13182
+ },
13183
+ output: jsonOutput,
13184
+ execute: guarded(async (args) => engine.restoreBackup({
13185
+ txid: args.txid,
13186
+ chapter: args.chapter,
13187
+ ...args.paths === void 0 ? {} : { paths: args.paths },
13188
+ root: args.project_root
13189
+ }))
12174
13190
  })
12175
13191
  ];
12176
13192
  }
@@ -12214,7 +13230,7 @@ const rootArg = (args) => {
12214
13230
  return args.root;
12215
13231
  };
12216
13232
  /**
12217
- * The seventeen tools hidden when `tools.mode=core`, plus the explicit
13233
+ * The eighteen tools hidden when `tools.mode=core`, plus the explicit
12218
13234
  * registry/settings-pack operations that are command-only by design (§6.8).
12219
13235
  * They stay reachable through `/novel admin <operation> <json>`; the caller
12220
13236
  * supplies the same arguments the tool schema would have accepted.
@@ -12242,6 +13258,7 @@ const OPERATIONS$1 = {
12242
13258
  novel_report: (engine, args) => engine.report(args),
12243
13259
  novel_guide: (engine, args) => engine.guide(args),
12244
13260
  novel_reopen: (engine, args) => engine.reopenChapter(args),
13261
+ novel_restore: (engine, args) => engine.restoreBackup(args),
12245
13262
  novel_registry_list: (engine) => engine.listProjects(),
12246
13263
  novel_registry_register: (engine, args) => engine.registerProject({ root: rootArg(args) }),
12247
13264
  novel_registry_activate: (engine, args) => engine.setActiveProject({ root: rootArg(args) }),
@@ -12305,16 +13322,17 @@ const bad = (text) => ({
12305
13322
  text
12306
13323
  });
12307
13324
  const failure = (error) => error instanceof EngineError ? bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`) : bad(String(error));
13325
+ const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
12308
13326
  const statusText = async (engine) => {
12309
13327
  const { data } = await engine.getStatus({});
12310
13328
  return [
12311
13329
  `《${data.project.title}》 ${data.project.genre} · ${data.project.platform}`,
12312
13330
  `第 ${data.chapter} 章 · 阶段 ${data.stage}`,
12313
13331
  `轮次 评审 ${data.rounds.review} / 修订 ${data.rounds.revise} / 校验 ${data.rounds.check}`,
12314
- data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}`,
13332
+ data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}(汉字 ${data.metrics.cjk_chars} + 标点 ${data.metrics.punctuation_chars},含标点)`,
12315
13333
  data.blocking.length === 0 ? "门控 全部通过" : `门控 ${data.blocking.length} 条阻塞:`,
12316
13334
  ...data.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
12317
- ...data.book === void 0 ? [] : [`全书:大纲 ${data.book.outline.total} 张卡(已归档 ${data.book.outline.archived},覆盖率 ${Math.round(data.book.outline.coverage * 100)}%)· 世界观 ${data.book.worldbook.characters} 角色 / ${data.book.worldbook.locations} 地点 / ${data.book.worldbook.factions} 势力 / ${data.book.worldbook.rules} 规则${data.book.strict ? " · 严格大纲" : ""}`, ...data.book.outline.current === null ? ["当前章卡:(无)"] : [`当前章卡:第${data.book.outline.current.num}章 ${data.book.outline.current.title} · ${data.book.outline.current.scene_type} · ${data.book.outline.current.target_words[0]}-${data.book.outline.current.target_words[1]} 字`]],
13335
+ ...data.book === void 0 ? [] : [`全书:大纲 ${data.book.outline.total} 张卡(规划覆盖 ${formatPercent(data.book.outline.planned_coverage)} · 归档覆盖 ${formatPercent(data.book.outline.archived_coverage)}${data.book.outline.missing_cards > 0 ? ` · 缺卡 ${data.book.outline.missing_cards}` : ""})· 世界观 ${data.book.worldbook.characters} 角色 / ${data.book.worldbook.locations} 地点 / ${data.book.worldbook.factions} 势力 / ${data.book.worldbook.rules} 规则${data.book.strict ? " · 严格大纲" : ""}`, ...data.book.outline.current === null ? ["当前章卡:(无)"] : [`当前章卡:第${data.book.outline.current.num}章 ${data.book.outline.current.title} · ${data.book.outline.current.scene_type} · ${data.book.outline.current.target_words[0]}-${data.book.outline.current.target_words[1]} 字`]],
12318
13336
  `下一步 ${data.next.tool}:${data.next.reason}`
12319
13337
  ].join("\n");
12320
13338
  };
@@ -12351,7 +13369,7 @@ function createCommands(engine) {
12351
13369
  }
12352
13370
  case "stats": {
12353
13371
  const stats = await engine.stats({});
12354
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.plannedChapters} 卡(覆盖 ${Math.round(stats.outlineCoverage * 100)}%)· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
13372
+ return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
12355
13373
  }
12356
13374
  case "outline": {
12357
13375
  const status = await engine.getStatus({});
@@ -12365,7 +13383,7 @@ function createCommands(engine) {
12365
13383
  if (book === void 0) return ok("这本书还没有书级规划数据。");
12366
13384
  return ok([
12367
13385
  `蓝图:${book.premise === "" ? "(空)" : book.premise}`,
12368
- `大纲:${book.outline.total} 张卡 · 已归档 ${book.outline.archived} · 覆盖率 ${Math.round(book.outline.coverage * 100)}% · 严格模式 ${book.strict ? "开" : "关"}`,
13386
+ `大纲:${book.outline.total} 张卡 · 规划覆盖 ${formatPercent(book.outline.planned_coverage)} · 归档覆盖 ${formatPercent(book.outline.archived_coverage)}${book.outline.missing_cards > 0 ? ` · 缺卡 ${book.outline.missing_cards}` : ""} · 严格模式 ${book.strict ? "开" : "关"}`,
12369
13387
  `当前章卡:${book.outline.current === null ? "(无)" : `第${book.outline.current.num}章 ${book.outline.current.title} / ${book.outline.current.scene_type}`}`,
12370
13388
  `世界观:${book.worldbook.characters} 角色 · ${book.worldbook.locations} 地点 · ${book.worldbook.factions} 势力 · ${book.worldbook.rules} 规则`
12371
13389
  ].join("\n"));
@@ -12454,7 +13472,7 @@ function createCommands(engine) {
12454
13472
  if (invocation.signal.aborted) return bad("已取消");
12455
13473
  try {
12456
13474
  const stats = await engine.stats({});
12457
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.plannedChapters} 卡(覆盖 ${Math.round(stats.outlineCoverage * 100)}%)· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
13475
+ return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
12458
13476
  } catch (error) {
12459
13477
  return failure(error);
12460
13478
  }
@@ -12520,7 +13538,7 @@ function createSkill() {
12520
13538
  /** Build the payload from one synchronized snapshot of the project. */
12521
13539
  async function panelPayload(engine) {
12522
13540
  return {
12523
- schemaVersion: 2,
13541
+ schemaVersion: 3,
12524
13542
  ...await engine.panelBundle()
12525
13543
  };
12526
13544
  }
@@ -12562,7 +13580,7 @@ function panelHandler(engine, guard = denyAll) {
12562
13580
  response.setHeader("content-type", "application/json; charset=utf-8");
12563
13581
  response.setHeader("cache-control", "no-store");
12564
13582
  response.end(JSON.stringify({
12565
- schemaVersion: 2,
13583
+ schemaVersion: 3,
12566
13584
  error: "还没有项目:调用 novel_init 创建。",
12567
13585
  empty: true
12568
13586
  }));
@@ -12572,7 +13590,7 @@ function panelHandler(engine, guard = denyAll) {
12572
13590
  response.setHeader("content-type", "application/json; charset=utf-8");
12573
13591
  response.setHeader("cache-control", "no-store");
12574
13592
  response.end(JSON.stringify({
12575
- schemaVersion: 2,
13593
+ schemaVersion: 3,
12576
13594
  error: error instanceof Error ? error.message : String(error)
12577
13595
  }));
12578
13596
  }