dsh-novel-write-engine 0.2.3 → 0.2.5

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
@@ -20,6 +20,8 @@ const PANEL_CHAPTER_LIMIT = 30;
20
20
  /** Project-relative locations. Every write goes through these constants. */
21
21
  const PROJECT_FILE = "novel.yaml";
22
22
  const OUTLINE_FILE = "outline/outline.yaml";
23
+ /** Last outline replaced by a normal planning write; one level is enough for repair. */
24
+ const OUTLINE_DEPRECATED_FILE = "outline/outline.deprecated.yaml";
23
25
  const WORLDBOOK_FILE = "outline/worldbook.yaml";
24
26
  const STATE_DIR = "state";
25
27
  const CHAPTERS_DIR = "chapters";
@@ -243,6 +245,9 @@ const ERROR_CODES = {
243
245
  /** The exact same keyed submission already failed in the same context. */
244
246
  RETRY_UNCHANGED: "ENGINE_RETRY_UNCHANGED",
245
247
  GATE_BLOCKED: "ENGINE_GATE_BLOCKED",
248
+ CHECK_SUBMISSION_INVALID: "ENGINE_CHECK_SUBMISSION_INVALID",
249
+ PENDING_PARTIAL: "ENGINE_PENDING_PARTIAL",
250
+ IO_PERMISSION: "ENGINE_IO_PERMISSION",
246
251
  READING_PROOF_INVALID: "ENGINE_READING_PROOF_INVALID",
247
252
  SCORE_MISMATCH: "ENGINE_SCORE_MISMATCH",
248
253
  /** A revision tried to rewrite reviewer-owned issue fields. */
@@ -1391,7 +1396,7 @@ const duplicateParagraph = (ctx) => {
1391
1396
  }
1392
1397
  const blocker = ctx.rulepack.crosschapter.duplicate_similarity;
1393
1398
  const severity = best.score >= blocker ? "blocker" : "advisory";
1394
- return [v(CROSS_RULES.DUPLICATE_PARAGRAPH, severity, currentParagraph + 1, `paragraph ${currentParagraph + 1} reuses ${Math.round(best.score * 100)}% of paragraph ${previousParagraph - matched.start + 1} from previous source ${matched.source} (advisory ≥${Math.round(advisory * 100)}%, blocker ≥${Math.round(blocker * 100)}%)`)];
1399
+ return [v(CROSS_RULES.DUPLICATE_PARAGRAPH, severity, currentParagraph + 1, `paragraph ${currentParagraph + 1} reuses ${Math.round(best.score * 100)}% of paragraph ${previousParagraph + 1} from previous source ${matched.source} (advisory ≥${Math.round(advisory * 100)}%, blocker ≥${Math.round(blocker * 100)}%)`)];
1395
1400
  };
1396
1401
  const foreshadowDormant = (ctx) => {
1397
1402
  const ledger = ctx.evidence.foreshadows;
@@ -1506,11 +1511,12 @@ const timeRegression = (ctx) => {
1506
1511
  const ordered = [...ledger.items].sort((left, right) => left.chapter - right.chapter);
1507
1512
  const last = ordered[ordered.length - 1];
1508
1513
  const previous = ordered.length >= 2 ? ordered[ordered.length - 2] : null;
1509
- const currentFromText = dayOf(ctx.text);
1514
+ const currentMatch = /(?:Day\s*(\d+)|第\s*(\d+)\s*[天日])/i.exec(ctx.text);
1515
+ const currentFromText = currentMatch === null ? null : Number(currentMatch[1] ?? currentMatch[2]);
1510
1516
  const current = currentFromText ?? dayOf(last.story_time);
1511
1517
  const before = previous === null ? null : dayOf(previous.story_time);
1512
1518
  if (current === null) return [];
1513
- if (currentFromText !== null && dayOf(last.story_time) !== null && currentFromText < dayOf(last.story_time)) return [v(CROSS_RULES.TIME_REGRESSION, "advisory", ctx.chapter, `story time goes back from ${last.story_time} to Day ${currentFromText}`)];
1519
+ if (currentFromText !== null && dayOf(last.story_time) !== null && currentFromText < dayOf(last.story_time)) return [v(CROSS_RULES.TIME_REGRESSION, "advisory", paragraphOfIndex(ctx.text, currentMatch.index), `story time goes back from ${last.story_time} to Day ${currentFromText}`)];
1514
1520
  if (before === null || current >= before) return [];
1515
1521
  return [v(CROSS_RULES.TIME_REGRESSION, "advisory", last.chapter, `story time goes back from ${previous.story_time} to ${last.story_time}`)];
1516
1522
  };
@@ -1920,6 +1926,8 @@ const SCHEMAS = {
1920
1926
  machine: req("object"),
1921
1927
  semantic: req("object"),
1922
1928
  acknowledged: f("array"),
1929
+ failure_class: f("string", { enum: ["submission", "content"] }),
1930
+ counted: f("boolean"),
1923
1931
  verdict: req("string", { enum: ["pass", "fail"] })
1924
1932
  },
1925
1933
  worldPlan: {
@@ -2002,6 +2010,15 @@ const nullableInteger = (kind, value, path) => {
2002
2010
  const validateRecords = (kind, value, path, validate) => {
2003
2011
  for (const [index, raw] of listAt(kind, value, path).entries()) validate(objectAt(kind, raw, `${path}[${index}]`), `${path}[${index}]`);
2004
2012
  };
2013
+ const duplicateNames = (kind, rows, path, nameOf) => {
2014
+ const seen = /* @__PURE__ */ new Set();
2015
+ for (const row of rows) {
2016
+ const name = nameOf(row);
2017
+ if (typeof name !== "string" || name === "") continue;
2018
+ if (seen.has(name)) invalid$1(kind, `${path} contains duplicate name "${name}"`);
2019
+ seen.add(name);
2020
+ }
2021
+ };
2005
2022
  const validateSummary = (kind, value, path) => {
2006
2023
  const summary = objectAt(kind, value, path);
2007
2024
  for (const key of [
@@ -2095,6 +2112,7 @@ const validateNested = (kind, record) => {
2095
2112
  listAt(kind, row.growth, `${path}.growth`);
2096
2113
  integerAt(kind, row.updated_at, `${path}.updated_at`);
2097
2114
  });
2115
+ duplicateNames(kind, record.characters, "characters", (row) => row.name);
2098
2116
  return;
2099
2117
  case "world":
2100
2118
  validateRecords(kind, record.locations, "locations", (row, path) => {
@@ -2112,6 +2130,20 @@ const validateNested = (kind, record) => {
2112
2130
  "factions",
2113
2131
  "rules"
2114
2132
  ]) listAt(kind, record[key], key);
2133
+ {
2134
+ const names = [];
2135
+ for (const key of [
2136
+ "locations",
2137
+ "abilities",
2138
+ "artifacts",
2139
+ "factions",
2140
+ "rules"
2141
+ ]) {
2142
+ const rows = Array.isArray(record[key]) ? record[key] : [];
2143
+ for (const row of rows) if (typeof row.name === "string") names.push(row.name);
2144
+ }
2145
+ if (new Set(names).size !== names.length) invalid$1(kind, "world entities must have unique names");
2146
+ }
2115
2147
  return;
2116
2148
  case "locks": {
2117
2149
  integerAt(kind, record.locked_at_chapter, "locked_at_chapter");
@@ -2185,6 +2217,12 @@ const validateNested = (kind, record) => {
2185
2217
  stringAt(kind, row.evidence, `${path}.evidence`);
2186
2218
  if (row.note !== void 0) stringAt(kind, row.note, `${path}.note`);
2187
2219
  });
2220
+ if (semantic.findings !== void 0) validateRecords(kind, semantic.findings, "semantic.findings", (row, path) => {
2221
+ stringAt(kind, row.code, `${path}.code`);
2222
+ stringAt(kind, row.detail, `${path}.detail`);
2223
+ if (row.scope !== void 0) stringAt(kind, row.scope, `${path}.scope`);
2224
+ if (row.hint !== void 0) stringAt(kind, row.hint, `${path}.hint`);
2225
+ });
2188
2226
  return;
2189
2227
  }
2190
2228
  case "outline": {
@@ -2384,7 +2422,7 @@ var FsStore = class {
2384
2422
  if ((await promises.lstat(current)).isSymbolicLink()) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": "${segment}" is a symlink`);
2385
2423
  } catch (error) {
2386
2424
  if (isMissing(error)) break;
2387
- throw error;
2425
+ throw ioFailure(error);
2388
2426
  }
2389
2427
  }
2390
2428
  return absolute;
@@ -2401,7 +2439,7 @@ var FsStore = class {
2401
2439
  return true;
2402
2440
  } catch (error) {
2403
2441
  if (isMissing(error)) return false;
2404
- throw error;
2442
+ throw ioFailure(error);
2405
2443
  }
2406
2444
  }
2407
2445
  async stat(relativePath) {
@@ -2414,7 +2452,7 @@ var FsStore = class {
2414
2452
  };
2415
2453
  } catch (error) {
2416
2454
  if (isMissing(error)) return null;
2417
- throw error;
2455
+ throw ioFailure(error);
2418
2456
  }
2419
2457
  }
2420
2458
  async list(relativeDir) {
@@ -2422,7 +2460,7 @@ var FsStore = class {
2422
2460
  return (await promises.readdir(await this.safePath(relativeDir))).sort();
2423
2461
  } catch (error) {
2424
2462
  if (isMissing(error)) return [];
2425
- throw error;
2463
+ throw ioFailure(error);
2426
2464
  }
2427
2465
  }
2428
2466
  /**
@@ -2439,7 +2477,7 @@ var FsStore = class {
2439
2477
  })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
2440
2478
  } catch (error) {
2441
2479
  if (isMissing(error)) return [];
2442
- throw error;
2480
+ throw ioFailure(error);
2443
2481
  }
2444
2482
  }
2445
2483
  /**
@@ -2457,7 +2495,7 @@ var FsStore = class {
2457
2495
  };
2458
2496
  } catch (error) {
2459
2497
  if (isMissing(error)) return null;
2460
- throw error;
2498
+ throw ioFailure(error);
2461
2499
  }
2462
2500
  }
2463
2501
  /**
@@ -2477,7 +2515,7 @@ var FsStore = class {
2477
2515
  };
2478
2516
  } catch (error) {
2479
2517
  if (isMissing(error)) return null;
2480
- throw error;
2518
+ throw ioFailure(error);
2481
2519
  }
2482
2520
  }
2483
2521
  async readText(relativePath) {
@@ -2485,7 +2523,7 @@ var FsStore = class {
2485
2523
  return await promises.readFile(await this.safePath(relativePath), "utf8");
2486
2524
  } catch (error) {
2487
2525
  if (isMissing(error)) return null;
2488
- throw error;
2526
+ throw ioFailure(error);
2489
2527
  }
2490
2528
  }
2491
2529
  /** Read and narrow one of the twelve engine documents. */
@@ -2519,7 +2557,7 @@ var FsStore = class {
2519
2557
  await promises.mkdir(current);
2520
2558
  } catch (error) {
2521
2559
  if (error.code === "EEXIST") continue;
2522
- throw error;
2560
+ throw ioFailure(error);
2523
2561
  }
2524
2562
  }
2525
2563
  }
@@ -2540,7 +2578,7 @@ var FsStore = class {
2540
2578
  await syncDirectory(dirname(absolute));
2541
2579
  } catch (error) {
2542
2580
  await promises.rm(temp, { force: true }).catch(() => void 0);
2543
- throw error;
2581
+ throw ioFailure(error);
2544
2582
  }
2545
2583
  }
2546
2584
  async writeDoc(relativePath, document) {
@@ -2585,7 +2623,7 @@ var FsStore = class {
2585
2623
  return await promises.readFile(await this.safePath(relativePath));
2586
2624
  } catch (error) {
2587
2625
  if (isMissing(error)) return null;
2588
- throw error;
2626
+ throw ioFailure(error);
2589
2627
  }
2590
2628
  }
2591
2629
  async writeBytes(relativePath, bytes) {
@@ -2604,7 +2642,7 @@ var FsStore = class {
2604
2642
  await syncDirectory(dirname(absolute));
2605
2643
  } catch (error) {
2606
2644
  await promises.rm(temp, { force: true }).catch(() => void 0);
2607
- throw error;
2645
+ throw ioFailure(error);
2608
2646
  }
2609
2647
  }
2610
2648
  /** Content hash of a file, or null when missing. */
@@ -2632,6 +2670,12 @@ async function syncDirectory(directory) {
2632
2670
  function isMissing(error) {
2633
2671
  return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
2634
2672
  }
2673
+ function ioFailure(error) {
2674
+ if (error instanceof EngineFaultError) return error;
2675
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
2676
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") return new EngineFaultError(ERROR_CODES.IO_PERMISSION, `filesystem permission denied (${code}): ${error instanceof Error ? error.message : String(error)}`, "fix the project directory permissions, or restore a readable copy before retrying", { cause: error });
2677
+ return error;
2678
+ }
2635
2679
  /** Join project-relative segments with forward slashes. */
2636
2680
  function joinRel(...parts) {
2637
2681
  return parts.filter((part) => part !== "").join("/");
@@ -2755,21 +2799,18 @@ async function reconcileJournal(store) {
2755
2799
  };
2756
2800
  }
2757
2801
  /**
2758
- * Resolve a pending transaction left by a crash. The pending row is written
2759
- * after the pre-images are captured and before the first rename, so it is the
2760
- * only record that can distinguish "renamed but not journaled" from "crashed
2761
- * before touching a target". A fully matching pending transaction is promoted
2762
- * to committed; a transaction whose targets all still match their pre-images
2763
- * is closed as aborted; any mixture is refused as a partial commit.
2802
+ * Read-only classification of the last pending transaction. Status/doctor/
2803
+ * audit must never call recoverPending: recovery writes a terminal journal row,
2804
+ * and a read command is not allowed to mutate the project.
2764
2805
  */
2765
- async function recoverPending(store) {
2806
+ async function inspectPending(store) {
2766
2807
  const { records } = await readJournal(store);
2767
2808
  const last = [...records].reverse().find((record) => record.op !== "lock_reclaimed");
2768
- if (last?.status !== "pending") return;
2809
+ if (last?.status !== "pending") return { state: "none" };
2769
2810
  const matchesExpected = async () => {
2770
2811
  if (last.hashes === void 0) return false;
2771
2812
  for (const [path, expected] of Object.entries(last.hashes)) if (await store.hashBytes(path) !== expected) return false;
2772
- return true;
2813
+ return Object.keys(last.hashes).length > 0;
2773
2814
  };
2774
2815
  const matchesBefore = async () => {
2775
2816
  if (last.before === void 0) return false;
@@ -2777,9 +2818,45 @@ async function recoverPending(store) {
2777
2818
  const actual = await store.hashBytes(path);
2778
2819
  if (expected === null ? actual !== null : actual !== expected) return false;
2779
2820
  }
2780
- return true;
2821
+ return Object.keys(last.before).length > 0;
2822
+ };
2823
+ if (await matchesExpected()) return {
2824
+ state: "complete",
2825
+ txid: last.txid,
2826
+ files: last.files,
2827
+ detail: "all target files match the pending hashes; the next write can promote this transaction",
2828
+ stagingDir: joinRel(STAGING_DIR, last.txid)
2829
+ };
2830
+ if (await matchesBefore()) return {
2831
+ state: "untouched",
2832
+ txid: last.txid,
2833
+ files: last.files,
2834
+ detail: "no target changed; the next write can close this transaction as aborted",
2835
+ stagingDir: joinRel(STAGING_DIR, last.txid)
2781
2836
  };
2782
- if (await matchesExpected()) {
2837
+ return {
2838
+ state: "partial",
2839
+ txid: last.txid,
2840
+ files: last.files,
2841
+ detail: "some targets changed and others did not; automatic recovery is unsafe",
2842
+ stagingDir: joinRel(STAGING_DIR, last.txid)
2843
+ };
2844
+ }
2845
+ /**
2846
+ * Resolve a pending transaction left by a crash. The pending row is written
2847
+ * after the pre-images are captured and before the first rename, so it is the
2848
+ * only record that can distinguish "renamed but not journaled" from "crashed
2849
+ * before touching a target". A fully matching pending transaction is promoted
2850
+ * to committed; a transaction whose targets all still match their pre-images
2851
+ * is closed as aborted; any mixture is refused as a partial commit.
2852
+ */
2853
+ async function recoverPending(store) {
2854
+ const inspection = await inspectPending(store);
2855
+ if (inspection.state === "none" || inspection.txid === void 0) return;
2856
+ const { records } = await readJournal(store);
2857
+ const last = [...records].reverse().find((record) => record.txid === inspection.txid);
2858
+ if (last === void 0 || last.status !== "pending") return;
2859
+ if (inspection.state === "complete") {
2783
2860
  await appendJournal(store, {
2784
2861
  ...last,
2785
2862
  status: "committed"
@@ -2787,7 +2864,7 @@ async function recoverPending(store) {
2787
2864
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
2788
2865
  return;
2789
2866
  }
2790
- if (await matchesBefore()) {
2867
+ if (inspection.state === "untouched") {
2791
2868
  await appendJournal(store, {
2792
2869
  ...last,
2793
2870
  status: "aborted",
@@ -2797,7 +2874,7 @@ async function recoverPending(store) {
2797
2874
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
2798
2875
  return;
2799
2876
  }
2800
- throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `pending transaction ${last.txid} partially changed the project; automatic recovery is unsafe`, `restore from backups/ or ${STAGING_DIR}/${last.txid}/before/ before retrying`);
2877
+ throw new EngineFaultError(ERROR_CODES.PENDING_PARTIAL, `pending transaction ${last.txid} partially changed the project; automatic recovery is unsafe`, `inspect ${STAGING_DIR}/${last.txid}/before/ and state/journal.jsonl before restoring anything`);
2801
2878
  }
2802
2879
  /**
2803
2880
  * A crash during append can leave one JSONL fragment without a newline. Since
@@ -2989,22 +3066,43 @@ var FileLock = class {
2989
3066
  if (Date.now() - mtimeMs <= this.staleMs) return false;
2990
3067
  } else if (lockRecordIsLive(record)) return false;
2991
3068
  const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
3069
+ let claimByRename = false;
2992
3070
  try {
2993
3071
  await promises.link(this.absolutePath, claimPath);
2994
3072
  } catch (error) {
2995
3073
  const code = error.code;
2996
3074
  if (code === "ENOENT") return true;
2997
3075
  if (code === "EPERM" || code === "EACCES") return false;
2998
- throw error;
3076
+ if ([
3077
+ "ENOSYS",
3078
+ "ENOTSUP",
3079
+ "EOPNOTSUPP",
3080
+ "EXDEV",
3081
+ "EINVAL"
3082
+ ].includes(code ?? "")) try {
3083
+ await promises.rename(this.absolutePath, claimPath);
3084
+ claimByRename = true;
3085
+ } catch (renameError) {
3086
+ if (renameError.code === "ENOENT") return true;
3087
+ throw renameError;
3088
+ }
3089
+ else throw error;
2999
3090
  }
3000
3091
  const moved = await this.readAt(claimPath);
3001
3092
  const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
3002
3093
  if (!(record === null ? moved === null && Date.now() - claimMtime > this.staleMs : moved !== null && moved.pid === record.pid && moved.startedAt === record.startedAt && moved.nonce === record.nonce)) {
3003
- await promises.rm(claimPath, { force: true }).catch(() => void 0);
3094
+ if (claimByRename) await promises.access(this.absolutePath).then(async () => {
3095
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
3096
+ }, async () => {
3097
+ await promises.rename(claimPath, this.absolutePath).catch(async () => {
3098
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
3099
+ });
3100
+ });
3101
+ else await promises.rm(claimPath, { force: true }).catch(() => void 0);
3004
3102
  return false;
3005
3103
  }
3006
- const current = await this.readAt(this.absolutePath);
3007
- if (!(record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce)) {
3104
+ const current = claimByRename ? null : await this.readAt(this.absolutePath);
3105
+ if (!(claimByRename || (record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce))) {
3008
3106
  await promises.rm(claimPath, { force: true }).catch(() => void 0);
3009
3107
  return false;
3010
3108
  }
@@ -3023,6 +3121,7 @@ const BACKUP_MANIFEST = "manifest.json";
3023
3121
  async function listBackups(store) {
3024
3122
  const entries = [];
3025
3123
  for (const name of await store.list(BACKUP_DIR)) {
3124
+ if (name === "migrations") continue;
3026
3125
  const info = await store.stat(joinRel(BACKUP_DIR, name));
3027
3126
  if (info === null || !info.isDirectory) continue;
3028
3127
  if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
@@ -3810,7 +3909,7 @@ function buildEpub(project, chapters) {
3810
3909
  const escapeXml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3811
3910
  // src/engine/book.ts
3812
3911
  const LENIENT_TITLE_PREFIX$1 = /^第\s*\d+\s*章\s*/;
3813
- const text$1 = (value, where, max = LIMITS.maxOutlineTextFieldChars) => {
3912
+ const text$2 = (value, where, max = LIMITS.maxOutlineTextFieldChars) => {
3814
3913
  if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
3815
3914
  const trimmed = value.trim();
3816
3915
  if (Array.from(trimmed).length > max) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} is ${Array.from(trimmed).length} characters; limit is ${max}`);
@@ -3829,13 +3928,13 @@ const stringArray$1 = (value, where, maxItems = 20) => {
3829
3928
  if (value.length > maxItems) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains ${value.length} items; limit is ${maxItems}`);
3830
3929
  const seen = /* @__PURE__ */ new Set();
3831
3930
  return value.map((item, index) => {
3832
- const entry = text$1(item, `${where}[${index}]`, 200);
3931
+ const entry = text$2(item, `${where}[${index}]`, 200);
3833
3932
  if (seen.has(entry)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains duplicate "${entry}"`);
3834
3933
  seen.add(entry);
3835
3934
  return entry;
3836
3935
  });
3837
3936
  };
3838
- const positiveInteger = (value, where) => {
3937
+ const positiveInteger$1 = (value, where) => {
3839
3938
  if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
3840
3939
  return value;
3841
3940
  };
@@ -3845,7 +3944,7 @@ const wordPair = (value, where) => {
3845
3944
  if (!Number.isInteger(min) || !Number.isInteger(max) || min <= 0 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be [min, max] positive integers with min <= max`);
3846
3945
  return [min, max];
3847
3946
  };
3848
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3947
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3849
3948
  /** Remove a leading 第N章 prefix; stored titles are bodies. */
3850
3949
  function stripChapterPrefix(title) {
3851
3950
  return title.replace(LENIENT_TITLE_PREFIX$1, "").trim();
@@ -3947,7 +4046,7 @@ const BOOK_PATCH_KEYS = /* @__PURE__ */ new Set([
3947
4046
  /** Apply a partial book patch; omitted fields keep their existing value. */
3948
4047
  function mergeBookPlan(existing, patch) {
3949
4048
  if (patch === void 0) return { ...existing };
3950
- if (!isRecord$1(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
4049
+ if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
3951
4050
  for (const key of Object.keys(patch)) if (!BOOK_PATCH_KEYS.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `book has unknown field "${key}"`, `allowed: ${[...BOOK_PATCH_KEYS].join(", ")}`);
3952
4051
  const result = { ...existing };
3953
4052
  for (const key of [
@@ -3975,15 +4074,15 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
3975
4074
  if (!Array.isArray(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
3976
4075
  if (input.length > LIMITS.maxOutlineVolumes) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains ${input.length} items; limit is ${LIMITS.maxOutlineVolumes}`);
3977
4076
  const volumes = input.map((raw, index) => {
3978
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
3979
- const start = positiveInteger(raw.start_chapter, `volumes[${index}].start_chapter`);
3980
- const end = positiveInteger(raw.end_chapter, `volumes[${index}].end_chapter`);
4077
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
4078
+ const start = positiveInteger$1(raw.start_chapter, `volumes[${index}].start_chapter`);
4079
+ const end = positiveInteger$1(raw.end_chapter, `volumes[${index}].end_chapter`);
3981
4080
  if (end < start) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] end_chapter ${end} is before start_chapter ${start}`);
3982
4081
  const status = raw.status ?? "planned";
3983
4082
  if (status !== "planned" && status !== "writing" && status !== "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}].status must be planned|writing|done`);
3984
4083
  return {
3985
- id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : `V${index + 1}`,
3986
- title: text$1(raw.title, `volumes[${index}].title`, 120),
4084
+ id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : "",
4085
+ title: text$2(raw.title, `volumes[${index}].title`, 120),
3987
4086
  start_chapter: start,
3988
4087
  end_chapter: end,
3989
4088
  goal: optionalText(raw.goal, `volumes[${index}].goal`),
@@ -3992,6 +4091,23 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
3992
4091
  status
3993
4092
  };
3994
4093
  });
4094
+ const usedIds = /* @__PURE__ */ new Set();
4095
+ const usedTitles = /* @__PURE__ */ new Set();
4096
+ for (const volume of volumes) {
4097
+ if (usedTitles.has(volume.title) && options.rejectDuplicateTitles !== false) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume title "${volume.title}"`);
4098
+ usedTitles.add(volume.title);
4099
+ if (volume.id === "") continue;
4100
+ if (usedIds.has(volume.id)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume id "${volume.id}"`);
4101
+ usedIds.add(volume.id);
4102
+ }
4103
+ let nextId = 1;
4104
+ for (const volume of volumes) {
4105
+ if (volume.id !== "") continue;
4106
+ while (usedIds.has(`V${nextId}`)) nextId += 1;
4107
+ volume.id = `V${nextId}`;
4108
+ usedIds.add(volume.id);
4109
+ nextId += 1;
4110
+ }
3995
4111
  volumes.sort((left, right) => left.start_chapter - right.start_chapter || left.end_chapter - right.end_chapter);
3996
4112
  const ids = /* @__PURE__ */ new Set();
3997
4113
  for (const [index, volume] of volumes.entries()) {
@@ -4075,8 +4191,8 @@ function normalizeOutlineChapters(input, options) {
4075
4191
  const archived = new Map(options.archived.map((record) => [record.num, record]));
4076
4192
  const normalized = /* @__PURE__ */ new Map();
4077
4193
  for (const [index, raw] of input.entries()) {
4078
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4079
- const num = positiveInteger(raw.num, `chapters[${index}].num`);
4194
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4195
+ const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4080
4196
  if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`, "raise total_chapters with novel_blueprint before planning a longer book");
4081
4197
  if (normalized.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
4082
4198
  const record = archived.get(num);
@@ -4093,13 +4209,13 @@ function normalizeOutlineChapters(input, options) {
4093
4209
  if (cast.length !== base.cast.length || cast.some((name) => !base.cast.includes(name))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} is archived; its cast cannot change`, "call novel_reopen first if the manuscript must be revised");
4094
4210
  normalized.set(num, {
4095
4211
  ...base,
4096
- goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$1(raw.goal, `chapter ${num} goal`),
4097
- conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$1(raw.conflict, `chapter ${num} conflict`),
4098
- hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$1(raw.hook, `chapter ${num} hook`),
4212
+ goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$2(raw.goal, `chapter ${num} goal`),
4213
+ conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$2(raw.conflict, `chapter ${num} conflict`),
4214
+ hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$2(raw.hook, `chapter ${num} hook`),
4099
4215
  notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
4100
4216
  volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
4101
4217
  status: "archived",
4102
- archived_at: base.archived_at ?? options.now,
4218
+ archived_at: base.archived_at ?? record.archived_at ?? null,
4103
4219
  word_count: record.word_count,
4104
4220
  score: record.score
4105
4221
  });
@@ -4114,18 +4230,19 @@ function normalizeOutlineChapters(input, options) {
4114
4230
  "情感爆发章",
4115
4231
  "纯叙事章"
4116
4232
  ].includes(raw.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}].scene_type is invalid`);
4117
- const cast = stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
4233
+ const previousCard = existing.get(num);
4234
+ const cast = raw.cast == null && previousCard?.status === "planned" ? previousCard.cast : stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
4118
4235
  normalized.set(num, {
4119
4236
  num,
4120
- volume: volumeNumberFor(options.volumes, num, raw.volume),
4237
+ volume: raw.volume !== void 0 ? volumeNumberFor(options.volumes, num, raw.volume) : previousCard?.status === "planned" ? volumeForChapter(options.volumes, num, previousCard.volume) : volumeNumberFor(options.volumes, num, void 0),
4121
4238
  title,
4122
4239
  scene_type: raw.scene_type,
4123
- goal: text$1(raw.goal, `chapters[${index}].goal`),
4124
- conflict: text$1(raw.conflict, `chapters[${index}].conflict`),
4125
- hook: text$1(raw.hook, `chapters[${index}].hook`),
4240
+ goal: text$2(raw.goal, `chapters[${index}].goal`),
4241
+ conflict: text$2(raw.conflict, `chapters[${index}].conflict`),
4242
+ hook: text$2(raw.hook, `chapters[${index}].hook`),
4126
4243
  cast,
4127
4244
  target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
4128
- notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
4245
+ notes: raw.notes == null && previousCard?.status === "planned" ? previousCard.notes : optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
4129
4246
  status: "planned",
4130
4247
  archived_at: null,
4131
4248
  word_count: null,
@@ -4139,7 +4256,7 @@ function normalizeOutlineChapters(input, options) {
4139
4256
  ...base,
4140
4257
  volume: volumeForChapter(options.volumes, record.num, base.volume),
4141
4258
  status: "archived",
4142
- archived_at: base.archived_at ?? options.now,
4259
+ archived_at: base.archived_at ?? record.archived_at ?? null,
4143
4260
  word_count: record.word_count,
4144
4261
  score: record.score
4145
4262
  });
@@ -4148,7 +4265,7 @@ function normalizeOutlineChapters(input, options) {
4148
4265
  }
4149
4266
  /** Validate a document loaded from disk. Throws a fault, not an input error. */
4150
4267
  function validateOutlineDocument(value, options) {
4151
- if (!isRecord$1(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
4268
+ if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
4152
4269
  try {
4153
4270
  if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml schema_version must be 1, got ${String(value.schema_version)}`);
4154
4271
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4160,7 +4277,7 @@ function validateOutlineDocument(value, options) {
4160
4277
  ]);
4161
4278
  for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml has unknown key "${key}"`);
4162
4279
  if (typeof value.updated_at !== "string" || value.updated_at.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml updated_at must be a string");
4163
- if (!isRecord$1(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
4280
+ if (!isRecord$2(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
4164
4281
  const allowedBook = /* @__PURE__ */ new Set([
4165
4282
  "premise",
4166
4283
  "logline",
@@ -4175,14 +4292,17 @@ function validateOutlineDocument(value, options) {
4175
4292
  ]);
4176
4293
  for (const key of Object.keys(value.book)) if (!allowedBook.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml book has unknown key "${key}"`);
4177
4294
  const book = mergeBookPlan(emptyBookPlan(), value.book);
4178
- const volumes = normalizeVolumes(value.volumes, { totalChapters: options.totalChapters });
4295
+ const volumes = normalizeVolumes(value.volumes, {
4296
+ totalChapters: options.totalChapters,
4297
+ rejectDuplicateTitles: false
4298
+ });
4179
4299
  if (!Array.isArray(value.chapters)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapters must be an array");
4180
4300
  if (value.chapters.length > LIMITS.maxOutlineChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters contains ${value.chapters.length} items; limit is ${LIMITS.maxOutlineChapters}`);
4181
4301
  const chapters = [];
4182
4302
  const seen = /* @__PURE__ */ new Set();
4183
4303
  for (const [index, raw] of value.chapters.entries()) {
4184
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4185
- const num = positiveInteger(raw.num, `chapters[${index}].num`);
4304
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4305
+ const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4186
4306
  if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`);
4187
4307
  if (seen.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
4188
4308
  seen.add(num);
@@ -4208,9 +4328,9 @@ function validateOutlineDocument(value, options) {
4208
4328
  volume: volumeNumberFor(volumes, num, raw.volume),
4209
4329
  title: chapterTitleBody(raw.title, options.titleRule, `chapters[${index}].title`),
4210
4330
  scene_type: scene,
4211
- goal: planned ? text$1(raw.goal, `chapters[${index}].goal`) : optionalText(raw.goal, `chapters[${index}].goal`),
4212
- conflict: planned ? text$1(raw.conflict, `chapters[${index}].conflict`) : optionalText(raw.conflict, `chapters[${index}].conflict`),
4213
- hook: planned ? text$1(raw.hook, `chapters[${index}].hook`) : optionalText(raw.hook, `chapters[${index}].hook`),
4331
+ goal: planned ? text$2(raw.goal, `chapters[${index}].goal`) : optionalText(raw.goal, `chapters[${index}].goal`),
4332
+ conflict: planned ? text$2(raw.conflict, `chapters[${index}].conflict`) : optionalText(raw.conflict, `chapters[${index}].conflict`),
4333
+ hook: planned ? text$2(raw.hook, `chapters[${index}].hook`) : optionalText(raw.hook, `chapters[${index}].hook`),
4214
4334
  cast: stringArray$1(raw.cast, `chapters[${index}].cast`, 8),
4215
4335
  target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
4216
4336
  notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
@@ -4247,7 +4367,7 @@ function mergeWorldbook(existing, patch, now) {
4247
4367
  ...existing,
4248
4368
  updated_at: now
4249
4369
  };
4250
- if (!isRecord$1(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
4370
+ if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
4251
4371
  const result = {
4252
4372
  ...existing,
4253
4373
  updated_at: now,
@@ -4267,21 +4387,21 @@ function mergeWorldbook(existing, patch, now) {
4267
4387
  if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} must be an array`);
4268
4388
  if (value.length > LIMITS.maxWorldbookEntries) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} contains ${value.length} items; limit is ${LIMITS.maxWorldbookEntries}`);
4269
4389
  return value.map((raw, index) => {
4270
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
4390
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
4271
4391
  return raw;
4272
4392
  });
4273
4393
  };
4274
4394
  if (patch.rules !== void 0) {
4275
4395
  result.rules = records(patch.rules, "rules").map((raw, index) => ({
4276
- name: text$1(raw.name, `rules[${index}].name`, 120),
4277
- description: text$1(raw.description, `rules[${index}].description`, LIMITS.maxWorldbookTextChars)
4396
+ name: text$2(raw.name, `rules[${index}].name`, 120),
4397
+ description: text$2(raw.description, `rules[${index}].description`, LIMITS.maxWorldbookTextChars)
4278
4398
  }));
4279
4399
  uniqueNames(result.rules, "rules");
4280
4400
  }
4281
4401
  if (patch.factions !== void 0) {
4282
4402
  result.factions = records(patch.factions, "factions").map((raw, index) => ({
4283
- name: text$1(raw.name, `factions[${index}].name`, 120),
4284
- description: text$1(raw.description, `factions[${index}].description`, LIMITS.maxWorldbookTextChars),
4403
+ name: text$2(raw.name, `factions[${index}].name`, 120),
4404
+ description: text$2(raw.description, `factions[${index}].description`, LIMITS.maxWorldbookTextChars),
4285
4405
  goal: optionalText(raw.goal, `factions[${index}].goal`)
4286
4406
  }));
4287
4407
  uniqueNames(result.factions, "factions");
@@ -4291,8 +4411,8 @@ function mergeWorldbook(existing, patch, now) {
4291
4411
  const capacity = raw.capacity ?? 0;
4292
4412
  if (!Number.isInteger(capacity) || capacity < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a non-negative integer`);
4293
4413
  return {
4294
- name: text$1(raw.name, `locations[${index}].name`, 120),
4295
- description: text$1(raw.description, `locations[${index}].description`, LIMITS.maxWorldbookTextChars),
4414
+ name: text$2(raw.name, `locations[${index}].name`, 120),
4415
+ description: text$2(raw.description, `locations[${index}].description`, LIMITS.maxWorldbookTextChars),
4296
4416
  layout: optionalText(raw.layout, `locations[${index}].layout`),
4297
4417
  capacity
4298
4418
  };
@@ -4301,17 +4421,17 @@ function mergeWorldbook(existing, patch, now) {
4301
4421
  }
4302
4422
  if (patch.characters !== void 0) {
4303
4423
  result.characters = records(patch.characters, "characters").map((raw, index) => ({
4304
- name: text$1(raw.name, `characters[${index}].name`, 120),
4305
- role: text$1(raw.role, `characters[${index}].role`, 60),
4306
- description: text$1(raw.description, `characters[${index}].description`, LIMITS.maxWorldbookTextChars),
4424
+ name: text$2(raw.name, `characters[${index}].name`, 120),
4425
+ role: text$2(raw.role, `characters[${index}].role`, 60),
4426
+ description: text$2(raw.description, `characters[${index}].description`, LIMITS.maxWorldbookTextChars),
4307
4427
  arc: optionalText(raw.arc, `characters[${index}].arc`)
4308
4428
  }));
4309
4429
  uniqueNames(result.characters, "characters");
4310
4430
  }
4311
4431
  if (patch.glossary !== void 0) {
4312
4432
  result.glossary = records(patch.glossary, "glossary").map((raw, index) => ({
4313
- term: text$1(raw.term, `glossary[${index}].term`, 120),
4314
- definition: text$1(raw.definition, `glossary[${index}].definition`, LIMITS.maxWorldbookTextChars)
4433
+ term: text$2(raw.term, `glossary[${index}].term`, 120),
4434
+ definition: text$2(raw.definition, `glossary[${index}].definition`, LIMITS.maxWorldbookTextChars)
4315
4435
  }));
4316
4436
  const seen = /* @__PURE__ */ new Set();
4317
4437
  for (const entry of result.glossary) {
@@ -4320,8 +4440,8 @@ function mergeWorldbook(existing, patch, now) {
4320
4440
  }
4321
4441
  }
4322
4442
  if (patch.backstory !== void 0) result.backstory = records(patch.backstory, "backstory").map((raw, index) => ({
4323
- time: text$1(raw.time, `backstory[${index}].time`, 120),
4324
- event: text$1(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
4443
+ time: text$2(raw.time, `backstory[${index}].time`, 120),
4444
+ event: text$2(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
4325
4445
  }));
4326
4446
  if (patch.facts !== void 0) {
4327
4447
  result.facts = records(patch.facts, "facts").map((raw, index) => {
@@ -4330,9 +4450,9 @@ function mergeWorldbook(existing, patch, now) {
4330
4450
  const tolerance = raw.tolerance ?? 0;
4331
4451
  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`);
4332
4452
  return {
4333
- alias: text$1(raw.alias, `facts[${index}].alias`, 120),
4453
+ alias: text$2(raw.alias, `facts[${index}].alias`, 120),
4334
4454
  value,
4335
- unit: text$1(raw.unit, `facts[${index}].unit`, 40),
4455
+ unit: text$2(raw.unit, `facts[${index}].unit`, 40),
4336
4456
  tolerance,
4337
4457
  ...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
4338
4458
  };
@@ -4347,7 +4467,7 @@ function mergeWorldbook(existing, patch, now) {
4347
4467
  }
4348
4468
  /** Validate a worldbook loaded from disk. */
4349
4469
  function validateWorldbookDocument(value, now) {
4350
- if (!isRecord$1(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
4470
+ if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
4351
4471
  try {
4352
4472
  if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml schema_version must be 1, got ${String(value.schema_version)}`);
4353
4473
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4758,7 +4878,7 @@ function parseOutlineData(content, format) {
4758
4878
  } catch (error) {
4759
4879
  throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} is invalid: ${error instanceof Error ? error.message : String(error)}`);
4760
4880
  }
4761
- if (!isRecord$1(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
4881
+ if (!isRecord$2(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
4762
4882
  if (parsed.schema_version !== void 0 && parsed.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} schema_version must be 1, got ${String(parsed.schema_version)}`);
4763
4883
  const warnings = [];
4764
4884
  const allowed = /* @__PURE__ */ new Set([
@@ -4853,8 +4973,238 @@ function renderWorldbookMarkdown(project, document) {
4853
4973
  section("角色", document.characters.map((entry) => `- **${oneLine(entry.name)}**(${oneLine(entry.role)}):${oneLine(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine(entry.arc)}`}`));
4854
4974
  section("术语", document.glossary.map((entry) => `- **${oneLine(entry.term)}**:${oneLine(entry.definition)}`));
4855
4975
  section("前史", document.backstory.map((entry) => `- ${oneLine(entry.time)}:${oneLine(entry.event)}`));
4976
+ section("数值事实", (document.facts ?? []).map((fact) => `- **${oneLine(fact.alias)}**:${fact.value}${oneLine(fact.unit)}${fact.tolerance === void 0 ? "" : `(容差 ${fact.tolerance})`}${fact.note === void 0 || fact.note === "" ? "" : `;${oneLine(fact.note)}`}`));
4856
4977
  return parts.join("\n");
4857
4978
  }
4979
+ // src/engine/outline-merge.ts
4980
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4981
+ const text$1 = (value, where) => {
4982
+ if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
4983
+ return value.trim();
4984
+ };
4985
+ const positiveInteger = (value, where) => {
4986
+ if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
4987
+ return value;
4988
+ };
4989
+ const parseChapterNumbers = (value, field) => {
4990
+ if (value === void 0) return /* @__PURE__ */ new Set();
4991
+ if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of chapter numbers`);
4992
+ const result = /* @__PURE__ */ new Set();
4993
+ for (const [index, raw] of value.entries()) {
4994
+ const num = positiveInteger(raw, `${field}[${index}]`);
4995
+ if (result.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate chapter ${num}`);
4996
+ result.add(num);
4997
+ }
4998
+ return result;
4999
+ };
5000
+ const parseVolumeTitles = (value, field) => {
5001
+ if (value === void 0) return /* @__PURE__ */ new Set();
5002
+ if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of volume titles`);
5003
+ const result = /* @__PURE__ */ new Set();
5004
+ for (const [index, raw] of value.entries()) {
5005
+ const title = text$1(raw, `${field}[${index}]`);
5006
+ if (result.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate "${title}"`);
5007
+ result.add(title);
5008
+ }
5009
+ return result;
5010
+ };
5011
+ const submittedNumbers = (input) => {
5012
+ const result = /* @__PURE__ */ new Set();
5013
+ if (!Array.isArray(input)) return result;
5014
+ for (const raw of input) {
5015
+ if (!isRecord$1(raw) || !Number.isInteger(raw.num) || raw.num < 1) continue;
5016
+ result.add(raw.num);
5017
+ }
5018
+ return result;
5019
+ };
5020
+ const volumeByTitle = (volumes) => new Map(volumes.map((volume) => [volume.title, volume]));
5021
+ /**
5022
+ * `volumes` is an upsert keyed by title: omitted volumes survive, and removal
5023
+ * requires naming the volume in `remove_volumes`. This deliberately does not
5024
+ * accept a positional or full-list replacement.
5025
+ */
5026
+ const mergeVolumes = (options) => {
5027
+ const removed = parseVolumeTitles(options.remove_volumes, "remove_volumes");
5028
+ if (options.volumes === void 0 && removed.size === 0) return {
5029
+ volumes: [...options.existingVolumes],
5030
+ added: [],
5031
+ updated: [],
5032
+ removed: []
5033
+ };
5034
+ if (options.volumes !== void 0 && !Array.isArray(options.volumes)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
5035
+ if (options.volumes !== void 0 || removed.size > 0) {
5036
+ const existingTitles = /* @__PURE__ */ new Set();
5037
+ for (const volume of options.existingVolumes) {
5038
+ if (existingTitles.has(volume.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline has duplicate volume title "${volume.title}"`, "repair the volume titles with novel_amend before submitting volumes");
5039
+ existingTitles.add(volume.title);
5040
+ }
5041
+ }
5042
+ const byTitle = volumeByTitle(options.existingVolumes);
5043
+ const usedIds = new Set(options.existingVolumes.map((volume) => volume.id));
5044
+ let nextGeneratedId = 1;
5045
+ const generatedId = () => {
5046
+ while (usedIds.has(`V${nextGeneratedId}`)) nextGeneratedId += 1;
5047
+ const id = `V${nextGeneratedId}`;
5048
+ usedIds.add(id);
5049
+ nextGeneratedId += 1;
5050
+ return id;
5051
+ };
5052
+ const submitted = /* @__PURE__ */ new Set();
5053
+ for (const [index, raw] of (options.volumes ?? []).entries()) {
5054
+ if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
5055
+ const title = text$1(raw.title, `volumes[${index}].title`);
5056
+ if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
5057
+ submitted.add(title);
5058
+ const base = byTitle.get(title);
5059
+ const explicitId = typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : void 0;
5060
+ const id = base?.id ?? explicitId ?? generatedId();
5061
+ if (!usedIds.has(id)) usedIds.add(id);
5062
+ byTitle.set(title, {
5063
+ ...base ?? {},
5064
+ ...raw,
5065
+ id,
5066
+ title
5067
+ });
5068
+ }
5069
+ for (const title of removed) {
5070
+ if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${title}" cannot be both submitted and removed`);
5071
+ const existing = byTitle.get(title);
5072
+ if (existing === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": it is not in the plan`);
5073
+ if (existing.status === "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": its status is done`, "a completed volume is archive evidence; remove only planned/writing volumes");
5074
+ byTitle.delete(title);
5075
+ }
5076
+ const volumes = normalizeVolumes([...byTitle.values()], { totalChapters: options.totalChapters });
5077
+ const before = volumeByTitle(options.existingVolumes);
5078
+ const after = volumeByTitle(volumes);
5079
+ return {
5080
+ volumes,
5081
+ added: [...after.keys()].filter((title) => !before.has(title)),
5082
+ updated: [...after.keys()].filter((title) => before.has(title) && stableJson(before.get(title)) !== stableJson(after.get(title))),
5083
+ removed: [...removed]
5084
+ };
5085
+ };
5086
+ const remapVolume = (card, oldVolumes, newVolumes) => {
5087
+ if (card.volume === null || card.volume === void 0) return card;
5088
+ const previous = oldVolumes[card.volume - 1];
5089
+ if (previous === void 0) return {
5090
+ ...card,
5091
+ volume: null
5092
+ };
5093
+ const nextIndex = newVolumes.findIndex((volume) => volume.title === previous.title);
5094
+ if (nextIndex < 0) return {
5095
+ ...card,
5096
+ volume: null
5097
+ };
5098
+ const next = newVolumes[nextIndex];
5099
+ return {
5100
+ ...card,
5101
+ volume: card.num >= next.start_chapter && card.num <= next.end_chapter ? nextIndex + 1 : null
5102
+ };
5103
+ };
5104
+ const assertArchivedCardVolumes = (archivedChapters, cards, oldVolumes, newVolumes, operation = "outline write") => {
5105
+ const byNum = new Map(cards.map((card) => [card.num, card]));
5106
+ const newByTitle = volumeByTitle(newVolumes);
5107
+ for (const record of archivedChapters) {
5108
+ const card = byNum.get(record.num);
5109
+ const previous = card !== void 0 && card.volume !== null && card.volume !== void 0 ? oldVolumes[card.volume - 1] : oldVolumes.find((volume) => record.num >= volume.start_chapter && record.num <= volume.end_chapter);
5110
+ if (previous === void 0) continue;
5111
+ const next = newByTitle.get(previous.title);
5112
+ if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} would leave archived chapter ${record.num} outside its volume "${previous.title}"`, "keep the volume/range that covers archived cards, or call novel_reopen first");
5113
+ }
5114
+ };
5115
+ /**
5116
+ * Final-document guard: every archived chapter with a volume-aware outline
5117
+ * must point at a volume that actually covers it. The old-card guard above
5118
+ * only proves the volume list did not drop the old range; an amend can still
5119
+ * write a card with `volume:null` or a stale index.
5120
+ */
5121
+ const assertArchivedCardsInVolumes = (archivedChapters, cards, volumes, operation = "outline write") => {
5122
+ if (volumes.length === 0) return;
5123
+ const byNum = new Map(cards.map((card) => [card.num, card]));
5124
+ for (const record of archivedChapters) {
5125
+ const card = byNum.get(record.num);
5126
+ const volume = card !== void 0 && card.volume !== null && card.volume !== void 0 ? volumes[card.volume - 1] : void 0;
5127
+ if (volume === void 0 || record.num < volume.start_chapter || record.num > volume.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} would leave archived chapter ${record.num} without a covering volume`, "keep a volume/range that covers every archived card, or call novel_reopen first");
5128
+ }
5129
+ };
5130
+ /**
5131
+ * Merge chapter cards by num. Existing planned cards are appended back when
5132
+ * omitted; removal is name-only (`remove`), and archived cards are always
5133
+ * backfilled from the chapter index.
5134
+ */
5135
+ const mergeOutline = (options) => {
5136
+ const submitted = submittedNumbers(options.chapters);
5137
+ const removed = parseChapterNumbers(options.remove, "remove");
5138
+ const volumeChanges = mergeVolumes(options);
5139
+ assertArchivedCardVolumes(options.archivedChapters, options.existing, options.existingVolumes, volumeChanges.volumes, "novel_outline");
5140
+ const existingByNum = new Map(options.existing.map((card) => [card.num, card]));
5141
+ const archivedByNum = new Map(options.archivedChapters.map((record) => [record.num, record]));
5142
+ const archivedNums = new Set(archivedByNum.keys());
5143
+ for (const num of removed) {
5144
+ if (submitted.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} cannot be both submitted and removed`);
5145
+ if (num === options.activeChapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove the active chapter ${num} card`, "archive or move the active chapter first; the card is required while the chapter is in flight");
5146
+ if (archivedByNum.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is archived`, "archived cards are evidence; call novel_reopen first");
5147
+ if (existingByNum.get(num)?.status !== "planned") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is not a planned card`);
5148
+ }
5149
+ const existingForNormalize = options.existing.map((card) => archivedNums.has(card.num) ? remapVolume(card, options.existingVolumes, volumeChanges.volumes) : card);
5150
+ const normalized = normalizeOutlineChapters(options.chapters, {
5151
+ volumes: volumeChanges.volumes,
5152
+ existing: existingForNormalize,
5153
+ archived: options.archivedChapters,
5154
+ titleRule: options.titleRule,
5155
+ totalChapters: options.totalChapters,
5156
+ defaultTargetWords: options.defaultTargetWords,
5157
+ now: options.now
5158
+ });
5159
+ const byNum = new Map(normalized.map((card) => [card.num, card]));
5160
+ const updated = /* @__PURE__ */ new Set();
5161
+ const added = /* @__PURE__ */ new Set();
5162
+ const reindexed = /* @__PURE__ */ new Set();
5163
+ for (const card of normalized) {
5164
+ if (!submitted.has(card.num)) continue;
5165
+ const before = existingByNum.get(card.num);
5166
+ if (before === void 0) added.add(card.num);
5167
+ else if (stableJson(before) !== stableJson(card)) updated.add(card.num);
5168
+ }
5169
+ for (const card of options.existing) {
5170
+ if (submitted.has(card.num) || removed.has(card.num)) continue;
5171
+ if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${card.num} has an archived outline card but is not in the chapter index`, "repair the outline or reopen/archive the chapter so the card status matches the index");
5172
+ if (archivedNums.has(card.num)) {
5173
+ if (!byNum.has(card.num)) byNum.set(card.num, {
5174
+ ...card,
5175
+ status: "archived"
5176
+ });
5177
+ const current = byNum.get(card.num);
5178
+ if (stableJson(card) !== stableJson(current)) reindexed.add(card.num);
5179
+ continue;
5180
+ }
5181
+ const remapped = remapVolume(card, options.existingVolumes, volumeChanges.volumes);
5182
+ if (byNum.has(card.num)) {
5183
+ const current = byNum.get(card.num);
5184
+ if (stableJson(current) !== stableJson(remapped)) {
5185
+ byNum.set(card.num, remapped);
5186
+ reindexed.add(card.num);
5187
+ }
5188
+ } else {
5189
+ byNum.set(card.num, remapped);
5190
+ if (stableJson(card) !== stableJson(remapped)) reindexed.add(card.num);
5191
+ }
5192
+ }
5193
+ for (const num of removed) byNum.delete(num);
5194
+ const chapters = [...byNum.values()].sort((left, right) => left.num - right.num);
5195
+ assertArchivedCardsInVolumes(options.archivedChapters, chapters, volumeChanges.volumes, "novel_outline");
5196
+ return {
5197
+ chapters,
5198
+ volumes: volumeChanges.volumes,
5199
+ added: [...added].sort((left, right) => left - right),
5200
+ updated: [...updated].sort((left, right) => left - right),
5201
+ reindexed: [...reindexed].sort((left, right) => left - right),
5202
+ removed: [...removed].sort((left, right) => left - right),
5203
+ volumes_added: volumeChanges.added,
5204
+ volumes_updated: volumeChanges.updated,
5205
+ volumes_removed: volumeChanges.removed
5206
+ };
5207
+ };
4858
5208
  // src/engine/state-delta.ts
4859
5209
  const SECTIONS = [
4860
5210
  "characters",
@@ -4878,6 +5228,28 @@ const setOwn = (record, key, value) => {
4878
5228
  writable: true
4879
5229
  });
4880
5230
  };
5231
+ /** Validate resource balances without accepting numeric strings or fractions. */
5232
+ const validateResources = (state, who) => {
5233
+ if (state.resources === void 0) return;
5234
+ if (!isRecord(state.resources)) fail$1(`${who}.current_state.resources must be an object`);
5235
+ for (const [name, value] of Object.entries(state.resources)) {
5236
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) fail$1(`${who}.current_state.resources.${name} must be an integer`);
5237
+ if (value < 0) throw new EngineInputError(ERROR_CODES.RESOURCE_NEGATIVE_BALANCE, `${who}'s ${name} balance is ${value}`, "state a non-negative integer balance; spend the resource before the balance reaches zero");
5238
+ }
5239
+ };
5240
+ /** Current state is a patch; resources are a nested map, not a whole replacement. */
5241
+ const mergeCurrentState = (base, patch) => {
5242
+ const merged = {
5243
+ ...base,
5244
+ ...patch
5245
+ };
5246
+ if (patch.resources !== void 0 && !isRecord(patch.resources)) fail$1("current_state.resources must be an object");
5247
+ if (patch.resources !== void 0) merged.resources = {
5248
+ ...isRecord(base.resources) ? base.resources : {},
5249
+ ...patch.resources
5250
+ };
5251
+ return merged;
5252
+ };
4881
5253
  function fail$1(detail) {
4882
5254
  throw new EngineInputError(ERROR_CODES.DELTA_UNVERIFIED, `state_delta is incomplete: ${detail}`, "declare every entity the chapter touched as created, updated, or no_change with a reason");
4883
5255
  }
@@ -4971,6 +5343,20 @@ function applyStateDelta(input) {
4971
5343
  const timeline = structuredClone(input.timeline);
4972
5344
  const relationships = structuredClone(input.relationships);
4973
5345
  const assigned = Object.create(null);
5346
+ const warnings = [];
5347
+ const normalizedWorld = Object.create(null);
5348
+ const warnReclass = (name, from, to, reason) => {
5349
+ normalizedWorld[name] = {
5350
+ from,
5351
+ to,
5352
+ reason
5353
+ };
5354
+ const code = reason === "existing-created-as-updated" ? "WORLD_ELEMENT_RECLASSIFIED_CREATED_TO_UPDATED" : reason === "missing-updated-as-created" ? "WORLD_ELEMENT_RECLASSIFIED_UPDATED_TO_CREATED" : "WORLD_ELEMENT_RECLASSIFIED_NO_CHANGE_TO_CREATED";
5355
+ warnings.push({
5356
+ code,
5357
+ message: `world.${name}: ${from} normalized to ${to} against state/world.yaml (${reason})`
5358
+ });
5359
+ };
4974
5360
  const appeared = [...new Set(summary.characters_appeared)];
4975
5361
  const byName = new Map(characters.characters.map((character) => [character.name, character]));
4976
5362
  const narrator = input.narrator ?? "";
@@ -4991,7 +5377,7 @@ function applyStateDelta(input) {
4991
5377
  "profile",
4992
5378
  "current_state",
4993
5379
  "growth"
4994
- ]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"港币\":-3},\"status\":\"...\"}" : field === "profile" ? "{\"age\":31,\"occupation\":\"...\"}" : "{\"change\":\"...\",\"status_change\":\"...\"}"}`);
5380
+ ]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"港币\":3},\"status\":\"...\"}" : field === "profile" ? "{\"age\":31,\"occupation\":\"...\"}" : "{\"change\":\"...\",\"status_change\":\"...\"}"}`);
4995
5381
  if (declared.growth !== void 0) {
4996
5382
  const growth = declared.growth;
4997
5383
  for (const field of ["change", "status_change"]) if (typeof growth[field] !== "string" || growth[field].trim() === "") fail$1(`characters.${name}.growth.${field} must be a non-empty string`);
@@ -5002,12 +5388,14 @@ function applyStateDelta(input) {
5002
5388
  if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
5003
5389
  const rawHint = input.roleHints?.[name];
5004
5390
  const hinted = canonicalRole(rawHint) ?? (rawHint === void 0 && (name === narratorName || name === narrator) ? "protagonist" : "extra");
5391
+ const currentState = declared.current_state ?? {};
5392
+ validateResources(currentState, `characters.${name}`);
5005
5393
  const created = {
5006
5394
  name,
5007
5395
  role: normalizeRole(declared.role, hinted),
5008
5396
  first_appearance: chapter,
5009
5397
  profile: declared.profile ?? {},
5010
- current_state: declared.current_state ?? {},
5398
+ current_state: currentState,
5011
5399
  growth: [],
5012
5400
  updated_at: chapter
5013
5401
  };
@@ -5028,10 +5416,11 @@ function applyStateDelta(input) {
5028
5416
  ...existing.profile,
5029
5417
  ...declared.profile
5030
5418
  };
5031
- if (declared.current_state !== void 0) existing.current_state = {
5032
- ...existing.current_state,
5033
- ...declared.current_state
5034
- };
5419
+ if (declared.current_state !== void 0) {
5420
+ const merged = mergeCurrentState(existing.current_state, declared.current_state);
5421
+ validateResources(merged, `characters.${name}`);
5422
+ existing.current_state = merged;
5423
+ }
5035
5424
  if (declared.growth !== void 0) existing.growth.push({
5036
5425
  ...declared.growth,
5037
5426
  chapter
@@ -5055,6 +5444,7 @@ function applyStateDelta(input) {
5055
5444
  ...state,
5056
5445
  resources
5057
5446
  };
5447
+ validateResources(character.current_state, `characters.${change.character}`);
5058
5448
  }
5059
5449
  const findWorld = (name) => {
5060
5450
  const location = world.locations.find((candidate) => candidate.name === name);
@@ -5090,8 +5480,11 @@ function applyStateDelta(input) {
5090
5480
  if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
5091
5481
  if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
5092
5482
  const declared = delta.world[name];
5093
- if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
5094
5483
  if (declared.kind !== void 0 && declared.kind !== kind) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${kind}`);
5484
+ if (findWorld(name) !== null) warnings.push({
5485
+ code: "WORLD_ELEMENT_ALREADY_IN_LEDGER",
5486
+ message: `summary.new_elements lists "${name}", but state/world.yaml already has it; ledger existence wins`
5487
+ });
5095
5488
  }
5096
5489
  for (const [name, raw] of Object.entries(delta.world)) {
5097
5490
  const declared = entry(raw, `world.${name}`);
@@ -5100,22 +5493,33 @@ function applyStateDelta(input) {
5100
5493
  if (declared.layout !== void 0 && typeof declared.layout !== "string") fail$1(`world.${name}.layout must be a string`);
5101
5494
  if (declared.capacity !== void 0 && (!Number.isInteger(declared.capacity) || declared.capacity < 0)) fail$1(`world.${name}.capacity must be a non-negative integer`);
5102
5495
  if (declared.detail !== void 0 && !isRecord(declared.detail)) fail$1(`world.${name}.detail must be an object`);
5103
- if (declared.detail !== void 0) {
5104
- if (declared.action !== "created") {
5105
- for (const reserved of [
5106
- "name",
5107
- "first_appearance",
5108
- "description"
5109
- ]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
5110
- }
5111
- }
5112
5496
  const expected = expectedKind(name);
5113
- if (expected !== null) {
5114
- if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
5115
- if (declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
5116
- }
5497
+ if (expected !== null && declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
5117
5498
  const existing = findWorld(name);
5118
- if (declared.action === "created") {
5499
+ let action = declared.action;
5500
+ if (action === "created" && existing !== null) {
5501
+ if (declared.kind !== void 0 && declared.kind !== existing.kind) fail$1(`world.${name} is declared as ${declared.kind} but the ledger has ${existing.kind}; kind cannot change`);
5502
+ if (expected !== null && expected !== existing.kind) fail$1(`world.${name} is listed as ${expected} but the ledger has ${existing.kind}`);
5503
+ action = "updated";
5504
+ warnReclass(name, "created", "updated", "existing-created-as-updated");
5505
+ } else if (action === "updated" && existing === null) {
5506
+ if ((declared.kind ?? expected) === null) fail$1(`world.${name} does not exist; declare kind (location|ability|artifact|faction|rule) or list it in summary.new_elements`);
5507
+ if (!input.text.includes(name)) fail$1(`world entity "${name}" is new but does not appear in the manuscript`);
5508
+ action = "created";
5509
+ warnReclass(name, "updated", "created", "missing-updated-as-created");
5510
+ } else if (action === "no_change" && existing === null && expected !== null) {
5511
+ if (!input.text.includes(name)) fail$1(`world entity "${name}" is new but does not appear in the manuscript`);
5512
+ action = "created";
5513
+ warnReclass(name, "no_change", "created", "missing-no-change-as-created");
5514
+ }
5515
+ if (declared.detail !== void 0 && action !== "created") {
5516
+ for (const reserved of [
5517
+ "name",
5518
+ "first_appearance",
5519
+ "description"
5520
+ ]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
5521
+ }
5522
+ if (action === "created") {
5119
5523
  if (name.trim() === "") fail$1("a created world entity has an empty name");
5120
5524
  if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
5121
5525
  if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
@@ -5128,7 +5532,7 @@ function applyStateDelta(input) {
5128
5532
  "faction",
5129
5533
  "rule"
5130
5534
  ].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
5131
- if (expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
5535
+ if (declared.action === "created" && expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
5132
5536
  if (kind === "location") world.locations.push({
5133
5537
  name,
5134
5538
  first_appearance: chapter,
@@ -5147,7 +5551,8 @@ function applyStateDelta(input) {
5147
5551
  continue;
5148
5552
  }
5149
5553
  if (existing === null) fail$1(`world entity "${name}" does not exist; created must be used first`);
5150
- if (declared.action === "updated") {
5554
+ if (declared.kind !== void 0 && existing.kind !== declared.kind) fail$1(`world.${name} is declared as ${declared.kind} but the ledger has ${existing.kind}; kind cannot change`);
5555
+ if (action === "updated") {
5151
5556
  if (existing.kind === "location") {
5152
5557
  const location = existing.value;
5153
5558
  if (declared.description !== void 0) location.description = declared.description;
@@ -5290,6 +5695,7 @@ function applyStateDelta(input) {
5290
5695
  const declared = entry(delta.timeline[key], `timeline.${key}`);
5291
5696
  if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
5292
5697
  if (declared.characters !== void 0 && (!Array.isArray(declared.characters) || declared.characters.some((character) => typeof character !== "string"))) fail$1(`timeline.${key}.characters must be a string array`);
5698
+ for (const character of declared.characters ?? []) if (byName.get(character) === void 0) fail$1(`timeline.${key} names unknown character "${character}"`);
5293
5699
  const allocated = allocateIds("TE", timeline.next_seq, 1);
5294
5700
  const id = allocated.ids[0];
5295
5701
  if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
@@ -5356,7 +5762,9 @@ function applyStateDelta(input) {
5356
5762
  foreshadows,
5357
5763
  timeline,
5358
5764
  relationships,
5359
- assigned
5765
+ assigned,
5766
+ warnings,
5767
+ normalized_world: normalizedWorld
5360
5768
  };
5361
5769
  }
5362
5770
  // src/engine/insight.ts
@@ -6692,6 +7100,26 @@ const SEMANTIC_CHECK_IDS = [
6692
7100
  "L2-VOICE",
6693
7101
  "L2-POV"
6694
7102
  ];
7103
+ /**
7104
+ * A caller supplied evidence that cannot be evaluated as a content judgement.
7105
+ * It must never be persisted as a failed check or consume a content round.
7106
+ */
7107
+ var CheckSubmissionError = class extends EngineInputError {
7108
+ items;
7109
+ passed;
7110
+ failed;
7111
+ rounds_consumed = 0;
7112
+ failure_class = "submission";
7113
+ constructor(items) {
7114
+ const passed = items.filter((item) => item.state === "pass").length;
7115
+ const failed = items.filter((item) => item.state !== "pass" && item.state !== "fail").length;
7116
+ const first = items.find((item) => item.state !== "pass" && item.state !== "fail");
7117
+ super(ERROR_CODES.CHECK_SUBMISSION_INVALID, `semantic submission is invalid: ${first?.id ?? "semantic"} ${first?.state ?? "invalid"}${first?.detail === void 0 ? "" : ` (${first.detail})`}; ${passed} passed, ${failed} failed; no check was written and no round was consumed`, "resubmit novel_check with all five semantic items; each needs a distinct verbatim quote of at least 8 characters");
7118
+ this.items = items;
7119
+ this.passed = passed;
7120
+ this.failed = failed;
7121
+ }
7122
+ };
6695
7123
  /** Extract the quoted part of an evidence string, tolerating "第 N 段:" labels. */
6696
7124
  function evidenceQuote(value) {
6697
7125
  let candidate = value.trim();
@@ -6701,151 +7129,312 @@ function evidenceQuote(value) {
6701
7129
  candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
6702
7130
  return candidate;
6703
7131
  }
6704
- /** Sentence spans, including the terminating punctuation, for evidence de-duplication. */
7132
+ const CLOSING_PUNCTUATION = /* @__PURE__ */ new Set([
7133
+ "」",
7134
+ "』",
7135
+ "”",
7136
+ "\"",
7137
+ "’",
7138
+ "'",
7139
+ ")",
7140
+ ")",
7141
+ "】",
7142
+ "]"
7143
+ ]);
7144
+ /**
7145
+ * Split text into sentence spans while treating every non-empty line as a
7146
+ * paragraph boundary. A quote may cover multiple sentences or paragraphs; the
7147
+ * spans are only used to prove that no two evidence items reuse the same
7148
+ * passage. Trailing closing quotes belong to the sentence they terminate.
7149
+ */
6705
7150
  function sentenceSpans(text) {
6706
7151
  const spans = [];
6707
- let start = 0;
6708
- for (let index = 0; index < text.length; index += 1) if ("。!?!?".includes(text[index])) {
6709
- spans.push({
6710
- start,
6711
- end: index + 1
6712
- });
6713
- start = index + 1;
7152
+ let paragraph = 1;
7153
+ let lineStart = 0;
7154
+ let sentenceStart = 0;
7155
+ for (let index = 0; index < text.length; index += 1) {
7156
+ const char = text[index];
7157
+ if (char === "\n") {
7158
+ if (text.slice(lineStart, index).trim() !== "") {
7159
+ if (index > sentenceStart) spans.push({
7160
+ start: sentenceStart,
7161
+ end: index,
7162
+ paragraph
7163
+ });
7164
+ paragraph += 1;
7165
+ }
7166
+ lineStart = index + 1;
7167
+ sentenceStart = index + 1;
7168
+ continue;
7169
+ }
7170
+ if ("。!?!?".includes(char)) {
7171
+ let end = index + 1;
7172
+ while (end < text.length && CLOSING_PUNCTUATION.has(text[end])) end += 1;
7173
+ spans.push({
7174
+ start: sentenceStart,
7175
+ end,
7176
+ paragraph
7177
+ });
7178
+ sentenceStart = end;
7179
+ index = end - 1;
7180
+ }
6714
7181
  }
6715
- if (start < text.length) spans.push({
6716
- start,
6717
- end: text.length
7182
+ if (sentenceStart < text.length) spans.push({
7183
+ start: sentenceStart,
7184
+ end: text.length,
7185
+ paragraph
6718
7186
  });
6719
7187
  return spans;
6720
7188
  }
7189
+ function nearestParagraph(text, quote) {
7190
+ const paragraphs = splitParagraphs(text);
7191
+ let best = null;
7192
+ for (const [index, paragraph] of paragraphs.entries()) {
7193
+ const score = textSimilarity(paragraph, quote);
7194
+ if (best === null || score > best.score) best = {
7195
+ index,
7196
+ paragraph,
7197
+ score
7198
+ };
7199
+ }
7200
+ if (best === null) return null;
7201
+ return {
7202
+ paragraph: best.index + 1,
7203
+ quote: Array.from(best.paragraph).slice(0, 120).join(""),
7204
+ score: Math.round(best.score * 1e3) / 1e3
7205
+ };
7206
+ }
7207
+ const submissionState = (state) => state !== "pass" && state !== "fail";
7208
+ const codeForState = (state) => {
7209
+ switch (state) {
7210
+ case "not_found": return "SEMANTIC_EVIDENCE_NOT_FOUND";
7211
+ case "too_short": return "SEMANTIC_EVIDENCE_TOO_SHORT";
7212
+ case "duplicate": return "SEMANTIC_EVIDENCE_DUPLICATE";
7213
+ case "overlap": return "SEMANTIC_EVIDENCE_OVERLAP";
7214
+ case "same_sentence": return "SEMANTIC_EVIDENCE_SAME_SENTENCE";
7215
+ case "missing": return "SEMANTIC_MISSING";
7216
+ case "boundary": return "SEMANTIC_EVIDENCE_BOUNDARY";
7217
+ default: return "SEMANTIC_ITEM_INVALID";
7218
+ }
7219
+ };
7220
+ const detailFor = (result) => {
7221
+ if (result.detail !== void 0) return result.detail;
7222
+ switch (result.state) {
7223
+ case "not_found": return `${result.id} cites a quote that does not appear verbatim in the manuscript`;
7224
+ case "too_short": return `${result.id} quotes fewer than 8 characters`;
7225
+ case "duplicate": return `${result.id} repeats ${result.conflict_with ?? "another item"}'s quote`;
7226
+ case "overlap": return `${result.id} overlaps ${result.conflict_with ?? "another item"}'s accepted evidence interval`;
7227
+ case "same_sentence": return `${result.id} reuses a sentence already cited by ${result.conflict_with ?? "another item"}`;
7228
+ case "boundary": return `${result.id} cannot be mapped onto a sentence boundary`;
7229
+ case "missing": return `${result.id} is required by the P6 task book`;
7230
+ default: return `${result.id} is invalid`;
7231
+ }
7232
+ };
6721
7233
  /**
6722
- * Validate the model-supplied L2 semantic items. Evidence must be a real
6723
- * substring of the manuscript; a failed verdict is itself a blocking finding.
6724
- * The model cannot turn the check green by asserting it.
7234
+ * Validate and evaluate all five L2 semantic items.
7235
+ *
7236
+ * Evidence is located by character range, not by requiring one sentence. Two
7237
+ * items may not overlap or reuse a sentence; a quote that exists but fails
7238
+ * those rules is reported as OVERLAP/SAME_SENTENCE instead of the old generic
7239
+ * "overlapping text or the same sentence" message.
6725
7240
  */
6726
- function evaluateSemantic(text, items) {
6727
- const blocking = [];
6728
- const seen = /* @__PURE__ */ new Set();
6729
- const seenQuotes = /* @__PURE__ */ new Set();
7241
+ function evaluateSemantic(text, input) {
7242
+ const items = [];
6730
7243
  const spans = [];
6731
7244
  const sentences = sentenceSpans(text);
6732
- const usedSentences = /* @__PURE__ */ new Set();
6733
- for (const [index, item] of items.entries()) {
7245
+ const usedSentences = /* @__PURE__ */ new Map();
7246
+ const seenIds = /* @__PURE__ */ new Set();
7247
+ const acceptedQuotes = /* @__PURE__ */ new Map();
7248
+ const rawItems = Array.isArray(input) ? input : [];
7249
+ if (!Array.isArray(input)) items.push({
7250
+ id: "semantic",
7251
+ state: "invalid",
7252
+ detail: "semantic must be an array of five items"
7253
+ });
7254
+ for (const [index, raw] of rawItems.entries()) {
6734
7255
  const where = `semantic[${index}]`;
6735
- if (typeof item !== "object" || item === null) {
6736
- blocking.push({
6737
- code: "SEMANTIC_ITEM_INVALID",
6738
- scope: "check",
7256
+ if (typeof raw !== "object" || raw === null) {
7257
+ items.push({
7258
+ id: where,
7259
+ state: "invalid",
6739
7260
  detail: `${where} is not an object`
6740
7261
  });
6741
7262
  continue;
6742
7263
  }
7264
+ const item = raw;
6743
7265
  if (typeof item.id !== "string" || item.id.trim() === "") {
6744
- blocking.push({
6745
- code: "SEMANTIC_ITEM_INVALID",
6746
- scope: "check",
7266
+ items.push({
7267
+ id: where,
7268
+ state: "invalid",
6747
7269
  detail: `${where} has no id`
6748
7270
  });
6749
7271
  continue;
6750
7272
  }
6751
- if (seen.has(item.id)) {
6752
- blocking.push({
6753
- code: "SEMANTIC_ITEM_INVALID",
6754
- scope: "check",
6755
- detail: `${item.id} is declared twice`
7273
+ const id = item.id.trim();
7274
+ if (seenIds.has(id)) {
7275
+ items.push({
7276
+ id,
7277
+ state: "invalid",
7278
+ detail: `${id} is declared twice`
6756
7279
  });
6757
7280
  continue;
6758
7281
  }
6759
- seen.add(item.id);
7282
+ seenIds.add(id);
6760
7283
  if (item.verdict !== "pass" && item.verdict !== "fail") {
6761
- blocking.push({
6762
- code: "SEMANTIC_ITEM_INVALID",
6763
- scope: "check",
6764
- detail: `${item.id} has verdict ${String(item.verdict)}; expected pass|fail`
7284
+ items.push({
7285
+ id,
7286
+ state: "invalid",
7287
+ detail: `${id} has verdict ${String(item.verdict)}; expected pass|fail`
6765
7288
  });
6766
7289
  continue;
6767
7290
  }
6768
7291
  if (typeof item.evidence !== "string" || item.evidence.trim() === "") {
6769
- blocking.push({
6770
- code: "SEMANTIC_EVIDENCE_INVALID",
6771
- scope: "check",
6772
- detail: `${item.id} has no evidence quote`
7292
+ items.push({
7293
+ id,
7294
+ state: "invalid",
7295
+ detail: `${id} has no evidence quote`
6773
7296
  });
6774
7297
  continue;
6775
7298
  }
6776
7299
  const quote = evidenceQuote(item.evidence);
6777
7300
  if (Array.from(quote).length < 8) {
6778
- blocking.push({
6779
- code: "SEMANTIC_EVIDENCE_INVALID",
6780
- scope: "check",
6781
- detail: `${item.id} quotes fewer than 8 characters`
7301
+ items.push({
7302
+ id,
7303
+ state: "too_short",
7304
+ quote,
7305
+ detail: `${id} quotes fewer than 8 characters`
6782
7306
  });
6783
7307
  continue;
6784
7308
  }
6785
- if (!text.includes(quote)) {
6786
- blocking.push({
6787
- code: "SEMANTIC_EVIDENCE_INVALID",
6788
- scope: "check",
6789
- detail: `${item.id} cites "${quote}", which does not appear in the manuscript`
7309
+ const duplicate = acceptedQuotes.get(quote);
7310
+ if (duplicate !== void 0) {
7311
+ items.push({
7312
+ id,
7313
+ state: "duplicate",
7314
+ quote,
7315
+ conflict_with: duplicate,
7316
+ detail: `${id} repeats ${duplicate}'s quote exactly`
6790
7317
  });
6791
7318
  continue;
6792
7319
  }
6793
- if (seenQuotes.has(quote)) {
6794
- blocking.push({
6795
- code: "SEMANTIC_EVIDENCE_INVALID",
6796
- scope: "check",
6797
- detail: `${item.id} repeats another semantic item's quote; each check needs distinct evidence`
7320
+ const occurrences = [];
7321
+ let found = text.indexOf(quote);
7322
+ while (found !== -1) {
7323
+ occurrences.push(found);
7324
+ found = text.indexOf(quote, found + 1);
7325
+ }
7326
+ if (occurrences.length === 0) {
7327
+ const nearest = nearestParagraph(text, quote);
7328
+ items.push({
7329
+ id,
7330
+ state: "not_found",
7331
+ quote,
7332
+ ...nearest === null ? {} : { nearest },
7333
+ detail: `${id} cites "${Array.from(quote).slice(0, 40).join("")}", which does not appear verbatim in the manuscript`
6798
7334
  });
6799
7335
  continue;
6800
7336
  }
6801
- let start = text.indexOf(quote);
6802
- let sentence = -1;
6803
- while (start !== -1) {
7337
+ let accepted = null;
7338
+ let overlapWith;
7339
+ let sameSentenceWith;
7340
+ let boundary = false;
7341
+ for (const start of occurrences) {
6804
7342
  const end = start + quote.length;
6805
- const overlaps = spans.some((span) => start < span.end && span.start < end);
6806
- const sentenceIndex = sentences.findIndex((candidate) => start >= candidate.start && end <= candidate.end);
6807
- if (!overlaps && sentenceIndex !== -1 && !usedSentences.has(sentenceIndex)) {
6808
- sentence = sentenceIndex;
6809
- break;
7343
+ const conflict = spans.find((span) => start < span.end && span.start < end);
7344
+ if (conflict !== void 0) {
7345
+ overlapWith ??= conflict.id;
7346
+ continue;
7347
+ }
7348
+ const covered = sentences.map((sentence, sentenceIndex) => ({
7349
+ sentence,
7350
+ sentenceIndex
7351
+ })).filter(({ sentence }) => start < sentence.end && sentence.start < end);
7352
+ if (covered.length === 0) {
7353
+ boundary = true;
7354
+ continue;
6810
7355
  }
6811
- start = text.indexOf(quote, start + 1);
7356
+ const used = covered.find(({ sentenceIndex }) => usedSentences.has(sentenceIndex));
7357
+ if (used !== void 0) {
7358
+ sameSentenceWith ??= usedSentences.get(used.sentenceIndex);
7359
+ continue;
7360
+ }
7361
+ accepted = {
7362
+ start,
7363
+ end,
7364
+ id
7365
+ };
7366
+ for (const { sentenceIndex } of covered) usedSentences.set(sentenceIndex, id);
7367
+ break;
6812
7368
  }
6813
- if (start === -1 || sentence === -1) {
6814
- blocking.push({
6815
- code: "SEMANTIC_EVIDENCE_INVALID",
6816
- scope: "check",
6817
- detail: `${item.id} cites overlapping text or the same sentence as another check; each check needs a distinct passage`
7369
+ if (accepted === null) {
7370
+ if (overlapWith !== void 0) items.push({
7371
+ id,
7372
+ state: "overlap",
7373
+ quote,
7374
+ conflict_with: overlapWith,
7375
+ detail: `${id} overlaps ${overlapWith}'s accepted evidence interval`
7376
+ });
7377
+ else if (sameSentenceWith !== void 0) items.push({
7378
+ id,
7379
+ state: "same_sentence",
7380
+ quote,
7381
+ conflict_with: sameSentenceWith,
7382
+ detail: `${id} reuses a sentence already cited by ${sameSentenceWith}`
7383
+ });
7384
+ else items.push({
7385
+ id,
7386
+ state: boundary ? "boundary" : "same_sentence",
7387
+ quote,
7388
+ ...boundary ? {} : { conflict_with: id },
7389
+ detail: boundary ? `${id} cannot be mapped onto a sentence boundary` : `${id} reuses a sentence already cited by another item`
6818
7390
  });
6819
7391
  continue;
6820
7392
  }
6821
- spans.push({
6822
- start,
6823
- end: start + quote.length
6824
- });
6825
- usedSentences.add(sentence);
6826
- seenQuotes.add(quote);
6827
- if (item.verdict === "fail") blocking.push({
6828
- code: "SEMANTIC_CHECK_FAILED",
6829
- scope: "check",
6830
- detail: `${item.id}: ${typeof item.note === "string" && item.note.trim() !== "" ? item.note : "semantic check failed"}`
7393
+ spans.push(accepted);
7394
+ acceptedQuotes.set(quote, id);
7395
+ items.push({
7396
+ id,
7397
+ state: item.verdict === "fail" ? "fail" : "pass",
7398
+ quote,
7399
+ ...item.verdict === "fail" ? { detail: `${id}: ${typeof item.note === "string" && item.note.trim() !== "" ? item.note : "semantic check failed"}` } : {}
6831
7400
  });
6832
7401
  }
6833
- const required = new Set(SEMANTIC_CHECK_IDS);
6834
- for (const id of SEMANTIC_CHECK_IDS) if (!seen.has(id)) blocking.push({
6835
- code: "SEMANTIC_MISSING",
6836
- scope: "check",
7402
+ for (const id of SEMANTIC_CHECK_IDS) if (!seenIds.has(id)) items.push({
7403
+ id,
7404
+ state: "missing",
6837
7405
  detail: `${id} is required by the P6 task book`
6838
7406
  });
6839
- for (const id of seen) if (!required.has(id)) blocking.push({
6840
- code: "SEMANTIC_ITEM_INVALID",
6841
- scope: "check",
7407
+ for (const id of seenIds) if (!SEMANTIC_CHECK_IDS.includes(id)) items.push({
7408
+ id,
7409
+ state: "invalid",
6842
7410
  detail: `${id} is not one of the required P6 semantic checks`
6843
7411
  });
7412
+ const findings = items.filter((item) => item.state === "fail").map((item) => ({
7413
+ code: "SEMANTIC_CHECK_FAILED",
7414
+ scope: "check",
7415
+ detail: item.detail ?? `${item.id}: semantic check failed`
7416
+ }));
7417
+ const submissionItems = items.filter((item) => submissionState(item.state));
7418
+ const blocking = [...submissionItems.map((item) => ({
7419
+ code: codeForState(item.state),
7420
+ scope: "check",
7421
+ detail: detailFor(item)
7422
+ })), ...findings];
6844
7423
  return {
7424
+ items,
7425
+ findings,
6845
7426
  blocking,
7427
+ submission_failed: submissionItems.length > 0,
7428
+ content_failed: findings.length > 0,
6846
7429
  failed: blocking.length > 0
6847
7430
  };
6848
7431
  }
7432
+ /** Throw a typed submission error when shape/evidence errors exist. */
7433
+ function validateSemanticSubmission(text, input) {
7434
+ const evaluation = evaluateSemantic(text, input);
7435
+ if (evaluation.submission_failed) throw new CheckSubmissionError(evaluation.items);
7436
+ return evaluation;
7437
+ }
6849
7438
  // src/engine/gates.ts
6850
7439
  const block = (code, detail, hint, scope) => ({
6851
7440
  code,
@@ -6855,6 +7444,7 @@ const block = (code, detail, hint, scope) => ({
6855
7444
  });
6856
7445
  /** PG-01: the model must have received the current brief before submitting. */
6857
7446
  const briefRequired = (input) => {
7447
+ if (input.summaryOnlyRevision) return [];
6858
7448
  if (input.briefStage === null) return [block("PG-01", "this operation needs a brief, but no brief stage is mapped")];
6859
7449
  const record = input.pipeline.briefs[input.briefStage];
6860
7450
  if (record === void 0) return [block("PG-01", `brief ${input.briefStage} has not been issued`, `call novel_brief with stage ${input.briefStage} first`)];
@@ -6951,7 +7541,29 @@ const summarySchema = (input) => {
6951
7541
  "locations",
6952
7542
  "abilities",
6953
7543
  "artifacts"
6954
- ]) if (!textArray(newElements[key])) problems.push(`new_elements.${key} must be a string array`);
7544
+ ]) {
7545
+ if (!textArray(newElements[key])) problems.push(`new_elements.${key} must be a string array`);
7546
+ for (const name of textArray(newElements[key]) ? newElements[key] : []) {
7547
+ if (!input.text.includes(name)) problems.push(`new_elements.${key} lists "${name}", but the manuscript never mentions it`);
7548
+ if (input.operation !== "archiveChapter") {
7549
+ const ledger = input.evidence.world;
7550
+ if (ledger !== null && [
7551
+ ledger.locations,
7552
+ ledger.abilities,
7553
+ ledger.artifacts,
7554
+ ledger.factions,
7555
+ ledger.rules
7556
+ ].some((rows) => rows.some((row) => row.name === name))) problems.push(`new_elements.${key} lists "${name}", but state/world.yaml already has it; remove it or use updated/no_change`);
7557
+ }
7558
+ }
7559
+ }
7560
+ if (textArray(summary.characters_appeared)) {
7561
+ const narrator = input.pipeline.draft?.narrator ?? "";
7562
+ for (const name of summary.characters_appeared) {
7563
+ const alias = narrator.includes(name) && (narrator.includes("第一人称") || narrator.includes("「我」")) && input.text.includes("我");
7564
+ if (!input.text.includes(name) && !alias) problems.push(`characters_appeared lists "${name}", but the manuscript never mentions it`);
7565
+ }
7566
+ }
6955
7567
  const resources = summary.resource_changes;
6956
7568
  if (!Array.isArray(resources) || !resources.every((item) => typeof item === "object" && item !== null && isText(item.character) && isText(item.resource) && Number.isInteger(item.delta) && item.delta !== 0 && isText(item.reason))) problems.push("resource_changes must contain character/resource/non-zero delta/reason");
6957
7569
  if (!isText(summary.opening)) problems.push("opening must be text");
@@ -6994,7 +7606,7 @@ const outlineAlignment = (input) => {
6994
7606
  const card = outline.chapters.find((candidate) => candidate.num === input.chapter);
6995
7607
  if (card === void 0) {
6996
7608
  if (!outline.book.strict) return [];
6997
- return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit every existing card plus this one with novel_outline (full replacement); or turn strict off with novel_blueprint")];
7609
+ return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit only the missing card with novel_outline (upsert by num; omitted cards are kept); do not turn strict off")];
6998
7610
  }
6999
7611
  const problems = [];
7000
7612
  if (card.status === "archived") problems.push(`chapter ${input.chapter} is already archived but is still the active chapter; call novel_reopen first`);
@@ -7150,6 +7762,7 @@ const consistency = (input, options) => {
7150
7762
  };
7151
7763
  /** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
7152
7764
  const rounds = (input) => {
7765
+ if (input.summaryOnly === true) return [];
7153
7766
  if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
7154
7767
  const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
7155
7768
  if (!roundsExhausted(input.rounds, counter)) return [];
@@ -7215,7 +7828,13 @@ function applicableGates(input) {
7215
7828
  "PG-06",
7216
7829
  "PG-13"
7217
7830
  ];
7218
- case "submitRevision": return [
7831
+ case "submitRevision": return input.summaryOnly === true ? [
7832
+ ...always,
7833
+ "PG-04",
7834
+ "PG-03",
7835
+ "PG-05",
7836
+ "PG-06"
7837
+ ] : [
7219
7838
  ...always,
7220
7839
  "PG-04",
7221
7840
  "PG-03",
@@ -7783,6 +8402,8 @@ var EngineService = class {
7783
8402
  stores = /* @__PURE__ */ new Map();
7784
8403
  mutex = new KeyedMutex();
7785
8404
  replay = /* @__PURE__ */ new Map();
8405
+ /** Bumped when an export artifact disappeared; makes the replay key miss. */
8406
+ exportArtifactEpoch = /* @__PURE__ */ new Map();
7786
8407
  /** Status, panel and gates ask for the same stage context repeatedly. */
7787
8408
  briefContextCache = /* @__PURE__ */ new WeakMap();
7788
8409
  briefs;
@@ -7810,6 +8431,7 @@ var EngineService = class {
7810
8431
  this.disposed = true;
7811
8432
  this.stores.clear();
7812
8433
  this.replay.clear();
8434
+ this.exportArtifactEpoch.clear();
7813
8435
  this.compiled = null;
7814
8436
  }
7815
8437
  get rulepack() {
@@ -8212,6 +8834,7 @@ var EngineService = class {
8212
8834
  "ending",
8213
8835
  "strict",
8214
8836
  "volumes",
8837
+ "confirm_remove",
8215
8838
  "root"
8216
8839
  ]);
8217
8840
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_blueprint argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
@@ -8228,9 +8851,12 @@ var EngineService = class {
8228
8851
  this.assertPlanningDocumentReadable(snapshot, "outline", "novel_blueprint");
8229
8852
  this.assertArchiveIndexReadable(snapshot, "novel_blueprint");
8230
8853
  const now = this.now().toISOString();
8231
- const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "root"));
8854
+ const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "confirm_remove" && key !== "root"));
8232
8855
  const book = mergeBookPlan(snapshot.outline.book, bookPatch);
8233
8856
  const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
8857
+ const removedVolumes = snapshot.outline.volumes.filter((volume) => !volumes.some((next) => next.title === volume.title)).map((volume) => volume.title);
8858
+ if (removedVolumes.length > 0 && input.confirm_remove !== true) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel_blueprint would remove volumes: ${removedVolumes.join(", ")}`, "pass confirm_remove:true to replace the volume list, or omit volumes to keep them");
8859
+ assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, volumes, "novel_blueprint");
8234
8860
  let chapters = snapshot.outline.chapters.map((card) => ({
8235
8861
  ...card,
8236
8862
  volume: volumeForChapter(volumes, card.num, card.volume)
@@ -8245,6 +8871,7 @@ var EngineService = class {
8245
8871
  chapters
8246
8872
  }, record, now, snapshot.project.targets.words_per_chapter).chapters;
8247
8873
  }
8874
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_blueprint");
8248
8875
  const outline = {
8249
8876
  ...snapshot.outline,
8250
8877
  updated_at: now,
@@ -8284,19 +8911,113 @@ var EngineService = class {
8284
8911
  }
8285
8912
  });
8286
8913
  }
8914
+ outlinePlanChange(snapshot, input, now) {
8915
+ const book = mergeBookPlan(snapshot.outline.book, input.book === void 0 ? void 0 : {
8916
+ ...input.book,
8917
+ ...input.strict === void 0 ? {} : { strict: input.strict }
8918
+ });
8919
+ if (input.strict !== void 0) {
8920
+ book.strict = input.strict;
8921
+ book.strict_explicit = true;
8922
+ } else if (input.book?.strict === void 0) {
8923
+ if ((input.chapters?.length ?? 0) > 0 && snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
8924
+ book.strict = true;
8925
+ book.strict_explicit = true;
8926
+ }
8927
+ }
8928
+ const merged = mergeOutline({
8929
+ chapters: input.chapters ?? [],
8930
+ remove: input.remove,
8931
+ volumes: input.volumes,
8932
+ remove_volumes: input.remove_volumes,
8933
+ existing: snapshot.outline.chapters,
8934
+ archivedChapters: snapshot.chapters.chapters,
8935
+ existingVolumes: snapshot.outline.volumes,
8936
+ titleRule: this.rulepack.format.title,
8937
+ totalChapters: snapshot.project.targets.total_chapters,
8938
+ defaultTargetWords: snapshot.project.targets.words_per_chapter,
8939
+ now,
8940
+ activeChapter: snapshot.pipeline.chapter
8941
+ });
8942
+ return {
8943
+ outline: {
8944
+ schema_version: 1,
8945
+ updated_at: now,
8946
+ book,
8947
+ volumes: merged.volumes,
8948
+ chapters: merged.chapters
8949
+ },
8950
+ data: {
8951
+ planned: merged.chapters.filter((card) => card.status === "planned").length,
8952
+ archived: merged.chapters.filter((card) => card.status === "archived").length,
8953
+ total: merged.chapters.length,
8954
+ strict: book.strict,
8955
+ volumes: merged.volumes.length,
8956
+ added: merged.added,
8957
+ updated: merged.updated,
8958
+ reindexed: merged.reindexed,
8959
+ removed: merged.removed,
8960
+ volumes_added: merged.volumes_added,
8961
+ volumes_updated: merged.volumes_updated,
8962
+ volumes_removed: merged.volumes_removed
8963
+ }
8964
+ };
8965
+ }
8287
8966
  async submitOutline(input) {
8288
8967
  const allowed = /* @__PURE__ */ new Set([
8289
8968
  "book",
8290
8969
  "volumes",
8291
8970
  "chapters",
8292
8971
  "strict",
8972
+ "remove",
8973
+ "remove_volumes",
8974
+ "dry_run",
8293
8975
  "root"
8294
8976
  ]);
8295
8977
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
8296
8978
  const current = await this.load(input.root);
8979
+ this.assertPublicBookHasNoStrictExplicit(input.book, "novel_outline");
8297
8980
  this.assertProjectReadable(current, "novel_outline");
8298
8981
  this.assertPlanningDocumentReadable(current, "outline", "novel_outline");
8299
8982
  this.assertArchiveIndexReadable(current, "novel_outline");
8983
+ if ((input.chapters === void 0 || Array.isArray(input.chapters) && input.chapters.length === 0) && (input.remove === void 0 || input.remove.length === 0) && (input.remove_volumes === void 0 || input.remove_volumes.length === 0) && (input.volumes === void 0 || Array.isArray(input.volumes) && input.volumes.length === 0) && (input.book === void 0 || typeof input.book === "object" && input.book !== null && Object.keys(input.book).length === 0) && input.strict === void 0 && input.dry_run !== true) return {
8984
+ data: {
8985
+ planned: current.outline.chapters.filter((card) => card.status === "planned").length,
8986
+ archived: current.outline.chapters.filter((card) => card.status === "archived").length,
8987
+ total: current.outline.chapters.length,
8988
+ strict: current.outline.book.strict,
8989
+ volumes: current.outline.volumes.length,
8990
+ added: [],
8991
+ updated: [],
8992
+ reindexed: [],
8993
+ removed: [],
8994
+ volumes_added: [],
8995
+ volumes_updated: [],
8996
+ volumes_removed: []
8997
+ },
8998
+ revision: current.pipeline.revision,
8999
+ warnings: []
9000
+ };
9001
+ if (input.dry_run === true) {
9002
+ const plan = this.outlinePlanChange(current, input, this.now().toISOString());
9003
+ this.assertOutlineWritable(current, plan.outline, "novel_outline");
9004
+ return {
9005
+ data: {
9006
+ ...plan.data,
9007
+ added: [],
9008
+ updated: [],
9009
+ reindexed: [],
9010
+ removed: [],
9011
+ dry_run: true,
9012
+ would_add: plan.data.added,
9013
+ would_update: plan.data.updated,
9014
+ would_reindex: plan.data.reindexed,
9015
+ would_remove: plan.data.removed
9016
+ },
9017
+ revision: current.pipeline.revision,
9018
+ warnings: []
9019
+ };
9020
+ }
8300
9021
  return this.mutate({
8301
9022
  root: input.root,
8302
9023
  op: "submitOutline",
@@ -8305,72 +9026,34 @@ var EngineService = class {
8305
9026
  this.assertProjectReadable(snapshot, "novel_outline");
8306
9027
  this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline");
8307
9028
  this.assertArchiveIndexReadable(snapshot, "novel_outline");
8308
- const now = this.now().toISOString();
8309
- const book = mergeBookPlan(snapshot.outline.book, input.book === void 0 ? void 0 : {
8310
- ...input.book,
8311
- ...input.strict === void 0 ? {} : { strict: input.strict }
8312
- });
8313
- if (input.strict !== void 0) {
8314
- book.strict = input.strict;
8315
- book.strict_explicit = true;
8316
- } else if (input.book?.strict === void 0) {
8317
- if (snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
8318
- book.strict = true;
8319
- book.strict_explicit = true;
8320
- }
8321
- }
8322
- const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
8323
- const chapters = normalizeOutlineChapters(input.chapters, {
8324
- volumes,
8325
- existing: snapshot.outline.chapters,
8326
- archived: snapshot.chapters.chapters,
8327
- titleRule: this.rulepack.format.title,
8328
- totalChapters: snapshot.project.targets.total_chapters,
8329
- defaultTargetWords: snapshot.project.targets.words_per_chapter,
8330
- now
8331
- });
8332
- const outline = {
8333
- schema_version: 1,
8334
- updated_at: now,
8335
- book,
8336
- volumes,
8337
- chapters
8338
- };
8339
- this.assertActiveCardPreserved(snapshot, book.strict, chapters, "novel_outline");
8340
- this.assertOutlineWritable(snapshot, outline, "novel_outline");
8341
- const planned = chapters.filter((card) => card.status === "planned").length;
8342
- const archived = chapters.filter((card) => card.status === "archived").length;
8343
- const kept = new Set(chapters.filter((card) => card.status === "planned").map((card) => card.num));
8344
- const dropped = snapshot.outline.chapters.filter((card) => card.status === "planned" && !kept.has(card.num)).map((card) => card.num);
9029
+ const plan = this.outlinePlanChange(snapshot, input, this.now().toISOString());
9030
+ this.assertOutlineWritable(snapshot, plan.outline, "novel_outline");
8345
9031
  const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_outline", this.now(), shortDigest(stableJson(input))) });
9032
+ const files = [{
9033
+ path: OUTLINE_FILE,
9034
+ text: yaml(plan.outline)
9035
+ }, {
9036
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9037
+ text: yaml(nextPipeline)
9038
+ }];
9039
+ const prior = await context.store.readText(OUTLINE_FILE);
9040
+ if (prior !== null) files.push({
9041
+ path: OUTLINE_DEPRECATED_FILE,
9042
+ text: prior
9043
+ });
8346
9044
  return {
8347
- files: [{
8348
- path: OUTLINE_FILE,
8349
- text: yaml(outline)
8350
- }, {
8351
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8352
- text: yaml(nextPipeline)
8353
- }],
9045
+ files,
8354
9046
  backup: true,
8355
- data: {
8356
- planned,
8357
- archived,
8358
- total: chapters.length,
8359
- strict: book.strict,
8360
- volumes: volumes.length
8361
- },
8362
- warnings: dropped.length === 0 ? [] : [{
8363
- code: "OUTLINE_PLANNED_DROPPED",
8364
- message: `this replacement dropped planned cards: ${dropped.join(", ")}; resubmit every existing card when adding one`
8365
- }],
9047
+ data: plan.data,
9048
+ warnings: [],
8366
9049
  event: {
8367
9050
  name: "novel/planned",
8368
9051
  payload: {
8369
9052
  schemaVersion: 1,
8370
9053
  projectRoot: snapshot.root,
8371
9054
  kind: "outline",
8372
- chapters: chapters.length,
8373
- volumes: volumes.length
9055
+ chapters: plan.data.total,
9056
+ volumes: plan.data.volumes
8374
9057
  }
8375
9058
  }
8376
9059
  };
@@ -8386,6 +9069,7 @@ var EngineService = class {
8386
9069
  ]);
8387
9070
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline_import argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
8388
9071
  const parsed = parseOutlineInput(input.content, input.format);
9072
+ this.assertPublicBookHasNoStrictExplicit(parsed.book, "novel_outline_import");
8389
9073
  const snapshot = await this.load(input.root);
8390
9074
  this.assertProjectReadable(snapshot, "novel_outline_import");
8391
9075
  this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline_import");
@@ -8411,6 +9095,7 @@ var EngineService = class {
8411
9095
  defaultTargetWords: snapshot.project.targets.words_per_chapter,
8412
9096
  now
8413
9097
  });
9098
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_outline_import");
8414
9099
  const candidate = {
8415
9100
  schema_version: 1,
8416
9101
  updated_at: now,
@@ -8418,6 +9103,9 @@ var EngineService = class {
8418
9103
  volumes,
8419
9104
  chapters
8420
9105
  };
9106
+ assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, volumes, "novel_outline_import");
9107
+ this.assertActiveCardPreserved(snapshot, chapters, "novel_outline_import");
9108
+ this.assertOutlineWritable(snapshot, candidate, "novel_outline_import");
8421
9109
  const preview = diffLines(yaml(snapshot.outline), yaml(candidate)).hunks.filter((hunk) => hunk.kind !== "same").slice(0, 80).map((hunk) => `${hunk.kind === "add" ? "+" : "-"} ${hunk.line}`).join("\n");
8422
9110
  const summary = {
8423
9111
  format: parsed.format,
@@ -8461,6 +9149,7 @@ var EngineService = class {
8461
9149
  defaultTargetWords: fresh.project.targets.words_per_chapter,
8462
9150
  now
8463
9151
  });
9152
+ assertArchivedCardVolumes(fresh.chapters.chapters, fresh.outline.chapters, fresh.outline.volumes, nextVolumes, "novel_outline_import");
8464
9153
  const outline = {
8465
9154
  schema_version: 1,
8466
9155
  updated_at: now,
@@ -8468,7 +9157,7 @@ var EngineService = class {
8468
9157
  volumes: nextVolumes,
8469
9158
  chapters: nextChapters
8470
9159
  };
8471
- this.assertActiveCardPreserved(fresh, nextBook.strict, nextChapters, "novel_outline_import");
9160
+ this.assertActiveCardPreserved(fresh, nextChapters, "novel_outline_import");
8472
9161
  this.assertOutlineWritable(fresh, outline, "novel_outline_import");
8473
9162
  const appliedPreview = diffLines(yaml(fresh.outline), yaml(outline)).hunks.filter((hunk) => hunk.kind !== "same").slice(0, 80).map((hunk) => `${hunk.kind === "add" ? "+" : "-"} ${hunk.line}`).join("\n");
8474
9163
  const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import_outline", this.now(), shortDigest(input.content)) });
@@ -8737,6 +9426,53 @@ var EngineService = class {
8737
9426
  run: async (snapshot, context) => {
8738
9427
  const manifest = await readBackupManifest(context.store, input.txid);
8739
9428
  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");
9429
+ if (input.paths?.length === 1 && input.paths[0] === "outline/outline.yaml") {
9430
+ this.assertArchiveIndexReadable(snapshot, "novel_restore");
9431
+ if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring outline/outline.yaml does not accept a chapter", "omit chapter when paths is exactly [outline/outline.yaml]");
9432
+ if (!manifest.files.includes("outline/outline.yaml")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "choose a backup created by novel_outline, novel_blueprint or a planning amendment");
9433
+ const text = await context.store.readText(`backups/${input.txid}/${OUTLINE_FILE}`);
9434
+ if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "the file was created by that transaction, so it has no pre-image to restore");
9435
+ let document;
9436
+ try {
9437
+ document = validateOutlineDocument(parse(text), {
9438
+ titleRule: this.rulepack.format.title,
9439
+ totalChapters: snapshot.project.targets.total_chapters
9440
+ });
9441
+ } catch (error) {
9442
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid outline: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
9443
+ }
9444
+ const restoredCards = new Map(document.chapters.map((card) => [card.num, card]));
9445
+ const archivedNums = new Set(snapshot.chapters.chapters.map((record) => record.num));
9446
+ for (const record of snapshot.chapters.chapters) {
9447
+ const restoredCard = restoredCards.get(record.num);
9448
+ if (restoredCard?.status !== "archived") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would make archived chapter ${record.num} ${restoredCard === void 0 ? "missing its card" : "a planned card"}`, "call novel_reopen before restoring a pre-archive outline, or choose an outline backup that already contains the archived card");
9449
+ }
9450
+ for (const card of document.chapters) if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would restore an archived card for chapter ${card.num}, but that chapter is not archived`, "choose an outline backup that matches the current chapter index, or reopen/archive the chapter first");
9451
+ const files = [{
9452
+ path: OUTLINE_FILE,
9453
+ text
9454
+ }];
9455
+ const prior = await context.store.readText(OUTLINE_FILE);
9456
+ if (prior !== null) files.push({
9457
+ path: OUTLINE_DEPRECATED_FILE,
9458
+ text: prior
9459
+ });
9460
+ const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
9461
+ files.push({
9462
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9463
+ text: yaml(nextPipeline)
9464
+ });
9465
+ return {
9466
+ files,
9467
+ backup: true,
9468
+ data: {
9469
+ txid: input.txid,
9470
+ chapter: snapshot.pipeline.chapter,
9471
+ restored: [OUTLINE_FILE]
9472
+ },
9473
+ warnings: []
9474
+ };
9475
+ }
8740
9476
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
8741
9477
  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");
8742
9478
  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");
@@ -8848,6 +9584,13 @@ var EngineService = class {
8848
9584
  async journalBlocking(snapshot) {
8849
9585
  const store = this.storeFor(snapshot.root);
8850
9586
  if (await this.lockIsLive(store)) return [];
9587
+ const pending = await inspectPending(store);
9588
+ if (pending.state === "partial") return [{
9589
+ code: ERROR_CODES.PENDING_PARTIAL,
9590
+ scope: "journal",
9591
+ detail: `pending transaction ${pending.txid ?? "unknown"} partially changed the project: ${pending.detail ?? "mixed before/after hashes"}`,
9592
+ hint: `do not restore the whole project; inspect ${pending.stagingDir ?? "staging"}/before/ or state/journal.jsonl first`
9593
+ }];
8851
9594
  if (await store.exists("novel.yaml")) {
8852
9595
  const journalPresent = await store.exists(joinRel(STATE_DIR, STATE_FILES.journal));
8853
9596
  const journal = journalPresent ? await readJournal(store) : null;
@@ -8945,6 +9688,11 @@ var EngineService = class {
8945
9688
  async statusFor(snapshot, chapter) {
8946
9689
  const metrics = await this.metricsFor(snapshot, chapter);
8947
9690
  const journal = await this.journalBlocking(snapshot);
9691
+ const pending = await inspectPending(this.storeFor(snapshot.root));
9692
+ const pendingWarnings = pending.state === "complete" || pending.state === "untouched" ? [{
9693
+ code: pending.state === "complete" ? "PENDING_RECOVERABLE" : "PENDING_ABORTABLE",
9694
+ message: `${pending.detail ?? "pending transaction detected"} (${pending.txid ?? "unknown"})`
9695
+ }] : [];
8948
9696
  const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
8949
9697
  tool: "novel_audit",
8950
9698
  args: {},
@@ -8964,6 +9712,7 @@ var EngineService = class {
8964
9712
  stage: snapshot.pipeline.stage,
8965
9713
  rounds: { ...snapshot.pipeline.rounds },
8966
9714
  blocking: [...this.effectiveBlocking(snapshot), ...journal],
9715
+ ...pendingWarnings.length === 0 ? {} : { warnings: pendingWarnings },
8967
9716
  state_errors: this.stateErrors(snapshot),
8968
9717
  selection: {
8969
9718
  source: snapshot.selection.source,
@@ -9499,6 +10248,21 @@ var EngineService = class {
9499
10248
  detail: snapshot.outlineError ?? (snapshot.outlinePresent ? `${snapshot.outline.chapters.length} 张章卡 / ${snapshot.outline.volumes.length} 卷 / strict=${String(snapshot.outline.book.strict)}` : "outline/outline.yaml is missing"),
9500
10249
  ...snapshot.outlineError !== null ? { hint: "repair outline/outline.yaml with novel_amend" } : snapshot.outlinePresent ? {} : { hint: "create a card with novel_outline before relying on PG-13" }
9501
10250
  });
10251
+ if (snapshot.handoff !== null && snapshot.outlineError === null && snapshot.outline.volumes.length > 0) {
10252
+ const chapter = snapshot.pipeline.chapter;
10253
+ const expectedIndex = snapshot.outline.volumes.findIndex((volume) => chapter >= volume.start_chapter && chapter <= volume.end_chapter);
10254
+ if (expectedIndex >= 0) {
10255
+ const expected = snapshot.outline.volumes[expectedIndex];
10256
+ const position = projectHandoff(snapshot.handoff, chapter);
10257
+ if (position.volume !== expectedIndex + 1 || position.volume_name !== expected.title) add({
10258
+ id: "handoff-outline",
10259
+ severity: "warning",
10260
+ title: "交接卷与大纲不一致",
10261
+ detail: `handoff 指向第 ${position.volume} 卷「${position.volume_name}」,当前章 ${chapter} 的大纲卷是第 ${expectedIndex + 1} 卷「${expected.title}」`,
10262
+ hint: "用 novel_amend 修正 state/handoff.yaml,或重新归档以推进交接;归档前请确认卷/幕是作者想要的"
10263
+ });
10264
+ }
10265
+ }
9502
10266
  add({
9503
10267
  id: "worldbook",
9504
10268
  severity: snapshot.worldbookError !== null ? "error" : snapshot.worldbookPresent ? "ok" : "info",
@@ -9518,6 +10282,14 @@ var EngineService = class {
9518
10282
  const journal = await readJournal(store);
9519
10283
  const journalCommitted = hasCommittedJournal(journal.records);
9520
10284
  const health = await reconcileJournal(store);
10285
+ const pending = await inspectPending(store);
10286
+ if (pending.state !== "none") add({
10287
+ id: "pending-transaction",
10288
+ severity: pending.state === "partial" ? "error" : "warning",
10289
+ title: pending.state === "partial" ? "事务只完成了一部分" : pending.state === "complete" ? "事务可安全提升" : "事务可安全回滚",
10290
+ detail: `${pending.txid ?? "unknown"}: ${pending.detail ?? pending.state}`,
10291
+ hint: pending.state === "partial" ? `先检查 ${pending.stagingDir ?? "staging"}/before/;不要从整库备份恢复` : "下一次写操作会自动完成恢复;只读命令不会改盘"
10292
+ });
9521
10293
  add({
9522
10294
  id: "journal",
9523
10295
  severity: !journalPresent || !journalCommitted || journal.malformed > 0 || health.mismatches.length > 0 ? "error" : "ok",
@@ -10413,7 +11185,11 @@ var EngineService = class {
10413
11185
  async submitRevision(input) {
10414
11186
  const hasContent = input.content !== void 0;
10415
11187
  const hasPatch = input.patch !== void 0;
10416
- 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");
11188
+ const summaryOnly = !hasContent && !hasPatch && input.summary !== void 0;
11189
+ if (!hasContent && !hasPatch && !summaryOnly) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires content, patch, or summary", "send revised text, a patch, or a full summary for a summary-only repair");
11190
+ if (hasContent && hasPatch) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise accepts only one of content or patch", "send the full revised text, or a patch with expected_hash and unique edits");
11191
+ if (summaryOnly && (input.issues !== void 0 || input.proof !== void 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary-only repair cannot change review issues or reading proof", "send only chapter and summary; use content/patch for a prose revision that needs a new proof");
11192
+ if (!summaryOnly && input.proof === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires proof for a content or patch revision", "provide opening/middle/ending quotes from the revised text");
10417
11193
  if (hasPatch) {
10418
11194
  const patch = input.patch;
10419
11195
  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");
@@ -10434,7 +11210,13 @@ var EngineService = class {
10434
11210
  }))}`,
10435
11211
  run: async (snapshot, context) => {
10436
11212
  this.assertChapter(snapshot, input.chapter);
10437
- const revisedContent = input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
11213
+ if (summaryOnly && ![
11214
+ "P5",
11215
+ "P6",
11216
+ "ready_to_archive"
11217
+ ].includes(snapshot.pipeline.stage)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `summary-only repair is not available at ${snapshot.pipeline.stage}`, "use novel_revise with content/patch for an early-stage rewrite, or wait until P5/P6");
11218
+ const revisedContent = summaryOnly ? snapshot.text ?? "" : input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
11219
+ if (summaryOnly && snapshot.text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary-only repair requires an existing draft", "write the draft first, then repair its summary");
10438
11220
  if (typeof revisedContent !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must be a string", "send the full revised text as a string, or a patch object");
10439
11221
  if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
10440
11222
  let patchedReview = null;
@@ -10497,8 +11279,10 @@ var EngineService = class {
10497
11279
  text: revisedContent,
10498
11280
  summary: input.summary,
10499
11281
  summaryProvided: input.summary !== void 0,
11282
+ summaryOnly,
11283
+ summaryOnlyRevision: summaryOnly,
10500
11284
  review: patchedReview,
10501
- proof: input.proof
11285
+ proof: input.proof ?? snapshot.review?.proof ?? null
10502
11286
  });
10503
11287
  const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
10504
11288
  if (input.summary === void 0 && inheritedSummary !== void 0) {
@@ -10517,24 +11301,27 @@ var EngineService = class {
10517
11301
  content_hash: sha256(snapshot.text ?? ""),
10518
11302
  updated_at: this.now().toISOString()
10519
11303
  };
10520
- const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
11304
+ const nextStage$1 = summaryOnly ? snapshot.pipeline.stage === "P5" ? "P6" : snapshot.pipeline.stage : nextStage(snapshot.pipeline.stage, "submitRevision", 0);
10521
11305
  const nextPipeline = pipelineWith(snapshot, context.revision, {
10522
11306
  stage: nextStage$1,
10523
11307
  draft: {
10524
11308
  ...baseDraft,
10525
11309
  summary: effectiveSummary,
10526
- content_hash: sha256(revisedContent),
11310
+ content_hash: summaryOnly ? baseDraft.content_hash : sha256(revisedContent),
10527
11311
  updated_at: this.now().toISOString()
10528
11312
  },
10529
- rounds: {
11313
+ rounds: summaryOnly ? { ...snapshot.pipeline.rounds } : {
10530
11314
  ...snapshot.pipeline.rounds,
10531
11315
  revise: snapshot.pipeline.rounds.revise + 1
10532
11316
  },
10533
- blocking: [],
11317
+ blocking: summaryOnly ? snapshot.pipeline.blocking.filter((item) => item.code !== "PG-05") : [],
10534
11318
  ...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
10535
- last_action: action("novel_revise", this.now(), sha256(revisedContent))
11319
+ last_action: action(summaryOnly ? "novel_revise_summary" : "novel_revise", this.now(), sha256(revisedContent))
10536
11320
  });
10537
- const files = [
11321
+ const files = summaryOnly ? [{
11322
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
11323
+ text: yaml(nextPipeline)
11324
+ }] : [
10538
11325
  ...snapshot.text === null ? [] : [{
10539
11326
  path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
10540
11327
  text: snapshot.text
@@ -10552,17 +11339,19 @@ var EngineService = class {
10552
11339
  text: yaml(nextPipeline)
10553
11340
  }
10554
11341
  ];
10555
- if (reviewChanged && patchedReview !== null) files.push({
11342
+ if (!summaryOnly && reviewChanged && patchedReview !== null) files.push({
10556
11343
  path: reviewFile(input.chapter),
10557
11344
  text: yaml(patchedReview)
10558
11345
  });
10559
11346
  return {
10560
11347
  files,
10561
- backup: true,
11348
+ backup: !summaryOnly,
10562
11349
  data: {
10563
11350
  chapter: input.chapter,
10564
11351
  stage: nextStage$1,
10565
- metrics: machine.metrics
11352
+ metrics: machine.metrics,
11353
+ summary_only: summaryOnly,
11354
+ rounds_consumed: summaryOnly ? 0 : 1
10566
11355
  },
10567
11356
  warnings: machineWarnings(machine)
10568
11357
  };
@@ -10583,7 +11372,9 @@ var EngineService = class {
10583
11372
  run: async (snapshot, context) => {
10584
11373
  this.assertChapter(snapshot, input.chapter);
10585
11374
  const text = snapshot.text ?? "";
10586
- const semantic = Array.isArray(input.semantic) ? input.semantic : [];
11375
+ if (input.acknowledge !== void 0 && !Array.isArray(input.acknowledge)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "acknowledge must be an array of advisory codes", "omit acknowledge or pass an array of advisory codes");
11376
+ const semanticEvaluation = validateSemanticSubmission(text, input.semantic);
11377
+ const semantic = input.semantic;
10587
11378
  await this.assertGates(snapshot, "runCheck", {
10588
11379
  briefStage: "P6",
10589
11380
  text,
@@ -10604,8 +11395,8 @@ var EngineService = class {
10604
11395
  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`);
10605
11396
  const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
10606
11397
  const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
10607
- const semanticResult = evaluateSemantic(text, semantic);
10608
- const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
11398
+ const semanticResult = semanticEvaluation;
11399
+ const verdict = machineBlocking.length === 0 && !semanticResult.content_failed ? "pass" : "fail";
10609
11400
  const check = {
10610
11401
  schema_version: 1,
10611
11402
  chapter: input.chapter,
@@ -10616,8 +11407,15 @@ var EngineService = class {
10616
11407
  advisories: machineAdvisories,
10617
11408
  metrics: scopedMachine.metrics
10618
11409
  },
10619
- semantic: { items: semantic },
11410
+ semantic: {
11411
+ items: semantic,
11412
+ ...semanticResult.findings.length === 0 ? {} : { findings: semanticResult.findings }
11413
+ },
10620
11414
  ...acknowledged.length === 0 ? {} : { acknowledged },
11415
+ ...verdict === "fail" ? {
11416
+ failure_class: "content",
11417
+ counted: true
11418
+ } : {},
10621
11419
  verdict
10622
11420
  };
10623
11421
  try {
@@ -10631,12 +11429,12 @@ var EngineService = class {
10631
11429
  code: violation.code,
10632
11430
  scope: "check",
10633
11431
  detail: violation.detail
10634
- })), ...semanticResult.blocking] : [];
11432
+ })), ...semanticResult.findings] : [];
10635
11433
  const nextPipeline = pipelineWith(snapshot, context.revision, {
10636
11434
  stage: nextStage$2,
10637
11435
  rounds: {
10638
11436
  ...snapshot.pipeline.rounds,
10639
- check: snapshot.pipeline.rounds.check + (verdict === "fail" || fullScope ? 1 : 0)
11437
+ check: snapshot.pipeline.rounds.check + (verdict === "fail" ? 1 : 0)
10640
11438
  },
10641
11439
  blocking: blockingItems,
10642
11440
  last_action: action("novel_check", this.now(), sha256(text))
@@ -10665,7 +11463,13 @@ var EngineService = class {
10665
11463
  advisory_codes: advisoryCodes,
10666
11464
  acknowledged,
10667
11465
  metrics: scopedMachine.metrics,
10668
- stage: nextStage$2
11466
+ stage: nextStage$2,
11467
+ semantic: {
11468
+ items: semanticResult.items,
11469
+ findings: semanticResult.findings
11470
+ },
11471
+ rounds: { check: nextPipeline.rounds.check },
11472
+ counted: verdict === "fail"
10669
11473
  },
10670
11474
  warnings: [
10671
11475
  ...machineWarnings({
@@ -10713,7 +11517,9 @@ var EngineService = class {
10713
11517
  chapter: input.chapter,
10714
11518
  assigned_ids: already.assigned_ids ?? {},
10715
11519
  metrics: countWordMetrics(archivedText),
10716
- score: already.score
11520
+ score: already.score,
11521
+ delta_warnings: [],
11522
+ normalized_world: {}
10717
11523
  },
10718
11524
  warnings: [],
10719
11525
  replay: true
@@ -10906,8 +11712,11 @@ var EngineService = class {
10906
11712
  chapter,
10907
11713
  assigned_ids: ledger.assigned,
10908
11714
  metrics,
10909
- score
11715
+ score,
11716
+ delta_warnings: ledger.warnings,
11717
+ normalized_world: ledger.normalized_world
10910
11718
  },
11719
+ warnings: ledger.warnings,
10911
11720
  event: {
10912
11721
  name: "novel/archived",
10913
11722
  payload: {
@@ -11190,6 +11999,15 @@ var EngineService = class {
11190
11999
  code: "JOURNAL_HASH_MISMATCH",
11191
12000
  message: `${health.mismatches.length} file(s) changed after the last commit; first: ${health.mismatches[0].path}`
11192
12001
  });
12002
+ const pending = await inspectPending(store);
12003
+ if (pending.state === "partial") warnings.push({
12004
+ code: ERROR_CODES.PENDING_PARTIAL,
12005
+ message: `pending transaction ${pending.txid ?? "unknown"} is partial: ${pending.detail ?? "mixed hashes"}; do not restore the whole project`
12006
+ });
12007
+ else if (pending.state === "complete" || pending.state === "untouched") warnings.push({
12008
+ code: pending.state === "complete" ? "PENDING_RECOVERABLE" : "PENDING_ABORTABLE",
12009
+ message: `${pending.txid ?? "unknown"}: ${pending.detail ?? pending.state}`
12010
+ });
11193
12011
  const requested = input.limit ?? 50;
11194
12012
  const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
11195
12013
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
@@ -11232,10 +12050,22 @@ var EngineService = class {
11232
12050
  const current = await this.load(input.root);
11233
12051
  this.assertExportIndexReadable(current, input.kind);
11234
12052
  }
12053
+ const artifactSnapshot = await this.load(input.root);
12054
+ const artifactStore = this.storeFor(artifactSnapshot.root);
12055
+ const suffix = input.chapter === void 0 ? "" : `-ch${String(input.chapter).padStart(3, "0")}`;
12056
+ const expectedArtifacts = input.kind === "outline" ? [joinRel(EXPORT_DIR, "outline.md")] : input.kind === "worldbook" ? [joinRel(EXPORT_DIR, "worldbook.md")] : input.kind === "proposal" ? [joinRel(EXPORT_DIR, "proposal.md")] : [
12057
+ joinRel(EXPORT_DIR, `book${suffix}.${input.kind}`),
12058
+ joinRel(EXPORT_DIR, "cover.svg"),
12059
+ joinRel(EXPORT_DIR, "blurb.txt")
12060
+ ];
12061
+ const artifactsPresent = (await Promise.all(expectedArtifacts.map((path) => artifactStore.exists(path)))).every(Boolean);
12062
+ const artifactKey = `${artifactSnapshot.root}|${input.kind}|${input.chapter ?? "all"}`;
12063
+ if (!artifactsPresent) this.exportArtifactEpoch.set(artifactKey, (this.exportArtifactEpoch.get(artifactKey) ?? 0) + 1);
12064
+ const artifactEpoch = this.exportArtifactEpoch.get(artifactKey) ?? 0;
11235
12065
  return this.mutate({
11236
12066
  root: input.root,
11237
12067
  op: "export",
11238
- key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
12068
+ key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${artifactEpoch}:${sha256(stableJson({
11239
12069
  chapter: snapshot.pipeline.chapter,
11240
12070
  text: sha256(snapshot.text ?? ""),
11241
12071
  plan_title: stripTitlePrefix(snapshot.plan?.title ?? ""),
@@ -11457,14 +12287,14 @@ var EngineService = class {
11457
12287
  root: active,
11458
12288
  source: "active"
11459
12289
  };
11460
- else if (registry.projects.length === 1) selection = {
11461
- root: registry.projects[0].root,
11462
- source: "single"
11463
- };
11464
12290
  else if (rows.has(configured) && !registry.projects.some((entry) => entry.root === configured)) selection = {
11465
12291
  root: configured,
11466
12292
  source: "config"
11467
12293
  };
12294
+ else if (registry.projects.length === 1) selection = {
12295
+ root: registry.projects[0].root,
12296
+ source: "single"
12297
+ };
11468
12298
  else selection = {
11469
12299
  root: null,
11470
12300
  source: "none"
@@ -11963,17 +12793,23 @@ var EngineService = class {
11963
12793
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is unreadable; ${operation} would overwrite the unreadable planning document`, `read ${path} with novel_state and repair it with novel_amend before ${operation}`);
11964
12794
  }
11965
12795
  /**
11966
- * A strict outline cannot silently lose the active chapter's card while the
11967
- * chapter is still being written. Otherwise a late replacement turns a
11968
- * strict project non-strict in effect and archive would auto-create the card
11969
- * the author was required to provide.
12796
+ * `strict_explicit` is an internal provenance bit, not an author choice.
12797
+ * Exposing it lets a caller forge "the author chose strict:false" without
12798
+ * going through the strict option. Stored documents may still contain it.
12799
+ */
12800
+ assertPublicBookHasNoStrictExplicit(book, operation) {
12801
+ if (typeof book !== "object" || book === null || Array.isArray(book)) return;
12802
+ if (Object.hasOwn(book, "strict_explicit")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot set book.strict_explicit`, "use the public strict boolean; strict_explicit is an internal provenance marker");
12803
+ }
12804
+ /**
12805
+ * An import is a full replacement, unlike novel_outline's keyed upsert. It
12806
+ * must not silently lose the active card while strict mode still requires it.
11970
12807
  */
11971
- assertActiveCardPreserved(snapshot, strict, chapters, operation) {
11972
- if (!strict) return;
12808
+ assertActiveCardPreserved(snapshot, chapters, operation) {
11973
12809
  const chapter = snapshot.pipeline.chapter;
11974
12810
  if (!snapshot.outline.chapters.some((card) => card.num === chapter)) return;
11975
12811
  if (chapters.some((card) => card.num === chapter)) return;
11976
- throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card while strict mode is on`, "include the active card in the replacement, or turn strict mode off with novel_blueprint {\"strict\":false}");
12812
+ throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card`, "include the active card in the imported outline; import replaces the whole plan and cannot remove an in-flight card");
11977
12813
  }
11978
12814
  /**
11979
12815
  * The outline is a derived progress view and the PG-13 input. Every write
@@ -12053,6 +12889,7 @@ var EngineService = class {
12053
12889
  const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
12054
12890
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
12055
12891
  const journal = await readJournal(this.storeFor(snapshot.root));
12892
+ const pending = await inspectPending(this.storeFor(snapshot.root));
12056
12893
  const journalBlocking = await this.journalBlocking(snapshot);
12057
12894
  const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
12058
12895
  const counts = worldbookCounts(snapshot.worldbook);
@@ -12186,7 +13023,12 @@ var EngineService = class {
12186
13023
  activity,
12187
13024
  ...advisories.length === 0 ? {} : { advisories },
12188
13025
  ...acknowledgedAdvisories === 0 ? {} : { acknowledgedAdvisories },
12189
- ...legacy.length === 0 ? {} : { legacy }
13026
+ ...legacy.length === 0 ? {} : { legacy },
13027
+ ...pending.state === "none" ? {} : { pending: {
13028
+ state: pending.state,
13029
+ txid: pending.txid ?? "",
13030
+ detail: pending.detail ?? ""
13031
+ } }
12190
13032
  };
12191
13033
  }
12192
13034
  /**
@@ -12864,6 +13706,13 @@ var EngineService = class {
12864
13706
  rounds: snapshot.pipeline.rounds,
12865
13707
  min_score: this.config.quality.minScore,
12866
13708
  rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
13709
+ documents: sha256(stableJson({
13710
+ project: snapshot.project,
13711
+ outline: snapshot.outline,
13712
+ worldbook: snapshot.worldbook,
13713
+ plan: snapshot.plan,
13714
+ worldPlan: snapshot.worldPlan
13715
+ })),
12867
13716
  context
12868
13717
  }));
12869
13718
  }
@@ -12909,6 +13758,8 @@ var EngineService = class {
12909
13758
  sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
12910
13759
  summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
12911
13760
  summaryProvided: extras.summaryProvided,
13761
+ summaryOnly: extras.summaryOnly,
13762
+ summaryOnlyRevision: extras.summaryOnlyRevision ?? snapshot.pipeline.last_action?.op === "novel_revise_summary",
12912
13763
  review: extras.review ?? snapshot.review,
12913
13764
  check: extras.check ?? snapshot.check,
12914
13765
  semantic: extras.semantic ?? [],
@@ -13309,7 +14160,14 @@ var EngineService = class {
13309
14160
  STATE_FILES.handoff
13310
14161
  ].map((file) => joinRel(STATE_DIR, file));
13311
14162
  stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
13312
- const stateCandidates = journalWasPresent ? stateFiles : stateFiles.filter((path) => path !== joinRel(STATE_DIR, STATE_FILES.journal));
14163
+ const pending = await inspectPending(store);
14164
+ if (pending.state === "partial") throw new EngineFaultError(ERROR_CODES.PENDING_PARTIAL, `pending transaction ${pending.txid ?? "unknown"} partially changed this directory`, `inspect ${pending.stagingDir ?? ".staging"}/ before initializing over it`);
14165
+ if (pending.state === "complete" || pending.state === "untouched") {
14166
+ await assertJournalConsistent(store);
14167
+ if (await store.exists("novel.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, "a project already exists at this root after pending recovery", "use a different directory, or call novel_status to inspect the existing project");
14168
+ }
14169
+ const journalPath = joinRel(STATE_DIR, STATE_FILES.journal);
14170
+ const stateCandidates = journalWasPresent && (await readJournal(store)).records.some((record) => record.status === "committed" && record.op !== "lock_reclaimed") ? stateFiles : stateFiles.filter((path) => path !== journalPath);
13313
14171
  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");
13314
14172
  await assertJournalConsistent(store);
13315
14173
  const created = await options.run(this.emptySnapshot(root, selection), {
@@ -13824,7 +14682,9 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
13824
14682
  if (kind !== "relationships") return problems;
13825
14683
  }
13826
14684
  if (kind === "characters") {
13827
- const names = new Set((Array.isArray(nextDocument.characters) ? nextDocument.characters : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
14685
+ const rows = Array.isArray(nextDocument.characters) ? nextDocument.characters : [];
14686
+ const names = new Set(rows.map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
14687
+ if (names.size !== rows.length) problems.push("characters must have unique names");
13828
14688
  if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
13829
14689
  for (const name of Object.keys(snapshot.locks.characters)) if (!names.has(name)) problems.push(`lock for removed character "${name}" still exists`);
13830
14690
  }
@@ -13834,6 +14694,21 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
13834
14694
  return problems;
13835
14695
  }
13836
14696
  if (kind === "world") {
14697
+ const allNames = [];
14698
+ for (const section of [
14699
+ "locations",
14700
+ "abilities",
14701
+ "artifacts",
14702
+ "factions",
14703
+ "rules"
14704
+ ]) {
14705
+ const rows = Array.isArray(nextDocument[section]) ? nextDocument[section] : [];
14706
+ for (const raw of rows) {
14707
+ const name = recordOf(raw)?.name;
14708
+ if (typeof name === "string") allNames.push(name);
14709
+ }
14710
+ }
14711
+ if (new Set(allNames).size !== allNames.length) problems.push("world entities must have unique names");
13837
14712
  const locations = new Set((Array.isArray(nextDocument.locations) ? nextDocument.locations : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
13838
14713
  if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
13839
14714
  for (const name of Object.keys(snapshot.locks.locations)) if (!locations.has(name)) problems.push(`lock for removed location "${name}" still exists`);
@@ -13923,6 +14798,19 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
13923
14798
  const row = recordOf(raw);
13924
14799
  if (row === null || !Number.isInteger(row.start_chapter) || !Number.isInteger(row.end_chapter) || row.end_chapter < row.start_chapter) problems.push(`volumes[${index}] must have start_chapter <= end_chapter`);
13925
14800
  }
14801
+ if (snapshot.outlineError === null) try {
14802
+ const nextVolumes = normalizeVolumes(volumes, {
14803
+ totalChapters: snapshot.project.targets.total_chapters,
14804
+ rejectDuplicateTitles: false
14805
+ });
14806
+ assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, nextVolumes, "novel_amend");
14807
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, [...byNum.values()].map((row) => ({
14808
+ num: row.num,
14809
+ volume: row.volume ?? null
14810
+ })), nextVolumes, "novel_amend");
14811
+ } catch (error) {
14812
+ problems.push(error instanceof Error ? error.message : String(error));
14813
+ }
13926
14814
  for (const record of snapshot.chapters.chapters) {
13927
14815
  const card = byNum.get(record.num);
13928
14816
  if (card === void 0) {
@@ -14164,6 +15052,8 @@ function diffLines(before, after) {
14164
15052
  hunks
14165
15053
  };
14166
15054
  }
15055
+ // src/tools/semantics.ts
15056
+ const arraySemanticsDescription = (semantics, description) => `[x-semantics: ${semantics}] ${description}`;
14167
15057
  // src/tools/index.ts
14168
15058
  /**
14169
15059
  * Providers sometimes serialize a free-form `json` parameter as a JSON string
@@ -14182,7 +15072,8 @@ const asJson = (value, field) => {
14182
15072
  const fail = (error) => {
14183
15073
  if (error instanceof EngineError) {
14184
15074
  const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
14185
- throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}`);
15075
+ const details = "items" in error && Array.isArray(error.items) ? `\nitems: ${JSON.stringify(error.items)}` : "";
15076
+ throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}${details}`);
14186
15077
  }
14187
15078
  throw error;
14188
15079
  };
@@ -14239,10 +15130,11 @@ const jsonOutput = {
14239
15130
  schema: { type: "json" },
14240
15131
  render: (_args, raw) => withReplay(text(payload(raw)), raw)
14241
15132
  };
14242
- const stringArray = {
15133
+ const stringArray = (semantics, description) => ({
14243
15134
  type: "array",
14244
- items: { type: "string" }
14245
- };
15135
+ items: { type: "string" },
15136
+ description: arraySemanticsDescription(semantics, description)
15137
+ });
14246
15138
  const root = {
14247
15139
  type: "string",
14248
15140
  description: "Project root. Passing it selects this project for the current session and persists the choice. When omitted: session binding -> unique project in the workspace -> single registered project -> configured projectRoot -> error. Multiple projects require an explicit choice."
@@ -14284,7 +15176,8 @@ function createNovelTools(engine, options = {}) {
14284
15176
  description: { type: "string" },
14285
15177
  words_per_chapter: {
14286
15178
  type: "array",
14287
- items: { type: "integer" }
15179
+ items: { type: "integer" },
15180
+ description: arraySemanticsDescription("replace", "Fixed [min,max] word target for chapters.")
14288
15181
  },
14289
15182
  total_chapters: { type: "integer" },
14290
15183
  premise: {
@@ -14292,11 +15185,11 @@ function createNovelTools(engine, options = {}) {
14292
15185
  description: "Optional one-sentence premise for the book plan."
14293
15186
  },
14294
15187
  logline: { type: "string" },
14295
- themes: stringArray,
15188
+ themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
14296
15189
  tone: { type: "string" },
14297
15190
  pov: { type: "string" },
14298
15191
  audience: { type: "string" },
14299
- selling_points: stringArray,
15192
+ selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
14300
15193
  ending: { type: "string" },
14301
15194
  strict_outline: { type: "boolean" },
14302
15195
  project_root: initRoot
@@ -14393,10 +15286,16 @@ function createNovelTools(engine, options = {}) {
14393
15286
  type: "string",
14394
15287
  required: true
14395
15288
  },
14396
- constraints: stringArray,
14397
- characters: { type: "json" },
14398
- locations: { type: "json" },
14399
- continuity_notes: stringArray,
15289
+ constraints: stringArray("replace", "P1 constraints; supplied list replaces the plan list."),
15290
+ characters: {
15291
+ type: "json",
15292
+ description: arraySemanticsDescription("replace", "P1 cast; supplied list replaces the cast.")
15293
+ },
15294
+ locations: {
15295
+ type: "json",
15296
+ description: arraySemanticsDescription("replace", "P1 locations; supplied list replaces them.")
15297
+ },
15298
+ continuity_notes: stringArray("replace", "P1 continuity notes; supplied list replaces them."),
14400
15299
  project_root: root
14401
15300
  },
14402
15301
  output: jsonOutput,
@@ -14428,7 +15327,7 @@ function createNovelTools(engine, options = {}) {
14428
15327
  required: true
14429
15328
  },
14430
15329
  beats: {
14431
- ...stringArray,
15330
+ ...stringArray("replace", "Ordered plan beats; supplied list replaces the beats."),
14432
15331
  required: true
14433
15332
  },
14434
15333
  opening: {
@@ -14442,11 +15341,12 @@ function createNovelTools(engine, options = {}) {
14442
15341
  target_words: {
14443
15342
  type: "array",
14444
15343
  items: { type: "integer" },
14445
- required: true
15344
+ required: true,
15345
+ description: arraySemanticsDescription("replace", "Plan [min,max] word target.")
14446
15346
  },
14447
15347
  cast: {
14448
15348
  type: "json",
14449
- description: "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger."
15349
+ description: arraySemanticsDescription("replace", "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger.")
14450
15350
  },
14451
15351
  project_root: root
14452
15352
  },
@@ -14517,9 +15417,13 @@ function createNovelTools(engine, options = {}) {
14517
15417
  },
14518
15418
  dimensions: {
14519
15419
  type: "json",
14520
- required: true
15420
+ required: true,
15421
+ description: arraySemanticsDescription("replace", "Fifteen scored dimensions.")
15422
+ },
15423
+ issues: {
15424
+ type: "json",
15425
+ description: arraySemanticsDescription("replace", "Review issue list.")
14521
15426
  },
14522
- issues: { type: "json" },
14523
15427
  total: {
14524
15428
  type: "integer",
14525
15429
  required: true,
@@ -14527,7 +15431,8 @@ function createNovelTools(engine, options = {}) {
14527
15431
  },
14528
15432
  proof: {
14529
15433
  type: "json",
14530
- required: true
15434
+ required: true,
15435
+ description: arraySemanticsDescription("replace", "Three reading-proof quotes.")
14531
15436
  },
14532
15437
  project_root: root
14533
15438
  },
@@ -14557,15 +15462,17 @@ function createNovelTools(engine, options = {}) {
14557
15462
  type: "json",
14558
15463
  description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
14559
15464
  },
14560
- summary: { type: "json" },
15465
+ summary: {
15466
+ type: "json",
15467
+ description: "Full ten-field summary. Omit content+patch with a summary to repair only pipeline.draft.summary; this does not consume a revise round."
15468
+ },
14561
15469
  issues: {
14562
15470
  type: "json",
14563
- description: "Updated issue statuses and evidence for PG-08."
15471
+ description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
14564
15472
  },
14565
15473
  proof: {
14566
15474
  type: "json",
14567
- required: true,
14568
- description: "Three quotes from the revised text; PG-09 verifies them."
15475
+ description: arraySemanticsDescription("replace", "Three quotes from the revised text; required unless this is a summary-only repair. PG-09 verifies them.")
14569
15476
  },
14570
15477
  project_root: root
14571
15478
  },
@@ -14588,16 +15495,16 @@ function createNovelTools(engine, options = {}) {
14588
15495
  type: "integer",
14589
15496
  required: true
14590
15497
  },
14591
- scope: stringArray,
15498
+ scope: stringArray("replace", "Requested check scopes; supplied list replaces the request."),
14592
15499
  semantic: {
14593
15500
  type: "json",
14594
15501
  required: true,
14595
- description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
15502
+ description: arraySemanticsDescription("replace", "Exactly the five P6 semantic items, each with a verbatim evidence quote.")
14596
15503
  },
14597
15504
  acknowledge: {
14598
15505
  type: "array",
14599
15506
  items: { type: "string" },
14600
- description: "Advisory codes read and accepted; blockers cannot be acknowledged."
15507
+ description: arraySemanticsDescription("replace", "Advisory codes read and accepted; blockers cannot be acknowledged.")
14601
15508
  },
14602
15509
  project_root: root
14603
15510
  },
@@ -14696,7 +15603,7 @@ function createNovelTools(engine, options = {}) {
14696
15603
  chapter_range: {
14697
15604
  type: "array",
14698
15605
  items: { type: "integer" },
14699
- description: "For kind=chapters: inclusive [start, end] range."
15606
+ description: arraySemanticsDescription("replace", "For kind=chapters: inclusive [start, end] range.")
14700
15607
  },
14701
15608
  project_root: root
14702
15609
  },
@@ -14864,11 +15771,11 @@ function createNovelTools(engine, options = {}) {
14864
15771
  parameters: {
14865
15772
  premise: { type: "string" },
14866
15773
  logline: { type: "string" },
14867
- themes: stringArray,
15774
+ themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
14868
15775
  tone: { type: "string" },
14869
15776
  pov: { type: "string" },
14870
15777
  audience: { type: "string" },
14871
- selling_points: stringArray,
15778
+ selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
14872
15779
  ending: { type: "string" },
14873
15780
  strict: {
14874
15781
  type: "boolean",
@@ -14876,7 +15783,11 @@ function createNovelTools(engine, options = {}) {
14876
15783
  },
14877
15784
  volumes: {
14878
15785
  type: "json",
14879
- description: "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes."
15786
+ description: arraySemanticsDescription("replace", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes; dropping any volume requires confirm_remove:true.")
15787
+ },
15788
+ confirm_remove: {
15789
+ type: "boolean",
15790
+ description: "Explicitly confirm a volume-list replacement that drops existing volumes."
14880
15791
  },
14881
15792
  project_root: root
14882
15793
  },
@@ -14892,12 +15803,13 @@ function createNovelTools(engine, options = {}) {
14892
15803
  ending: args.ending,
14893
15804
  strict: args.strict,
14894
15805
  volumes: asJson(args.volumes, "volumes"),
15806
+ confirm_remove: args.confirm_remove,
14895
15807
  root: args.project_root
14896
15808
  }))
14897
15809
  }),
14898
15810
  defineTool({
14899
15811
  name: "novel_outline",
14900
- description: "Write volume/chapter cards. Archived cards are preserved and cannot be rewritten. On the first submission strict mode defaults on; with an existing card, PG-13 checks title, scene, target words and cast even in non-strict mode.",
15812
+ description: "Upsert volume/chapter cards by key: chapters by num, volumes by title. Omitted cards are preserved; deletion requires remove/remove_volumes. Archived cards are preserved and cannot be rewritten. Use dry_run to preview. On the first submission strict mode defaults on; with an existing card, PG-13 checks title, scene, target words and cast even in non-strict mode.",
14901
15813
  parameters: {
14902
15814
  book: {
14903
15815
  type: "json",
@@ -14905,12 +15817,25 @@ function createNovelTools(engine, options = {}) {
14905
15817
  },
14906
15818
  volumes: {
14907
15819
  type: "json",
14908
- description: "Optional full volume list; omitted keeps current volumes."
15820
+ description: arraySemanticsDescription("upsert", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Upserts by title; omitted volumes are kept.")
14909
15821
  },
14910
15822
  chapters: {
14911
15823
  type: "json",
14912
- required: true,
14913
- description: "Chapter cards: [{num,title,scene_type,goal,conflict,hook,cast?,target_words:[min,max],volume?,notes?}]."
15824
+ description: arraySemanticsDescription("upsert", "Chapter cards: [{num,title,scene_type,goal,conflict,hook,cast?,target_words:[min,max],volume?,notes?}]. Upserts by num; omitted cards are kept.")
15825
+ },
15826
+ remove: {
15827
+ type: "array",
15828
+ items: { type: "integer" },
15829
+ description: arraySemanticsDescription("remove", "Explicit chapter numbers to delete.")
15830
+ },
15831
+ remove_volumes: {
15832
+ type: "array",
15833
+ items: { type: "string" },
15834
+ description: arraySemanticsDescription("remove", "Explicit volume titles to delete.")
15835
+ },
15836
+ dry_run: {
15837
+ type: "boolean",
15838
+ description: "Preview changes without writing or journaling."
14914
15839
  },
14915
15840
  strict: { type: "boolean" },
14916
15841
  project_root: root
@@ -14920,6 +15845,9 @@ function createNovelTools(engine, options = {}) {
14920
15845
  book: asJson(args.book, "book"),
14921
15846
  volumes: asJson(args.volumes, "volumes"),
14922
15847
  chapters: asJson(args.chapters, "chapters"),
15848
+ remove: args.remove,
15849
+ remove_volumes: args.remove_volumes,
15850
+ dry_run: args.dry_run,
14923
15851
  strict: args.strict,
14924
15852
  root: args.project_root
14925
15853
  }))
@@ -14958,17 +15886,35 @@ function createNovelTools(engine, options = {}) {
14958
15886
  parameters: {
14959
15887
  premise: { type: "string" },
14960
15888
  setting: { type: "string" },
14961
- rules: { type: "json" },
14962
- factions: { type: "json" },
14963
- locations: { type: "json" },
14964
- characters: { type: "json" },
14965
- glossary: { type: "json" },
14966
- backstory: { type: "json" },
15889
+ rules: {
15890
+ type: "json",
15891
+ description: arraySemanticsDescription("replace", "Rules section list.")
15892
+ },
15893
+ factions: {
15894
+ type: "json",
15895
+ description: arraySemanticsDescription("replace", "Factions section list.")
15896
+ },
15897
+ locations: {
15898
+ type: "json",
15899
+ description: arraySemanticsDescription("replace", "Locations section list.")
15900
+ },
15901
+ characters: {
15902
+ type: "json",
15903
+ description: arraySemanticsDescription("replace", "Characters section list.")
15904
+ },
15905
+ glossary: {
15906
+ type: "json",
15907
+ description: arraySemanticsDescription("replace", "Glossary section list.")
15908
+ },
15909
+ backstory: {
15910
+ type: "json",
15911
+ description: arraySemanticsDescription("replace", "Backstory section list.")
15912
+ },
14967
15913
  facts: {
14968
15914
  type: "json",
14969
- description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
15915
+ description: arraySemanticsDescription("replace", "Declared numeric canon: [{alias,value,unit,tolerance?,note?}].")
14970
15916
  },
14971
- themes: stringArray,
15917
+ themes: stringArray("replace", "Worldbook themes; supplied list replaces the list."),
14972
15918
  project_root: root
14973
15919
  },
14974
15920
  output: jsonOutput,
@@ -15060,7 +16006,7 @@ function createNovelTools(engine, options = {}) {
15060
16006
  }),
15061
16007
  defineTool({
15062
16008
  name: "novel_restore",
15063
- 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.",
16009
+ description: "Restore an active chapter file, or outline/outline.yaml, from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
15064
16010
  parameters: {
15065
16011
  txid: {
15066
16012
  type: "string",
@@ -15070,7 +16016,7 @@ function createNovelTools(engine, options = {}) {
15070
16016
  paths: {
15071
16017
  type: "array",
15072
16018
  items: { type: "string" },
15073
- description: "Project-relative chapter files; omitted restores every restorable chapter file that the backup manifest records."
16019
+ description: arraySemanticsDescription("replace", "Project-relative chapter files, or exactly [outline/outline.yaml]; omitted restores every restorable chapter file the manifest records.")
15074
16020
  },
15075
16021
  project_root: root
15076
16022
  },
@@ -15228,7 +16174,12 @@ const bad = (text) => ({
15228
16174
  kind: "error",
15229
16175
  text
15230
16176
  });
15231
- const failure = (error) => error instanceof EngineError ? bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`) : bad(String(error));
16177
+ const failure = (error) => {
16178
+ if (!(error instanceof EngineError)) return bad(String(error));
16179
+ const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
16180
+ const items = "items" in error && Array.isArray(error.items) ? `\nitems: ${JSON.stringify(error.items)}` : "";
16181
+ return bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}${items}`);
16182
+ };
15232
16183
  const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
15233
16184
  const statusText = async (engine) => {
15234
16185
  const { data } = await engine.getStatus({});
@@ -15818,6 +16769,119 @@ function compileRulepack(raw) {
15818
16769
  "exclusions",
15819
16770
  "exemptions"
15820
16771
  ], "root");
16772
+ const section = (name, keys) => {
16773
+ const value = root[name];
16774
+ if (value === void 0) return null;
16775
+ const objectValue = object(value, name);
16776
+ knownKeys(objectValue, keys, name);
16777
+ return objectValue;
16778
+ };
16779
+ const nested = (value, key, keys) => {
16780
+ if (value === null || value[key] === void 0) return null;
16781
+ const objectValue = object(value[key], key);
16782
+ knownKeys(objectValue, keys, key);
16783
+ return objectValue;
16784
+ };
16785
+ nested(section("format", [
16786
+ "paragraph_max_words",
16787
+ "forbid_markdown",
16788
+ "forbid_semicolon",
16789
+ "forbid_english_ellipsis",
16790
+ "title",
16791
+ "system_labels",
16792
+ "scene_separators",
16793
+ "markdown_markers"
16794
+ ]), "title", [
16795
+ "pattern",
16796
+ "min_chars",
16797
+ "max_chars"
16798
+ ]);
16799
+ const punctuation = section("punctuation", [
16800
+ "dash",
16801
+ "period_density",
16802
+ "ellipsis_run",
16803
+ "uniform_sentence",
16804
+ "consecutive_le",
16805
+ "dialogue_dominant_ratio"
16806
+ ]);
16807
+ const dash = nested(punctuation, "dash", [
16808
+ "base",
16809
+ "scale_words",
16810
+ "token"
16811
+ ]);
16812
+ if (dash !== null && dash.base !== void 0) {
16813
+ const base = object(dash.base, "punctuation.dash.base");
16814
+ knownKeys(base, SCENE_TYPES, "punctuation.dash.base");
16815
+ }
16816
+ nested(punctuation, "period_density", ["min", "max"]);
16817
+ nested(punctuation, "ellipsis_run", ["max", "token"]);
16818
+ nested(punctuation, "uniform_sentence", ["min_sentences", "ratio"]);
16819
+ nested(punctuation, "consecutive_le", ["max", "char"]);
16820
+ const semantics = section("semantics", [
16821
+ "adjective_density",
16822
+ "pleasure_density",
16823
+ "sensory_density",
16824
+ "water_words"
16825
+ ]);
16826
+ nested(semantics, "adjective_density", ["max", "adjectives"]);
16827
+ nested(semantics, "pleasure_density", [
16828
+ "min",
16829
+ "keywords",
16830
+ "window"
16831
+ ]);
16832
+ nested(semantics, "sensory_density", [
16833
+ "min",
16834
+ "senses",
16835
+ "window"
16836
+ ]);
16837
+ const waterWords = nested(semantics, "water_words", [
16838
+ "window",
16839
+ "threshold",
16840
+ "scene_thresholds",
16841
+ "terms"
16842
+ ]);
16843
+ if (waterWords !== null && waterWords.scene_thresholds !== void 0) knownKeys(object(waterWords.scene_thresholds, "semantics.water_words.scene_thresholds"), SCENE_TYPES, "semantics.water_words.scene_thresholds");
16844
+ const lexicon = section("lexicon", [
16845
+ "zero_tolerance",
16846
+ "soft",
16847
+ "contextual",
16848
+ "meta_narration",
16849
+ "reader_intrusion",
16850
+ "god_view",
16851
+ "conclusion_first"
16852
+ ]);
16853
+ nested(lexicon, "conclusion_first", ["verbs", "causals"]);
16854
+ for (const key of ["reader_intrusion", "god_view"]) {
16855
+ if (lexicon === null || lexicon[key] === void 0) continue;
16856
+ for (const [index, raw] of array(lexicon[key], `lexicon.${key}`).entries()) knownKeys(object(raw, `lexicon.${key}[${index}]`), ["pattern"], `lexicon.${key}[${index}]`);
16857
+ }
16858
+ const platform = object(root.platform, "platform");
16859
+ knownKeys(platform, PLATFORMS, "platform");
16860
+ for (const name of PLATFORMS) {
16861
+ const profile = object(platform[name], `platform.${name}`);
16862
+ knownKeys(profile, ["words_per_chapter", "dialogue_ratio"], `platform.${name}`);
16863
+ }
16864
+ const genres = object(root.genres, "genres");
16865
+ knownKeys(genres, GENRES, "genres");
16866
+ for (const name of GENRES) knownKeys(object(genres[name], `genres.${name}`), ["note"], `genres.${name}`);
16867
+ section("crosschapter", [
16868
+ "duplicate_similarity",
16869
+ "duplicate_advisory",
16870
+ "duplicate_min_chars",
16871
+ "foreshadow_min_dormancy",
16872
+ "foreshadow_dormant_after",
16873
+ "check_time",
16874
+ "lock_similarity"
16875
+ ]);
16876
+ const quality = section("quality", ["dimensions"]);
16877
+ if (quality?.dimensions !== void 0) for (const [index, rawDimension] of array(quality.dimensions, "quality.dimensions").entries()) {
16878
+ const dimension = object(rawDimension, `quality.dimensions[${index}]`);
16879
+ knownKeys(dimension, ["id", "max"], `quality.dimensions[${index}]`);
16880
+ }
16881
+ if (root.exclusions !== void 0) for (const [index, rawEntry] of array(root.exclusions, "exclusions").entries()) {
16882
+ const entry = object(rawEntry, `exclusions[${index}]`);
16883
+ knownKeys(entry, ["pair", "family"], `exclusions[${index}]`);
16884
+ }
15821
16885
  const pack = normalize(raw);
15822
16886
  const rawPlatform = object(root.platform, "platform");
15823
16887
  for (const platform of PLATFORMS) if (rawPlatform[platform] === void 0) invalid(`platform.${platform} is missing`);
@@ -16153,4 +17217,4 @@ function connectionFence(scope) {
16153
17217
  }
16154
17218
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
16155
17219
  }
16156
- export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
17220
+ export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };