dsh-novel-write-engine 0.1.1 → 0.1.3

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
@@ -54,7 +54,13 @@ const LIMITS = {
54
54
  /** How long a writer waits for the keyed mutex before failing. */
55
55
  lockWaitMs: 1e4,
56
56
  /** Reading proofs shorter than this cannot be verified. */
57
- minQuoteChars: 12
57
+ minQuoteChars: 12,
58
+ /** World-plan text fields are protected context, so they need a hard bound. */
59
+ maxWorldPlanItems: 64,
60
+ maxWorldPlanTextChars: 4e3,
61
+ maxWorldPlanObjectiveChars: 2e3,
62
+ /** Cast plus summary may exceed eight, but not without a bound. */
63
+ maxActiveCast: 12
58
64
  };
59
65
  /** The order used to render the nine-phase progress strip. */
60
66
  const PHASE_STRIP = [
@@ -455,6 +461,16 @@ function dialogueCharacterRatio(paragraph) {
455
461
  for (const match of paragraph.matchAll(/["“]([^"“”]*)["”]/g)) quoted += Array.from(match[1]).length;
456
462
  return quoted / total;
457
463
  }
464
+ /**
465
+ * The opening quote rule is shared by PG-05 (draft/revise) and the P5 fast
466
+ * check. Keeping one implementation prevents the two paths from disagreeing
467
+ * about what counts as the first two paragraphs.
468
+ */
469
+ function openingInFirstTwoParagraphs(text, opening) {
470
+ const needle = opening.trim();
471
+ if (needle === "") return false;
472
+ return splitParagraphs(text).slice(0, 2).some((paragraph) => paragraph.includes(needle));
473
+ }
458
474
  // src/domain/simhash.ts
459
475
  const MASK64 = (1n << 64n) - 1n;
460
476
  /** FNV-1a over UTF-16 code units; deterministic across platforms and Node versions. */
@@ -529,105 +545,47 @@ const TRANSITIONS = [
529
545
  {
530
546
  from: "idle",
531
547
  op: "submitWorldPlan",
532
- to: () => "P2",
533
- guards: ["PG-01", "PG-02"]
548
+ to: () => "P2"
534
549
  },
535
550
  {
536
551
  from: "done",
537
552
  op: "submitWorldPlan",
538
- to: () => "P2",
539
- guards: ["PG-01", "PG-02"]
553
+ to: () => "P2"
540
554
  },
541
555
  {
542
556
  from: "P2",
543
557
  op: "submitChapterPlan",
544
- to: () => "P3",
545
- guards: ["PG-01", "PG-02"]
558
+ to: () => "P3"
546
559
  },
547
560
  {
548
561
  from: "P3",
549
562
  op: "submitDraft",
550
- to: () => "P4",
551
- guards: [
552
- "PG-01",
553
- "PG-02",
554
- "PG-03",
555
- "PG-04",
556
- "PG-05",
557
- "PG-06"
558
- ]
563
+ to: () => "P4"
559
564
  },
560
565
  {
561
566
  from: "P4",
562
567
  op: "submitReview",
563
- to: (context) => context.openBlockers > 0 ? "P5" : "P6",
564
- guards: [
565
- "PG-01",
566
- "PG-02",
567
- "PG-07",
568
- "PG-09",
569
- "PG-11"
570
- ]
568
+ to: (context) => context.openBlockers > 0 ? "P5" : "P6"
571
569
  },
572
570
  {
573
571
  from: "P5",
574
572
  op: "submitRevision",
575
- to: () => "P4",
576
- guards: [
577
- "PG-01",
578
- "PG-02",
579
- "PG-03",
580
- "PG-04",
581
- "PG-05",
582
- "PG-08",
583
- "PG-09"
584
- ]
573
+ to: () => "P4"
585
574
  },
586
575
  {
587
576
  from: "P6",
588
577
  op: "submitRevision",
589
- to: () => "P4",
590
- guards: [
591
- "PG-01",
592
- "PG-02",
593
- "PG-03",
594
- "PG-04",
595
- "PG-05",
596
- "PG-08",
597
- "PG-09"
598
- ]
578
+ to: () => "P4"
599
579
  },
600
580
  {
601
581
  from: "P6",
602
582
  op: "runCheck",
603
- to: () => "ready_to_archive",
604
- guards: [
605
- "PG-01",
606
- "PG-02",
607
- "PG-04",
608
- "PG-05",
609
- "PG-08",
610
- "PG-09",
611
- "PG-10",
612
- "PG-11"
613
- ]
583
+ to: (context) => context.completeCheck === false ? "P6" : "ready_to_archive"
614
584
  },
615
585
  {
616
586
  from: "ready_to_archive",
617
587
  op: "archiveChapter",
618
- to: () => "done",
619
- guards: [
620
- "PG-01",
621
- "PG-02",
622
- "PG-03",
623
- "PG-04",
624
- "PG-05",
625
- "PG-06",
626
- "PG-07",
627
- "PG-08",
628
- "PG-10",
629
- "PG-12"
630
- ]
588
+ to: () => "done"
631
589
  }
632
590
  ];
633
591
  /** Find the row for this (stage, op), if any. */
@@ -639,10 +597,13 @@ function canRun(stage, op) {
639
597
  return findTransition(stage, op) !== void 0;
640
598
  }
641
599
  /** Compute the next stage, or throw `ENGINE_STAGE_ORDER` with an actionable message. */
642
- function nextStage(stage, op, openBlockers) {
600
+ function nextStage(stage, op, openBlockers, context = {}) {
643
601
  const transition = findTransition(stage, op);
644
602
  if (transition === void 0) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `operation ${op} is not allowed at stage ${stage}`, `allowed here: ${availableOps(stage).join(", ")}; call novel_status for the full picture`);
645
- return transition.to({ openBlockers });
603
+ return transition.to({
604
+ openBlockers,
605
+ ...context
606
+ });
646
607
  }
647
608
  /** Operations legal at a stage, in table order. */
648
609
  function availableOps(stage) {
@@ -732,6 +693,24 @@ function countWordMetrics(text) {
732
693
  function countWordMetricsOfParagraph(paragraph) {
733
694
  return matchCount(paragraph, CJK) + matchCount(paragraph, ENGLISH_WORD) + matchCount(paragraph, NUMBER) + matchCount(paragraph, CHINESE_PUNCT) + matchCount(paragraph, ENGLISH_PUNCT);
734
695
  }
696
+ // src/domain/stable.ts
697
+ /**
698
+ * Locale-independent ordering and hashing primitives.
699
+ *
700
+ * A digest must not depend on the process locale. `localeCompare` can reorder
701
+ * CJK keys differently under different ICU collations, so every persisted sort
702
+ * and every digest uses UTF-16 code-unit order instead.
703
+ */
704
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
705
+ const normalize$1 = (input) => {
706
+ if (Array.isArray(input)) return input.map(normalize$1);
707
+ if (input instanceof Date) return input;
708
+ if (typeof input === "object" && input !== null && typeof input.toJSON === "function") return input;
709
+ if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== void 0).sort(([left], [right]) => compareText(left, right)).map(([key, value]) => [key, normalize$1(value)]));
710
+ return input;
711
+ };
712
+ /** Deterministic JSON used by every replay key and digest. */
713
+ const stableJson = (value) => JSON.stringify(normalize$1(value)) ?? "null";
735
714
  // src/codes.ts
736
715
  /** Stable rule codes. One owner per code: the rules registry refuses duplicates. */
737
716
  const FORMAT_RULES = {
@@ -1023,10 +1002,16 @@ const adjectiveDensity = (ctx) => {
1023
1002
  return [v(SEMANTIC_RULES.ADJECTIVE_DENSITY, "advisory", 1, `adjectives every ${(100 / density).toFixed(1)} chars; limit is ${max}%`)];
1024
1003
  };
1025
1004
  const slidingWindows = (text, size) => {
1026
- if (text.length <= size) return [text];
1005
+ const positions = [];
1006
+ for (let index = 0; index < text.length; index += 1) if (/\S/.test(text[index])) positions.push(index);
1007
+ if (positions.length <= size) return [text];
1027
1008
  const windows = [];
1028
- for (let start = 0; start + size <= text.length; start += 1) windows.push(text.slice(start, start + size));
1029
- const tail = text.slice(text.length - size);
1009
+ for (let startIndex = 0; startIndex + size <= positions.length; startIndex += 1) {
1010
+ const start = positions[startIndex];
1011
+ const end = startIndex + size < positions.length ? positions[startIndex + size] : text.length;
1012
+ windows.push(text.slice(start, end));
1013
+ }
1014
+ const tail = text.slice(positions[positions.length - size]);
1030
1015
  if (windows[windows.length - 1] !== tail) windows.push(tail);
1031
1016
  return windows;
1032
1017
  };
@@ -1104,10 +1089,18 @@ const revisitedTooSoon = (ctx) => {
1104
1089
  }).map((item) => v(CROSS_RULES.FS_REVISITED_TOO_SOON, "advisory", 1, `foreshadow ${item.id} reappears after ${ctx.chapter - item.last_touched} chapters (minimum ${min})`));
1105
1090
  };
1106
1091
  const resourceMismatch = (ctx) => {
1107
- return (ctx.summary?.resource_changes ?? []).flatMap((change) => {
1092
+ const changes = ctx.summary?.resource_changes ?? [];
1093
+ const positive = /(增加|多了|得到|获得|赚|加|上升|增|\+)/;
1094
+ const negative = /(减少|少了|失去|消耗|花|损失|下降|减|-|−)/;
1095
+ return changes.flatMap((change) => {
1108
1096
  if (change.delta === 0) return [];
1109
- const magnitude = String(Math.abs(change.delta));
1110
- return ctx.text.includes(change.character) && ctx.text.includes(magnitude) ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", 1, `${change.character}'s ${change.resource} change (${change.delta}) is not stated in the text`)];
1097
+ const magnitude = Math.abs(change.delta);
1098
+ return [...ctx.text.matchAll(/\d+/g)].some((match) => {
1099
+ if (Number(match[0]) !== magnitude) return false;
1100
+ const index = match.index ?? 0;
1101
+ const window = ctx.text.slice(Math.max(0, index - 80), Math.min(ctx.text.length, index + match[0].length + 80));
1102
+ return window.includes(change.character) && window.includes(change.resource) && (change.delta > 0 ? positive : negative).test(window);
1103
+ }) ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", 1, `${change.character}'s ${change.resource} change (${change.delta}) is not stated with the right sign in the text`)];
1111
1104
  });
1112
1105
  };
1113
1106
  const timeRegression = (ctx) => {
@@ -1263,6 +1256,11 @@ const lexiconCodes = /* @__PURE__ */ new Set([
1263
1256
  "GOD_VIEW",
1264
1257
  "CONCLUSION_FIRST"
1265
1258
  ]);
1259
+ const integrityCodes = new Set(Object.values(CROSS_RULES));
1260
+ /** Which runCheck scope owns one machine finding. */
1261
+ function scopeOfRule(code) {
1262
+ return integrityCodes.has(code) ? "integrity" : "content";
1263
+ }
1266
1264
  /**
1267
1265
  * Run the deterministic pass. Format and punctuation run before the lexicon so
1268
1266
  * the report reads in the order an author fixes things; the lexicon runs last
@@ -2101,16 +2099,25 @@ async function recoverPending(store) {
2101
2099
  }
2102
2100
  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`);
2103
2101
  }
2104
- /** Throw before a write when the journal or the last commit is inconsistent. */
2105
- async function assertJournalConsistent(store) {
2102
+ /**
2103
+ * Throw before a write when the journal or the last commit is inconsistent.
2104
+ *
2105
+ * `repairPaths` is used only by an explicit amend: a damaged ledger is by
2106
+ * definition a file whose bytes no longer match the last committed hash, and
2107
+ * refusing every write would make the documented repair path unreachable. All
2108
+ * other mismatches still fail closed.
2109
+ */
2110
+ async function assertJournalConsistent(store, repairPaths = []) {
2106
2111
  await recoverPending(store);
2107
2112
  const { records } = await readJournal(store);
2108
2113
  const committed = new Set(records.filter((record) => record.status === "committed" && record.op !== "lock_reclaimed").map((record) => record.txid));
2109
2114
  for (const entry of await store.list(STAGING_DIR)) if (committed.has(entry)) await store.removeTree(joinRel(STAGING_DIR, entry)).catch(() => void 0);
2110
2115
  const { malformed, mismatches } = await reconcileJournal(store);
2111
2116
  assertJournalHealthy(malformed);
2112
- if (mismatches.length > 0) {
2113
- const first = mismatches[0];
2117
+ const allowed = new Set(repairPaths);
2118
+ const fatal = mismatches.filter((mismatch) => !allowed.has(mismatch.path));
2119
+ if (fatal.length > 0) {
2120
+ const first = fatal[0];
2114
2121
  throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `journal hash mismatch for ${first.path} (txid ${first.txid}); expected ${first.expected}, found ${first.actual ?? "missing"}`, "restore the file from backups/ or fix the external change before retrying");
2115
2122
  }
2116
2123
  }
@@ -2474,7 +2481,7 @@ var ProjectRegistry = class {
2474
2481
  rows.push(row);
2475
2482
  const next = {
2476
2483
  ...document,
2477
- projects: rows.sort((a, b) => a.root.localeCompare(b.root))
2484
+ projects: rows.sort((a, b) => compareText(a.root, b.root))
2478
2485
  };
2479
2486
  await this.write(next);
2480
2487
  return next;
@@ -2548,18 +2555,20 @@ const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").r
2548
2555
  function chapterHeading(chapter) {
2549
2556
  return `第${chapter.num}章 ${chapter.title}`;
2550
2557
  }
2558
+ /** Remove blank lines at the document edges, never manuscript indentation. */
2559
+ const trimBlankEdges = (text) => text.replace(/^\n+|\n+$/g, "");
2551
2560
  /** Markdown export: one heading per chapter, body unchanged (it is plain text). */
2552
2561
  function renderMarkdown(project, chapters) {
2553
2562
  const parts = [`# ${project.title}`, ""];
2554
2563
  if (project.author !== "") parts.push(`作者:${project.author}`, "");
2555
2564
  if (project.description !== "") parts.push(`> ${project.description}`, "");
2556
- for (const chapter of chapters) parts.push(`## ${chapterHeading(chapter)}`, "", chapter.text.trim(), "");
2565
+ for (const chapter of chapters) parts.push(`## ${chapterHeading(chapter)}`, "", trimBlankEdges(chapter.text), "");
2557
2566
  return parts.join("\n");
2558
2567
  }
2559
2568
  /** Plain-text export: no markdown syntax, no BOM. */
2560
2569
  function renderPlainText(project, chapters) {
2561
2570
  const parts = [project.title, ""];
2562
- for (const chapter of chapters) parts.push(chapterHeading(chapter), "", chapter.text.trim(), "");
2571
+ for (const chapter of chapters) parts.push(chapterHeading(chapter), "", trimBlankEdges(chapter.text), "");
2563
2572
  return parts.join("\n");
2564
2573
  }
2565
2574
  /** Blurb built from the project record; deterministic, no model call. */
@@ -2588,9 +2597,10 @@ function renderCover(project) {
2588
2597
  }
2589
2598
  /** XHTML chapter document for the EPUB package. */
2590
2599
  function renderEpubChapter(chapter) {
2591
- const paragraphs = chapter.text.split(/\n+/).map((line) => line.trim()).filter((line) => line !== "").map((line) => `<p>${escapeHtml(line)}</p>`).join("\n");
2600
+ const paragraphs = chapter.text.split(/\n+/).map((line) => line.replace(/\r$/, "")).filter((line) => line.trim() !== "").map((line) => `<p class="body">${escapeHtml(line)}</p>`).join("\n");
2592
2601
  return `<?xml version="1.0" encoding="utf-8"?>
2593
- <html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeHtml(chapterHeading(chapter))}</title></head>
2602
+ <html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeHtml(chapterHeading(chapter))}</title>
2603
+ <style>.body { white-space: pre-wrap; margin: 0 0 0.6em; }</style></head>
2594
2604
  <body><h1>${escapeHtml(chapterHeading(chapter))}</h1>${paragraphs}</body></html>`;
2595
2605
  }
2596
2606
  // src/export/epub.ts
@@ -2824,6 +2834,162 @@ var BriefLibrary = class {
2824
2834
  }
2825
2835
  };
2826
2836
  const hash = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
2837
+ // src/engine/handoff.ts
2838
+ const ACT_DIGITS = [
2839
+ "零",
2840
+ "一",
2841
+ "二",
2842
+ "三",
2843
+ "四",
2844
+ "五",
2845
+ "六",
2846
+ "七",
2847
+ "八",
2848
+ "九"
2849
+ ];
2850
+ /** Chinese numerals for the act labels the task books use. */
2851
+ const chineseNumber = (value) => {
2852
+ if (!Number.isInteger(value) || value < 0) return String(value);
2853
+ if (value < 10) return ACT_DIGITS[value];
2854
+ if (value === 10) return "十";
2855
+ if (value < 20) return `十${ACT_DIGITS[value - 10]}`;
2856
+ if (value < 100) {
2857
+ const tens = Math.floor(value / 10);
2858
+ const ones = value % 10;
2859
+ return `${ACT_DIGITS[tens]}十${ones === 0 ? "" : ACT_DIGITS[ones]}`;
2860
+ }
2861
+ return String(value);
2862
+ };
2863
+ const parseActNumber = (act) => {
2864
+ const match = /^第\s*([零一二三四五六七八九十百\d]+)\s*幕$/.exec(act.trim());
2865
+ if (match === null) return null;
2866
+ const raw = match[1];
2867
+ if (/^\d+$/.test(raw)) return Number(raw);
2868
+ if (raw === "十") return 10;
2869
+ const ten = raw.indexOf("十");
2870
+ if (ten !== -1) {
2871
+ const left = raw.slice(0, ten);
2872
+ const right = raw.slice(ten + 1);
2873
+ const tens = left === "" ? 1 : ACT_DIGITS.indexOf(left);
2874
+ const ones = right === "" ? 0 : ACT_DIGITS.indexOf(right);
2875
+ if (tens < 0 || ones < 0) return null;
2876
+ return tens * 10 + ones;
2877
+ }
2878
+ const index = ACT_DIGITS.indexOf(raw);
2879
+ return index < 0 ? null : index;
2880
+ };
2881
+ const nextAct = (act) => {
2882
+ const value = parseActNumber(act);
2883
+ return value === null ? act : `第${chineseNumber(value + 1)}幕`;
2884
+ };
2885
+ /** `n/d`, with a non-zero denominator; `0/0` is the initialized handoff. */
2886
+ const parseActProgress = (value) => {
2887
+ if (typeof value !== "string") return null;
2888
+ const match = /^(\d+)\/(\d+)$/.exec(value.trim());
2889
+ if (match === null) return null;
2890
+ const done = Number(match[1]);
2891
+ const total = Number(match[2]);
2892
+ if (!Number.isSafeInteger(done) || !Number.isSafeInteger(total)) return null;
2893
+ if (done < 0 || total < 0 || done > total) return null;
2894
+ return {
2895
+ done,
2896
+ total
2897
+ };
2898
+ };
2899
+ const firstPosition = (chapter) => {
2900
+ const block = Math.max(0, chapter - 1);
2901
+ return {
2902
+ volume: 1,
2903
+ volume_name: "第一卷",
2904
+ act: `第${chineseNumber(Math.floor(block / 5) + 1)}幕`,
2905
+ act_progress: `${block % 5 + 1}/5`
2906
+ };
2907
+ };
2908
+ const advanceProgress = (previous) => {
2909
+ const act = typeof previous.act === "string" && previous.act.trim() !== "" ? previous.act : "第一幕";
2910
+ const progress = parseActProgress(previous.act_progress);
2911
+ if (progress === null || progress.total === 0) return {
2912
+ act,
2913
+ act_progress: "1/5"
2914
+ };
2915
+ if (progress.done < progress.total) return {
2916
+ act,
2917
+ act_progress: `${progress.done + 1}/${progress.total}`
2918
+ };
2919
+ return {
2920
+ act: nextAct(act),
2921
+ act_progress: `1/${progress.total}`
2922
+ };
2923
+ };
2924
+ const volumeOf = (previous) => previous !== null && Number.isInteger(previous.volume) && previous.volume >= 1 ? previous.volume : 1;
2925
+ const volumeNameOf = (previous, volume) => previous !== null && typeof previous.volume_name === "string" && previous.volume_name.trim() !== "" ? previous.volume_name : `第${volume}卷`;
2926
+ /**
2927
+ * Advance from the stored handoff of the previous chapter to the next chapter.
2928
+ * The archive override is applied as a starting point: changing volume resets
2929
+ * the act, while changing only the act starts its first beat.
2930
+ */
2931
+ const advanceHandoff = (previous, chapter, override) => {
2932
+ if (previous === null) {
2933
+ const position = firstPosition(chapter);
2934
+ const volume = override?.volume ?? position.volume;
2935
+ return {
2936
+ volume,
2937
+ volume_name: override?.volume_name ?? (volume === position.volume ? position.volume_name : `第${volume}卷`),
2938
+ act: override?.act ?? position.act,
2939
+ act_progress: override?.act_progress ?? (volume === position.volume ? position.act_progress : "1/5")
2940
+ };
2941
+ }
2942
+ const previousVolume = volumeOf(previous);
2943
+ const volume = override?.volume ?? previousVolume;
2944
+ const volumeChanged = volume !== previousVolume;
2945
+ const base = volumeChanged ? {
2946
+ act: "第一幕",
2947
+ act_progress: "1/5"
2948
+ } : advanceProgress(previous);
2949
+ const act = override?.act ?? base.act;
2950
+ const actProgress = override?.act_progress ?? (override?.act !== void 0 && override.act !== previous.act ? "1/5" : base.act_progress);
2951
+ return {
2952
+ volume,
2953
+ volume_name: override?.volume_name ?? (volumeChanged ? `第${volume}卷` : volumeNameOf(previous, volume)),
2954
+ act,
2955
+ act_progress: actProgress
2956
+ };
2957
+ };
2958
+ /** Project the stored handoff to the active chapter without changing the file. */
2959
+ const projectHandoff = (previous, chapter) => {
2960
+ if (previous === null) return firstPosition(chapter);
2961
+ const current = Number.isInteger(previous.current_chapter) ? previous.current_chapter : chapter - 1;
2962
+ if (current >= chapter) {
2963
+ const volume = volumeOf(previous);
2964
+ return {
2965
+ volume,
2966
+ volume_name: volumeNameOf(previous, volume),
2967
+ act: previous.act,
2968
+ act_progress: previous.act_progress
2969
+ };
2970
+ }
2971
+ let position = {
2972
+ volume: volumeOf(previous),
2973
+ volume_name: volumeNameOf(previous, volumeOf(previous)),
2974
+ act: previous.act,
2975
+ act_progress: previous.act_progress
2976
+ };
2977
+ for (let step = current; step < chapter; step += 1) {
2978
+ const fake = {
2979
+ ...previous,
2980
+ volume: position.volume,
2981
+ volume_name: position.volume_name,
2982
+ act: position.act,
2983
+ act_progress: position.act_progress
2984
+ };
2985
+ position = {
2986
+ volume: position.volume,
2987
+ volume_name: position.volume_name,
2988
+ ...advanceProgress(fake)
2989
+ };
2990
+ }
2991
+ return position;
2992
+ };
2827
2993
  // src/engine/context.ts
2828
2994
  const compactSummary = (row) => ({
2829
2995
  num: row.num,
@@ -2841,13 +3007,113 @@ const sceneDistribution = (chapters) => {
2841
3007
  return counts;
2842
3008
  };
2843
3009
  const pendingForeshadows = (ledger) => ledger.items.filter((item) => item.status !== "resolved");
3010
+ const unique = (values) => {
3011
+ const seen = /* @__PURE__ */ new Set();
3012
+ const result = [];
3013
+ for (const value of values) {
3014
+ if (value === "" || seen.has(value)) continue;
3015
+ seen.add(value);
3016
+ result.push(value);
3017
+ }
3018
+ return result;
3019
+ };
3020
+ /** Match known names at occurrence level, longest first, without substring theft. */
3021
+ const mentionedNames = (text, characters) => {
3022
+ let remaining = text;
3023
+ const names = [...new Set(characters.characters.map((character) => character.name))].filter((name) => name !== "").sort((left, right) => {
3024
+ const length = Array.from(right).length - Array.from(left).length;
3025
+ return length !== 0 ? length : compareText(left, right);
3026
+ });
3027
+ const matched = [];
3028
+ for (const name of names) {
3029
+ if (!remaining.includes(name)) continue;
3030
+ matched.push(name);
3031
+ remaining = remaining.split(name).join("\0");
3032
+ }
3033
+ return matched;
3034
+ };
3035
+ /** The names the current chapter is actually about. */
3036
+ const activeCastNames = (plan, characters, text, appearances) => {
3037
+ const explicit = unique([...(plan?.cast ?? []).map((member) => member.name), ...plan?.summary?.characters_appeared ?? []]);
3038
+ const narrator = (plan?.narrator ?? "").split(/[((]/, 1)[0].trim();
3039
+ const narratorKnown = narrator !== "" && characters.characters.some((character) => character.name === narrator);
3040
+ const planText = [
3041
+ plan?.title ?? "",
3042
+ plan?.narrator ?? "",
3043
+ plan?.opening ?? "",
3044
+ plan?.ending ?? "",
3045
+ ...plan?.beats ?? [],
3046
+ text ?? ""
3047
+ ].join("\n");
3048
+ const byName = new Map(characters.characters.map((character) => [character.name, character]));
3049
+ const orderedMentioned = mentionedNames(planText, characters).map((name) => byName.get(name)).sort((left, right) => {
3050
+ const byRecency = recencyOf(right, appearances) - recencyOf(left, appearances);
3051
+ return byRecency !== 0 ? byRecency : compareText(left.name, right.name);
3052
+ }).map((character) => character.name);
3053
+ if (explicit.length > 0) return unique([...explicit, ...orderedMentioned]).slice(0, LIMITS.maxActiveCast);
3054
+ return unique([...narratorKnown ? [narrator] : [], ...orderedMentioned]).slice(0, 8);
3055
+ };
3056
+ const recencyOf = (character, appearances) => Math.max(Number(character.updated_at ?? 0), appearances.get(character.name) ?? 0);
3057
+ const appearancesByChapter = (chapters) => {
3058
+ const result = /* @__PURE__ */ new Map();
3059
+ for (const row of chapters.chapters) for (const name of row.summary.characters_appeared) result.set(name, Math.max(result.get(name) ?? 0, row.num));
3060
+ return result;
3061
+ };
3062
+ const compactCharacter = (character, appearances) => ({
3063
+ name: character.name,
3064
+ role: character.role,
3065
+ first_appearance: character.first_appearance,
3066
+ last_seen: recencyOf(character, appearances)
3067
+ });
3068
+ const castCard = (name, character, member, declared) => {
3069
+ return {
3070
+ ...character ?? declared ?? { name },
3071
+ name,
3072
+ ...member === void 0 ? {} : {
3073
+ entry_state: member.entry_state,
3074
+ goal: member.goal,
3075
+ knowledge: member.knowledge,
3076
+ exit_change: member.exit_change
3077
+ }
3078
+ };
3079
+ };
3080
+ const worldPlanBrief = (plan) => plan === null ? null : {
3081
+ chapter: plan.chapter,
3082
+ objective: plan.objective,
3083
+ constraints: plan.constraints,
3084
+ characters: plan.characters,
3085
+ locations: plan.locations,
3086
+ continuity_notes: plan.continuity_notes
3087
+ };
3088
+ const filterLocks = (locks, cast, locations) => {
3089
+ const characterNames = new Set(cast);
3090
+ const locationNames = new Set(locations);
3091
+ return {
3092
+ ...locks,
3093
+ characters: characterNames.size === 0 ? locks.characters : Object.fromEntries(Object.entries(locks.characters).filter(([name]) => characterNames.has(name))),
3094
+ locations: locationNames.size === 0 ? locks.locations : Object.fromEntries(Object.entries(locks.locations).filter(([name]) => locationNames.has(name)))
3095
+ };
3096
+ };
2844
3097
  /**
2845
3098
  * Build the stage-specific context pack. The task book explains the rules; this
2846
3099
  * pack is the minimum project state the model needs to apply them without
2847
3100
  * guessing who exists, what was locked, or what the previous chapter ended on.
2848
3101
  */
2849
- function briefContext(input) {
3102
+ function unbudgetedBriefContext(input) {
2850
3103
  const previousEnding = input.previousText === null ? "" : input.previousText.trim().slice(-400);
3104
+ const appearances = appearancesByChapter(input.chapters);
3105
+ const activeNames = activeCastNames(input.plan, input.characters, input.text, appearances);
3106
+ const activeSet = new Set(activeNames);
3107
+ const planCast = new Map((input.plan?.cast ?? []).map((member) => [member.name, member]));
3108
+ const byName = new Map(input.characters.characters.map((character) => [character.name, character]));
3109
+ const worldPlanByName = new Map((input.worldPlan?.characters ?? []).map((character) => [character.name, character]));
3110
+ const activeCast = activeNames.map((name) => castCard(name, byName.get(name), planCast.get(name), worldPlanByName.get(name)));
3111
+ const candidateNames = new Set(input.stage === "P2" ? (input.worldPlan?.characters ?? []).map((character) => character.name) : []);
3112
+ const otherCharacters = input.characters.characters.filter((character) => !activeSet.has(character.name) && !candidateNames.has(character.name)).sort((left, right) => {
3113
+ const byRecency = recencyOf(right, appearances) - recencyOf(left, appearances);
3114
+ return byRecency !== 0 ? byRecency : compareText(left.name, right.name);
3115
+ }).map((character) => compactCharacter(character, appearances));
3116
+ const worldPlan = worldPlanBrief(input.worldPlan ?? null);
2851
3117
  const base = {
2852
3118
  project: {
2853
3119
  title: input.project.title,
@@ -2859,26 +3125,38 @@ function briefContext(input) {
2859
3125
  stage: input.pipeline.stage,
2860
3126
  scene_distribution: sceneDistribution(input.chapters),
2861
3127
  previous_ending: previousEnding,
2862
- ...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error }
3128
+ ...input.world_plan_error === void 0 || input.world_plan_error === null ? {} : { world_plan_error: input.world_plan_error },
3129
+ ...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error },
3130
+ ...input.review_error === void 0 || input.review_error === null ? {} : { review_error: input.review_error },
3131
+ ...input.check_error === void 0 || input.check_error === null ? {} : { check_error: input.check_error }
2863
3132
  };
2864
3133
  switch (input.stage) {
2865
- case "P1": return {
2866
- ...base,
2867
- recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary),
2868
- open_foreshadows: pendingForeshadows(input.foreshadows),
2869
- volume: input.handoff?.volume ?? 1,
2870
- volume_name: input.handoff?.volume_name ?? "",
2871
- act: input.handoff?.act ?? ""
2872
- };
3134
+ case "P1": {
3135
+ const position = input.handoff === null ? null : projectHandoff(input.handoff, input.chapter);
3136
+ return {
3137
+ ...base,
3138
+ recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary),
3139
+ open_foreshadows: pendingForeshadows(input.foreshadows),
3140
+ volume: position?.volume ?? 1,
3141
+ volume_name: position?.volume_name ?? "",
3142
+ act: position?.act ?? "",
3143
+ act_progress: position?.act_progress ?? ""
3144
+ };
3145
+ }
2873
3146
  case "P2": return {
2874
3147
  ...base,
3148
+ world_plan: worldPlan,
3149
+ characters: [...input.worldPlan?.characters ?? []],
3150
+ other_characters: otherCharacters,
2875
3151
  plan: input.plan,
2876
3152
  open_foreshadows: pendingForeshadows(input.foreshadows)
2877
3153
  };
2878
3154
  case "P3": return {
2879
3155
  ...base,
2880
- characters: input.characters.characters,
2881
- locks: input.locks,
3156
+ world_plan: worldPlan,
3157
+ characters: activeCast,
3158
+ other_characters: otherCharacters,
3159
+ locks: filterLocks(input.locks, activeNames, (input.worldPlan?.locations ?? []).map((location) => location.name)),
2882
3160
  precheck: input.plan,
2883
3161
  open_foreshadows: pendingForeshadows(input.foreshadows),
2884
3162
  recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary)
@@ -2887,8 +3165,11 @@ function briefContext(input) {
2887
3165
  ...base,
2888
3166
  text: input.text ?? "",
2889
3167
  summary: input.plan?.summary ?? null,
3168
+ plan: input.plan,
2890
3169
  machine: input.machine ?? null,
2891
- characters: input.characters.characters,
3170
+ world_plan: worldPlan,
3171
+ characters: activeCast,
3172
+ other_characters: otherCharacters,
2892
3173
  check: input.check,
2893
3174
  score_history: input.chapters.chapters.slice(-5).map((row) => ({
2894
3175
  num: row.num,
@@ -2898,15 +3179,23 @@ function briefContext(input) {
2898
3179
  case "P5": return {
2899
3180
  ...base,
2900
3181
  text: input.text ?? "",
3182
+ plan: input.plan,
2901
3183
  machine: input.machine ?? null,
2902
3184
  review: input.review,
2903
- issues: input.review?.issues ?? []
3185
+ issues: input.review?.issues ?? [],
3186
+ world_plan: worldPlan,
3187
+ characters: activeCast,
3188
+ other_characters: otherCharacters
2904
3189
  };
2905
3190
  case "P6": return {
2906
3191
  ...base,
2907
3192
  text: input.text ?? "",
3193
+ plan: input.plan,
2908
3194
  machine: input.machine ?? null,
3195
+ world_plan: worldPlan,
2909
3196
  characters: input.characters.characters,
3197
+ active_cast: activeCast,
3198
+ other_characters: otherCharacters,
2910
3199
  world: input.world,
2911
3200
  locks: input.locks,
2912
3201
  foreshadows: input.foreshadows,
@@ -2922,6 +3211,7 @@ function briefContext(input) {
2922
3211
  };
2923
3212
  case "P8": return {
2924
3213
  ...base,
3214
+ plan: input.plan,
2925
3215
  check: input.check,
2926
3216
  review: input.review,
2927
3217
  summary: input.plan?.summary ?? null,
@@ -2931,6 +3221,264 @@ function briefContext(input) {
2931
3221
  default: return base;
2932
3222
  }
2933
3223
  }
3224
+ /** Per-stage context budgets, measured in JSON characters (metadata excluded). */
3225
+ const CONTEXT_BUDGETS = {
3226
+ P1: 6e3,
3227
+ P2: 8e3,
3228
+ P3: 16e3,
3229
+ P4: 24e3,
3230
+ P5: 24e3,
3231
+ P6: 32e3,
3232
+ P8: 12e3
3233
+ };
3234
+ const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
3235
+ const asArray = (value) => Array.isArray(value) ? value : null;
3236
+ const jsonSize = (value) => JSON.stringify(value).length;
3237
+ const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(value) ? 1 : 0;
3238
+ /**
3239
+ * Apply the stage budget after the stage-specific pack exists. Protected keys
3240
+ * are never candidates; optional ledgers are trimmed round-robin so one large
3241
+ * list cannot starve every other kind of context.
3242
+ */
3243
+ function withContextBudget(stage, raw) {
3244
+ const context = structuredClone(raw);
3245
+ const budget = CONTEXT_BUDGETS[stage] ?? 8e3;
3246
+ const smallP2Candidates = stage === "P2" && Array.isArray(context.characters) && context.characters.length <= 32;
3247
+ const protectedKeys = [
3248
+ "project",
3249
+ "chapter",
3250
+ "stage",
3251
+ "previous_ending",
3252
+ "text",
3253
+ "plan",
3254
+ "precheck",
3255
+ "review",
3256
+ "check",
3257
+ "machine",
3258
+ "summary",
3259
+ "world_plan",
3260
+ "active_cast",
3261
+ ...stage === "P3" || stage === "P4" || stage === "P5" || smallP2Candidates ? ["characters"] : []
3262
+ ];
3263
+ const mentioned = [
3264
+ typeof context.text === "string" ? context.text : "",
3265
+ stableJson(context.plan ?? ""),
3266
+ stableJson(context.review ?? ""),
3267
+ stableJson(context.check ?? ""),
3268
+ stableJson(context.summary ?? ""),
3269
+ stableJson(context.world_plan ?? "")
3270
+ ].join("\n");
3271
+ const omitted = {};
3272
+ const truncated = /* @__PURE__ */ new Set();
3273
+ const candidates = [];
3274
+ const stableKey = (value) => {
3275
+ const row = asRecord(value);
3276
+ if (row === null) return stableJson(value);
3277
+ for (const key of [
3278
+ "id",
3279
+ "name",
3280
+ "title"
3281
+ ]) {
3282
+ const item = row[key];
3283
+ if (typeof item === "string" && item !== "") return item;
3284
+ }
3285
+ return stableJson(row);
3286
+ };
3287
+ const addArray = (key, array, rank, tie = stableKey) => {
3288
+ if (protectedKeys.includes(key)) return;
3289
+ let prepared = false;
3290
+ const sort = () => {
3291
+ if (prepared) return;
3292
+ prepared = true;
3293
+ array()?.sort((left, right) => {
3294
+ const byRank = rank(right) - rank(left);
3295
+ return byRank !== 0 ? byRank : compareText(tie(left), tie(right));
3296
+ });
3297
+ };
3298
+ candidates.push({
3299
+ key,
3300
+ count: () => array()?.length ?? 0,
3301
+ trim: () => {
3302
+ sort();
3303
+ const list = array();
3304
+ if (list === null || list.length === 0) return false;
3305
+ list.pop();
3306
+ return true;
3307
+ },
3308
+ prepare: sort
3309
+ });
3310
+ };
3311
+ const addObject = (key, map, rank) => {
3312
+ if (protectedKeys.includes(key)) return;
3313
+ let keys = null;
3314
+ const ensure = () => {
3315
+ const target = map();
3316
+ keys ??= target === null ? [] : Object.keys(target);
3317
+ keys.sort((left, right) => {
3318
+ const byRank = rank(right, map()?.[right]) - rank(left, map()?.[left]);
3319
+ return byRank !== 0 ? byRank : compareText(left, right);
3320
+ });
3321
+ return keys;
3322
+ };
3323
+ candidates.push({
3324
+ key,
3325
+ count: () => Object.keys(map() ?? {}).length,
3326
+ trim: () => {
3327
+ const target = map();
3328
+ if (target === null) return false;
3329
+ const keyToDelete = ensure().pop();
3330
+ if (keyToDelete === void 0 || !Object.hasOwn(target, keyToDelete)) return false;
3331
+ delete target[keyToDelete];
3332
+ return true;
3333
+ },
3334
+ prepare: ensure
3335
+ });
3336
+ };
3337
+ const chapter = (value) => asRecord(value)?.num ?? 0;
3338
+ const activeNames = new Set(Array.isArray(context.active_cast) ? context.active_cast.map((value) => asRecord(value)?.name ?? "").filter((name) => name !== "") : []);
3339
+ const recency = (row) => Math.max(Number(row.updated_at ?? 0), Number(row.last_seen ?? 0));
3340
+ addArray("characters", () => asArray(context.characters), (value) => {
3341
+ const row = asRecord(value);
3342
+ const name = typeof row?.name === "string" ? row.name : "";
3343
+ return (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e5 + recency(row ?? {});
3344
+ }, (value) => {
3345
+ const row = asRecord(value);
3346
+ return typeof row?.name === "string" ? row.name : stableKey(value);
3347
+ });
3348
+ addArray("other_characters", () => asArray(context.other_characters), (value) => {
3349
+ const row = asRecord(value);
3350
+ return Number(row?.last_seen ?? 0);
3351
+ }, (value) => {
3352
+ const row = asRecord(value);
3353
+ return typeof row?.name === "string" ? row.name : stableKey(value);
3354
+ });
3355
+ const worldPlan = asRecord(context.world_plan);
3356
+ addArray("world_plan.characters", () => asArray(worldPlan?.characters), (value) => {
3357
+ const row = asRecord(value);
3358
+ const name = typeof row?.name === "string" ? row.name : "";
3359
+ return (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e5;
3360
+ }, (value) => {
3361
+ const row = asRecord(value);
3362
+ return typeof row?.name === "string" ? row.name : stableKey(value);
3363
+ });
3364
+ addArray("world_plan.locations", () => asArray(worldPlan?.locations), (value) => {
3365
+ const row = asRecord(value);
3366
+ const name = typeof row?.name === "string" ? row.name : "";
3367
+ return mentionScore(mentioned, name) * 1e3;
3368
+ }, (value) => {
3369
+ const row = asRecord(value);
3370
+ return typeof row?.name === "string" ? row.name : stableKey(value);
3371
+ });
3372
+ const world = asRecord(context.world);
3373
+ for (const key of [
3374
+ "locations",
3375
+ "abilities",
3376
+ "artifacts",
3377
+ "factions",
3378
+ "rules"
3379
+ ]) addArray(`world.${key}`, () => asArray(world?.[key]), (value) => {
3380
+ const row = asRecord(value);
3381
+ const name = typeof row?.name === "string" ? row.name : "";
3382
+ return mentionScore(mentioned, name) * 1e3 + Number(row?.first_appearance ?? 0);
3383
+ }, (value) => {
3384
+ const row = asRecord(value);
3385
+ return typeof row?.name === "string" ? row.name : stableKey(value);
3386
+ });
3387
+ const locks = asRecord(context.locks);
3388
+ for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e3);
3389
+ addArray("open_foreshadows", () => asArray(context.open_foreshadows), (value) => {
3390
+ const row = asRecord(value);
3391
+ const title = typeof row?.title === "string" ? row.title : "";
3392
+ return mentionScore(mentioned, title) * 1e3 - Number(row?.last_touched ?? 0);
3393
+ }, (value) => {
3394
+ const row = asRecord(value);
3395
+ return `${typeof row?.title === "string" ? row.title : ""}\u0000${typeof row?.id === "string" ? row.id : ""}`;
3396
+ });
3397
+ const foreshadows = asRecord(context.foreshadows);
3398
+ addArray("foreshadows", () => asArray(foreshadows?.items), (value) => {
3399
+ const row = asRecord(value);
3400
+ const title = typeof row?.title === "string" ? row.title : "";
3401
+ const id = typeof row?.id === "string" ? row.id : "";
3402
+ return Math.max(mentionScore(mentioned, title), mentionScore(mentioned, id)) * 1e3 - Number(row?.last_touched ?? 0);
3403
+ }, (value) => {
3404
+ const row = asRecord(value);
3405
+ return `${typeof row?.id === "string" ? row.id : ""}\u0000${typeof row?.title === "string" ? row.title : ""}`;
3406
+ });
3407
+ addArray("timeline", () => asArray(context.timeline), (value) => {
3408
+ const row = asRecord(value);
3409
+ const text = `${String(row?.description ?? "")}${String(row?.location ?? "")}`;
3410
+ return mentionScore(mentioned, text) * 1e3 + Number(row?.chapter ?? 0);
3411
+ }, (value) => {
3412
+ const row = asRecord(value);
3413
+ return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.chapter ?? "")}`;
3414
+ });
3415
+ const relationships = asRecord(context.relationships);
3416
+ addArray("relationships", () => asArray(relationships?.items), (value) => {
3417
+ const row = asRecord(value);
3418
+ const text = `${String(row?.a ?? "")}${String(row?.b ?? "")}`;
3419
+ return mentionScore(mentioned, text) * 1e3 + Number(row?.since ?? 0);
3420
+ }, (value) => {
3421
+ const row = asRecord(value);
3422
+ return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.a ?? "")}\u0000${String(row?.b ?? "")}`;
3423
+ });
3424
+ addArray("recent_summaries", () => asArray(context.recent_summaries), (value) => chapter(value), (value) => String(chapter(value)).padStart(8, "0"));
3425
+ addArray("score_history", () => asArray(context.score_history), (value) => Number(asRecord(value)?.num ?? 0), (value) => String(Number(asRecord(value)?.num ?? 0)).padStart(8, "0"));
3426
+ for (const candidate of candidates) candidate.prepare();
3427
+ const used = () => jsonSize(context);
3428
+ const buildMeta = (finalUsed) => ({
3429
+ version: 1,
3430
+ stage,
3431
+ budget,
3432
+ used: finalUsed,
3433
+ over_budget: false,
3434
+ protected: protectedKeys,
3435
+ truncated: [...truncated].sort(compareText),
3436
+ omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => compareText(left, right))),
3437
+ strategy: "stage-v2"
3438
+ });
3439
+ const totalWith = (finalUsed, meta) => {
3440
+ const metaSize = jsonSize({
3441
+ ...meta,
3442
+ used: finalUsed
3443
+ });
3444
+ return finalUsed + 1 + 15 + metaSize;
3445
+ };
3446
+ let finalUsed = used();
3447
+ let metaBase = buildMeta(finalUsed);
3448
+ while (totalWith(finalUsed, metaBase) > budget) {
3449
+ let changed = false;
3450
+ for (const candidate of candidates) {
3451
+ if (totalWith(finalUsed, metaBase) <= budget) break;
3452
+ if (candidate.count() === 0) continue;
3453
+ candidate.prepare();
3454
+ if (candidate.trim()) {
3455
+ omitted[candidate.key] = (omitted[candidate.key] ?? 0) + 1;
3456
+ truncated.add(candidate.key);
3457
+ changed = true;
3458
+ finalUsed = used();
3459
+ metaBase = buildMeta(finalUsed);
3460
+ }
3461
+ }
3462
+ if (!changed) break;
3463
+ }
3464
+ const overBudget = totalWith(finalUsed, metaBase) > budget;
3465
+ const meta = {
3466
+ ...metaBase,
3467
+ over_budget: overBudget
3468
+ };
3469
+ return {
3470
+ ...context,
3471
+ context_meta: meta
3472
+ };
3473
+ }
3474
+ /**
3475
+ * Build the stage-specific context pack plus the deterministic budget report.
3476
+ * The budget report is part of the brief digest; a truncated pack cannot be
3477
+ * silently mistaken for the full ledger set.
3478
+ */
3479
+ function briefContext(input) {
3480
+ return withContextBudget(input.stage, unbudgetedBriefContext(input));
3481
+ }
2934
3482
  // src/engine/state-delta.ts
2935
3483
  const SECTIONS = [
2936
3484
  "characters",
@@ -3545,6 +4093,7 @@ const wordCount = (input) => {
3545
4093
  };
3546
4094
  /** PG-04: format blockers must be zero before length is judged. */
3547
4095
  const format = (input, options) => {
4096
+ if (input.scope !== void 0 && !input.scope.includes("content")) return [];
3548
4097
  const violations = runRules([
3549
4098
  "MD_CONTAMINATION",
3550
4099
  "ILLEGAL_QUOTE",
@@ -3626,7 +4175,7 @@ const summarySchema = (input) => {
3626
4175
  else {
3627
4176
  const opening = summary.opening;
3628
4177
  if (Array.from(opening).length < LIMITS.minQuoteChars) problems.push(`opening must be at least ${LIMITS.minQuoteChars} characters`);
3629
- if (!splitParagraphs(input.text).slice(0, 2).some((paragraph) => paragraph.includes(opening))) problems.push("opening must be quoted from the first two paragraphs");
4178
+ if (!openingInFirstTwoParagraphs(input.text, opening)) problems.push("opening must be quoted from the first two paragraphs");
3630
4179
  }
3631
4180
  const continuity = summary.continuity;
3632
4181
  if (typeof continuity !== "object" || continuity === null) problems.push("continuity must be an object");
@@ -3717,8 +4266,8 @@ function verifyReference(text, reference) {
3717
4266
  if (typeof reference !== "object" || reference === null) return "reading proof reference is not an object";
3718
4267
  if (typeof reference.quote !== "string") return "reading proof quote is not text";
3719
4268
  if (!Number.isInteger(reference.paragraph) || reference.paragraph < 1) return "reading proof paragraph must be a positive integer";
3720
- const quote = reference.quote;
3721
- if (Array.from(quote.trim()).length < LIMITS.minQuoteChars) return `quote "${quote}" is shorter than ${LIMITS.minQuoteChars} characters`;
4269
+ const quote = reference.quote.trim();
4270
+ if (Array.from(quote).length < LIMITS.minQuoteChars) return `quote "${reference.quote}" is shorter than ${LIMITS.minQuoteChars} characters`;
3722
4271
  const actual = splitParagraphs(text).findIndex((paragraph) => paragraph.includes(quote));
3723
4272
  if (actual === -1) return `quote "${quote}" does not appear in the text`;
3724
4273
  if (Math.abs(actual + 1 - reference.paragraph) > 1) return `quote "${quote}" is at paragraph ${actual + 1}, not ${reference.paragraph}`;
@@ -3757,6 +4306,7 @@ const readingProof = (input) => {
3757
4306
  };
3758
4307
  /** PG-10: cross-chapter reconciliation must be clean. */
3759
4308
  const consistency = (input, options) => {
4309
+ if (input.scope !== void 0 && !input.scope.includes("integrity")) return [];
3760
4310
  const violations = runRules([
3761
4311
  "DUPLICATE_PARAGRAPH",
3762
4312
  "FORESHADOW_DORMANT",
@@ -3803,7 +4353,8 @@ const archiveAtomic = (input) => {
3803
4353
  if (input.archive.chapterExists) problems.push("this chapter is already in the index");
3804
4354
  if (!input.archive.checkPassed) problems.push("the check document has not passed");
3805
4355
  if (!input.archive.hashMatches) problems.push("the manuscript changed since the check was written");
3806
- return problems.length === 0 ? [] : [block("PG-12", problems.join("; "), "re-run novel_check on the current manuscript")];
4356
+ if (input.check === null || !Array.isArray(input.check.scope) || !input.check.scope.includes("content") || !input.check.scope.includes("integrity")) problems.push("the check document did not cover content+integrity");
4357
+ return problems.length === 0 ? [] : [block("PG-12", problems.join("; "), "call novel_rollback to P6, then re-run novel_check on the current manuscript")];
3807
4358
  };
3808
4359
  /** Fixed order; evaluation collects every failure instead of stopping at the first. */
3809
4360
  const GATE_FUNCTIONS = {
@@ -3857,7 +4408,8 @@ function applicableGates(input) {
3857
4408
  "PG-03",
3858
4409
  "PG-05",
3859
4410
  "PG-09",
3860
- "PG-08"
4411
+ "PG-08",
4412
+ "PG-11"
3861
4413
  ];
3862
4414
  case "submitReview": return [
3863
4415
  ...always,
@@ -3868,7 +4420,10 @@ function applicableGates(input) {
3868
4420
  case "runCheck": return [
3869
4421
  ...always,
3870
4422
  "PG-04",
4423
+ "PG-03",
3871
4424
  "PG-05",
4425
+ "PG-06",
4426
+ "PG-07",
3872
4427
  "PG-08",
3873
4428
  "PG-09",
3874
4429
  "PG-10",
@@ -3915,7 +4470,7 @@ const STAGE_TO_PHASE = {
3915
4470
  P4: "P4",
3916
4471
  P5: "P5",
3917
4472
  P6: "P6",
3918
- ready_to_archive: "P8",
4473
+ ready_to_archive: "P7",
3919
4474
  done: "P9"
3920
4475
  };
3921
4476
  /** The nine-phase progress strip: phases before the active one are done. */
@@ -3961,6 +4516,11 @@ function projectSummary(snapshot) {
3961
4516
  revision: snapshot.revision,
3962
4517
  totalWords: snapshot.totalWords,
3963
4518
  totalChapters: snapshot.chapters.length,
4519
+ averageScore: snapshot.averageScore ?? (snapshot.chapters.length === 0 ? 0 : snapshot.chapters.reduce((sum, chapter) => sum + chapter.score, 0) / snapshot.chapters.length),
4520
+ ...snapshot.volume === void 0 ? {} : { volume: snapshot.volume },
4521
+ ...snapshot.volume_name === void 0 ? {} : { volume_name: snapshot.volume_name },
4522
+ ...snapshot.act === void 0 ? {} : { act: snapshot.act },
4523
+ ...snapshot.act_progress === void 0 ? {} : { act_progress: snapshot.act_progress },
3964
4524
  phases: snapshot.phases,
3965
4525
  blocking: snapshot.blocking,
3966
4526
  finished: snapshot.finished,
@@ -3971,9 +4531,11 @@ function projectSummary(snapshot) {
3971
4531
  };
3972
4532
  }
3973
4533
  function projectChapters(snapshot) {
3974
- const rows = snapshot.chapters.map(projectChapter);
3975
4534
  const draft = snapshot.draft;
3976
- if (draft !== void 0 && draft !== null && !rows.some((row) => row.num === draft.num)) rows.push({
4535
+ const draftNeedsRow = draft !== void 0 && draft !== null && !snapshot.chapters.some((row) => row.num === draft.num);
4536
+ const archivedLimit = draftNeedsRow ? 29 : 30;
4537
+ const rows = snapshot.chapters.slice(-archivedLimit).map(projectChapter);
4538
+ if (draftNeedsRow) rows.push({
3977
4539
  num: draft.num,
3978
4540
  title: draft.title,
3979
4541
  wordCount: draft.wordCount,
@@ -4093,7 +4655,31 @@ const ROUND_TRANSITIONS = {
4093
4655
  P5: "P5",
4094
4656
  P6: "P6"
4095
4657
  };
4658
+ /** Forward order of stages; rollback may only move to an equal or earlier rank. */
4659
+ const STAGE_RANK = {
4660
+ idle: 0,
4661
+ P2: 1,
4662
+ P3: 2,
4663
+ P4: 3,
4664
+ P5: 4,
4665
+ P6: 5,
4666
+ ready_to_archive: 6,
4667
+ done: 7
4668
+ };
4669
+ /** Internal, journal-tracked archive idempotency ledger; not a user state doc. */
4670
+ const ARCHIVE_DIGESTS = joinRel(STATE_DIR, "archive-digests.json");
4671
+ const LEDGER_PATHS = {
4672
+ chapters: joinRel(STATE_DIR, STATE_FILES.chapters),
4673
+ characters: joinRel(STATE_DIR, STATE_FILES.characters),
4674
+ world: joinRel(STATE_DIR, STATE_FILES.world),
4675
+ locks: joinRel(STATE_DIR, STATE_FILES.locks),
4676
+ timeline: joinRel(STATE_DIR, STATE_FILES.timeline),
4677
+ foreshadows: joinRel(STATE_DIR, STATE_FILES.foreshadows),
4678
+ relationships: joinRel(STATE_DIR, STATE_FILES.relationships),
4679
+ handoff: joinRel(STATE_DIR, STATE_FILES.handoff)
4680
+ };
4096
4681
  const MUTABLE_STATE = {
4682
+ [PROJECT_FILE]: "project",
4097
4683
  [joinRel(STATE_DIR, STATE_FILES.pipeline)]: "pipeline",
4098
4684
  [joinRel(STATE_DIR, STATE_FILES.chapters)]: "chapters",
4099
4685
  [joinRel(STATE_DIR, STATE_FILES.characters)]: "characters",
@@ -4113,6 +4699,8 @@ var EngineService = class {
4113
4699
  stores = /* @__PURE__ */ new Map();
4114
4700
  mutex = new KeyedMutex();
4115
4701
  replay = /* @__PURE__ */ new Map();
4702
+ /** Status, panel and gates ask for the same stage context repeatedly. */
4703
+ briefContextCache = /* @__PURE__ */ new WeakMap();
4116
4704
  briefs;
4117
4705
  now;
4118
4706
  /** The project this process last touched; the panel follows it without a workspace hint. */
@@ -4180,6 +4768,8 @@ var EngineService = class {
4180
4768
  async initProject(input) {
4181
4769
  const title = typeof input.title === "string" ? input.title.trim() : "";
4182
4770
  if (title === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "title must not be empty");
4771
+ if (input.author !== void 0 && typeof input.author !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "author must be a string");
4772
+ if (input.description !== void 0 && typeof input.description !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "description must be a string");
4183
4773
  if (!GENRES.includes(input.genre)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown genre "${String(input.genre)}"`, `allowed: ${GENRES.join(", ")}`);
4184
4774
  if (!PLATFORMS.includes(input.platform)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown platform "${String(input.platform)}"`, `allowed: ${PLATFORMS.join(", ")}`);
4185
4775
  if (input.words_per_chapter !== void 0) {
@@ -4246,12 +4836,50 @@ var EngineService = class {
4246
4836
  emptyAllowed: true
4247
4837
  });
4248
4838
  }
4839
+ /**
4840
+ * Read-only paths must surface a committed-journal mismatch. The write path
4841
+ * already fails closed; without this, status recommends the next write and
4842
+ * the model only learns about the mismatch when that write aborts.
4843
+ */
4844
+ async journalBlocking(snapshot) {
4845
+ const store = this.storeFor(snapshot.root);
4846
+ if (await store.exists(".engine.lock")) return [];
4847
+ try {
4848
+ const { malformed, mismatches } = await reconcileJournal(store);
4849
+ const items = [];
4850
+ if (malformed > 0) items.push({
4851
+ code: "JOURNAL_MALFORMED",
4852
+ scope: "journal",
4853
+ detail: `${malformed} malformed journal line(s)`,
4854
+ hint: "inspect the journal with novel_audit; restore a known-good journal before writing"
4855
+ });
4856
+ for (const mismatch of mismatches) items.push({
4857
+ code: "JOURNAL_HASH_MISMATCH",
4858
+ scope: "journal",
4859
+ detail: `${mismatch.path} changed after commit ${mismatch.txid}`,
4860
+ hint: "restore the file from backups/ or inspect with novel_audit; writes remain fail-closed"
4861
+ });
4862
+ return items;
4863
+ } catch (error) {
4864
+ return [{
4865
+ code: "JOURNAL_UNREADABLE",
4866
+ scope: "journal",
4867
+ detail: errorMessage(error),
4868
+ hint: "inspect state/journal.jsonl and restore a known-good copy"
4869
+ }];
4870
+ }
4871
+ }
4249
4872
  async getStatus(input = {}) {
4250
4873
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4251
4874
  const snapshot = await this.load(input.root);
4252
4875
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4253
4876
  const metrics = await this.metricsFor(snapshot, chapter);
4254
- const next = this.nextStep(snapshot);
4877
+ const journal = await this.journalBlocking(snapshot);
4878
+ const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
4879
+ tool: "novel_audit",
4880
+ args: {},
4881
+ reason: `${journal[0].code}: ${journal[0].detail}; inspect and restore before the next write`
4882
+ } : this.nextStep(snapshot);
4255
4883
  return {
4256
4884
  data: {
4257
4885
  project: {
@@ -4264,8 +4892,18 @@ var EngineService = class {
4264
4892
  active_chapter: snapshot.pipeline.chapter,
4265
4893
  stage: snapshot.pipeline.stage,
4266
4894
  rounds: { ...snapshot.pipeline.rounds },
4267
- blocking: this.effectiveBlocking(snapshot),
4895
+ blocking: [...this.effectiveBlocking(snapshot), ...journal],
4896
+ state_errors: this.stateErrors(snapshot),
4268
4897
  metrics,
4898
+ ...snapshot.handoff === null ? {} : (() => {
4899
+ const position = projectHandoff(snapshot.handoff, snapshot.pipeline.chapter);
4900
+ return {
4901
+ volume: position.volume,
4902
+ volume_name: position.volume_name,
4903
+ act: position.act,
4904
+ act_progress: position.act_progress
4905
+ };
4906
+ })(),
4269
4907
  next
4270
4908
  },
4271
4909
  revision: snapshot.pipeline.revision,
@@ -4291,17 +4929,50 @@ var EngineService = class {
4291
4929
  const snapshot = await this.load(input.root);
4292
4930
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4293
4931
  const store = this.storeFor(snapshot.root);
4294
- switch (input.kind) {
4295
- case "pipeline": return {
4296
- data: snapshot.pipeline,
4932
+ const ledgerPath = LEDGER_PATHS[input.kind];
4933
+ if (ledgerPath !== void 0) {
4934
+ const damage = snapshot.ledgerErrors.find((error) => error.path === ledgerPath);
4935
+ if (damage !== void 0) return {
4936
+ data: {
4937
+ error: damage.code,
4938
+ message: damage.message,
4939
+ path: damage.path,
4940
+ raw: await store.readText(damage.path)
4941
+ },
4297
4942
  revision: snapshot.pipeline.revision,
4298
4943
  warnings: []
4299
4944
  };
4300
- case "chapters": return {
4301
- data: snapshot.chapters,
4945
+ }
4946
+ switch (input.kind) {
4947
+ case "pipeline": return {
4948
+ data: snapshot.pipeline,
4302
4949
  revision: snapshot.pipeline.revision,
4303
4950
  warnings: []
4304
4951
  };
4952
+ case "chapters": {
4953
+ const range = input.chapter_range;
4954
+ if (range !== void 0) {
4955
+ if (!Array.isArray(range) || range.length !== 2 || !Number.isInteger(range[0]) || !Number.isInteger(range[1]) || range[0] < 1 || range[1] < range[0]) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter_range must be [start, end] positive integers with start <= end");
4956
+ }
4957
+ const rows = range === void 0 ? snapshot.chapters.chapters : snapshot.chapters.chapters.filter((row) => row.num >= range[0] && row.num <= range[1]);
4958
+ return {
4959
+ data: input.include_summary === true ? {
4960
+ schema_version: 1,
4961
+ chapters: rows
4962
+ } : {
4963
+ schema_version: 1,
4964
+ chapters: rows.map((row) => ({
4965
+ num: row.num,
4966
+ title: row.title,
4967
+ word_count: row.word_count,
4968
+ score: row.score,
4969
+ status: row.status
4970
+ }))
4971
+ },
4972
+ revision: snapshot.pipeline.revision,
4973
+ warnings: []
4974
+ };
4975
+ }
4305
4976
  case "characters": return {
4306
4977
  data: snapshot.characters,
4307
4978
  revision: snapshot.pipeline.revision,
@@ -4337,30 +5008,79 @@ var EngineService = class {
4337
5008
  revision: snapshot.pipeline.revision,
4338
5009
  warnings: []
4339
5010
  };
4340
- case "review": return {
4341
- data: chapter === snapshot.pipeline.chapter ? snapshot.review : await store.readDoc(reviewFile(chapter), "review"),
4342
- revision: snapshot.pipeline.revision,
4343
- warnings: []
4344
- };
4345
- case "check": return {
4346
- data: chapter === snapshot.pipeline.chapter ? snapshot.check : await store.readDoc(checkFile(chapter), "check"),
4347
- revision: snapshot.pipeline.revision,
4348
- warnings: []
4349
- };
4350
- case "novel": return {
4351
- data: snapshot.project,
4352
- revision: snapshot.pipeline.revision,
4353
- warnings: []
4354
- };
5011
+ case "review":
5012
+ if (chapter === snapshot.pipeline.chapter && snapshot.reviewError !== null) return {
5013
+ data: {
5014
+ error: "REVIEW_UNREADABLE",
5015
+ message: snapshot.reviewError,
5016
+ path: reviewFile(chapter),
5017
+ raw: await store.readText(reviewFile(chapter))
5018
+ },
5019
+ revision: snapshot.pipeline.revision,
5020
+ warnings: []
5021
+ };
5022
+ return {
5023
+ data: chapter === snapshot.pipeline.chapter ? snapshot.review : await store.readDoc(reviewFile(chapter), "review"),
5024
+ revision: snapshot.pipeline.revision,
5025
+ warnings: []
5026
+ };
5027
+ case "check":
5028
+ if (chapter === snapshot.pipeline.chapter && snapshot.checkError !== null) return {
5029
+ data: {
5030
+ error: "CHECK_UNREADABLE",
5031
+ message: snapshot.checkError,
5032
+ path: checkFile(chapter),
5033
+ raw: await store.readText(checkFile(chapter))
5034
+ },
5035
+ revision: snapshot.pipeline.revision,
5036
+ warnings: []
5037
+ };
5038
+ return {
5039
+ data: chapter === snapshot.pipeline.chapter ? snapshot.check : await store.readDoc(checkFile(chapter), "check"),
5040
+ revision: snapshot.pipeline.revision,
5041
+ warnings: []
5042
+ };
5043
+ case "novel":
5044
+ if (snapshot.projectError !== null) return {
5045
+ data: {
5046
+ error: "PROJECT_UNREADABLE",
5047
+ message: snapshot.projectError,
5048
+ path: PROJECT_FILE,
5049
+ raw: await store.readText(PROJECT_FILE)
5050
+ },
5051
+ revision: snapshot.pipeline.revision,
5052
+ warnings: []
5053
+ };
5054
+ return {
5055
+ data: snapshot.project,
5056
+ revision: snapshot.pipeline.revision,
5057
+ warnings: []
5058
+ };
4355
5059
  case "planWorld": {
5060
+ if (chapter === snapshot.pipeline.chapter) {
5061
+ if (snapshot.worldPlanError !== null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${planWorldFile(chapter)}: ${snapshot.worldPlanError}`, "run novel_status and repair or re-plan the world plan");
5062
+ return {
5063
+ data: snapshot.worldPlan,
5064
+ revision: snapshot.pipeline.revision,
5065
+ warnings: []
5066
+ };
5067
+ }
4356
5068
  const raw = await store.readText(planWorldFile(chapter));
4357
5069
  return {
4358
- data: raw === null ? null : parsePlanYaml(raw, planWorldFile(chapter)),
5070
+ data: raw === null ? null : narrow("worldPlan", parsePlanYaml(raw, planWorldFile(chapter))),
4359
5071
  revision: snapshot.pipeline.revision,
4360
5072
  warnings: []
4361
5073
  };
4362
5074
  }
4363
5075
  case "planChapter": {
5076
+ if (chapter === snapshot.pipeline.chapter) {
5077
+ if (snapshot.planError !== null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${planChapterFile(chapter)}: ${snapshot.planError}`, "run novel_status and repair or re-plan the chapter");
5078
+ return {
5079
+ data: snapshot.plan,
5080
+ revision: snapshot.pipeline.revision,
5081
+ warnings: []
5082
+ };
5083
+ }
4364
5084
  const raw = await store.readText(planChapterFile(chapter));
4365
5085
  return {
4366
5086
  data: raw === null ? null : parsePlanYaml(raw, planChapterFile(chapter)),
@@ -4373,7 +5093,12 @@ var EngineService = class {
4373
5093
  }
4374
5094
  async advise(input = {}) {
4375
5095
  const snapshot = await this.load(input.root);
4376
- const next = this.nextStep(snapshot);
5096
+ const journal = await this.journalBlocking(snapshot);
5097
+ const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
5098
+ tool: "novel_audit",
5099
+ args: {},
5100
+ reason: `${journal[0].code}: ${journal[0].detail}; inspect and restore before the next write`
5101
+ } : this.nextStep(snapshot);
4377
5102
  return {
4378
5103
  data: {
4379
5104
  stage: snapshot.pipeline.stage,
@@ -4381,7 +5106,8 @@ var EngineService = class {
4381
5106
  tool: next.tool,
4382
5107
  args: next.args,
4383
5108
  reason: next.reason,
4384
- blocking: this.effectiveBlocking(snapshot)
5109
+ blocking: [...this.effectiveBlocking(snapshot), ...journal],
5110
+ state_errors: this.stateErrors(snapshot)
4385
5111
  },
4386
5112
  revision: snapshot.pipeline.revision,
4387
5113
  warnings: []
@@ -4416,7 +5142,10 @@ var EngineService = class {
4416
5142
  /** P4 must apply exclusions before its arithmetic closes. */
4417
5143
  score_exclusions: this.rulepack.exclusions,
4418
5144
  next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
4419
- ...snapshot.planError === null ? {} : { plan_error: snapshot.planError }
5145
+ ...snapshot.worldPlanError === null ? {} : { world_plan_error: snapshot.worldPlanError },
5146
+ ...snapshot.planError === null ? {} : { plan_error: snapshot.planError },
5147
+ ...snapshot.reviewError === null ? {} : { review_error: snapshot.reviewError },
5148
+ ...snapshot.checkError === null ? {} : { check_error: snapshot.checkError }
4420
5149
  };
4421
5150
  const data = {
4422
5151
  stage: input.stage,
@@ -4457,6 +5186,27 @@ var EngineService = class {
4457
5186
  }
4458
5187
  async submitWorldPlan(input) {
4459
5188
  if (typeof input.objective !== "string" || input.objective.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "world plan objective must not be empty");
5189
+ const constraints = stringList(input.constraints, "constraints");
5190
+ const continuity_notes = stringList(input.continuity_notes, "continuity_notes");
5191
+ const objectiveChars = Array.from(input.objective).length;
5192
+ if (objectiveChars > LIMITS.maxWorldPlanObjectiveChars) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `world plan objective is ${objectiveChars} characters; limit is ${LIMITS.maxWorldPlanObjectiveChars}`, "keep the protected objective within the P2 context budget");
5193
+ for (const [field, values] of [["constraints", constraints], ["continuity_notes", continuity_notes]]) {
5194
+ if (values.length > LIMITS.maxWorldPlanItems) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `world plan ${field} has ${values.length} items; limit is ${LIMITS.maxWorldPlanItems}`, "merge related constraints/notes so the protected context stays bounded");
5195
+ const chars = values.reduce((total, value) => total + Array.from(value).length, 0);
5196
+ if (chars > LIMITS.maxWorldPlanTextChars) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `world plan ${field} has ${chars} characters; limit is ${LIMITS.maxWorldPlanTextChars}`, "keep constraints/continuity notes concise; the protected context has a fixed budget");
5197
+ }
5198
+ const characters = objectList(input.characters, "characters");
5199
+ for (const [index, character] of characters.entries()) {
5200
+ requireText(character, "name", `characters[${index}].name`);
5201
+ requireText(character, "role", `characters[${index}].role`);
5202
+ requireText(character, "current_state", `characters[${index}].current_state`);
5203
+ }
5204
+ const locations = objectList(input.locations, "locations");
5205
+ for (const [index, location] of locations.entries()) {
5206
+ requireText(location, "name", `locations[${index}].name`);
5207
+ requireText(location, "layout", `locations[${index}].layout`);
5208
+ if (typeof location.capacity !== "number" || !Number.isFinite(location.capacity)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a finite number`);
5209
+ }
4460
5210
  return this.mutate({
4461
5211
  root: input.root,
4462
5212
  op: "submitWorldPlan",
@@ -4467,10 +5217,10 @@ var EngineService = class {
4467
5217
  const plan = {
4468
5218
  chapter,
4469
5219
  objective: input.objective,
4470
- constraints: input.constraints,
4471
- characters: input.characters,
4472
- locations: input.locations,
4473
- continuity_notes: input.continuity_notes
5220
+ constraints,
5221
+ characters,
5222
+ locations,
5223
+ continuity_notes
4474
5224
  };
4475
5225
  const path = planWorldFile(chapter);
4476
5226
  const nextPipeline = pipelineWith(snapshot, context.revision, {
@@ -4505,6 +5255,15 @@ var EngineService = class {
4505
5255
  }
4506
5256
  async submitChapterPlan(input) {
4507
5257
  if (typeof input.title !== "string" || input.title.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan title must not be empty");
5258
+ const titleTemplate = this.rulepack.format.title;
5259
+ if (LENIENT_TITLE_PREFIX.test(input.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan title must not include the 第N章 prefix", "submit the title body only; the engine adds the canonical prefix");
5260
+ const titleBody = input.title.trim();
5261
+ const titleLength = Array.from(titleBody).length;
5262
+ if (titleLength < titleTemplate.min_chars || titleLength > titleTemplate.max_chars) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan title body must be ${titleTemplate.min_chars}..${titleTemplate.max_chars} characters, got ${titleLength}`, "use the same title body the P3 title rule will judge");
5263
+ if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan narrator must not be empty");
5264
+ const beats = stringList(input.beats, "beats");
5265
+ if (beats.length < 5 || beats.length > 8) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan beats must contain 5..8 non-empty items, got ${beats.length}`, "write the five to eight causal beats the P2 task book requires");
5266
+ for (const [field, value] of [["opening", input.opening], ["ending", input.ending]]) if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan ${field} must not be empty`);
4508
5267
  if (!SCENE_TYPES.includes(input.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown scene_type "${String(input.scene_type)}"`, `allowed: ${SCENE_TYPES.join(", ")}`);
4509
5268
  if (!Array.isArray(input.target_words) || input.target_words.length !== 2) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "target_words must be [min, max] positive integers with min <= max");
4510
5269
  const [min, max] = input.target_words;
@@ -4515,11 +5274,14 @@ var EngineService = class {
4515
5274
  key: (snapshot) => `plan:${snapshot.pipeline.chapter}:${sha256(stableJson(input))}`,
4516
5275
  run: async (snapshot, context) => {
4517
5276
  await this.assertGates(snapshot, "submitChapterPlan", { briefStage: "P2" });
5277
+ const cast = normalizeCast(input.cast, snapshot);
4518
5278
  const chapter = snapshot.pipeline.chapter;
4519
5279
  const path = planChapterFile(chapter);
4520
5280
  const plan = {
4521
5281
  ...input,
4522
5282
  chapter,
5283
+ beats,
5284
+ ...cast === void 0 ? {} : { cast },
4523
5285
  schema_version: 1
4524
5286
  };
4525
5287
  delete plan.root;
@@ -4548,6 +5310,7 @@ var EngineService = class {
4548
5310
  async submitDraft(input) {
4549
5311
  if (typeof input.content !== "string" || input.content.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "draft content must not be empty");
4550
5312
  if (typeof input.chapter_title !== "string" || input.chapter_title.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter_title must not be empty");
5313
+ if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "narrator must not be empty", "use the narration person/name validated at P2; a blank narrator would make the stored plan unreadable");
4551
5314
  if (!SCENE_TYPES.includes(input.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown scene_type "${String(input.scene_type)}"`, `allowed: ${SCENE_TYPES.join(", ")}`);
4552
5315
  return this.mutate({
4553
5316
  root: input.root,
@@ -4574,18 +5337,16 @@ var EngineService = class {
4574
5337
  summary: input.summary
4575
5338
  });
4576
5339
  const chapter = input.chapter;
4577
- const planPath = planChapterFile(chapter);
4578
- const planText = await context.store.readText(planPath);
5340
+ const plan = snapshot.plan;
5341
+ if (plan === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no usable plan`, "rollback to P2 and submit novel_plan_chapter; the draft was not written");
4579
5342
  const mergedPlan = {
4580
- ...(planText === null ? null : parsePlanYaml(planText, planPath)) ?? {
4581
- schema_version: 1,
4582
- chapter
4583
- },
5343
+ ...plan,
4584
5344
  title: input.chapter_title,
4585
5345
  scene_type: input.scene_type,
4586
5346
  narrator: input.narrator,
4587
5347
  summary: input.summary
4588
5348
  };
5349
+ const planPath = planChapterFile(chapter);
4589
5350
  const nextPipeline = pipelineWith(snapshot, context.revision, {
4590
5351
  stage: "P4",
4591
5352
  blocking: [],
@@ -4653,6 +5414,11 @@ var EngineService = class {
4653
5414
  "fix"
4654
5415
  ]) if (typeof issue[field] !== "string" || issue[field].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} has no ${field}`, "every issue needs id, severity, status, dimension, location, problem and fix");
4655
5416
  if (issue.evidence !== void 0 && typeof issue.evidence !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} evidence must be a string`, "quote the revised text as evidence, or omit the field");
5417
+ if (issue.severity === "blocker" && issue.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} is a blocker and cannot be waived`, "fix the issue and quote the revised text, or keep it open");
5418
+ if (this.rulepack.quality.dimensions.find((item) => item.id === issue.dimension) === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} references unknown dimension "${issue.dimension}"`, `use one of: ${this.rulepack.quality.dimensions.map((item) => item.id).join(", ")}`);
5419
+ const paragraph = parseParagraphLocation(issue.location);
5420
+ const paragraphCount = splitParagraphs(text).length;
5421
+ if (paragraph === null || paragraph < 1 || paragraph > paragraphCount) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} location "${issue.location}" is outside the chapter`, `use 第 1 段 .. 第 ${paragraphCount} 段`);
4656
5422
  }
4657
5423
  const review = {
4658
5424
  schema_version: 1,
@@ -4757,7 +5523,15 @@ var EngineService = class {
4757
5523
  seen.add(submitted.id);
4758
5524
  const before = byId.get(submitted.id);
4759
5525
  if (before === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `revision references unknown issue ${submitted.id}`, "only update issues from the current review; new issues belong in the next review");
4760
- if (before.severity === "blocker" && submitted.severity !== "blocker") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be downgraded`, "keep severity blocker and mark it fixed with evidence");
5526
+ for (const field of [
5527
+ "dimension",
5528
+ "location",
5529
+ "problem",
5530
+ "fix"
5531
+ ]) if (submitted[field] !== void 0 && submitted[field] !== before[field]) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change its ${field} during revision`, "only status and evidence may change; new findings belong in the next review");
5532
+ if (submitted.severity !== void 0 && submitted.severity !== before.severity) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change severity during revision`, "only status and evidence may change; a blocker stays a blocker until re-reviewed");
5533
+ if (before.severity === "blocker" && submitted.severity !== void 0 && submitted.severity !== "blocker") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be downgraded`, "keep severity blocker and mark it fixed with evidence");
5534
+ if (before.severity === "blocker" && submitted.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be waived`, "keep severity blocker and mark it fixed with evidence");
4761
5535
  const index = issues.findIndex((issue) => issue.id === submitted.id);
4762
5536
  issues[index] = {
4763
5537
  ...before,
@@ -4786,7 +5560,7 @@ var EngineService = class {
4786
5560
  });
4787
5561
  if (input.summary === void 0 && snapshot.plan?.summary !== void 0) {
4788
5562
  const opening = snapshot.plan.summary.opening;
4789
- if (!splitParagraphs(input.content).slice(0, 2).some((paragraph) => paragraph.includes(opening))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
5563
+ if (!openingInFirstTwoParagraphs(input.content, opening)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
4790
5564
  }
4791
5565
  const machine = this.machine(snapshot, input.content, { summary: input.summary ?? snapshot.plan?.summary ?? null });
4792
5566
  const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
@@ -4835,12 +5609,13 @@ var EngineService = class {
4835
5609
  });
4836
5610
  }
4837
5611
  async runCheck(input) {
5612
+ const scope = normalizeCheckScope(input.scope);
4838
5613
  return this.mutate({
4839
5614
  root: input.root,
4840
5615
  op: "runCheck",
4841
5616
  key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
4842
5617
  semantic: input.semantic ?? [],
4843
- scope: input.scope ?? []
5618
+ scope
4844
5619
  }))}`,
4845
5620
  run: async (snapshot, context) => {
4846
5621
  this.assertChapter(snapshot, input.chapter);
@@ -4850,35 +5625,41 @@ var EngineService = class {
4850
5625
  briefStage: "P6",
4851
5626
  text,
4852
5627
  proof: snapshot.review?.proof ?? null,
4853
- semantic
5628
+ semantic,
5629
+ scope
4854
5630
  });
4855
5631
  const machine = this.machine(snapshot, text, { summary: snapshot.plan?.summary ?? null });
4856
- const machineBlocking = machine.violations.filter((violation) => violation.severity === "blocker");
5632
+ const scopedMachine = {
5633
+ ...machine,
5634
+ violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
5635
+ };
5636
+ const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
4857
5637
  const semanticResult = evaluateSemantic(text, semantic);
4858
5638
  const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
4859
5639
  const check = {
4860
5640
  schema_version: 1,
4861
5641
  chapter: input.chapter,
4862
- scope: input.scope ?? ["content", "integrity"],
5642
+ scope,
4863
5643
  content_hash: sha256(text),
4864
5644
  machine: {
4865
- violations: machine.violations,
4866
- metrics: machine.metrics
5645
+ violations: scopedMachine.violations,
5646
+ metrics: scopedMachine.metrics
4867
5647
  },
4868
5648
  semantic: { items: semantic },
4869
5649
  verdict
4870
5650
  };
4871
- const nextStage = verdict === "pass" ? "ready_to_archive" : "P6";
5651
+ const fullScope = scope.includes("content") && scope.includes("integrity");
5652
+ const nextStage$2 = verdict === "pass" ? nextStage(snapshot.pipeline.stage, "runCheck", 0, { completeCheck: fullScope }) : "P6";
4872
5653
  const blockingItems = verdict === "fail" ? [...machineBlocking.map((violation) => ({
4873
5654
  code: violation.code,
4874
5655
  scope: "check",
4875
5656
  detail: violation.detail
4876
5657
  })), ...semanticResult.blocking] : [];
4877
5658
  const nextPipeline = pipelineWith(snapshot, context.revision, {
4878
- stage: nextStage,
5659
+ stage: nextStage$2,
4879
5660
  rounds: {
4880
5661
  ...snapshot.pipeline.rounds,
4881
- check: snapshot.pipeline.rounds.check + 1
5662
+ check: snapshot.pipeline.rounds.check + (verdict === "fail" || fullScope ? 1 : 0)
4882
5663
  },
4883
5664
  blocking: blockingItems,
4884
5665
  last_action: action("novel_check", this.now(), sha256(text))
@@ -4894,21 +5675,25 @@ var EngineService = class {
4894
5675
  data: {
4895
5676
  chapter: input.chapter,
4896
5677
  verdict,
4897
- violations: machine.violations.length,
4898
- metrics: machine.metrics,
4899
- stage: nextStage
5678
+ violations: scopedMachine.violations.length,
5679
+ metrics: scopedMachine.metrics,
5680
+ stage: nextStage$2
4900
5681
  },
4901
- warnings: machineWarnings(machine)
5682
+ warnings: [...machineWarnings(scopedMachine), ...verdict === "pass" && !fullScope ? [{
5683
+ code: "CHECK_SCOPE_PARTIAL",
5684
+ message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
5685
+ }] : []]
4902
5686
  };
4903
5687
  }
4904
5688
  });
4905
5689
  }
4906
5690
  async archiveChapter(input) {
4907
- const archiveDigest = sha256(stableJson({
5691
+ const handoffOverride = normalizeHandoffOverride(input.handoff);
5692
+ const archiveDigest = `v2:${sha256(stableJson({
4908
5693
  chapter: input.chapter,
4909
5694
  state_delta: input.state_delta,
4910
- handoff: input.handoff
4911
- }));
5695
+ handoff: handoffOverride
5696
+ }))}`;
4912
5697
  return this.mutate({
4913
5698
  root: input.root,
4914
5699
  op: "archiveChapter",
@@ -4920,7 +5705,7 @@ var EngineService = class {
4920
5705
  const already = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
4921
5706
  if (already !== void 0) {
4922
5707
  if (already.archive_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} has no archive_digest; the index entry is not trustworthy`, "restore the chapter index from backups/ or re-archive from the original manuscript");
4923
- if (already.archive_digest !== archiveDigest) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} is already archived with a different state_delta`, "archive is idempotent by chapter; start the next chapter instead of replaying old data");
5708
+ if (already.archive_digest !== archiveDigest && already.archive_digest !== legacyArchiveDigest(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} is already archived with a different state_delta`, "archive is idempotent by chapter; start the next chapter instead of replaying old data");
4924
5709
  const archivedText = await this.readChapter(snapshot, input.chapter);
4925
5710
  if (archivedText === null || sha256(archivedText) !== already.content_hash) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `archived chapter ${input.chapter} no longer matches its index`, "restore the chapter from backups/ before retrying");
4926
5711
  return {
@@ -4939,6 +5724,7 @@ var EngineService = class {
4939
5724
  const text = snapshot.text ?? "";
4940
5725
  const summary = snapshot.plan?.summary;
4941
5726
  if (summary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the draft has no summary; resubmit the draft with a summary", "call novel_write_draft with the full summary block");
5727
+ if (snapshot.review !== null && snapshot.pipeline.review_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review has no review_digest; it predates digest-bound reviews and cannot be archived blindly", "re-run novel_review for this chapter, then re-run the archive");
4942
5728
  if (snapshot.review !== null && snapshot.pipeline.review_digest !== void 0 && snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review changed after submission; its digest no longer matches", "re-run novel_review instead of editing reviews/ directly");
4943
5729
  await this.assertGates(snapshot, "archiveChapter", {
4944
5730
  briefStage: "P8",
@@ -4962,7 +5748,7 @@ var EngineService = class {
4962
5748
  const metrics = countWordMetrics(text);
4963
5749
  const score = snapshot.review?.total ?? 0;
4964
5750
  const chapter = input.chapter;
4965
- const storedTitle = (snapshot.plan?.title ?? "").replace(/^第\s*\d+\s*章\s*/, "").trim();
5751
+ const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
4966
5752
  const record = {
4967
5753
  num: chapter,
4968
5754
  title: storedTitle === "" ? `第${chapter}章` : storedTitle,
@@ -4997,18 +5783,22 @@ var EngineService = class {
4997
5783
  for (const item of foreshadows.items) if (item.status !== "resolved" && chapter - item.last_touched >= this.rulepack.crosschapter.foreshadow_dormant_after) item.status = "dormant";
4998
5784
  const chapters = structuredClone(snapshot.chapters);
4999
5785
  chapters.chapters.push(record);
5000
- const totalChapters = snapshot.project.targets.total_chapters;
5001
- const derivedVolume = Math.max(1, Math.ceil(chapter / 20));
5786
+ const position = advanceHandoff(snapshot.handoff, chapter, handoffOverride);
5002
5787
  const handoff = {
5003
5788
  schema_version: 1,
5004
5789
  current_chapter: chapter,
5005
- volume: input.handoff?.volume ?? derivedVolume,
5006
- volume_name: input.handoff?.volume_name ?? `第${derivedVolume}卷`,
5007
- act: input.handoff?.act ?? `第${Math.max(1, Math.ceil((chapter - 1) % 20 / 5) + 1)}幕`,
5008
- act_progress: input.handoff?.act_progress ?? (totalChapters === null ? `进至第${chapter}章` : `${chapter}/${totalChapters}`),
5790
+ volume: position.volume,
5791
+ volume_name: position.volume_name,
5792
+ act: position.act,
5793
+ act_progress: position.act_progress,
5009
5794
  next_chapter: chapter + 1,
5010
5795
  last_summary: summary.events.join(";").slice(0, 200),
5011
- open_hooks: foreshadows.items.filter((item) => item.status !== "resolved").slice(-5).map((item) => ({
5796
+ open_hooks: foreshadows.items.filter((item) => item.status !== "resolved").sort((left, right) => {
5797
+ const byAge = left.last_touched - right.last_touched;
5798
+ if (byAge !== 0) return byAge;
5799
+ const byId = compareText(left.id, right.id);
5800
+ return byId !== 0 ? byId : compareText(left.title, right.title);
5801
+ }).slice(0, 5).map((item) => ({
5012
5802
  id: item.id,
5013
5803
  title: item.title
5014
5804
  })),
@@ -5036,6 +5826,20 @@ var EngineService = class {
5036
5826
  ["relationships", ledger.relationships],
5037
5827
  ["handoff", handoff]
5038
5828
  ]);
5829
+ const digestRaw = await context.store.readText(ARCHIVE_DIGESTS);
5830
+ let archiveDigests = {};
5831
+ if (digestRaw !== null) {
5832
+ let parsed;
5833
+ try {
5834
+ parsed = JSON.parse(digestRaw);
5835
+ } catch (error) {
5836
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} is not valid JSON`, "restore the internal archive ledger before archiving; it protects idempotent replays", { cause: error });
5837
+ }
5838
+ const record = recordOf(parsed);
5839
+ if (record === null || Object.values(record).some((value) => typeof value !== "string")) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} must be a JSON object of chapter -> digest`, "restore the internal archive ledger before archiving");
5840
+ archiveDigests = record;
5841
+ }
5842
+ archiveDigests[String(chapter)] = archiveDigest;
5039
5843
  return {
5040
5844
  files: [
5041
5845
  {
@@ -5077,6 +5881,10 @@ var EngineService = class {
5077
5881
  {
5078
5882
  path: joinRel(STATE_DIR, STATE_FILES.relationships),
5079
5883
  text: yaml(ledger.relationships)
5884
+ },
5885
+ {
5886
+ path: ARCHIVE_DIGESTS,
5887
+ text: `${JSON.stringify(archiveDigests, null, 2)}\n`
5080
5888
  }
5081
5889
  ],
5082
5890
  backup: true,
@@ -5105,20 +5913,72 @@ var EngineService = class {
5105
5913
  if (typeof input.reason !== "string" || input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason");
5106
5914
  if (typeof input.content !== "object" || input.content === null || Array.isArray(input.content)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend content must be an object");
5107
5915
  const resolvedRoot = await this.resolveRoot(input.root);
5108
- const absolute = this.storeFor(resolvedRoot).path(input.path);
5916
+ const store = this.storeFor(resolvedRoot);
5917
+ const absolute = store.path(input.path);
5109
5918
  const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
5110
5919
  if (kind === void 0 || absolute.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `amend can only target state documents: ${Object.keys(MUTABLE_STATE).join(", ")}`, "edit novel content through the draft/revision tools, not amend");
5920
+ const repairPaths = [];
5921
+ for (const [path, docKind] of Object.entries(MUTABLE_STATE)) {
5922
+ if (docKind === "pipeline") continue;
5923
+ if (await this.stateDocIsUnreadable(store, path, docKind)) repairPaths.push(path);
5924
+ }
5111
5925
  return this.mutate({
5112
5926
  root: resolvedRoot,
5113
5927
  op: "amend",
5928
+ ...repairPaths.length === 0 ? {} : { repairPaths },
5114
5929
  key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
5115
5930
  run: async (snapshot, context) => {
5116
5931
  if (input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason", "state the intent so the journal explains the change");
5117
5932
  if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
5118
- const document = narrow(kind, {
5933
+ let projectBaseline = snapshot.project;
5934
+ let projectDamaged = snapshot.projectError !== null;
5935
+ if (kind === "project" && snapshot.projectError !== null) {
5936
+ const backup = await this.latestBackup(context.store, PROJECT_FILE);
5937
+ if (backup !== null) try {
5938
+ projectBaseline = narrow("project", parse(backup));
5939
+ projectDamaged = false;
5940
+ } catch {}
5941
+ }
5942
+ const document = kind === "project" ? snapshot.projectError !== null ? narrow("project", {
5943
+ ...input.content,
5944
+ schema_version: input.content.schema_version ?? 1
5945
+ }) : mergeProjectAmendment(input.content, snapshot.project) : narrow(kind, {
5119
5946
  ...input.content,
5120
5947
  schema_version: input.content.schema_version ?? 1
5121
5948
  });
5949
+ let oldDocument = snapshot[kind];
5950
+ if (kind === "project") oldDocument = projectBaseline;
5951
+ else if (snapshot.ledgerErrors.some((error) => error.path === input.path)) {
5952
+ const backup = await this.latestBackup(context.store, input.path);
5953
+ if (backup !== null) try {
5954
+ oldDocument = narrow(kind, parse(backup));
5955
+ } catch {}
5956
+ }
5957
+ const archivedFiles = kind === "chapters" || kind === "project" ? (await context.store.list(CHAPTERS_DIR)).map((name) => /^chapter-(\d{3})\.md$/.exec(name)).filter((match) => match !== null).map((match) => Number(match[1])).filter((num) => num !== snapshot.pipeline.chapter).length : 0;
5958
+ let archiveDigests = {};
5959
+ if (kind === "chapters") {
5960
+ const digestRaw = await context.store.readText(ARCHIVE_DIGESTS);
5961
+ if (digestRaw !== null) {
5962
+ let parsed;
5963
+ try {
5964
+ parsed = JSON.parse(digestRaw);
5965
+ } catch (error) {
5966
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} is not valid JSON`, "restore the internal archive ledger before repairing chapters", { cause: error });
5967
+ }
5968
+ const record = recordOf(parsed);
5969
+ if (record === null || Object.values(record).some((value) => typeof value !== "string")) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} must be a JSON object of chapter -> digest`, "restore the internal archive ledger before repairing chapters");
5970
+ archiveDigests = record;
5971
+ }
5972
+ }
5973
+ const problems = amendProblems(kind, oldDocument, document, snapshot, input.path, archivedFiles, projectBaseline, projectDamaged, archiveDigests);
5974
+ if (kind === "chapters") for (const raw of Array.isArray(document.chapters) ? document.chapters : []) {
5975
+ const row = recordOf(raw);
5976
+ if (row === null || typeof row.file !== "string" || typeof row.content_hash !== "string") continue;
5977
+ const text = await context.store.readText(row.file);
5978
+ if (text === null) problems.push(`archived chapter ${String(row.num)} has no manuscript at ${row.file}`);
5979
+ else if (sha256(text) !== row.content_hash) problems.push(`archived chapter ${String(row.num)} content_hash does not match ${row.file}`);
5980
+ }
5981
+ if (problems.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `amend would break a state invariant: ${problems.join("; ")}`, "keep archived chapters, entity references and next_seq monotonic; nothing was written");
5122
5982
  const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_amend", this.now(), shortDigest(input.reason)) });
5123
5983
  return {
5124
5984
  files: [{
@@ -5155,6 +6015,7 @@ var EngineService = class {
5155
6015
  this.assertChapter(snapshot, input.chapter);
5156
6016
  if (!Object.prototype.hasOwnProperty.call(ROUND_TRANSITIONS, input.toStage)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown rollback target "${String(input.toStage)}"`, `allowed targets: ${Object.keys(ROUND_TRANSITIONS).join(", ")}`);
5157
6017
  const stage = ROUND_TRANSITIONS[input.toStage];
6018
+ if (STAGE_RANK[stage] > STAGE_RANK[snapshot.pipeline.stage]) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `cannot roll back from ${snapshot.pipeline.stage} to later stage ${stage}`, "rollback only moves to the current stage or an earlier one; use the normal stage tools to advance");
5158
6019
  const nextPipeline = pipelineWith(snapshot, context.revision, {
5159
6020
  chapter: input.chapter,
5160
6021
  stage,
@@ -5236,8 +6097,14 @@ var EngineService = class {
5236
6097
  root: input.root,
5237
6098
  op: "export",
5238
6099
  key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
6100
+ chapter: snapshot.pipeline.chapter,
5239
6101
  text: sha256(snapshot.text ?? ""),
5240
- chapters: snapshot.chapters.chapters.map((chapter) => chapter.content_hash),
6102
+ plan_title: stripTitlePrefix(snapshot.plan?.title ?? ""),
6103
+ project: sha256(stableJson(snapshot.project)),
6104
+ chapters: snapshot.chapters.chapters.map((chapter) => ({
6105
+ content_hash: chapter.content_hash,
6106
+ title: chapter.title
6107
+ })),
5241
6108
  title: snapshot.project.title
5242
6109
  }))}`,
5243
6110
  run: async (snapshot, context) => {
@@ -5380,6 +6247,9 @@ var EngineService = class {
5380
6247
  op: "amend",
5381
6248
  key: () => `import:${input.kind}:${sha256(stableJson(input.content))}`,
5382
6249
  run: async (fresh, context) => {
6250
+ if (fresh.pipeline.revision !== snapshot.pipeline.revision) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `${input.kind} import was prepared against revision ${snapshot.pipeline.revision}, but the project is now at ${fresh.pipeline.revision}`, "re-run the import against the current project; nothing was written");
6251
+ const problems = amendProblems(input.kind, fresh[input.kind], document, fresh, path);
6252
+ if (problems.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `import would break a state invariant: ${problems.join("; ")}`, "fix the pack or the referencing ledgers; nothing was written");
5383
6253
  const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import", this.now(), shortDigest(input.kind)) });
5384
6254
  return {
5385
6255
  files: [{
@@ -5408,10 +6278,10 @@ var EngineService = class {
5408
6278
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
5409
6279
  const draft = archivedCurrent || snapshot.pipeline.stage === "done" ? null : {
5410
6280
  num: snapshot.pipeline.chapter,
5411
- title: (snapshot.plan?.title ?? "").replace(/^第\s*\d+\s*章\s*/, "").trim() || `第${snapshot.pipeline.chapter}章`,
6281
+ title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
5412
6282
  wordCount: draftWords,
5413
6283
  score: snapshot.review?.total ?? 0,
5414
- status: snapshot.pipeline.blocking.length > 0 ? "blocked" : "draft",
6284
+ status: this.effectiveBlocking(snapshot).length > 0 ? "blocked" : "draft",
5415
6285
  details: (snapshot.plan?.summary?.events ?? []).join(";").slice(0, 240)
5416
6286
  };
5417
6287
  return {
@@ -5424,17 +6294,97 @@ var EngineService = class {
5424
6294
  revision: snapshot.pipeline.revision,
5425
6295
  rounds: snapshot.pipeline.rounds,
5426
6296
  blocking: this.effectiveBlocking(snapshot),
6297
+ ...snapshot.handoff === null ? {} : (() => {
6298
+ const position = projectHandoff(snapshot.handoff, snapshot.pipeline.chapter);
6299
+ return {
6300
+ volume: position.volume,
6301
+ volume_name: position.volume_name,
6302
+ act: position.act,
6303
+ act_progress: position.act_progress
6304
+ };
6305
+ })(),
5427
6306
  next: finished ? void 0 : this.nextStep(snapshot),
5428
6307
  finished,
5429
6308
  chapters: archived,
5430
6309
  draft,
5431
6310
  totalWords: archived.reduce((total, chapter) => total + chapter.word_count, 0) + (draft === null ? 0 : draft.wordCount),
6311
+ averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
5432
6312
  phases: phaseStrip(snapshot.pipeline.stage, finished)
5433
6313
  };
5434
6314
  }
6315
+ /**
6316
+ * The archive stage has only one legal operation, so a status/panel that
6317
+ * recommends it without checking the same gates as the archive creates a
6318
+ * loop. Evaluate the real archive gates through `gateInput`, then add the
6319
+ * machine pass and semantic evidence that archive re-checks separately.
6320
+ */
6321
+ archivePreflight(snapshot) {
6322
+ if (snapshot.pipeline.stage !== "ready_to_archive") return null;
6323
+ const text = snapshot.text ?? "";
6324
+ const gateResult = evaluateGates(this.gateInput(snapshot, "archiveChapter", {
6325
+ briefStage: "P8",
6326
+ proof: snapshot.review?.proof ?? null
6327
+ }), {
6328
+ rulepack: this.rulepack,
6329
+ minScore: this.config.quality.minScore
6330
+ });
6331
+ if (!gateResult.passed) {
6332
+ const first = gateResult.blocking[0];
6333
+ const target = first.code === "PG-03" || first.code === "PG-04" || first.code === "PG-05" || first.code === "PG-06" ? "P3" : first.code === "PG-07" || first.code === "PG-08" || first.code === "PG-09" ? "P4" : first.code === "PG-11" ? "P4" : "P6";
6334
+ return {
6335
+ item: {
6336
+ ...first,
6337
+ scope: first.scope ?? "archive"
6338
+ },
6339
+ to_stage: target
6340
+ };
6341
+ }
6342
+ const summary = snapshot.plan?.summary ?? null;
6343
+ const blockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
6344
+ if (blockers.length > 0) return {
6345
+ item: {
6346
+ code: blockers[0].code,
6347
+ scope: "archive",
6348
+ detail: `${blockers.length} machine blocker(s) remain; first: ${blockers[0].detail}`,
6349
+ hint: "call novel_rollback with to_stage P3 and re-submit the draft and plan fields"
6350
+ },
6351
+ to_stage: "P3"
6352
+ };
6353
+ const semantic = evaluateSemantic(text, Array.isArray(snapshot.check?.semantic.items) ? snapshot.check.semantic.items : []);
6354
+ if (semantic.failed) return {
6355
+ item: {
6356
+ code: semantic.blocking[0]?.code ?? "SEMANTIC_CHECK_FAILED",
6357
+ scope: "archive",
6358
+ detail: `${semantic.blocking.length} semantic check item(s) fail`,
6359
+ hint: "call novel_rollback with to_stage P6, then re-run novel_check"
6360
+ },
6361
+ to_stage: "P6"
6362
+ };
6363
+ return null;
6364
+ }
5435
6365
  nextStep(snapshot) {
5436
6366
  const stage = snapshot.pipeline.stage;
5437
6367
  const chapter = snapshot.pipeline.chapter;
6368
+ if (snapshot.chapters.chapters.some((record) => record.num === chapter)) return {
6369
+ tool: "novel_amend",
6370
+ args: { path: joinRel(STATE_DIR, STATE_FILES.chapters) },
6371
+ reason: `chapter ${chapter} is the active chapter but is already in the index; read novel_state {kind:"chapters", include_summary:true}, remove it with novel_amend, then continue`
6372
+ };
6373
+ if (this.stateErrors(snapshot).length > 0) {
6374
+ const recovery = this.recoveryFor(snapshot);
6375
+ if (recovery.tool === "novel_amend") return recovery;
6376
+ const target = recovery.args.to_stage;
6377
+ if (STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[stage]) return recovery;
6378
+ }
6379
+ const reviewDigest = this.reviewDigestProblem(snapshot);
6380
+ if (reviewDigest !== null) return {
6381
+ tool: "novel_rollback",
6382
+ args: {
6383
+ chapter,
6384
+ to_stage: "P4"
6385
+ },
6386
+ reason: `${reviewDigest.code}: ${reviewDigest.detail}; ${reviewDigest.hint}`
6387
+ };
5438
6388
  const step = STAGE_NEXT[stage];
5439
6389
  const target = snapshot.project.targets.total_chapters;
5440
6390
  if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
@@ -5463,6 +6413,25 @@ var EngineService = class {
5463
6413
  },
5464
6414
  reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
5465
6415
  };
6416
+ if (stage === "P6" && snapshot.check !== null && snapshot.check.verdict === "pass" && (!snapshot.check.scope.includes("content") || !snapshot.check.scope.includes("integrity"))) return {
6417
+ tool: "novel_check",
6418
+ args: this.nextArgs("P6", chapter),
6419
+ reason: "the last check covered only one scope; run novel_check without scope (content+integrity) to reach archive"
6420
+ };
6421
+ if (stage === "P6" && snapshot.checkError !== null) return {
6422
+ tool: "novel_check",
6423
+ args: this.nextArgs("P6", chapter),
6424
+ reason: `CHECK_UNREADABLE@${checkFile(chapter)}; repair the check with novel_check before any stale revision blocker`
6425
+ };
6426
+ const archive = this.archivePreflight(snapshot);
6427
+ if (archive !== null) return {
6428
+ tool: "novel_rollback",
6429
+ args: {
6430
+ chapter,
6431
+ to_stage: archive.to_stage
6432
+ },
6433
+ reason: `${archive.item.code}: ${archive.item.detail}; ${archive.item.hint ?? ""}`.trim()
6434
+ };
5466
6435
  if (snapshot.pipeline.blocking.length > 0) {
5467
6436
  if (stage === "P5" || stage === "P6") return {
5468
6437
  tool: "novel_revise",
@@ -5489,20 +6458,176 @@ var EngineService = class {
5489
6458
  "P5",
5490
6459
  "P6"
5491
6460
  ].includes(stage)) return null;
5492
- const counter = stage === "P4" ? "review" : stage === "P5" ? "revise" : "check";
5493
- if (!roundsExhausted(snapshot.pipeline.rounds, counter)) return null;
5494
- return {
6461
+ const block = (counter) => ({
5495
6462
  code: "PG-11",
5496
6463
  scope: "rounds",
5497
6464
  detail: `round limit reached for ${counter} (review ${snapshot.pipeline.rounds.review}, revise ${snapshot.pipeline.rounds.revise}, check ${snapshot.pipeline.rounds.check})`,
5498
6465
  hint: "call novel_rollback with to_stage P4; the manuscript is kept for reference"
5499
- };
6466
+ });
6467
+ if (stage === "P4") return roundsExhausted(snapshot.pipeline.rounds, "review") ? block("review") : null;
6468
+ if (stage === "P5") return roundsExhausted(snapshot.pipeline.rounds, "revise") ? block("revise") : null;
6469
+ if (roundsExhausted(snapshot.pipeline.rounds, "check")) return block("check");
6470
+ if (snapshot.pipeline.blocking.length > 0 && roundsExhausted(snapshot.pipeline.rounds, "revise")) return block("revise");
6471
+ return null;
5500
6472
  }
5501
6473
  /** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
5502
6474
  effectiveBlocking(snapshot) {
6475
+ const state = this.stateErrors(snapshot).map((error) => ({
6476
+ code: "STATE_UNREADABLE",
6477
+ scope: "state",
6478
+ detail: `${error.code}: ${error.message}`,
6479
+ hint: this.recoveryFor(snapshot).reason
6480
+ }));
6481
+ const active = snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter) ? [{
6482
+ code: "ACTIVE_CHAPTER_INDEXED",
6483
+ scope: "state",
6484
+ detail: `chapter ${snapshot.pipeline.chapter} is active but already exists in state/chapters.yaml`,
6485
+ hint: "read the index with novel_state {kind:\"chapters\", include_summary:true}, remove the active row with novel_amend, then continue"
6486
+ }] : [];
5503
6487
  const extra = this.roundsLimitBlock(snapshot);
5504
- if (extra === null) return snapshot.pipeline.blocking;
5505
- return snapshot.pipeline.blocking.some((item) => item.code === extra.code) ? snapshot.pipeline.blocking : [...snapshot.pipeline.blocking, extra];
6488
+ const reviewDigest = this.reviewDigestProblem(snapshot);
6489
+ const archive = this.archivePreflight(snapshot);
6490
+ const base = [
6491
+ ...state,
6492
+ ...active,
6493
+ ...archive === null ? [] : [archive.item],
6494
+ ...reviewDigest === null ? [] : [reviewDigest],
6495
+ ...snapshot.pipeline.blocking
6496
+ ];
6497
+ if (extra === null) return base;
6498
+ return base.some((item) => item.code === extra.code) ? base : [...base, extra];
6499
+ }
6500
+ /**
6501
+ * A review that exists but cannot be tied to the pipeline digest would let a
6502
+ * hand-edited review pass archive. Archive already fails closed; status and
6503
+ * next must surface the same repair path instead of pointing at the archive
6504
+ * command forever.
6505
+ */
6506
+ reviewDigestProblem(snapshot) {
6507
+ if (snapshot.pipeline.stage !== "ready_to_archive" || snapshot.review === null) return null;
6508
+ const chapter = snapshot.pipeline.chapter;
6509
+ const hint = "call novel_rollback with to_stage P4, then re-run novel_review before archiving";
6510
+ if (snapshot.pipeline.review_digest === void 0) return {
6511
+ code: "REVIEW_DIGEST_MISSING",
6512
+ scope: "state",
6513
+ detail: `review ${reviewFile(chapter)} has no pipeline.review_digest; it predates digest-bound reviews`,
6514
+ hint
6515
+ };
6516
+ if (snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) return {
6517
+ code: "REVIEW_DIGEST_MISMATCH",
6518
+ scope: "state",
6519
+ detail: `review ${reviewFile(chapter)} changed after submission; its digest no longer matches`,
6520
+ hint
6521
+ };
6522
+ return null;
6523
+ }
6524
+ stateErrors(snapshot) {
6525
+ const errors = [...snapshot.ledgerErrors];
6526
+ if (snapshot.projectError !== null) errors.push({
6527
+ path: PROJECT_FILE,
6528
+ code: "PROJECT_UNREADABLE",
6529
+ message: snapshot.projectError
6530
+ });
6531
+ const chapter = snapshot.pipeline.chapter;
6532
+ if (snapshot.worldPlanError !== null) errors.push({
6533
+ path: planWorldFile(chapter),
6534
+ code: "WORLD_PLAN_UNREADABLE",
6535
+ message: snapshot.worldPlanError
6536
+ });
6537
+ if (snapshot.planError !== null) errors.push({
6538
+ path: planChapterFile(chapter),
6539
+ code: "PLAN_UNREADABLE",
6540
+ message: snapshot.planError
6541
+ });
6542
+ if (snapshot.reviewError !== null) errors.push({
6543
+ path: reviewFile(chapter),
6544
+ code: "REVIEW_UNREADABLE",
6545
+ message: snapshot.reviewError
6546
+ });
6547
+ if (snapshot.checkError !== null) errors.push({
6548
+ path: checkFile(chapter),
6549
+ code: "CHECK_UNREADABLE",
6550
+ message: snapshot.checkError
6551
+ });
6552
+ return errors;
6553
+ }
6554
+ /** The earliest rebuild point for the current state damage. */
6555
+ recoveryFor(snapshot) {
6556
+ const chapter = snapshot.pipeline.chapter;
6557
+ const errors = this.stateErrors(snapshot);
6558
+ if (snapshot.projectError !== null) return {
6559
+ tool: "novel_amend",
6560
+ args: {
6561
+ path: PROJECT_FILE,
6562
+ reason: `repair PROJECT_UNREADABLE: ${snapshot.projectError}`
6563
+ },
6564
+ reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; read novel_state novel for the raw document, then amend with a complete project document`
6565
+ };
6566
+ if (snapshot.ledgerErrors.length > 0) {
6567
+ const first = snapshot.ledgerErrors[0];
6568
+ return {
6569
+ tool: "novel_amend",
6570
+ args: {
6571
+ path: first.path,
6572
+ reason: `repair ${first.code}: ${first.message}`
6573
+ },
6574
+ reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; read novel_state for the raw document, then amend it with a complete replacement`
6575
+ };
6576
+ }
6577
+ const desired = snapshot.worldPlanError !== null ? "P1" : snapshot.planError !== null ? "P2" : snapshot.reviewError !== null ? "P4" : "P6";
6578
+ const target = this.clampRollbackTarget(snapshot.pipeline.stage, desired);
6579
+ const action = STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[snapshot.pipeline.stage] ? `rollback to ${target} and rebuild through the normal stage tools` : `repair the file at ${snapshot.pipeline.stage} with its normal stage tool`;
6580
+ return {
6581
+ tool: "novel_rollback",
6582
+ args: {
6583
+ chapter,
6584
+ to_stage: target
6585
+ },
6586
+ reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; ${action}`
6587
+ };
6588
+ }
6589
+ /** Never point at a stage later than the current one; rollback only moves backward. */
6590
+ clampRollbackTarget(stage, desired) {
6591
+ if (STAGE_RANK[ROUND_TRANSITIONS[desired]] <= STAGE_RANK[stage]) return desired;
6592
+ return {
6593
+ idle: "P1",
6594
+ P2: "P2",
6595
+ P3: "P3",
6596
+ P4: "P4",
6597
+ P5: "P5",
6598
+ P6: "P6",
6599
+ ready_to_archive: "P6",
6600
+ done: "P6"
6601
+ }[stage];
6602
+ }
6603
+ /** State damage that makes this operation unsafe to start. */
6604
+ stateGateBlock(snapshot, operation) {
6605
+ const planSensitive = [
6606
+ "submitDraft",
6607
+ "submitReview",
6608
+ "submitRevision",
6609
+ "runCheck",
6610
+ "archiveChapter"
6611
+ ];
6612
+ const worldPlanSensitive = ["submitChapterPlan", ...planSensitive];
6613
+ const reviewSensitive = [
6614
+ "submitRevision",
6615
+ "runCheck",
6616
+ "archiveChapter"
6617
+ ];
6618
+ const checkSensitive = ["archiveChapter"];
6619
+ const relevant = [...snapshot.ledgerErrors];
6620
+ if (snapshot.projectError !== null) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PROJECT_UNREADABLE"));
6621
+ if (snapshot.worldPlanError !== null && worldPlanSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "WORLD_PLAN_UNREADABLE"));
6622
+ if (snapshot.planError !== null && planSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PLAN_UNREADABLE"));
6623
+ if (snapshot.reviewError !== null && reviewSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "REVIEW_UNREADABLE"));
6624
+ if (snapshot.checkError !== null && checkSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "CHECK_UNREADABLE"));
6625
+ return relevant.map((error) => ({
6626
+ code: "STATE_UNREADABLE",
6627
+ scope: "state",
6628
+ detail: `${error.code}: ${error.message}`,
6629
+ hint: this.recoveryFor(snapshot).reason
6630
+ }));
5506
6631
  }
5507
6632
  /** Whether the stored brief still signs the current snapshot. */
5508
6633
  briefIsCurrent(snapshot, stage) {
@@ -5552,7 +6677,7 @@ var EngineService = class {
5552
6677
  }
5553
6678
  if (chapters.length === 0 && snapshot.text !== null && (chapter === void 0 || chapter === snapshot.pipeline.chapter)) chapters.push({
5554
6679
  num: snapshot.pipeline.chapter,
5555
- title: snapshot.plan?.title ?? "未命名",
6680
+ title: stripTitlePrefix(snapshot.plan?.title ?? "") || "未命名",
5556
6681
  text: snapshot.text
5557
6682
  });
5558
6683
  if (chapters.length === 0) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, "no chapter is available to export", "write or archive at least one chapter first");
@@ -5561,7 +6686,7 @@ var EngineService = class {
5561
6686
  machine(snapshot, text, extra) {
5562
6687
  return runMachineChecks(text, {
5563
6688
  chapter: snapshot.pipeline.chapter,
5564
- title: composeTitle(snapshot.pipeline.chapter, extra.title ?? snapshot.plan?.title ?? null),
6689
+ title: composeTitle(snapshot.pipeline.chapter, extra.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
5565
6690
  genre: snapshot.project.genre,
5566
6691
  sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
5567
6692
  rulepack: this.rulepack,
@@ -5579,12 +6704,20 @@ var EngineService = class {
5579
6704
  });
5580
6705
  }
5581
6706
  briefContextFor(stage, snapshot, chapter) {
6707
+ let cache = this.briefContextCache.get(snapshot);
6708
+ if (cache === void 0) {
6709
+ cache = /* @__PURE__ */ new Map();
6710
+ this.briefContextCache.set(snapshot, cache);
6711
+ }
6712
+ const cacheKey = `${stage}:${chapter}`;
6713
+ const cached = cache.get(cacheKey);
6714
+ if (cached !== void 0) return cached;
5582
6715
  const machine = [
5583
6716
  "P4",
5584
6717
  "P5",
5585
6718
  "P6"
5586
6719
  ].includes(stage) && snapshot.text !== null ? this.machine(snapshot, snapshot.text, { summary: snapshot.plan?.summary ?? null }) : void 0;
5587
- return briefContext({
6720
+ const context = briefContext({
5588
6721
  stage,
5589
6722
  chapter,
5590
6723
  project: snapshot.project,
@@ -5596,15 +6729,21 @@ var EngineService = class {
5596
6729
  foreshadows: snapshot.foreshadows,
5597
6730
  timeline: snapshot.timeline,
5598
6731
  relationships: snapshot.relationships,
6732
+ worldPlan: snapshot.worldPlan,
6733
+ world_plan_error: snapshot.worldPlanError,
5599
6734
  plan: snapshot.plan,
5600
6735
  plan_error: snapshot.planError,
5601
6736
  review: snapshot.review,
6737
+ review_error: snapshot.reviewError,
5602
6738
  check: snapshot.check,
6739
+ check_error: snapshot.checkError,
5603
6740
  text: snapshot.text,
5604
6741
  machine,
5605
6742
  previousText: snapshot.previousText,
5606
6743
  handoff: snapshot.handoff
5607
6744
  });
6745
+ cache.set(cacheKey, context);
6746
+ return context;
5608
6747
  }
5609
6748
  briefDigest(stage, snapshot, context) {
5610
6749
  const briefHash = this.briefs.render(stage, this.briefVariables(snapshot, snapshot.pipeline.chapter)).contentHash;
@@ -5632,15 +6771,19 @@ var EngineService = class {
5632
6771
  round: snapshot.pipeline.stage === "P5" ? snapshot.pipeline.rounds.revise + 1 : snapshot.pipeline.rounds.review + 1
5633
6772
  };
5634
6773
  }
5635
- async assertGates(snapshot, operation, extras) {
6774
+ /**
6775
+ * The one place a gate input is assembled. Archive preflight evaluates the
6776
+ * same object as the real archive, so a gate cannot pass preflight and then
6777
+ * fail the write (the old PG-06/scope holes did exactly that).
6778
+ */
6779
+ gateInput(snapshot, operation, extras = {}) {
5636
6780
  const text = extras.text ?? snapshot.text ?? "";
5637
- if (!canRun(snapshot.pipeline.stage, operation)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `${operation} is not allowed at stage ${snapshot.pipeline.stage}`, "call novel_status to see the next legal step");
5638
6781
  const archive = operation === "archiveChapter" ? {
5639
6782
  chapterExists: snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter),
5640
6783
  checkPassed: snapshot.check?.verdict === "pass",
5641
6784
  hashMatches: snapshot.check !== null && snapshot.text !== null && snapshot.check.content_hash === sha256(snapshot.text)
5642
6785
  } : null;
5643
- const result = evaluateGates({
6786
+ return {
5644
6787
  operation,
5645
6788
  stage: snapshot.pipeline.stage,
5646
6789
  text,
@@ -5650,7 +6793,7 @@ var EngineService = class {
5650
6793
  chapters: snapshot.chapters,
5651
6794
  briefDigest: extras.briefDigest ?? (extras.briefStage === void 0 || extras.briefStage === null ? null : this.briefDigest(extras.briefStage, snapshot, this.briefContextFor(extras.briefStage, snapshot, snapshot.pipeline.chapter))),
5652
6795
  briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
5653
- draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.plan?.title ?? null),
6796
+ draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
5654
6797
  sceneType: extras.sceneType ?? snapshot.plan?.scene_type ?? null,
5655
6798
  summary: extras.summary ?? snapshot.plan?.summary ?? null,
5656
6799
  summaryProvided: extras.summaryProvided,
@@ -5658,6 +6801,7 @@ var EngineService = class {
5658
6801
  check: extras.check ?? snapshot.check,
5659
6802
  semantic: extras.semantic ?? [],
5660
6803
  proof: extras.proof ?? null,
6804
+ scope: extras.scope,
5661
6805
  rounds: snapshot.pipeline.rounds,
5662
6806
  archive,
5663
6807
  evidence: {
@@ -5671,7 +6815,13 @@ var EngineService = class {
5671
6815
  previousText: snapshot.previousText,
5672
6816
  previousTexts: snapshot.previousTexts,
5673
6817
  priorSceneTypes: snapshot.chapters.chapters.map((record) => record.scene_type)
5674
- }, {
6818
+ };
6819
+ }
6820
+ async assertGates(snapshot, operation, extras) {
6821
+ if (!canRun(snapshot.pipeline.stage, operation)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `${operation} is not allowed at stage ${snapshot.pipeline.stage}`, "call novel_status to see the next legal step");
6822
+ const stateBlocking = this.stateGateBlock(snapshot, operation);
6823
+ if (stateBlocking.length > 0) throw new EngineBlockedError(ERROR_CODES.GATE_BLOCKED, `${operation} blocked by unreadable state: ${stateBlocking[0].detail}`, stateBlocking, this.recoveryFor(snapshot).reason);
6824
+ const result = evaluateGates(this.gateInput(snapshot, operation, extras), {
5675
6825
  rulepack: this.rulepack,
5676
6826
  minScore: this.config.quality.minScore
5677
6827
  });
@@ -5698,36 +6848,76 @@ var EngineService = class {
5698
6848
  }
5699
6849
  async load(root, retries = 1) {
5700
6850
  const store = this.storeFor(await this.resolveRoot(root));
5701
- if (!await store.exists("novel.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no novel project at ${store.root}`, "call novel_init to create one, or pass the correct project root");
5702
- const project = await store.readDoc(PROJECT_FILE, "project");
5703
- if (project === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${PROJECT_FILE} disappeared mid-read`);
6851
+ const projectExists = await store.exists(PROJECT_FILE);
6852
+ if (!projectExists) {
6853
+ const stateFiles = [
6854
+ STATE_FILES.pipeline,
6855
+ STATE_FILES.chapters,
6856
+ STATE_FILES.characters,
6857
+ STATE_FILES.world,
6858
+ STATE_FILES.locks,
6859
+ STATE_FILES.timeline,
6860
+ STATE_FILES.foreshadows,
6861
+ STATE_FILES.relationships,
6862
+ STATE_FILES.handoff
6863
+ ].map((file) => joinRel(STATE_DIR, file));
6864
+ if (!((await Promise.all(stateFiles.map((path) => store.exists(path)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0)) throw new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no novel project at ${store.root}`, "call novel_init to create one, or pass the correct project root");
6865
+ }
6866
+ let project = {
6867
+ ...ENGINE_PROJECT_INITIAL,
6868
+ title: "(项目文件不可读)"
6869
+ };
6870
+ let projectError = projectExists ? null : `${PROJECT_FILE} is missing`;
6871
+ if (projectExists) try {
6872
+ const read = await store.readDoc(PROJECT_FILE, "project");
6873
+ if (read === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${PROJECT_FILE} disappeared mid-read`);
6874
+ project = read;
6875
+ } catch (error) {
6876
+ if (error instanceof EngineFaultError && (error.code === ERROR_CODES.SCHEMA_TOO_NEW || error.code === ERROR_CODES.PATH_ESCAPE)) throw error;
6877
+ projectError = errorMessage(error);
6878
+ }
5704
6879
  const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
5705
6880
  if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
5706
- const chapters = await store.readDoc(joinRel("state", STATE_FILES.chapters), "chapters") ?? emptyChapters();
5707
- const characters = await store.readDoc(joinRel("state", STATE_FILES.characters), "characters") ?? emptyCharacters();
5708
- const world = await store.readDoc(joinRel("state", STATE_FILES.world), "world") ?? emptyWorld();
5709
- const locks = await store.readDoc(joinRel("state", STATE_FILES.locks), "locks") ?? {
6881
+ const ledgerErrors = [];
6882
+ const ledger = async (path, kind, code, fallback) => {
6883
+ const read = await this.recoverableDoc(store, path, kind);
6884
+ if (read.error !== null) ledgerErrors.push({
6885
+ path,
6886
+ code,
6887
+ message: read.error
6888
+ });
6889
+ else if (read.value === null) ledgerErrors.push({
6890
+ path,
6891
+ code,
6892
+ message: `${path} is missing`
6893
+ });
6894
+ return read.value ?? fallback;
6895
+ };
6896
+ const chapters = await ledger(joinRel(STATE_DIR, STATE_FILES.chapters), "chapters", "CHAPTERS_UNREADABLE", emptyChapters());
6897
+ const characters = await ledger(joinRel(STATE_DIR, STATE_FILES.characters), "characters", "CHARACTERS_UNREADABLE", emptyCharacters());
6898
+ const world = await ledger(joinRel(STATE_DIR, STATE_FILES.world), "world", "WORLD_UNREADABLE", emptyWorld());
6899
+ const locks = await ledger(joinRel(STATE_DIR, STATE_FILES.locks), "locks", "LOCKS_UNREADABLE", {
5710
6900
  schema_version: 1,
5711
6901
  locked_at_chapter: 0,
5712
6902
  characters: {},
5713
6903
  locations: {}
5714
- };
5715
- const timeline = await store.readDoc(joinRel("state", STATE_FILES.timeline), "timeline") ?? {
6904
+ });
6905
+ const timeline = await ledger(joinRel(STATE_DIR, STATE_FILES.timeline), "timeline", "TIMELINE_UNREADABLE", {
5716
6906
  schema_version: 1,
5717
6907
  next_seq: 1,
5718
6908
  items: []
5719
- };
5720
- const foreshadows = await store.readDoc(joinRel("state", STATE_FILES.foreshadows), "foreshadows") ?? {
6909
+ });
6910
+ const foreshadows = await ledger(joinRel(STATE_DIR, STATE_FILES.foreshadows), "foreshadows", "FORESHADOWS_UNREADABLE", {
5721
6911
  schema_version: 1,
5722
6912
  next_seq: 1,
5723
6913
  items: []
5724
- };
5725
- const relationships = await store.readDoc(joinRel("state", STATE_FILES.relationships), "relationships") ?? {
6914
+ });
6915
+ const relationships = await ledger(joinRel(STATE_DIR, STATE_FILES.relationships), "relationships", "RELATIONSHIPS_UNREADABLE", {
5726
6916
  schema_version: 1,
5727
6917
  next_seq: 1,
5728
6918
  items: []
5729
- };
5730
- const handoff = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.handoff), "handoff");
6919
+ });
6920
+ const handoff = await ledger(joinRel(STATE_DIR, STATE_FILES.handoff), "handoff", "HANDOFF_UNREADABLE", null);
5731
6921
  const chapter = pipeline.chapter;
5732
6922
  const text = await store.readText(chapterFile(chapter));
5733
6923
  const previousText = chapter > 1 ? await store.readText(chapterFile(chapter - 1)) : null;
@@ -5740,12 +6930,36 @@ var EngineService = class {
5740
6930
  let plan = null;
5741
6931
  let planError = null;
5742
6932
  if (planRaw !== null) try {
5743
- plan = parsePlanYaml(planRaw, planChapterFile(chapter));
6933
+ const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
6934
+ validateStoredChapterPlan(parsed, planChapterFile(chapter));
6935
+ plan = parsed;
6936
+ } catch (error) {
6937
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
6938
+ planError = errorMessage(error);
6939
+ }
6940
+ const worldPlanPath = planWorldFile(chapter);
6941
+ const worldPlanRaw = await store.readText(worldPlanPath);
6942
+ let worldPlan = null;
6943
+ let worldPlanError = null;
6944
+ if (worldPlanRaw !== null) try {
6945
+ const narrowed = narrow("worldPlan", parsePlanYaml(worldPlanRaw, worldPlanPath));
6946
+ validateWorldPlanShape(narrowed, worldPlanPath);
6947
+ worldPlan = narrowed;
5744
6948
  } catch (error) {
5745
- planError = error instanceof Error ? error.message : String(error);
6949
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
6950
+ worldPlanError = errorMessage(error);
5746
6951
  }
5747
- const review = await store.readDoc(reviewFile(chapter), "review");
5748
- const check = await store.readDoc(checkFile(chapter), "check");
6952
+ const reviewRead = await this.recoverableDoc(store, reviewFile(chapter), "review");
6953
+ const checkRead = await this.recoverableDoc(store, checkFile(chapter), "check");
6954
+ const review = reviewRead.value;
6955
+ let reviewError = reviewRead.error;
6956
+ const check = checkRead.value;
6957
+ let checkError = checkRead.error;
6958
+ const cycleRank = pipeline.stage === "done" ? STAGE_RANK.idle : STAGE_RANK[pipeline.stage];
6959
+ if (worldPlanRaw === null && worldPlanError === null && cycleRank >= STAGE_RANK.P2) worldPlanError = `${planWorldFile(chapter)} is missing`;
6960
+ if (planRaw === null && planError === null && cycleRank >= STAGE_RANK.P3) planError = `${planChapterFile(chapter)} is missing`;
6961
+ if (review === null && reviewError === null && cycleRank >= STAGE_RANK.P5) reviewError = `${reviewFile(chapter)} is missing`;
6962
+ if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
5749
6963
  const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
5750
6964
  if (latest === null || latest.revision !== pipeline.revision) {
5751
6965
  if (retries > 0) return this.load(store.root, retries - 1);
@@ -5755,6 +6969,7 @@ var EngineService = class {
5755
6969
  return {
5756
6970
  root: store.root,
5757
6971
  project,
6972
+ projectError,
5758
6973
  pipeline,
5759
6974
  chapters,
5760
6975
  characters,
@@ -5764,15 +6979,46 @@ var EngineService = class {
5764
6979
  foreshadows,
5765
6980
  relationships,
5766
6981
  handoff,
6982
+ worldPlan,
6983
+ ledgerErrors,
5767
6984
  text,
5768
6985
  previousText,
5769
6986
  previousTexts,
5770
6987
  plan,
5771
6988
  planError,
6989
+ worldPlanError,
5772
6990
  review,
5773
- check
6991
+ reviewError,
6992
+ check,
6993
+ checkError
5774
6994
  };
5775
6995
  }
6996
+ /** A repair may bypass the journal hash only when the document is unreadable. */
6997
+ async stateDocIsUnreadable(store, path, kind) {
6998
+ if (await store.readText(path) === null) return true;
6999
+ try {
7000
+ await store.readDoc(path, kind);
7001
+ return false;
7002
+ } catch (error) {
7003
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) return false;
7004
+ return true;
7005
+ }
7006
+ }
7007
+ /** Read a review/check without letting one corrupt file wedge every read. */
7008
+ async recoverableDoc(store, path, kind) {
7009
+ try {
7010
+ return {
7011
+ value: await store.readDoc(path, kind),
7012
+ error: null
7013
+ };
7014
+ } catch (error) {
7015
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
7016
+ return {
7017
+ value: null,
7018
+ error: errorMessage(error)
7019
+ };
7020
+ }
7021
+ }
5776
7022
  async mutate(options) {
5777
7023
  const root = await this.resolveRoot(options.root);
5778
7024
  const signal = this.signalContext.getStore();
@@ -5834,7 +7080,7 @@ var EngineService = class {
5834
7080
  };
5835
7081
  }
5836
7082
  }
5837
- await assertJournalConsistent(store);
7083
+ await assertJournalConsistent(store, options.repairPaths ?? []);
5838
7084
  const snapshot = await this.load(root);
5839
7085
  const key = options.key?.(snapshot);
5840
7086
  if (key !== void 0) {
@@ -5895,6 +7141,7 @@ var EngineService = class {
5895
7141
  return {
5896
7142
  root,
5897
7143
  project: { ...ENGINE_PROJECT_INITIAL },
7144
+ projectError: null,
5898
7145
  pipeline: emptyPipeline({
5899
7146
  revision: 1,
5900
7147
  chapter: 1,
@@ -5925,13 +7172,18 @@ var EngineService = class {
5925
7172
  items: []
5926
7173
  },
5927
7174
  handoff: null,
7175
+ worldPlan: null,
7176
+ ledgerErrors: [],
5928
7177
  text: null,
5929
7178
  previousText: null,
5930
7179
  previousTexts: [],
5931
7180
  plan: null,
5932
7181
  planError: null,
7182
+ worldPlanError: null,
5933
7183
  review: null,
5934
- check: null
7184
+ reviewError: null,
7185
+ check: null,
7186
+ checkError: null
5935
7187
  };
5936
7188
  }
5937
7189
  };
@@ -6007,18 +7259,415 @@ const machineWarnings = (machine) => machine.violations.filter((violation) => vi
6007
7259
  code: violation.code,
6008
7260
  message: `${violation.detail} (paragraph ${violation.location.paragraph})`
6009
7261
  }));
6010
- /** Titles are stored as bodies; the format rule judges the rendered heading. */
6011
- const composeTitle = (chapter, title) => {
6012
- if (title === null) return null;
6013
- return /^第\s*\d+\s*章/.test(title) ? title : `第${chapter}章 ${title}`;
6014
- };
6015
- const stableJson = (value) => {
7262
+ /** 0.1.2 archive digests used localeCompare; keep that comparison only for upgrades. */
7263
+ const legacyStableJson = (value) => {
6016
7264
  const normalize = (input) => {
6017
7265
  if (Array.isArray(input)) return input.map(normalize);
6018
- if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, normalize(value)]));
7266
+ if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, normalize(item)]));
6019
7267
  return input;
6020
7268
  };
6021
- return JSON.stringify(normalize(value));
7269
+ return JSON.stringify(normalize(value)) ?? "null";
7270
+ };
7271
+ const normalizeHandoffOverride = (value) => {
7272
+ if (value === void 0 || value === null) return void 0;
7273
+ if (typeof value !== "object" || Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff must be an object");
7274
+ const volume = value.volume;
7275
+ if (volume !== void 0 && (!Number.isInteger(volume) || volume < 1)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.volume must be a positive integer");
7276
+ const volumeName = value.volume_name;
7277
+ if (volumeName !== void 0 && (typeof volumeName !== "string" || volumeName.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.volume_name must be non-empty");
7278
+ const act = value.act;
7279
+ if (act !== void 0 && (typeof act !== "string" || act.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.act must be non-empty");
7280
+ const actProgress = value.act_progress;
7281
+ if (actProgress !== void 0 && parseActProgress(actProgress) === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.act_progress must be n/d with a positive denominator and n <= d");
7282
+ return {
7283
+ ...volume === void 0 ? {} : { volume },
7284
+ ...volumeName === void 0 ? {} : { volume_name: volumeName.trim() },
7285
+ ...act === void 0 ? {} : { act: act.trim() },
7286
+ ...actProgress === void 0 ? {} : { act_progress: actProgress.trim() }
7287
+ };
7288
+ };
7289
+ const legacyArchiveDigest = (input) => sha256(legacyStableJson({
7290
+ chapter: input.chapter,
7291
+ state_delta: input.state_delta,
7292
+ handoff: input.handoff
7293
+ }));
7294
+ const LENIENT_TITLE_PREFIX = /^第\s*\d+\s*章\s*/;
7295
+ /** Titles are stored as bodies; the format rule judges the rendered heading. */
7296
+ const stripTitlePrefix = (title) => title.replace(LENIENT_TITLE_PREFIX, "").trim();
7297
+ const composeTitle = (chapter, title, pattern) => {
7298
+ if (title === null) return null;
7299
+ const text = title.trim();
7300
+ if (pattern !== void 0 && new RegExp(pattern).test(text)) return text;
7301
+ const body = stripTitlePrefix(text);
7302
+ return `第${chapter}章 ${body === "" ? text : body}`;
7303
+ };
7304
+ const errorMessage = (error) => error instanceof Error ? `${error.message}${error.cause instanceof Error ? `: ${error.cause.message}` : ""}` : String(error);
7305
+ const recordOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
7306
+ const mergeProjectAmendment = (content, old) => {
7307
+ const allowed = /* @__PURE__ */ new Set([
7308
+ "schema_version",
7309
+ "title",
7310
+ "author",
7311
+ "description",
7312
+ "targets",
7313
+ "total_chapters"
7314
+ ]);
7315
+ for (const key of Object.keys(content)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel.yaml amend cannot change "${key}"`, "only title, author, description and targets.total_chapters are mutable");
7316
+ const merged = {
7317
+ ...old,
7318
+ ...content
7319
+ };
7320
+ if (content.targets !== void 0) {
7321
+ const targets = recordOf(content.targets);
7322
+ if (targets === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "targets must be an object");
7323
+ const allowedTargets = /* @__PURE__ */ new Set([
7324
+ "words_per_chapter",
7325
+ "dialogue_ratio",
7326
+ "total_chapters"
7327
+ ]);
7328
+ for (const key of Object.keys(targets)) if (!allowedTargets.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel.yaml targets cannot change "${key}"`, "only targets.total_chapters is mutable; words_per_chapter and dialogue_ratio are immutable");
7329
+ merged.targets = {
7330
+ ...old.targets,
7331
+ ...targets
7332
+ };
7333
+ }
7334
+ if (Object.hasOwn(content, "total_chapters")) merged.targets = {
7335
+ ...recordOf(merged.targets) ?? {},
7336
+ total_chapters: content.total_chapters
7337
+ };
7338
+ delete merged.total_chapters;
7339
+ return narrow("project", merged);
7340
+ };
7341
+ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archivedFiles = 0, projectBaseline = snapshot.project, projectDamaged = false, archiveDigests = {}) => {
7342
+ const problems = [];
7343
+ const old = recordOf(oldDocument);
7344
+ if (kind === "project") {
7345
+ const next = nextDocument;
7346
+ const targets = recordOf(next.targets);
7347
+ if (typeof next.title !== "string" || next.title.trim() === "") problems.push("title must not be empty");
7348
+ if (!projectDamaged) {
7349
+ if (next.genre !== projectBaseline.genre) problems.push("genre is immutable");
7350
+ if (next.platform !== projectBaseline.platform) problems.push("platform is immutable");
7351
+ if (next.created_at !== projectBaseline.created_at) problems.push("created_at is immutable");
7352
+ }
7353
+ const oldTargets = projectBaseline.targets;
7354
+ if (targets === null) problems.push("targets must be an object");
7355
+ else {
7356
+ if (!projectDamaged && (JSON.stringify(targets.words_per_chapter) !== JSON.stringify(oldTargets.words_per_chapter) || JSON.stringify(targets.dialogue_ratio) !== JSON.stringify(oldTargets.dialogue_ratio))) problems.push("words_per_chapter and dialogue_ratio are immutable; only total_chapters may change");
7357
+ const total = targets.total_chapters;
7358
+ if (total !== null && (!Number.isInteger(total) || total < 1)) problems.push("targets.total_chapters must be a positive integer or null");
7359
+ const archivedCount = Math.max(snapshot.chapters.chapters.length, archivedFiles);
7360
+ if (typeof total === "number" && Number.isInteger(total) && total < archivedCount) problems.push(`targets.total_chapters ${total} is below the ${archivedCount} archived chapter(s)`);
7361
+ }
7362
+ return problems;
7363
+ }
7364
+ if (kind === "chapters") {
7365
+ const active = snapshot.pipeline.chapter;
7366
+ const oldRows = Array.isArray(old?.chapters) ? old.chapters : [];
7367
+ const nextRows = Array.isArray(nextDocument.chapters) ? nextDocument.chapters : [];
7368
+ const rowNum = (raw) => {
7369
+ const row = recordOf(raw);
7370
+ return row !== null && Number.isInteger(row.num) ? row.num : null;
7371
+ };
7372
+ const oldArchived = oldRows.filter((raw) => {
7373
+ const num = rowNum(raw);
7374
+ return num !== null && num < active;
7375
+ });
7376
+ const nextArchived = nextRows.filter((raw) => {
7377
+ const num = rowNum(raw);
7378
+ return num !== null && num < active;
7379
+ });
7380
+ if (nextArchived.length < oldArchived.length) problems.push("archived chapters may not be deleted");
7381
+ const archivedCount = Math.max(0, active - 1, archivedFiles);
7382
+ if (nextArchived.length < archivedCount) problems.push(`chapters index has ${nextArchived.length} archived rows but ${archivedCount} chapter(s) are archived`);
7383
+ const byNum = /* @__PURE__ */ new Map();
7384
+ for (const [index, raw] of nextRows.entries()) {
7385
+ const row = recordOf(raw);
7386
+ if (row === null || !Number.isInteger(row.num)) {
7387
+ problems.push(`chapters[${index}] is not a valid record`);
7388
+ continue;
7389
+ }
7390
+ const num = row.num;
7391
+ if (num !== index + 1) problems.push(`chapters[${index}].num must be ${index + 1}`);
7392
+ if (num >= active) problems.push(`chapter ${num} is active or in the future; the index may contain only archived chapters`);
7393
+ if (byNum.has(num)) problems.push(`chapter ${num} appears more than once`);
7394
+ byNum.set(num, row);
7395
+ const knownDigest = archiveDigests[String(num)];
7396
+ if (knownDigest !== void 0 && row.archive_digest !== knownDigest) problems.push(`archived chapter ${num} cannot change its archive_digest`);
7397
+ }
7398
+ for (const raw of oldArchived) {
7399
+ const before = recordOf(raw);
7400
+ if (before === null || !Number.isInteger(before.num)) continue;
7401
+ const after = byNum.get(before.num);
7402
+ if (after === void 0) {
7403
+ problems.push(`archived chapter ${String(before.num)} is missing`);
7404
+ continue;
7405
+ }
7406
+ if (before.content_hash !== after.content_hash) problems.push(`archived chapter ${String(before.num)} cannot change its content_hash`);
7407
+ if (before.archive_digest !== after.archive_digest) problems.push(`archived chapter ${String(before.num)} cannot change its archive_digest`);
7408
+ if (stableJson(before.assigned_ids ?? {}) !== stableJson(after.assigned_ids ?? {})) problems.push(`archived chapter ${String(before.num)} cannot change its assigned_ids`);
7409
+ }
7410
+ return problems;
7411
+ }
7412
+ if (kind === "timeline" || kind === "foreshadows" || kind === "relationships") {
7413
+ if (snapshot.ledgerErrors.some((error) => error.code === "CHAPTERS_UNREADABLE")) {
7414
+ problems.push("cannot validate next_seq while chapters.yaml is unreadable");
7415
+ return problems;
7416
+ }
7417
+ if (kind === "foreshadows" && snapshot.ledgerErrors.some((error) => error.code === "HANDOFF_UNREADABLE")) {
7418
+ problems.push("cannot validate next_seq while handoff.yaml is unreadable");
7419
+ return problems;
7420
+ }
7421
+ const items = Array.isArray(nextDocument.items) ? nextDocument.items : [];
7422
+ const ids = /* @__PURE__ */ new Set();
7423
+ let maxSuffix = 0;
7424
+ for (const [index, raw] of items.entries()) {
7425
+ const row = recordOf(raw);
7426
+ if (row === null || typeof row.id !== "string" || row.id === "") {
7427
+ problems.push(`items[${index}].id must be a non-empty string`);
7428
+ continue;
7429
+ }
7430
+ if (ids.has(row.id)) problems.push(`item id "${row.id}" appears more than once`);
7431
+ ids.add(row.id);
7432
+ const match = /-(\d+)$/.exec(row.id);
7433
+ if (match !== null) maxSuffix = Math.max(maxSuffix, Number(match[1]));
7434
+ }
7435
+ const nextSeq = nextDocument.next_seq;
7436
+ const prefix = kind === "foreshadows" ? "FS-" : kind === "timeline" ? "TE-" : "RL-";
7437
+ let engineFloor = 0;
7438
+ for (const chapter of snapshot.chapters.chapters) for (const value of Object.values(chapter.assigned_ids ?? {})) {
7439
+ if (typeof value !== "string" || !value.startsWith(prefix)) continue;
7440
+ const match = /-(\d+)$/.exec(value);
7441
+ if (match !== null) engineFloor = Math.max(engineFloor, Number(match[1]));
7442
+ }
7443
+ if (kind === "foreshadows" && snapshot.handoff !== null) for (const hook of snapshot.handoff.open_hooks) {
7444
+ if (!hook.id.startsWith(prefix)) continue;
7445
+ const match = /-(\d+)$/.exec(hook.id);
7446
+ if (match !== null) engineFloor = Math.max(engineFloor, Number(match[1]));
7447
+ }
7448
+ if (!Number.isInteger(nextSeq) || nextSeq < 1) problems.push("next_seq must be a positive integer");
7449
+ else {
7450
+ if (nextSeq < maxSuffix + 1) problems.push(`next_seq ${String(nextSeq)} would collide with existing id suffix ${maxSuffix}`);
7451
+ if (nextSeq < items.length + 1) problems.push(`next_seq ${String(nextSeq)} is below items.length + 1`);
7452
+ if (nextSeq < engineFloor + 1) problems.push(`next_seq ${String(nextSeq)} would reuse an id already assigned by an archived chapter (${prefix}${String(engineFloor).padStart(3, "0")})`);
7453
+ const oldSeq = old?.next_seq;
7454
+ if (typeof oldSeq === "number" && nextSeq < oldSeq) problems.push(`next_seq cannot decrease from ${oldSeq} to ${String(nextSeq)}`);
7455
+ }
7456
+ if (kind !== "relationships") return problems;
7457
+ }
7458
+ if (kind === "characters") {
7459
+ const names = new Set((Array.isArray(nextDocument.characters) ? nextDocument.characters : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
7460
+ if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
7461
+ for (const name of Object.keys(snapshot.locks.characters)) if (!names.has(name)) problems.push(`lock for removed character "${name}" still exists`);
7462
+ }
7463
+ if (!snapshot.ledgerErrors.some((error) => error.code === "RELATIONSHIPS_UNREADABLE")) {
7464
+ for (const relationship of snapshot.relationships.items) if (!names.has(relationship.a) || !names.has(relationship.b)) problems.push(`relationship ${relationship.id} would reference a removed character`);
7465
+ }
7466
+ return problems;
7467
+ }
7468
+ if (kind === "world") {
7469
+ const locations = new Set((Array.isArray(nextDocument.locations) ? nextDocument.locations : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
7470
+ if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
7471
+ for (const name of Object.keys(snapshot.locks.locations)) if (!locations.has(name)) problems.push(`lock for removed location "${name}" still exists`);
7472
+ }
7473
+ return problems;
7474
+ }
7475
+ if (kind === "locks") {
7476
+ const characters = new Set(snapshot.characters.characters.map((character) => character.name));
7477
+ const locations = new Set(snapshot.world.locations.map((location) => location.name));
7478
+ if (snapshot.ledgerErrors.some((error) => error.code === "CHARACTERS_UNREADABLE")) problems.push("cannot validate locks while characters.yaml is unreadable");
7479
+ if (snapshot.ledgerErrors.some((error) => error.code === "WORLD_UNREADABLE")) problems.push("cannot validate locks while world.yaml is unreadable");
7480
+ for (const name of Object.keys(recordOf(nextDocument.characters) ?? {})) if (!characters.has(name)) problems.push(`lock references unknown character "${name}"`);
7481
+ for (const name of Object.keys(recordOf(nextDocument.locations) ?? {})) if (!locations.has(name)) problems.push(`lock references unknown location "${name}"`);
7482
+ return problems;
7483
+ }
7484
+ if (kind === "relationships") {
7485
+ if (snapshot.ledgerErrors.some((error) => error.code === "CHARACTERS_UNREADABLE")) {
7486
+ problems.push("cannot validate relationships while characters.yaml is unreadable");
7487
+ return problems;
7488
+ }
7489
+ const names = new Set(snapshot.characters.characters.map((character) => character.name));
7490
+ for (const raw of Array.isArray(nextDocument.items) ? nextDocument.items : []) {
7491
+ const row = recordOf(raw);
7492
+ if (row !== null && (typeof row.a !== "string" || typeof row.b !== "string")) problems.push("relationship endpoints must be strings");
7493
+ else if (row !== null && (!names.has(row.a) || !names.has(row.b))) problems.push(`relationship ${String(row.id)} references an unknown character`);
7494
+ }
7495
+ return problems;
7496
+ }
7497
+ if (kind === "handoff") {
7498
+ const current = nextDocument.current_chapter;
7499
+ const next = nextDocument.next_chapter;
7500
+ if (!Number.isInteger(current) || current < 0) problems.push("handoff.current_chapter must be a non-negative integer");
7501
+ if (!Number.isInteger(next) || next < 1) problems.push("handoff.next_chapter must be a positive integer");
7502
+ else if (typeof current === "number" && Number.isInteger(current) && next !== current + 1) problems.push("handoff.next_chapter must equal current_chapter + 1");
7503
+ if (!Number.isInteger(nextDocument.volume) || nextDocument.volume < 1) problems.push("handoff.volume must be a positive integer");
7504
+ for (const field of ["volume_name", "act"]) if (typeof nextDocument[field] !== "string" || nextDocument[field].trim() === "") problems.push(`handoff.${field} must be non-empty`);
7505
+ if (parseActProgress(nextDocument.act_progress) === null) problems.push("handoff.act_progress must be n/d with a positive denominator and n <= d");
7506
+ if (!snapshot.ledgerErrors.some((error) => error.code === "CHAPTERS_UNREADABLE")) {
7507
+ const archivedMax = snapshot.chapters.chapters.reduce((max, record) => Math.max(max, record.num), 0);
7508
+ if (typeof current === "number" && Number.isInteger(current) && current !== archivedMax) problems.push(`handoff.current_chapter ${String(current)} must match archived chapter ${archivedMax}`);
7509
+ }
7510
+ const hooks = Array.isArray(nextDocument.open_hooks) ? nextDocument.open_hooks : [];
7511
+ const seenHooks = /* @__PURE__ */ new Set();
7512
+ if (!snapshot.ledgerErrors.some((error) => error.code === "FORESHADOWS_UNREADABLE")) {
7513
+ const openById = new Map(snapshot.foreshadows.items.map((item) => [item.id, item]));
7514
+ for (const raw of hooks) {
7515
+ const hook = recordOf(raw);
7516
+ if (hook === null || typeof hook.id !== "string" || typeof hook.title !== "string") {
7517
+ problems.push("handoff.open_hooks must contain id/title strings");
7518
+ continue;
7519
+ }
7520
+ if (seenHooks.has(hook.id)) problems.push(`handoff.open_hooks duplicate ${hook.id}`);
7521
+ seenHooks.add(hook.id);
7522
+ const known = openById.get(hook.id);
7523
+ if (known === void 0) problems.push(`handoff.open_hooks references unknown foreshadow ${hook.id}`);
7524
+ else if (known.title !== hook.title) problems.push(`handoff.open_hooks title for ${hook.id} does not match foreshadows`);
7525
+ }
7526
+ }
7527
+ return problems;
7528
+ }
7529
+ return problems;
7530
+ };
7531
+ const castShapeProblems = (value) => {
7532
+ if (!Array.isArray(value)) return ["cast must be an array"];
7533
+ if (value.length < 1 || value.length > 8) return ["cast must contain 1..8 members"];
7534
+ const problems = [];
7535
+ const names = /* @__PURE__ */ new Set();
7536
+ for (const [index, raw] of value.entries()) {
7537
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
7538
+ problems.push(`cast[${index}] must be an object`);
7539
+ continue;
7540
+ }
7541
+ const member = raw;
7542
+ if (typeof member.name !== "string" || member.name.trim() === "") {
7543
+ problems.push(`cast[${index}].name must be non-empty`);
7544
+ continue;
7545
+ }
7546
+ if (names.has(member.name)) problems.push(`cast contains duplicate name "${member.name}"`);
7547
+ names.add(member.name);
7548
+ for (const field of [
7549
+ "entry_state",
7550
+ "goal",
7551
+ "exit_change"
7552
+ ]) if (member[field] !== void 0 && typeof member[field] !== "string") problems.push(`cast[${index}].${field} must be a string`);
7553
+ if (member.knowledge !== void 0 && (!Array.isArray(member.knowledge) || member.knowledge.some((item) => typeof item !== "string"))) problems.push(`cast[${index}].knowledge must be a string array`);
7554
+ }
7555
+ return problems;
7556
+ };
7557
+ const normalizeCast = (value, snapshot) => {
7558
+ if (value === void 0) return void 0;
7559
+ const problems = castShapeProblems(value);
7560
+ if (problems.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, problems.join("; "));
7561
+ const members = value;
7562
+ const known = /* @__PURE__ */ new Set([...(snapshot.worldPlan?.characters ?? []).map((character) => character.name), ...snapshot.characters.characters.map((character) => character.name)]);
7563
+ for (const member of members) if (!known.has(member.name)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cast member "${member.name}" is not declared in the P1 world plan or the character ledger`, "add the character to novel_plan_world first; cast cannot invent an actor");
7564
+ return members;
7565
+ };
7566
+ /** The summary fields the read model dereferences at P3–P6 and in the panel. */
7567
+ const summaryContextProblems = (value) => {
7568
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return ["summary must be an object when present"];
7569
+ const summary = value;
7570
+ const problems = [];
7571
+ const strings = (key) => {
7572
+ if (!Array.isArray(summary[key]) || summary[key].some((item) => typeof item !== "string")) problems.push(`summary.${key} must be a string array`);
7573
+ };
7574
+ strings("events");
7575
+ strings("characters_appeared");
7576
+ strings("pleasure_points");
7577
+ const foreshadows = recordOf(summary.foreshadows);
7578
+ if (foreshadows === null) problems.push("summary.foreshadows must be an object");
7579
+ else for (const key of [
7580
+ "planted",
7581
+ "advanced",
7582
+ "resolved"
7583
+ ]) if (!Array.isArray(foreshadows[key])) problems.push(`summary.foreshadows.${key} must be an array`);
7584
+ const elements = recordOf(summary.new_elements);
7585
+ if (elements === null) problems.push("summary.new_elements must be an object");
7586
+ else for (const key of [
7587
+ "locations",
7588
+ "abilities",
7589
+ "artifacts"
7590
+ ]) if (!Array.isArray(elements[key]) || elements[key].some((item) => typeof item !== "string")) problems.push(`summary.new_elements.${key} must be a string array`);
7591
+ if (!Array.isArray(summary.resource_changes)) problems.push("summary.resource_changes must be an array");
7592
+ if (typeof summary.opening !== "string") problems.push("summary.opening must be a string");
7593
+ const continuity = recordOf(summary.continuity);
7594
+ if (continuity === null) problems.push("summary.continuity must be an object");
7595
+ else for (const key of ["opens_from", "ends_with"]) if (typeof continuity[key] !== "string") problems.push(`summary.continuity.${key} must be a string`);
7596
+ if (!Number.isInteger(summary.hook_strength)) problems.push("summary.hook_strength must be an integer");
7597
+ if (![
7598
+ "high",
7599
+ "mid",
7600
+ "low"
7601
+ ].includes(summary.plot_advancement)) problems.push("summary.plot_advancement must be high|mid|low");
7602
+ return problems;
7603
+ };
7604
+ /** Validate the nested roster fields the context builder dereferences. */
7605
+ const validateWorldPlanShape = (plan, path) => {
7606
+ const fail = (detail) => {
7607
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not a usable world plan: ${detail}`);
7608
+ };
7609
+ for (const key of ["constraints", "continuity_notes"]) if (!Array.isArray(plan[key]) || plan[key].some((item) => typeof item !== "string")) fail(`${key} must be a string array`);
7610
+ for (const key of ["characters", "locations"]) {
7611
+ if (!Array.isArray(plan[key])) fail(`${key} must be an array`);
7612
+ for (const [index, raw] of plan[key].entries()) {
7613
+ const row = recordOf(raw);
7614
+ if (row === null || typeof row.name !== "string" || row.name.trim() === "") fail(`${key}[${index}].name must be a non-empty string`);
7615
+ }
7616
+ }
7617
+ };
7618
+ /** Validate the shape a stored chapter plan must have before any stage uses it. */
7619
+ const validateStoredChapterPlan = (plan, path) => {
7620
+ const fail = (detail) => {
7621
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not a usable chapter plan: ${detail}`);
7622
+ };
7623
+ const rawVersion = plan.schema_version;
7624
+ if (rawVersion !== void 0) {
7625
+ const version = typeof rawVersion === "number" ? rawVersion : null;
7626
+ if (version === null) fail("schema_version must be a positive integer");
7627
+ const checkedVersion = version;
7628
+ if (!Number.isInteger(checkedVersion) || checkedVersion < 1) fail("schema_version must be a positive integer");
7629
+ if (checkedVersion > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `${path} uses schema_version ${checkedVersion}, newer than supported 1`, "upgrade the plugin; the plan was left untouched");
7630
+ if (checkedVersion !== 1) fail(`schema_version must be 1`);
7631
+ }
7632
+ if (!Number.isInteger(plan.chapter) || plan.chapter < 1) fail("chapter must be a positive integer");
7633
+ for (const key of [
7634
+ "title",
7635
+ "narrator",
7636
+ "opening",
7637
+ "ending"
7638
+ ]) if (typeof plan[key] !== "string" || plan[key].trim() === "") fail(`${key} must be non-empty`);
7639
+ if (!SCENE_TYPES.includes(plan.scene_type)) fail(`unknown scene_type "${String(plan.scene_type)}"`);
7640
+ if (!Array.isArray(plan.beats) || plan.beats.some((beat) => typeof beat !== "string")) fail("beats must be a string array");
7641
+ if (!Array.isArray(plan.target_words) || plan.target_words.length !== 2 || plan.target_words.some((value) => !Number.isInteger(value) || value <= 0)) fail("target_words must be [min, max] positive integers");
7642
+ if (plan.summary !== void 0) {
7643
+ const problems = summaryContextProblems(plan.summary);
7644
+ if (problems.length > 0) fail(problems.join("; "));
7645
+ }
7646
+ if (plan.cast !== void 0) {
7647
+ const problems = castShapeProblems(plan.cast);
7648
+ if (problems.length > 0) fail(problems.join("; "));
7649
+ }
7650
+ };
7651
+ const CHECK_SCOPES = ["content", "integrity"];
7652
+ const normalizeCheckScope = (value) => {
7653
+ if (value === void 0) return [...CHECK_SCOPES];
7654
+ if (!Array.isArray(value) || value.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "scope must contain at least one of content, integrity");
7655
+ const unique = [...new Set(value)];
7656
+ for (const scope of unique) if (scope !== "content" && scope !== "integrity") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown check scope "${String(scope)}"`, `allowed: ${CHECK_SCOPES.join(", ")}`);
7657
+ return CHECK_SCOPES.filter((scope) => unique.includes(scope));
7658
+ };
7659
+ const stringList = (value, field) => {
7660
+ if (value === void 0) return [];
7661
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of non-empty strings`);
7662
+ return value;
7663
+ };
7664
+ const objectList = (value, field) => {
7665
+ if (value === void 0) return [];
7666
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "object" || item === null || Array.isArray(item))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of objects`);
7667
+ return value;
7668
+ };
7669
+ const requireText = (object, key, path) => {
7670
+ if (typeof object[key] !== "string" || object[key].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${path} must be a non-empty string`);
6022
7671
  };
6023
7672
  /** Parse a plan file with a machine code instead of leaking a YAML parser error. */
6024
7673
  const parsePlanYaml = (text, path) => {
@@ -6294,6 +7943,10 @@ function createNovelTools(engine) {
6294
7943
  type: "array",
6295
7944
  items: { type: "integer" }
6296
7945
  },
7946
+ cast: {
7947
+ type: "json",
7948
+ description: "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger."
7949
+ },
6297
7950
  project_root: root
6298
7951
  },
6299
7952
  output: jsonOutput,
@@ -6306,6 +7959,7 @@ function createNovelTools(engine) {
6306
7959
  opening: args.opening ?? "",
6307
7960
  ending: args.ending ?? "",
6308
7961
  target_words: [args.target_words?.[0] ?? 2e3, args.target_words?.[1] ?? 3e3],
7962
+ ...args.cast === void 0 ? {} : { cast: asJson(args.cast, "cast") },
6309
7963
  root: args.project_root
6310
7964
  }))
6311
7965
  }),
@@ -6517,12 +8171,23 @@ function createNovelTools(engine) {
6517
8171
  ]
6518
8172
  },
6519
8173
  chapter: { type: "integer" },
8174
+ include_summary: {
8175
+ type: "boolean",
8176
+ description: "For kind=chapters: include the full ten-field summary (default compact)."
8177
+ },
8178
+ chapter_range: {
8179
+ type: "array",
8180
+ items: { type: "integer" },
8181
+ description: "For kind=chapters: inclusive [start, end] range."
8182
+ },
6520
8183
  project_root: root
6521
8184
  },
6522
8185
  output: jsonOutput,
6523
8186
  execute: guarded(async (args) => engine.queryState({
6524
8187
  kind: args.kind,
6525
8188
  chapter: args.chapter,
8189
+ ...args.include_summary === void 0 ? {} : { include_summary: args.include_summary },
8190
+ ...args.chapter_range === void 0 ? {} : { chapter_range: args.chapter_range },
6526
8191
  root: args.project_root
6527
8192
  }))
6528
8193
  }),
@@ -6534,6 +8199,7 @@ function createNovelTools(engine) {
6534
8199
  type: "string",
6535
8200
  required: true,
6536
8201
  enum: [
8202
+ "novel.yaml",
6537
8203
  "state/chapters.yaml",
6538
8204
  "state/characters.yaml",
6539
8205
  "state/world.yaml",