dsh-novel-write-engine 0.2.4 → 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
@@ -245,6 +245,9 @@ const ERROR_CODES = {
245
245
  /** The exact same keyed submission already failed in the same context. */
246
246
  RETRY_UNCHANGED: "ENGINE_RETRY_UNCHANGED",
247
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",
248
251
  READING_PROOF_INVALID: "ENGINE_READING_PROOF_INVALID",
249
252
  SCORE_MISMATCH: "ENGINE_SCORE_MISMATCH",
250
253
  /** A revision tried to rewrite reviewer-owned issue fields. */
@@ -1393,7 +1396,7 @@ const duplicateParagraph = (ctx) => {
1393
1396
  }
1394
1397
  const blocker = ctx.rulepack.crosschapter.duplicate_similarity;
1395
1398
  const severity = best.score >= blocker ? "blocker" : "advisory";
1396
- 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)}%)`)];
1397
1400
  };
1398
1401
  const foreshadowDormant = (ctx) => {
1399
1402
  const ledger = ctx.evidence.foreshadows;
@@ -1508,11 +1511,12 @@ const timeRegression = (ctx) => {
1508
1511
  const ordered = [...ledger.items].sort((left, right) => left.chapter - right.chapter);
1509
1512
  const last = ordered[ordered.length - 1];
1510
1513
  const previous = ordered.length >= 2 ? ordered[ordered.length - 2] : null;
1511
- 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]);
1512
1516
  const current = currentFromText ?? dayOf(last.story_time);
1513
1517
  const before = previous === null ? null : dayOf(previous.story_time);
1514
1518
  if (current === null) return [];
1515
- 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}`)];
1516
1520
  if (before === null || current >= before) return [];
1517
1521
  return [v(CROSS_RULES.TIME_REGRESSION, "advisory", last.chapter, `story time goes back from ${previous.story_time} to ${last.story_time}`)];
1518
1522
  };
@@ -1922,6 +1926,8 @@ const SCHEMAS = {
1922
1926
  machine: req("object"),
1923
1927
  semantic: req("object"),
1924
1928
  acknowledged: f("array"),
1929
+ failure_class: f("string", { enum: ["submission", "content"] }),
1930
+ counted: f("boolean"),
1925
1931
  verdict: req("string", { enum: ["pass", "fail"] })
1926
1932
  },
1927
1933
  worldPlan: {
@@ -2004,6 +2010,15 @@ const nullableInteger = (kind, value, path) => {
2004
2010
  const validateRecords = (kind, value, path, validate) => {
2005
2011
  for (const [index, raw] of listAt(kind, value, path).entries()) validate(objectAt(kind, raw, `${path}[${index}]`), `${path}[${index}]`);
2006
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
+ };
2007
2022
  const validateSummary = (kind, value, path) => {
2008
2023
  const summary = objectAt(kind, value, path);
2009
2024
  for (const key of [
@@ -2097,6 +2112,7 @@ const validateNested = (kind, record) => {
2097
2112
  listAt(kind, row.growth, `${path}.growth`);
2098
2113
  integerAt(kind, row.updated_at, `${path}.updated_at`);
2099
2114
  });
2115
+ duplicateNames(kind, record.characters, "characters", (row) => row.name);
2100
2116
  return;
2101
2117
  case "world":
2102
2118
  validateRecords(kind, record.locations, "locations", (row, path) => {
@@ -2114,6 +2130,20 @@ const validateNested = (kind, record) => {
2114
2130
  "factions",
2115
2131
  "rules"
2116
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
+ }
2117
2147
  return;
2118
2148
  case "locks": {
2119
2149
  integerAt(kind, record.locked_at_chapter, "locked_at_chapter");
@@ -2187,6 +2217,12 @@ const validateNested = (kind, record) => {
2187
2217
  stringAt(kind, row.evidence, `${path}.evidence`);
2188
2218
  if (row.note !== void 0) stringAt(kind, row.note, `${path}.note`);
2189
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
+ });
2190
2226
  return;
2191
2227
  }
2192
2228
  case "outline": {
@@ -2386,7 +2422,7 @@ var FsStore = class {
2386
2422
  if ((await promises.lstat(current)).isSymbolicLink()) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": "${segment}" is a symlink`);
2387
2423
  } catch (error) {
2388
2424
  if (isMissing(error)) break;
2389
- throw error;
2425
+ throw ioFailure(error);
2390
2426
  }
2391
2427
  }
2392
2428
  return absolute;
@@ -2403,7 +2439,7 @@ var FsStore = class {
2403
2439
  return true;
2404
2440
  } catch (error) {
2405
2441
  if (isMissing(error)) return false;
2406
- throw error;
2442
+ throw ioFailure(error);
2407
2443
  }
2408
2444
  }
2409
2445
  async stat(relativePath) {
@@ -2416,7 +2452,7 @@ var FsStore = class {
2416
2452
  };
2417
2453
  } catch (error) {
2418
2454
  if (isMissing(error)) return null;
2419
- throw error;
2455
+ throw ioFailure(error);
2420
2456
  }
2421
2457
  }
2422
2458
  async list(relativeDir) {
@@ -2424,7 +2460,7 @@ var FsStore = class {
2424
2460
  return (await promises.readdir(await this.safePath(relativeDir))).sort();
2425
2461
  } catch (error) {
2426
2462
  if (isMissing(error)) return [];
2427
- throw error;
2463
+ throw ioFailure(error);
2428
2464
  }
2429
2465
  }
2430
2466
  /**
@@ -2441,7 +2477,7 @@ var FsStore = class {
2441
2477
  })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
2442
2478
  } catch (error) {
2443
2479
  if (isMissing(error)) return [];
2444
- throw error;
2480
+ throw ioFailure(error);
2445
2481
  }
2446
2482
  }
2447
2483
  /**
@@ -2459,7 +2495,7 @@ var FsStore = class {
2459
2495
  };
2460
2496
  } catch (error) {
2461
2497
  if (isMissing(error)) return null;
2462
- throw error;
2498
+ throw ioFailure(error);
2463
2499
  }
2464
2500
  }
2465
2501
  /**
@@ -2479,7 +2515,7 @@ var FsStore = class {
2479
2515
  };
2480
2516
  } catch (error) {
2481
2517
  if (isMissing(error)) return null;
2482
- throw error;
2518
+ throw ioFailure(error);
2483
2519
  }
2484
2520
  }
2485
2521
  async readText(relativePath) {
@@ -2487,7 +2523,7 @@ var FsStore = class {
2487
2523
  return await promises.readFile(await this.safePath(relativePath), "utf8");
2488
2524
  } catch (error) {
2489
2525
  if (isMissing(error)) return null;
2490
- throw error;
2526
+ throw ioFailure(error);
2491
2527
  }
2492
2528
  }
2493
2529
  /** Read and narrow one of the twelve engine documents. */
@@ -2521,7 +2557,7 @@ var FsStore = class {
2521
2557
  await promises.mkdir(current);
2522
2558
  } catch (error) {
2523
2559
  if (error.code === "EEXIST") continue;
2524
- throw error;
2560
+ throw ioFailure(error);
2525
2561
  }
2526
2562
  }
2527
2563
  }
@@ -2542,7 +2578,7 @@ var FsStore = class {
2542
2578
  await syncDirectory(dirname(absolute));
2543
2579
  } catch (error) {
2544
2580
  await promises.rm(temp, { force: true }).catch(() => void 0);
2545
- throw error;
2581
+ throw ioFailure(error);
2546
2582
  }
2547
2583
  }
2548
2584
  async writeDoc(relativePath, document) {
@@ -2587,7 +2623,7 @@ var FsStore = class {
2587
2623
  return await promises.readFile(await this.safePath(relativePath));
2588
2624
  } catch (error) {
2589
2625
  if (isMissing(error)) return null;
2590
- throw error;
2626
+ throw ioFailure(error);
2591
2627
  }
2592
2628
  }
2593
2629
  async writeBytes(relativePath, bytes) {
@@ -2606,7 +2642,7 @@ var FsStore = class {
2606
2642
  await syncDirectory(dirname(absolute));
2607
2643
  } catch (error) {
2608
2644
  await promises.rm(temp, { force: true }).catch(() => void 0);
2609
- throw error;
2645
+ throw ioFailure(error);
2610
2646
  }
2611
2647
  }
2612
2648
  /** Content hash of a file, or null when missing. */
@@ -2634,6 +2670,12 @@ async function syncDirectory(directory) {
2634
2670
  function isMissing(error) {
2635
2671
  return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
2636
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
+ }
2637
2679
  /** Join project-relative segments with forward slashes. */
2638
2680
  function joinRel(...parts) {
2639
2681
  return parts.filter((part) => part !== "").join("/");
@@ -2757,21 +2799,18 @@ async function reconcileJournal(store) {
2757
2799
  };
2758
2800
  }
2759
2801
  /**
2760
- * Resolve a pending transaction left by a crash. The pending row is written
2761
- * after the pre-images are captured and before the first rename, so it is the
2762
- * only record that can distinguish "renamed but not journaled" from "crashed
2763
- * before touching a target". A fully matching pending transaction is promoted
2764
- * to committed; a transaction whose targets all still match their pre-images
2765
- * 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.
2766
2805
  */
2767
- async function recoverPending(store) {
2806
+ async function inspectPending(store) {
2768
2807
  const { records } = await readJournal(store);
2769
2808
  const last = [...records].reverse().find((record) => record.op !== "lock_reclaimed");
2770
- if (last?.status !== "pending") return;
2809
+ if (last?.status !== "pending") return { state: "none" };
2771
2810
  const matchesExpected = async () => {
2772
2811
  if (last.hashes === void 0) return false;
2773
2812
  for (const [path, expected] of Object.entries(last.hashes)) if (await store.hashBytes(path) !== expected) return false;
2774
- return true;
2813
+ return Object.keys(last.hashes).length > 0;
2775
2814
  };
2776
2815
  const matchesBefore = async () => {
2777
2816
  if (last.before === void 0) return false;
@@ -2779,9 +2818,45 @@ async function recoverPending(store) {
2779
2818
  const actual = await store.hashBytes(path);
2780
2819
  if (expected === null ? actual !== null : actual !== expected) return false;
2781
2820
  }
2782
- 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)
2836
+ };
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)
2783
2843
  };
2784
- if (await matchesExpected()) {
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") {
2785
2860
  await appendJournal(store, {
2786
2861
  ...last,
2787
2862
  status: "committed"
@@ -2789,7 +2864,7 @@ async function recoverPending(store) {
2789
2864
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
2790
2865
  return;
2791
2866
  }
2792
- if (await matchesBefore()) {
2867
+ if (inspection.state === "untouched") {
2793
2868
  await appendJournal(store, {
2794
2869
  ...last,
2795
2870
  status: "aborted",
@@ -2799,7 +2874,7 @@ async function recoverPending(store) {
2799
2874
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
2800
2875
  return;
2801
2876
  }
2802
- 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`);
2803
2878
  }
2804
2879
  /**
2805
2880
  * A crash during append can leave one JSONL fragment without a newline. Since
@@ -2991,22 +3066,43 @@ var FileLock = class {
2991
3066
  if (Date.now() - mtimeMs <= this.staleMs) return false;
2992
3067
  } else if (lockRecordIsLive(record)) return false;
2993
3068
  const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
3069
+ let claimByRename = false;
2994
3070
  try {
2995
3071
  await promises.link(this.absolutePath, claimPath);
2996
3072
  } catch (error) {
2997
3073
  const code = error.code;
2998
3074
  if (code === "ENOENT") return true;
2999
3075
  if (code === "EPERM" || code === "EACCES") return false;
3000
- 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;
3001
3090
  }
3002
3091
  const moved = await this.readAt(claimPath);
3003
3092
  const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
3004
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)) {
3005
- 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);
3006
3102
  return false;
3007
3103
  }
3008
- const current = await this.readAt(this.absolutePath);
3009
- 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))) {
3010
3106
  await promises.rm(claimPath, { force: true }).catch(() => void 0);
3011
3107
  return false;
3012
3108
  }
@@ -3025,6 +3121,7 @@ const BACKUP_MANIFEST = "manifest.json";
3025
3121
  async function listBackups(store) {
3026
3122
  const entries = [];
3027
3123
  for (const name of await store.list(BACKUP_DIR)) {
3124
+ if (name === "migrations") continue;
3028
3125
  const info = await store.stat(joinRel(BACKUP_DIR, name));
3029
3126
  if (info === null || !info.isDirectory) continue;
3030
3127
  if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
@@ -4118,7 +4215,7 @@ function normalizeOutlineChapters(input, options) {
4118
4215
  notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
4119
4216
  volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
4120
4217
  status: "archived",
4121
- archived_at: base.archived_at ?? options.now,
4218
+ archived_at: base.archived_at ?? record.archived_at ?? null,
4122
4219
  word_count: record.word_count,
4123
4220
  score: record.score
4124
4221
  });
@@ -4159,7 +4256,7 @@ function normalizeOutlineChapters(input, options) {
4159
4256
  ...base,
4160
4257
  volume: volumeForChapter(options.volumes, record.num, base.volume),
4161
4258
  status: "archived",
4162
- archived_at: base.archived_at ?? options.now,
4259
+ archived_at: base.archived_at ?? record.archived_at ?? null,
4163
4260
  word_count: record.word_count,
4164
4261
  score: record.score
4165
4262
  });
@@ -4876,6 +4973,7 @@ function renderWorldbookMarkdown(project, document) {
4876
4973
  section("角色", document.characters.map((entry) => `- **${oneLine(entry.name)}**(${oneLine(entry.role)}):${oneLine(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine(entry.arc)}`}`));
4877
4974
  section("术语", document.glossary.map((entry) => `- **${oneLine(entry.term)}**:${oneLine(entry.definition)}`));
4878
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)}`}`));
4879
4977
  return parts.join("\n");
4880
4978
  }
4881
4979
  // src/engine/outline-merge.ts
@@ -5003,18 +5101,30 @@ const remapVolume = (card, oldVolumes, newVolumes) => {
5003
5101
  volume: card.num >= next.start_chapter && card.num <= next.end_chapter ? nextIndex + 1 : null
5004
5102
  };
5005
5103
  };
5006
- const assertArchivedVolumes = (options, volumes, removedTitles) => {
5007
- const removed = new Set(removedTitles);
5008
- const oldByTitle = volumeByTitle(options.existingVolumes);
5009
- const newByTitle = volumeByTitle(volumes);
5010
- for (const record of options.archivedChapters) {
5011
- const card = options.existing.find((entry) => entry.num === record.num);
5012
- if (card === void 0 || card.volume === null) continue;
5013
- const previous = options.existingVolumes[card.volume - 1];
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);
5014
5110
  if (previous === void 0) continue;
5015
- if (removed.has(previous.title) || !oldByTitle.has(previous.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${previous.title}": archived chapter ${record.num} belongs to it`, "archived cards are evidence; keep the volume or reopen the chapter first");
5016
5111
  const next = newByTitle.get(previous.title);
5017
- if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${previous.title}" no longer covers archived chapter ${record.num}`, "keep archived chapter ranges inside their existing volume");
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");
5018
5128
  }
5019
5129
  };
5020
5130
  /**
@@ -5026,7 +5136,7 @@ const mergeOutline = (options) => {
5026
5136
  const submitted = submittedNumbers(options.chapters);
5027
5137
  const removed = parseChapterNumbers(options.remove, "remove");
5028
5138
  const volumeChanges = mergeVolumes(options);
5029
- assertArchivedVolumes(options, volumeChanges.volumes, volumeChanges.removed);
5139
+ assertArchivedCardVolumes(options.archivedChapters, options.existing, options.existingVolumes, volumeChanges.volumes, "novel_outline");
5030
5140
  const existingByNum = new Map(options.existing.map((card) => [card.num, card]));
5031
5141
  const archivedByNum = new Map(options.archivedChapters.map((record) => [record.num, record]));
5032
5142
  const archivedNums = new Set(archivedByNum.keys());
@@ -5036,9 +5146,10 @@ const mergeOutline = (options) => {
5036
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");
5037
5147
  if (existingByNum.get(num)?.status !== "planned") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is not a planned card`);
5038
5148
  }
5149
+ const existingForNormalize = options.existing.map((card) => archivedNums.has(card.num) ? remapVolume(card, options.existingVolumes, volumeChanges.volumes) : card);
5039
5150
  const normalized = normalizeOutlineChapters(options.chapters, {
5040
5151
  volumes: volumeChanges.volumes,
5041
- existing: options.existing,
5152
+ existing: existingForNormalize,
5042
5153
  archived: options.archivedChapters,
5043
5154
  titleRule: options.titleRule,
5044
5155
  totalChapters: options.totalChapters,
@@ -5048,6 +5159,7 @@ const mergeOutline = (options) => {
5048
5159
  const byNum = new Map(normalized.map((card) => [card.num, card]));
5049
5160
  const updated = /* @__PURE__ */ new Set();
5050
5161
  const added = /* @__PURE__ */ new Set();
5162
+ const reindexed = /* @__PURE__ */ new Set();
5051
5163
  for (const card of normalized) {
5052
5164
  if (!submitted.has(card.num)) continue;
5053
5165
  const before = existingByNum.get(card.num);
@@ -5062,6 +5174,8 @@ const mergeOutline = (options) => {
5062
5174
  ...card,
5063
5175
  status: "archived"
5064
5176
  });
5177
+ const current = byNum.get(card.num);
5178
+ if (stableJson(card) !== stableJson(current)) reindexed.add(card.num);
5065
5179
  continue;
5066
5180
  }
5067
5181
  const remapped = remapVolume(card, options.existingVolumes, volumeChanges.volumes);
@@ -5069,19 +5183,22 @@ const mergeOutline = (options) => {
5069
5183
  const current = byNum.get(card.num);
5070
5184
  if (stableJson(current) !== stableJson(remapped)) {
5071
5185
  byNum.set(card.num, remapped);
5072
- if (card.status === "planned") updated.add(card.num);
5186
+ reindexed.add(card.num);
5073
5187
  }
5074
5188
  } else {
5075
5189
  byNum.set(card.num, remapped);
5076
- if (card.status === "planned" && stableJson(card) !== stableJson(remapped)) updated.add(card.num);
5190
+ if (stableJson(card) !== stableJson(remapped)) reindexed.add(card.num);
5077
5191
  }
5078
5192
  }
5079
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");
5080
5196
  return {
5081
- chapters: [...byNum.values()].sort((left, right) => left.num - right.num),
5197
+ chapters,
5082
5198
  volumes: volumeChanges.volumes,
5083
5199
  added: [...added].sort((left, right) => left - right),
5084
5200
  updated: [...updated].sort((left, right) => left - right),
5201
+ reindexed: [...reindexed].sort((left, right) => left - right),
5085
5202
  removed: [...removed].sort((left, right) => left - right),
5086
5203
  volumes_added: volumeChanges.added,
5087
5204
  volumes_updated: volumeChanges.updated,
@@ -5111,6 +5228,28 @@ const setOwn = (record, key, value) => {
5111
5228
  writable: true
5112
5229
  });
5113
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
+ };
5114
5253
  function fail$1(detail) {
5115
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");
5116
5255
  }
@@ -5204,6 +5343,20 @@ function applyStateDelta(input) {
5204
5343
  const timeline = structuredClone(input.timeline);
5205
5344
  const relationships = structuredClone(input.relationships);
5206
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
+ };
5207
5360
  const appeared = [...new Set(summary.characters_appeared)];
5208
5361
  const byName = new Map(characters.characters.map((character) => [character.name, character]));
5209
5362
  const narrator = input.narrator ?? "";
@@ -5224,7 +5377,7 @@ function applyStateDelta(input) {
5224
5377
  "profile",
5225
5378
  "current_state",
5226
5379
  "growth"
5227
- ]) 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\":\"...\"}"}`);
5228
5381
  if (declared.growth !== void 0) {
5229
5382
  const growth = declared.growth;
5230
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`);
@@ -5235,12 +5388,14 @@ function applyStateDelta(input) {
5235
5388
  if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
5236
5389
  const rawHint = input.roleHints?.[name];
5237
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}`);
5238
5393
  const created = {
5239
5394
  name,
5240
5395
  role: normalizeRole(declared.role, hinted),
5241
5396
  first_appearance: chapter,
5242
5397
  profile: declared.profile ?? {},
5243
- current_state: declared.current_state ?? {},
5398
+ current_state: currentState,
5244
5399
  growth: [],
5245
5400
  updated_at: chapter
5246
5401
  };
@@ -5261,10 +5416,11 @@ function applyStateDelta(input) {
5261
5416
  ...existing.profile,
5262
5417
  ...declared.profile
5263
5418
  };
5264
- if (declared.current_state !== void 0) existing.current_state = {
5265
- ...existing.current_state,
5266
- ...declared.current_state
5267
- };
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
+ }
5268
5424
  if (declared.growth !== void 0) existing.growth.push({
5269
5425
  ...declared.growth,
5270
5426
  chapter
@@ -5288,6 +5444,7 @@ function applyStateDelta(input) {
5288
5444
  ...state,
5289
5445
  resources
5290
5446
  };
5447
+ validateResources(character.current_state, `characters.${change.character}`);
5291
5448
  }
5292
5449
  const findWorld = (name) => {
5293
5450
  const location = world.locations.find((candidate) => candidate.name === name);
@@ -5323,8 +5480,11 @@ function applyStateDelta(input) {
5323
5480
  if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
5324
5481
  if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
5325
5482
  const declared = delta.world[name];
5326
- if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
5327
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
+ });
5328
5488
  }
5329
5489
  for (const [name, raw] of Object.entries(delta.world)) {
5330
5490
  const declared = entry(raw, `world.${name}`);
@@ -5333,22 +5493,33 @@ function applyStateDelta(input) {
5333
5493
  if (declared.layout !== void 0 && typeof declared.layout !== "string") fail$1(`world.${name}.layout must be a string`);
5334
5494
  if (declared.capacity !== void 0 && (!Number.isInteger(declared.capacity) || declared.capacity < 0)) fail$1(`world.${name}.capacity must be a non-negative integer`);
5335
5495
  if (declared.detail !== void 0 && !isRecord(declared.detail)) fail$1(`world.${name}.detail must be an object`);
5336
- if (declared.detail !== void 0) {
5337
- if (declared.action !== "created") {
5338
- for (const reserved of [
5339
- "name",
5340
- "first_appearance",
5341
- "description"
5342
- ]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
5343
- }
5344
- }
5345
5496
  const expected = expectedKind(name);
5346
- if (expected !== null) {
5347
- if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
5348
- if (declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
5349
- }
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}`);
5350
5498
  const existing = findWorld(name);
5351
- 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") {
5352
5523
  if (name.trim() === "") fail$1("a created world entity has an empty name");
5353
5524
  if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
5354
5525
  if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
@@ -5361,7 +5532,7 @@ function applyStateDelta(input) {
5361
5532
  "faction",
5362
5533
  "rule"
5363
5534
  ].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
5364
- 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`);
5365
5536
  if (kind === "location") world.locations.push({
5366
5537
  name,
5367
5538
  first_appearance: chapter,
@@ -5380,7 +5551,8 @@ function applyStateDelta(input) {
5380
5551
  continue;
5381
5552
  }
5382
5553
  if (existing === null) fail$1(`world entity "${name}" does not exist; created must be used first`);
5383
- 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") {
5384
5556
  if (existing.kind === "location") {
5385
5557
  const location = existing.value;
5386
5558
  if (declared.description !== void 0) location.description = declared.description;
@@ -5523,6 +5695,7 @@ function applyStateDelta(input) {
5523
5695
  const declared = entry(delta.timeline[key], `timeline.${key}`);
5524
5696
  if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
5525
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}"`);
5526
5699
  const allocated = allocateIds("TE", timeline.next_seq, 1);
5527
5700
  const id = allocated.ids[0];
5528
5701
  if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
@@ -5589,7 +5762,9 @@ function applyStateDelta(input) {
5589
5762
  foreshadows,
5590
5763
  timeline,
5591
5764
  relationships,
5592
- assigned
5765
+ assigned,
5766
+ warnings,
5767
+ normalized_world: normalizedWorld
5593
5768
  };
5594
5769
  }
5595
5770
  // src/engine/insight.ts
@@ -6925,6 +7100,26 @@ const SEMANTIC_CHECK_IDS = [
6925
7100
  "L2-VOICE",
6926
7101
  "L2-POV"
6927
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
+ };
6928
7123
  /** Extract the quoted part of an evidence string, tolerating "第 N 段:" labels. */
6929
7124
  function evidenceQuote(value) {
6930
7125
  let candidate = value.trim();
@@ -6934,151 +7129,312 @@ function evidenceQuote(value) {
6934
7129
  candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
6935
7130
  return candidate;
6936
7131
  }
6937
- /** 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
+ */
6938
7150
  function sentenceSpans(text) {
6939
7151
  const spans = [];
6940
- let start = 0;
6941
- for (let index = 0; index < text.length; index += 1) if ("。!?!?".includes(text[index])) {
6942
- spans.push({
6943
- start,
6944
- end: index + 1
6945
- });
6946
- 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
+ }
6947
7181
  }
6948
- if (start < text.length) spans.push({
6949
- start,
6950
- end: text.length
7182
+ if (sentenceStart < text.length) spans.push({
7183
+ start: sentenceStart,
7184
+ end: text.length,
7185
+ paragraph
6951
7186
  });
6952
7187
  return spans;
6953
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
+ };
6954
7233
  /**
6955
- * Validate the model-supplied L2 semantic items. Evidence must be a real
6956
- * substring of the manuscript; a failed verdict is itself a blocking finding.
6957
- * 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.
6958
7240
  */
6959
- function evaluateSemantic(text, items) {
6960
- const blocking = [];
6961
- const seen = /* @__PURE__ */ new Set();
6962
- const seenQuotes = /* @__PURE__ */ new Set();
7241
+ function evaluateSemantic(text, input) {
7242
+ const items = [];
6963
7243
  const spans = [];
6964
7244
  const sentences = sentenceSpans(text);
6965
- const usedSentences = /* @__PURE__ */ new Set();
6966
- 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()) {
6967
7255
  const where = `semantic[${index}]`;
6968
- if (typeof item !== "object" || item === null) {
6969
- blocking.push({
6970
- code: "SEMANTIC_ITEM_INVALID",
6971
- scope: "check",
7256
+ if (typeof raw !== "object" || raw === null) {
7257
+ items.push({
7258
+ id: where,
7259
+ state: "invalid",
6972
7260
  detail: `${where} is not an object`
6973
7261
  });
6974
7262
  continue;
6975
7263
  }
7264
+ const item = raw;
6976
7265
  if (typeof item.id !== "string" || item.id.trim() === "") {
6977
- blocking.push({
6978
- code: "SEMANTIC_ITEM_INVALID",
6979
- scope: "check",
7266
+ items.push({
7267
+ id: where,
7268
+ state: "invalid",
6980
7269
  detail: `${where} has no id`
6981
7270
  });
6982
7271
  continue;
6983
7272
  }
6984
- if (seen.has(item.id)) {
6985
- blocking.push({
6986
- code: "SEMANTIC_ITEM_INVALID",
6987
- scope: "check",
6988
- 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`
6989
7279
  });
6990
7280
  continue;
6991
7281
  }
6992
- seen.add(item.id);
7282
+ seenIds.add(id);
6993
7283
  if (item.verdict !== "pass" && item.verdict !== "fail") {
6994
- blocking.push({
6995
- code: "SEMANTIC_ITEM_INVALID",
6996
- scope: "check",
6997
- 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`
6998
7288
  });
6999
7289
  continue;
7000
7290
  }
7001
7291
  if (typeof item.evidence !== "string" || item.evidence.trim() === "") {
7002
- blocking.push({
7003
- code: "SEMANTIC_EVIDENCE_INVALID",
7004
- scope: "check",
7005
- detail: `${item.id} has no evidence quote`
7292
+ items.push({
7293
+ id,
7294
+ state: "invalid",
7295
+ detail: `${id} has no evidence quote`
7006
7296
  });
7007
7297
  continue;
7008
7298
  }
7009
7299
  const quote = evidenceQuote(item.evidence);
7010
7300
  if (Array.from(quote).length < 8) {
7011
- blocking.push({
7012
- code: "SEMANTIC_EVIDENCE_INVALID",
7013
- scope: "check",
7014
- 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`
7015
7306
  });
7016
7307
  continue;
7017
7308
  }
7018
- if (!text.includes(quote)) {
7019
- blocking.push({
7020
- code: "SEMANTIC_EVIDENCE_INVALID",
7021
- scope: "check",
7022
- 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`
7023
7317
  });
7024
7318
  continue;
7025
7319
  }
7026
- if (seenQuotes.has(quote)) {
7027
- blocking.push({
7028
- code: "SEMANTIC_EVIDENCE_INVALID",
7029
- scope: "check",
7030
- 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`
7031
7334
  });
7032
7335
  continue;
7033
7336
  }
7034
- let start = text.indexOf(quote);
7035
- let sentence = -1;
7036
- while (start !== -1) {
7337
+ let accepted = null;
7338
+ let overlapWith;
7339
+ let sameSentenceWith;
7340
+ let boundary = false;
7341
+ for (const start of occurrences) {
7037
7342
  const end = start + quote.length;
7038
- const overlaps = spans.some((span) => start < span.end && span.start < end);
7039
- const sentenceIndex = sentences.findIndex((candidate) => start >= candidate.start && end <= candidate.end);
7040
- if (!overlaps && sentenceIndex !== -1 && !usedSentences.has(sentenceIndex)) {
7041
- sentence = sentenceIndex;
7042
- break;
7343
+ const conflict = spans.find((span) => start < span.end && span.start < end);
7344
+ if (conflict !== void 0) {
7345
+ overlapWith ??= conflict.id;
7346
+ continue;
7043
7347
  }
7044
- start = text.indexOf(quote, start + 1);
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;
7355
+ }
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;
7045
7368
  }
7046
- if (start === -1 || sentence === -1) {
7047
- blocking.push({
7048
- code: "SEMANTIC_EVIDENCE_INVALID",
7049
- scope: "check",
7050
- 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`
7051
7390
  });
7052
7391
  continue;
7053
7392
  }
7054
- spans.push({
7055
- start,
7056
- end: start + quote.length
7057
- });
7058
- usedSentences.add(sentence);
7059
- seenQuotes.add(quote);
7060
- if (item.verdict === "fail") blocking.push({
7061
- code: "SEMANTIC_CHECK_FAILED",
7062
- scope: "check",
7063
- 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"}` } : {}
7064
7400
  });
7065
7401
  }
7066
- const required = new Set(SEMANTIC_CHECK_IDS);
7067
- for (const id of SEMANTIC_CHECK_IDS) if (!seen.has(id)) blocking.push({
7068
- code: "SEMANTIC_MISSING",
7069
- scope: "check",
7402
+ for (const id of SEMANTIC_CHECK_IDS) if (!seenIds.has(id)) items.push({
7403
+ id,
7404
+ state: "missing",
7070
7405
  detail: `${id} is required by the P6 task book`
7071
7406
  });
7072
- for (const id of seen) if (!required.has(id)) blocking.push({
7073
- code: "SEMANTIC_ITEM_INVALID",
7074
- scope: "check",
7407
+ for (const id of seenIds) if (!SEMANTIC_CHECK_IDS.includes(id)) items.push({
7408
+ id,
7409
+ state: "invalid",
7075
7410
  detail: `${id} is not one of the required P6 semantic checks`
7076
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];
7077
7423
  return {
7424
+ items,
7425
+ findings,
7078
7426
  blocking,
7427
+ submission_failed: submissionItems.length > 0,
7428
+ content_failed: findings.length > 0,
7079
7429
  failed: blocking.length > 0
7080
7430
  };
7081
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
+ }
7082
7438
  // src/engine/gates.ts
7083
7439
  const block = (code, detail, hint, scope) => ({
7084
7440
  code,
@@ -7088,6 +7444,7 @@ const block = (code, detail, hint, scope) => ({
7088
7444
  });
7089
7445
  /** PG-01: the model must have received the current brief before submitting. */
7090
7446
  const briefRequired = (input) => {
7447
+ if (input.summaryOnlyRevision) return [];
7091
7448
  if (input.briefStage === null) return [block("PG-01", "this operation needs a brief, but no brief stage is mapped")];
7092
7449
  const record = input.pipeline.briefs[input.briefStage];
7093
7450
  if (record === void 0) return [block("PG-01", `brief ${input.briefStage} has not been issued`, `call novel_brief with stage ${input.briefStage} first`)];
@@ -7184,7 +7541,29 @@ const summarySchema = (input) => {
7184
7541
  "locations",
7185
7542
  "abilities",
7186
7543
  "artifacts"
7187
- ]) 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
+ }
7188
7567
  const resources = summary.resource_changes;
7189
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");
7190
7569
  if (!isText(summary.opening)) problems.push("opening must be text");
@@ -7383,6 +7762,7 @@ const consistency = (input, options) => {
7383
7762
  };
7384
7763
  /** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
7385
7764
  const rounds = (input) => {
7765
+ if (input.summaryOnly === true) return [];
7386
7766
  if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
7387
7767
  const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
7388
7768
  if (!roundsExhausted(input.rounds, counter)) return [];
@@ -7448,7 +7828,13 @@ function applicableGates(input) {
7448
7828
  "PG-06",
7449
7829
  "PG-13"
7450
7830
  ];
7451
- 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
+ ] : [
7452
7838
  ...always,
7453
7839
  "PG-04",
7454
7840
  "PG-03",
@@ -8016,6 +8402,8 @@ var EngineService = class {
8016
8402
  stores = /* @__PURE__ */ new Map();
8017
8403
  mutex = new KeyedMutex();
8018
8404
  replay = /* @__PURE__ */ new Map();
8405
+ /** Bumped when an export artifact disappeared; makes the replay key miss. */
8406
+ exportArtifactEpoch = /* @__PURE__ */ new Map();
8019
8407
  /** Status, panel and gates ask for the same stage context repeatedly. */
8020
8408
  briefContextCache = /* @__PURE__ */ new WeakMap();
8021
8409
  briefs;
@@ -8043,6 +8431,7 @@ var EngineService = class {
8043
8431
  this.disposed = true;
8044
8432
  this.stores.clear();
8045
8433
  this.replay.clear();
8434
+ this.exportArtifactEpoch.clear();
8046
8435
  this.compiled = null;
8047
8436
  }
8048
8437
  get rulepack() {
@@ -8467,6 +8856,7 @@ var EngineService = class {
8467
8856
  const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
8468
8857
  const removedVolumes = snapshot.outline.volumes.filter((volume) => !volumes.some((next) => next.title === volume.title)).map((volume) => volume.title);
8469
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");
8470
8860
  let chapters = snapshot.outline.chapters.map((card) => ({
8471
8861
  ...card,
8472
8862
  volume: volumeForChapter(volumes, card.num, card.volume)
@@ -8481,6 +8871,7 @@ var EngineService = class {
8481
8871
  chapters
8482
8872
  }, record, now, snapshot.project.targets.words_per_chapter).chapters;
8483
8873
  }
8874
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_blueprint");
8484
8875
  const outline = {
8485
8876
  ...snapshot.outline,
8486
8877
  updated_at: now,
@@ -8564,6 +8955,7 @@ var EngineService = class {
8564
8955
  volumes: merged.volumes.length,
8565
8956
  added: merged.added,
8566
8957
  updated: merged.updated,
8958
+ reindexed: merged.reindexed,
8567
8959
  removed: merged.removed,
8568
8960
  volumes_added: merged.volumes_added,
8569
8961
  volumes_updated: merged.volumes_updated,
@@ -8584,6 +8976,7 @@ var EngineService = class {
8584
8976
  ]);
8585
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(", ")}`);
8586
8978
  const current = await this.load(input.root);
8979
+ this.assertPublicBookHasNoStrictExplicit(input.book, "novel_outline");
8587
8980
  this.assertProjectReadable(current, "novel_outline");
8588
8981
  this.assertPlanningDocumentReadable(current, "outline", "novel_outline");
8589
8982
  this.assertArchiveIndexReadable(current, "novel_outline");
@@ -8596,6 +8989,7 @@ var EngineService = class {
8596
8989
  volumes: current.outline.volumes.length,
8597
8990
  added: [],
8598
8991
  updated: [],
8992
+ reindexed: [],
8599
8993
  removed: [],
8600
8994
  volumes_added: [],
8601
8995
  volumes_updated: [],
@@ -8612,10 +9006,12 @@ var EngineService = class {
8612
9006
  ...plan.data,
8613
9007
  added: [],
8614
9008
  updated: [],
9009
+ reindexed: [],
8615
9010
  removed: [],
8616
9011
  dry_run: true,
8617
9012
  would_add: plan.data.added,
8618
9013
  would_update: plan.data.updated,
9014
+ would_reindex: plan.data.reindexed,
8619
9015
  would_remove: plan.data.removed
8620
9016
  },
8621
9017
  revision: current.pipeline.revision,
@@ -8673,6 +9069,7 @@ var EngineService = class {
8673
9069
  ]);
8674
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(", ")}`);
8675
9071
  const parsed = parseOutlineInput(input.content, input.format);
9072
+ this.assertPublicBookHasNoStrictExplicit(parsed.book, "novel_outline_import");
8676
9073
  const snapshot = await this.load(input.root);
8677
9074
  this.assertProjectReadable(snapshot, "novel_outline_import");
8678
9075
  this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline_import");
@@ -8698,6 +9095,7 @@ var EngineService = class {
8698
9095
  defaultTargetWords: snapshot.project.targets.words_per_chapter,
8699
9096
  now
8700
9097
  });
9098
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_outline_import");
8701
9099
  const candidate = {
8702
9100
  schema_version: 1,
8703
9101
  updated_at: now,
@@ -8705,6 +9103,9 @@ var EngineService = class {
8705
9103
  volumes,
8706
9104
  chapters
8707
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");
8708
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");
8709
9110
  const summary = {
8710
9111
  format: parsed.format,
@@ -8748,6 +9149,7 @@ var EngineService = class {
8748
9149
  defaultTargetWords: fresh.project.targets.words_per_chapter,
8749
9150
  now
8750
9151
  });
9152
+ assertArchivedCardVolumes(fresh.chapters.chapters, fresh.outline.chapters, fresh.outline.volumes, nextVolumes, "novel_outline_import");
8751
9153
  const outline = {
8752
9154
  schema_version: 1,
8753
9155
  updated_at: now,
@@ -8755,7 +9157,7 @@ var EngineService = class {
8755
9157
  volumes: nextVolumes,
8756
9158
  chapters: nextChapters
8757
9159
  };
8758
- this.assertActiveCardPreserved(fresh, nextBook.strict, nextChapters, "novel_outline_import");
9160
+ this.assertActiveCardPreserved(fresh, nextChapters, "novel_outline_import");
8759
9161
  this.assertOutlineWritable(fresh, outline, "novel_outline_import");
8760
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");
8761
9163
  const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import_outline", this.now(), shortDigest(input.content)) });
@@ -9182,6 +9584,13 @@ var EngineService = class {
9182
9584
  async journalBlocking(snapshot) {
9183
9585
  const store = this.storeFor(snapshot.root);
9184
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
+ }];
9185
9594
  if (await store.exists("novel.yaml")) {
9186
9595
  const journalPresent = await store.exists(joinRel(STATE_DIR, STATE_FILES.journal));
9187
9596
  const journal = journalPresent ? await readJournal(store) : null;
@@ -9279,6 +9688,11 @@ var EngineService = class {
9279
9688
  async statusFor(snapshot, chapter) {
9280
9689
  const metrics = await this.metricsFor(snapshot, chapter);
9281
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
+ }] : [];
9282
9696
  const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
9283
9697
  tool: "novel_audit",
9284
9698
  args: {},
@@ -9298,6 +9712,7 @@ var EngineService = class {
9298
9712
  stage: snapshot.pipeline.stage,
9299
9713
  rounds: { ...snapshot.pipeline.rounds },
9300
9714
  blocking: [...this.effectiveBlocking(snapshot), ...journal],
9715
+ ...pendingWarnings.length === 0 ? {} : { warnings: pendingWarnings },
9301
9716
  state_errors: this.stateErrors(snapshot),
9302
9717
  selection: {
9303
9718
  source: snapshot.selection.source,
@@ -9833,6 +10248,21 @@ var EngineService = class {
9833
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"),
9834
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" }
9835
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
+ }
9836
10266
  add({
9837
10267
  id: "worldbook",
9838
10268
  severity: snapshot.worldbookError !== null ? "error" : snapshot.worldbookPresent ? "ok" : "info",
@@ -9852,6 +10282,14 @@ var EngineService = class {
9852
10282
  const journal = await readJournal(store);
9853
10283
  const journalCommitted = hasCommittedJournal(journal.records);
9854
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
+ });
9855
10293
  add({
9856
10294
  id: "journal",
9857
10295
  severity: !journalPresent || !journalCommitted || journal.malformed > 0 || health.mismatches.length > 0 ? "error" : "ok",
@@ -10747,7 +11185,11 @@ var EngineService = class {
10747
11185
  async submitRevision(input) {
10748
11186
  const hasContent = input.content !== void 0;
10749
11187
  const hasPatch = input.patch !== void 0;
10750
- 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");
10751
11193
  if (hasPatch) {
10752
11194
  const patch = input.patch;
10753
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");
@@ -10768,7 +11210,13 @@ var EngineService = class {
10768
11210
  }))}`,
10769
11211
  run: async (snapshot, context) => {
10770
11212
  this.assertChapter(snapshot, input.chapter);
10771
- 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");
10772
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");
10773
11221
  if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
10774
11222
  let patchedReview = null;
@@ -10831,8 +11279,10 @@ var EngineService = class {
10831
11279
  text: revisedContent,
10832
11280
  summary: input.summary,
10833
11281
  summaryProvided: input.summary !== void 0,
11282
+ summaryOnly,
11283
+ summaryOnlyRevision: summaryOnly,
10834
11284
  review: patchedReview,
10835
- proof: input.proof
11285
+ proof: input.proof ?? snapshot.review?.proof ?? null
10836
11286
  });
10837
11287
  const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
10838
11288
  if (input.summary === void 0 && inheritedSummary !== void 0) {
@@ -10851,24 +11301,27 @@ var EngineService = class {
10851
11301
  content_hash: sha256(snapshot.text ?? ""),
10852
11302
  updated_at: this.now().toISOString()
10853
11303
  };
10854
- 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);
10855
11305
  const nextPipeline = pipelineWith(snapshot, context.revision, {
10856
11306
  stage: nextStage$1,
10857
11307
  draft: {
10858
11308
  ...baseDraft,
10859
11309
  summary: effectiveSummary,
10860
- content_hash: sha256(revisedContent),
11310
+ content_hash: summaryOnly ? baseDraft.content_hash : sha256(revisedContent),
10861
11311
  updated_at: this.now().toISOString()
10862
11312
  },
10863
- rounds: {
11313
+ rounds: summaryOnly ? { ...snapshot.pipeline.rounds } : {
10864
11314
  ...snapshot.pipeline.rounds,
10865
11315
  revise: snapshot.pipeline.rounds.revise + 1
10866
11316
  },
10867
- blocking: [],
11317
+ blocking: summaryOnly ? snapshot.pipeline.blocking.filter((item) => item.code !== "PG-05") : [],
10868
11318
  ...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
10869
- last_action: action("novel_revise", this.now(), sha256(revisedContent))
11319
+ last_action: action(summaryOnly ? "novel_revise_summary" : "novel_revise", this.now(), sha256(revisedContent))
10870
11320
  });
10871
- const files = [
11321
+ const files = summaryOnly ? [{
11322
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
11323
+ text: yaml(nextPipeline)
11324
+ }] : [
10872
11325
  ...snapshot.text === null ? [] : [{
10873
11326
  path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
10874
11327
  text: snapshot.text
@@ -10886,17 +11339,19 @@ var EngineService = class {
10886
11339
  text: yaml(nextPipeline)
10887
11340
  }
10888
11341
  ];
10889
- if (reviewChanged && patchedReview !== null) files.push({
11342
+ if (!summaryOnly && reviewChanged && patchedReview !== null) files.push({
10890
11343
  path: reviewFile(input.chapter),
10891
11344
  text: yaml(patchedReview)
10892
11345
  });
10893
11346
  return {
10894
11347
  files,
10895
- backup: true,
11348
+ backup: !summaryOnly,
10896
11349
  data: {
10897
11350
  chapter: input.chapter,
10898
11351
  stage: nextStage$1,
10899
- metrics: machine.metrics
11352
+ metrics: machine.metrics,
11353
+ summary_only: summaryOnly,
11354
+ rounds_consumed: summaryOnly ? 0 : 1
10900
11355
  },
10901
11356
  warnings: machineWarnings(machine)
10902
11357
  };
@@ -10917,7 +11372,9 @@ var EngineService = class {
10917
11372
  run: async (snapshot, context) => {
10918
11373
  this.assertChapter(snapshot, input.chapter);
10919
11374
  const text = snapshot.text ?? "";
10920
- 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;
10921
11378
  await this.assertGates(snapshot, "runCheck", {
10922
11379
  briefStage: "P6",
10923
11380
  text,
@@ -10938,8 +11395,8 @@ var EngineService = class {
10938
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`);
10939
11396
  const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
10940
11397
  const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
10941
- const semanticResult = evaluateSemantic(text, semantic);
10942
- const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
11398
+ const semanticResult = semanticEvaluation;
11399
+ const verdict = machineBlocking.length === 0 && !semanticResult.content_failed ? "pass" : "fail";
10943
11400
  const check = {
10944
11401
  schema_version: 1,
10945
11402
  chapter: input.chapter,
@@ -10950,8 +11407,15 @@ var EngineService = class {
10950
11407
  advisories: machineAdvisories,
10951
11408
  metrics: scopedMachine.metrics
10952
11409
  },
10953
- semantic: { items: semantic },
11410
+ semantic: {
11411
+ items: semantic,
11412
+ ...semanticResult.findings.length === 0 ? {} : { findings: semanticResult.findings }
11413
+ },
10954
11414
  ...acknowledged.length === 0 ? {} : { acknowledged },
11415
+ ...verdict === "fail" ? {
11416
+ failure_class: "content",
11417
+ counted: true
11418
+ } : {},
10955
11419
  verdict
10956
11420
  };
10957
11421
  try {
@@ -10965,12 +11429,12 @@ var EngineService = class {
10965
11429
  code: violation.code,
10966
11430
  scope: "check",
10967
11431
  detail: violation.detail
10968
- })), ...semanticResult.blocking] : [];
11432
+ })), ...semanticResult.findings] : [];
10969
11433
  const nextPipeline = pipelineWith(snapshot, context.revision, {
10970
11434
  stage: nextStage$2,
10971
11435
  rounds: {
10972
11436
  ...snapshot.pipeline.rounds,
10973
- check: snapshot.pipeline.rounds.check + (verdict === "fail" || fullScope ? 1 : 0)
11437
+ check: snapshot.pipeline.rounds.check + (verdict === "fail" ? 1 : 0)
10974
11438
  },
10975
11439
  blocking: blockingItems,
10976
11440
  last_action: action("novel_check", this.now(), sha256(text))
@@ -10999,7 +11463,13 @@ var EngineService = class {
10999
11463
  advisory_codes: advisoryCodes,
11000
11464
  acknowledged,
11001
11465
  metrics: scopedMachine.metrics,
11002
- 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"
11003
11473
  },
11004
11474
  warnings: [
11005
11475
  ...machineWarnings({
@@ -11047,7 +11517,9 @@ var EngineService = class {
11047
11517
  chapter: input.chapter,
11048
11518
  assigned_ids: already.assigned_ids ?? {},
11049
11519
  metrics: countWordMetrics(archivedText),
11050
- score: already.score
11520
+ score: already.score,
11521
+ delta_warnings: [],
11522
+ normalized_world: {}
11051
11523
  },
11052
11524
  warnings: [],
11053
11525
  replay: true
@@ -11240,8 +11712,11 @@ var EngineService = class {
11240
11712
  chapter,
11241
11713
  assigned_ids: ledger.assigned,
11242
11714
  metrics,
11243
- score
11715
+ score,
11716
+ delta_warnings: ledger.warnings,
11717
+ normalized_world: ledger.normalized_world
11244
11718
  },
11719
+ warnings: ledger.warnings,
11245
11720
  event: {
11246
11721
  name: "novel/archived",
11247
11722
  payload: {
@@ -11524,6 +11999,15 @@ var EngineService = class {
11524
11999
  code: "JOURNAL_HASH_MISMATCH",
11525
12000
  message: `${health.mismatches.length} file(s) changed after the last commit; first: ${health.mismatches[0].path}`
11526
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
+ });
11527
12011
  const requested = input.limit ?? 50;
11528
12012
  const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
11529
12013
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
@@ -11566,10 +12050,22 @@ var EngineService = class {
11566
12050
  const current = await this.load(input.root);
11567
12051
  this.assertExportIndexReadable(current, input.kind);
11568
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;
11569
12065
  return this.mutate({
11570
12066
  root: input.root,
11571
12067
  op: "export",
11572
- key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
12068
+ key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${artifactEpoch}:${sha256(stableJson({
11573
12069
  chapter: snapshot.pipeline.chapter,
11574
12070
  text: sha256(snapshot.text ?? ""),
11575
12071
  plan_title: stripTitlePrefix(snapshot.plan?.title ?? ""),
@@ -11791,14 +12287,14 @@ var EngineService = class {
11791
12287
  root: active,
11792
12288
  source: "active"
11793
12289
  };
11794
- else if (registry.projects.length === 1) selection = {
11795
- root: registry.projects[0].root,
11796
- source: "single"
11797
- };
11798
12290
  else if (rows.has(configured) && !registry.projects.some((entry) => entry.root === configured)) selection = {
11799
12291
  root: configured,
11800
12292
  source: "config"
11801
12293
  };
12294
+ else if (registry.projects.length === 1) selection = {
12295
+ root: registry.projects[0].root,
12296
+ source: "single"
12297
+ };
11802
12298
  else selection = {
11803
12299
  root: null,
11804
12300
  source: "none"
@@ -12297,15 +12793,23 @@ var EngineService = class {
12297
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}`);
12298
12794
  }
12299
12795
  /**
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
+ /**
12300
12805
  * An import is a full replacement, unlike novel_outline's keyed upsert. It
12301
12806
  * must not silently lose the active card while strict mode still requires it.
12302
12807
  */
12303
- assertActiveCardPreserved(snapshot, strict, chapters, operation) {
12304
- if (!strict) return;
12808
+ assertActiveCardPreserved(snapshot, chapters, operation) {
12305
12809
  const chapter = snapshot.pipeline.chapter;
12306
12810
  if (!snapshot.outline.chapters.some((card) => card.num === chapter)) return;
12307
12811
  if (chapters.some((card) => card.num === chapter)) return;
12308
- 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 imported outline; import replaces the whole plan and will not turn strict mode off for you");
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");
12309
12813
  }
12310
12814
  /**
12311
12815
  * The outline is a derived progress view and the PG-13 input. Every write
@@ -12385,6 +12889,7 @@ var EngineService = class {
12385
12889
  const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
12386
12890
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
12387
12891
  const journal = await readJournal(this.storeFor(snapshot.root));
12892
+ const pending = await inspectPending(this.storeFor(snapshot.root));
12388
12893
  const journalBlocking = await this.journalBlocking(snapshot);
12389
12894
  const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
12390
12895
  const counts = worldbookCounts(snapshot.worldbook);
@@ -12518,7 +13023,12 @@ var EngineService = class {
12518
13023
  activity,
12519
13024
  ...advisories.length === 0 ? {} : { advisories },
12520
13025
  ...acknowledgedAdvisories === 0 ? {} : { acknowledgedAdvisories },
12521
- ...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
+ } }
12522
13032
  };
12523
13033
  }
12524
13034
  /**
@@ -13196,6 +13706,13 @@ var EngineService = class {
13196
13706
  rounds: snapshot.pipeline.rounds,
13197
13707
  min_score: this.config.quality.minScore,
13198
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
+ })),
13199
13716
  context
13200
13717
  }));
13201
13718
  }
@@ -13241,6 +13758,8 @@ var EngineService = class {
13241
13758
  sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
13242
13759
  summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
13243
13760
  summaryProvided: extras.summaryProvided,
13761
+ summaryOnly: extras.summaryOnly,
13762
+ summaryOnlyRevision: extras.summaryOnlyRevision ?? snapshot.pipeline.last_action?.op === "novel_revise_summary",
13244
13763
  review: extras.review ?? snapshot.review,
13245
13764
  check: extras.check ?? snapshot.check,
13246
13765
  semantic: extras.semantic ?? [],
@@ -13641,7 +14160,14 @@ var EngineService = class {
13641
14160
  STATE_FILES.handoff
13642
14161
  ].map((file) => joinRel(STATE_DIR, file));
13643
14162
  stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
13644
- 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);
13645
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");
13646
14172
  await assertJournalConsistent(store);
13647
14173
  const created = await options.run(this.emptySnapshot(root, selection), {
@@ -14156,7 +14682,9 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
14156
14682
  if (kind !== "relationships") return problems;
14157
14683
  }
14158
14684
  if (kind === "characters") {
14159
- 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");
14160
14688
  if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
14161
14689
  for (const name of Object.keys(snapshot.locks.characters)) if (!names.has(name)) problems.push(`lock for removed character "${name}" still exists`);
14162
14690
  }
@@ -14166,6 +14694,21 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
14166
14694
  return problems;
14167
14695
  }
14168
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");
14169
14712
  const locations = new Set((Array.isArray(nextDocument.locations) ? nextDocument.locations : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
14170
14713
  if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
14171
14714
  for (const name of Object.keys(snapshot.locks.locations)) if (!locations.has(name)) problems.push(`lock for removed location "${name}" still exists`);
@@ -14255,6 +14798,19 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
14255
14798
  const row = recordOf(raw);
14256
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`);
14257
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
+ }
14258
14814
  for (const record of snapshot.chapters.chapters) {
14259
14815
  const card = byNum.get(record.num);
14260
14816
  if (card === void 0) {
@@ -14516,7 +15072,8 @@ const asJson = (value, field) => {
14516
15072
  const fail = (error) => {
14517
15073
  if (error instanceof EngineError) {
14518
15074
  const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
14519
- 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}`);
14520
15077
  }
14521
15078
  throw error;
14522
15079
  };
@@ -14905,15 +15462,17 @@ function createNovelTools(engine, options = {}) {
14905
15462
  type: "json",
14906
15463
  description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
14907
15464
  },
14908
- 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
+ },
14909
15469
  issues: {
14910
15470
  type: "json",
14911
15471
  description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
14912
15472
  },
14913
15473
  proof: {
14914
15474
  type: "json",
14915
- required: true,
14916
- description: arraySemanticsDescription("replace", "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.")
14917
15476
  },
14918
15477
  project_root: root
14919
15478
  },
@@ -15615,7 +16174,12 @@ const bad = (text) => ({
15615
16174
  kind: "error",
15616
16175
  text
15617
16176
  });
15618
- 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
+ };
15619
16183
  const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
15620
16184
  const statusText = async (engine) => {
15621
16185
  const { data } = await engine.getStatus({});
@@ -16205,6 +16769,119 @@ function compileRulepack(raw) {
16205
16769
  "exclusions",
16206
16770
  "exemptions"
16207
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
+ }
16208
16885
  const pack = normalize(raw);
16209
16886
  const rawPlatform = object(root.platform, "platform");
16210
16887
  for (const platform of PLATFORMS) if (rawPlatform[platform] === void 0) invalid(`platform.${platform} is missing`);