dsh-novel-write-engine 0.1.2 → 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 = {
@@ -2120,16 +2099,25 @@ async function recoverPending(store) {
2120
2099
  }
2121
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`);
2122
2101
  }
2123
- /** Throw before a write when the journal or the last commit is inconsistent. */
2124
- 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 = []) {
2125
2111
  await recoverPending(store);
2126
2112
  const { records } = await readJournal(store);
2127
2113
  const committed = new Set(records.filter((record) => record.status === "committed" && record.op !== "lock_reclaimed").map((record) => record.txid));
2128
2114
  for (const entry of await store.list(STAGING_DIR)) if (committed.has(entry)) await store.removeTree(joinRel(STAGING_DIR, entry)).catch(() => void 0);
2129
2115
  const { malformed, mismatches } = await reconcileJournal(store);
2130
2116
  assertJournalHealthy(malformed);
2131
- if (mismatches.length > 0) {
2132
- 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];
2133
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");
2134
2122
  }
2135
2123
  }
@@ -2493,7 +2481,7 @@ var ProjectRegistry = class {
2493
2481
  rows.push(row);
2494
2482
  const next = {
2495
2483
  ...document,
2496
- projects: rows.sort((a, b) => a.root.localeCompare(b.root))
2484
+ projects: rows.sort((a, b) => compareText(a.root, b.root))
2497
2485
  };
2498
2486
  await this.write(next);
2499
2487
  return next;
@@ -2567,18 +2555,20 @@ const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").r
2567
2555
  function chapterHeading(chapter) {
2568
2556
  return `第${chapter.num}章 ${chapter.title}`;
2569
2557
  }
2558
+ /** Remove blank lines at the document edges, never manuscript indentation. */
2559
+ const trimBlankEdges = (text) => text.replace(/^\n+|\n+$/g, "");
2570
2560
  /** Markdown export: one heading per chapter, body unchanged (it is plain text). */
2571
2561
  function renderMarkdown(project, chapters) {
2572
2562
  const parts = [`# ${project.title}`, ""];
2573
2563
  if (project.author !== "") parts.push(`作者:${project.author}`, "");
2574
2564
  if (project.description !== "") parts.push(`> ${project.description}`, "");
2575
- 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), "");
2576
2566
  return parts.join("\n");
2577
2567
  }
2578
2568
  /** Plain-text export: no markdown syntax, no BOM. */
2579
2569
  function renderPlainText(project, chapters) {
2580
2570
  const parts = [project.title, ""];
2581
- 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), "");
2582
2572
  return parts.join("\n");
2583
2573
  }
2584
2574
  /** Blurb built from the project record; deterministic, no model call. */
@@ -2607,9 +2597,10 @@ function renderCover(project) {
2607
2597
  }
2608
2598
  /** XHTML chapter document for the EPUB package. */
2609
2599
  function renderEpubChapter(chapter) {
2610
- 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");
2611
2601
  return `<?xml version="1.0" encoding="utf-8"?>
2612
- <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>
2613
2604
  <body><h1>${escapeHtml(chapterHeading(chapter))}</h1>${paragraphs}</body></html>`;
2614
2605
  }
2615
2606
  // src/export/epub.ts
@@ -2843,6 +2834,162 @@ var BriefLibrary = class {
2843
2834
  }
2844
2835
  };
2845
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
+ };
2846
2993
  // src/engine/context.ts
2847
2994
  const compactSummary = (row) => ({
2848
2995
  num: row.num,
@@ -2860,6 +3007,93 @@ const sceneDistribution = (chapters) => {
2860
3007
  return counts;
2861
3008
  };
2862
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
+ };
2863
3097
  /**
2864
3098
  * Build the stage-specific context pack. The task book explains the rules; this
2865
3099
  * pack is the minimum project state the model needs to apply them without
@@ -2867,6 +3101,19 @@ const pendingForeshadows = (ledger) => ledger.items.filter((item) => item.status
2867
3101
  */
2868
3102
  function unbudgetedBriefContext(input) {
2869
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);
2870
3117
  const base = {
2871
3118
  project: {
2872
3119
  title: input.project.title,
@@ -2878,28 +3125,38 @@ function unbudgetedBriefContext(input) {
2878
3125
  stage: input.pipeline.stage,
2879
3126
  scene_distribution: sceneDistribution(input.chapters),
2880
3127
  previous_ending: previousEnding,
3128
+ ...input.world_plan_error === void 0 || input.world_plan_error === null ? {} : { world_plan_error: input.world_plan_error },
2881
3129
  ...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error },
2882
3130
  ...input.review_error === void 0 || input.review_error === null ? {} : { review_error: input.review_error },
2883
3131
  ...input.check_error === void 0 || input.check_error === null ? {} : { check_error: input.check_error }
2884
3132
  };
2885
3133
  switch (input.stage) {
2886
- case "P1": return {
2887
- ...base,
2888
- recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary),
2889
- open_foreshadows: pendingForeshadows(input.foreshadows),
2890
- volume: input.handoff?.volume ?? 1,
2891
- volume_name: input.handoff?.volume_name ?? "",
2892
- act: input.handoff?.act ?? ""
2893
- };
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
+ }
2894
3146
  case "P2": return {
2895
3147
  ...base,
3148
+ world_plan: worldPlan,
3149
+ characters: [...input.worldPlan?.characters ?? []],
3150
+ other_characters: otherCharacters,
2896
3151
  plan: input.plan,
2897
3152
  open_foreshadows: pendingForeshadows(input.foreshadows)
2898
3153
  };
2899
3154
  case "P3": return {
2900
3155
  ...base,
2901
- characters: input.characters.characters,
2902
- 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)),
2903
3160
  precheck: input.plan,
2904
3161
  open_foreshadows: pendingForeshadows(input.foreshadows),
2905
3162
  recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary)
@@ -2910,7 +3167,9 @@ function unbudgetedBriefContext(input) {
2910
3167
  summary: input.plan?.summary ?? null,
2911
3168
  plan: input.plan,
2912
3169
  machine: input.machine ?? null,
2913
- characters: input.characters.characters,
3170
+ world_plan: worldPlan,
3171
+ characters: activeCast,
3172
+ other_characters: otherCharacters,
2914
3173
  check: input.check,
2915
3174
  score_history: input.chapters.chapters.slice(-5).map((row) => ({
2916
3175
  num: row.num,
@@ -2923,14 +3182,20 @@ function unbudgetedBriefContext(input) {
2923
3182
  plan: input.plan,
2924
3183
  machine: input.machine ?? null,
2925
3184
  review: input.review,
2926
- issues: input.review?.issues ?? []
3185
+ issues: input.review?.issues ?? [],
3186
+ world_plan: worldPlan,
3187
+ characters: activeCast,
3188
+ other_characters: otherCharacters
2927
3189
  };
2928
3190
  case "P6": return {
2929
3191
  ...base,
2930
3192
  text: input.text ?? "",
2931
3193
  plan: input.plan,
2932
3194
  machine: input.machine ?? null,
3195
+ world_plan: worldPlan,
2933
3196
  characters: input.characters.characters,
3197
+ active_cast: activeCast,
3198
+ other_characters: otherCharacters,
2934
3199
  world: input.world,
2935
3200
  locks: input.locks,
2936
3201
  foreshadows: input.foreshadows,
@@ -2968,7 +3233,7 @@ const CONTEXT_BUDGETS = {
2968
3233
  };
2969
3234
  const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2970
3235
  const asArray = (value) => Array.isArray(value) ? value : null;
2971
- const jsonSize = (value) => Array.from(JSON.stringify(value)).length;
3236
+ const jsonSize = (value) => JSON.stringify(value).length;
2972
3237
  const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(value) ? 1 : 0;
2973
3238
  /**
2974
3239
  * Apply the stage budget after the stage-specific pack exists. Protected keys
@@ -2978,6 +3243,7 @@ const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(va
2978
3243
  function withContextBudget(stage, raw) {
2979
3244
  const context = structuredClone(raw);
2980
3245
  const budget = CONTEXT_BUDGETS[stage] ?? 8e3;
3246
+ const smallP2Candidates = stage === "P2" && Array.isArray(context.characters) && context.characters.length <= 32;
2981
3247
  const protectedKeys = [
2982
3248
  "project",
2983
3249
  "chapter",
@@ -2989,21 +3255,25 @@ function withContextBudget(stage, raw) {
2989
3255
  "review",
2990
3256
  "check",
2991
3257
  "machine",
2992
- "summary"
3258
+ "summary",
3259
+ "world_plan",
3260
+ "active_cast",
3261
+ ...stage === "P3" || stage === "P4" || stage === "P5" || smallP2Candidates ? ["characters"] : []
2993
3262
  ];
2994
3263
  const mentioned = [
2995
3264
  typeof context.text === "string" ? context.text : "",
2996
- JSON.stringify(context.plan ?? ""),
2997
- JSON.stringify(context.review ?? ""),
2998
- JSON.stringify(context.check ?? ""),
2999
- JSON.stringify(context.summary ?? "")
3265
+ stableJson(context.plan ?? ""),
3266
+ stableJson(context.review ?? ""),
3267
+ stableJson(context.check ?? ""),
3268
+ stableJson(context.summary ?? ""),
3269
+ stableJson(context.world_plan ?? "")
3000
3270
  ].join("\n");
3001
3271
  const omitted = {};
3002
3272
  const truncated = /* @__PURE__ */ new Set();
3003
3273
  const candidates = [];
3004
3274
  const stableKey = (value) => {
3005
3275
  const row = asRecord(value);
3006
- if (row === null) return JSON.stringify(value) ?? "";
3276
+ if (row === null) return stableJson(value);
3007
3277
  for (const key of [
3008
3278
  "id",
3009
3279
  "name",
@@ -3012,10 +3282,10 @@ function withContextBudget(stage, raw) {
3012
3282
  const item = row[key];
3013
3283
  if (typeof item === "string" && item !== "") return item;
3014
3284
  }
3015
- return JSON.stringify(row);
3285
+ return stableJson(row);
3016
3286
  };
3017
- const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
3018
3287
  const addArray = (key, array, rank, tie = stableKey) => {
3288
+ if (protectedKeys.includes(key)) return;
3019
3289
  let prepared = false;
3020
3290
  const sort = () => {
3021
3291
  if (prepared) return;
@@ -3039,6 +3309,7 @@ function withContextBudget(stage, raw) {
3039
3309
  });
3040
3310
  };
3041
3311
  const addObject = (key, map, rank) => {
3312
+ if (protectedKeys.includes(key)) return;
3042
3313
  let keys = null;
3043
3314
  const ensure = () => {
3044
3315
  const target = map();
@@ -3064,10 +3335,36 @@ function withContextBudget(stage, raw) {
3064
3335
  });
3065
3336
  };
3066
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));
3067
3340
  addArray("characters", () => asArray(context.characters), (value) => {
3068
3341
  const row = asRecord(value);
3069
3342
  const name = typeof row?.name === "string" ? row.name : "";
3070
- return mentionScore(mentioned, name) * 1e3 + Number(row?.first_appearance ?? 0);
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;
3071
3368
  }, (value) => {
3072
3369
  const row = asRecord(value);
3073
3370
  return typeof row?.name === "string" ? row.name : stableKey(value);
@@ -3088,7 +3385,7 @@ function withContextBudget(stage, raw) {
3088
3385
  return typeof row?.name === "string" ? row.name : stableKey(value);
3089
3386
  });
3090
3387
  const locks = asRecord(context.locks);
3091
- for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => mentionScore(mentioned, name) * 1e3);
3388
+ for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e3);
3092
3389
  addArray("open_foreshadows", () => asArray(context.open_foreshadows), (value) => {
3093
3390
  const row = asRecord(value);
3094
3391
  const title = typeof row?.title === "string" ? row.title : "";
@@ -3127,11 +3424,7 @@ function withContextBudget(stage, raw) {
3127
3424
  addArray("recent_summaries", () => asArray(context.recent_summaries), (value) => chapter(value), (value) => String(chapter(value)).padStart(8, "0"));
3128
3425
  addArray("score_history", () => asArray(context.score_history), (value) => Number(asRecord(value)?.num ?? 0), (value) => String(Number(asRecord(value)?.num ?? 0)).padStart(8, "0"));
3129
3426
  for (const candidate of candidates) candidate.prepare();
3130
- const used = () => {
3131
- const measure = { ...context };
3132
- delete measure.context_meta;
3133
- return jsonSize(measure);
3134
- };
3427
+ const used = () => jsonSize(context);
3135
3428
  const buildMeta = (finalUsed) => ({
3136
3429
  version: 1,
3137
3430
  stage,
@@ -3139,24 +3432,22 @@ function withContextBudget(stage, raw) {
3139
3432
  used: finalUsed,
3140
3433
  over_budget: false,
3141
3434
  protected: protectedKeys,
3142
- truncated: [...truncated].sort(),
3143
- omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => left.localeCompare(right))),
3144
- strategy: "stage-v1"
3435
+ truncated: [...truncated].sort(compareText),
3436
+ omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => compareText(left, right))),
3437
+ strategy: "stage-v2"
3145
3438
  });
3146
- const totalWith = (finalUsed, meta) => jsonSize({
3147
- ...context,
3148
- context_meta: {
3439
+ const totalWith = (finalUsed, meta) => {
3440
+ const metaSize = jsonSize({
3149
3441
  ...meta,
3150
3442
  used: finalUsed
3151
- }
3152
- });
3443
+ });
3444
+ return finalUsed + 1 + 15 + metaSize;
3445
+ };
3153
3446
  let finalUsed = used();
3154
3447
  let metaBase = buildMeta(finalUsed);
3155
3448
  while (totalWith(finalUsed, metaBase) > budget) {
3156
3449
  let changed = false;
3157
3450
  for (const candidate of candidates) {
3158
- finalUsed = used();
3159
- metaBase = buildMeta(finalUsed);
3160
3451
  if (totalWith(finalUsed, metaBase) <= budget) break;
3161
3452
  if (candidate.count() === 0) continue;
3162
3453
  candidate.prepare();
@@ -3164,10 +3455,10 @@ function withContextBudget(stage, raw) {
3164
3455
  omitted[candidate.key] = (omitted[candidate.key] ?? 0) + 1;
3165
3456
  truncated.add(candidate.key);
3166
3457
  changed = true;
3458
+ finalUsed = used();
3459
+ metaBase = buildMeta(finalUsed);
3167
3460
  }
3168
3461
  }
3169
- finalUsed = used();
3170
- metaBase = buildMeta(finalUsed);
3171
3462
  if (!changed) break;
3172
3463
  }
3173
3464
  const overBudget = totalWith(finalUsed, metaBase) > budget;
@@ -3884,7 +4175,7 @@ const summarySchema = (input) => {
3884
4175
  else {
3885
4176
  const opening = summary.opening;
3886
4177
  if (Array.from(opening).length < LIMITS.minQuoteChars) problems.push(`opening must be at least ${LIMITS.minQuoteChars} characters`);
3887
- 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");
3888
4179
  }
3889
4180
  const continuity = summary.continuity;
3890
4181
  if (typeof continuity !== "object" || continuity === null) problems.push("continuity must be an object");
@@ -3975,8 +4266,8 @@ function verifyReference(text, reference) {
3975
4266
  if (typeof reference !== "object" || reference === null) return "reading proof reference is not an object";
3976
4267
  if (typeof reference.quote !== "string") return "reading proof quote is not text";
3977
4268
  if (!Number.isInteger(reference.paragraph) || reference.paragraph < 1) return "reading proof paragraph must be a positive integer";
3978
- const quote = reference.quote;
3979
- 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`;
3980
4271
  const actual = splitParagraphs(text).findIndex((paragraph) => paragraph.includes(quote));
3981
4272
  if (actual === -1) return `quote "${quote}" does not appear in the text`;
3982
4273
  if (Math.abs(actual + 1 - reference.paragraph) > 1) return `quote "${quote}" is at paragraph ${actual + 1}, not ${reference.paragraph}`;
@@ -4062,7 +4353,8 @@ const archiveAtomic = (input) => {
4062
4353
  if (input.archive.chapterExists) problems.push("this chapter is already in the index");
4063
4354
  if (!input.archive.checkPassed) problems.push("the check document has not passed");
4064
4355
  if (!input.archive.hashMatches) problems.push("the manuscript changed since the check was written");
4065
- 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")];
4066
4358
  };
4067
4359
  /** Fixed order; evaluation collects every failure instead of stopping at the first. */
4068
4360
  const GATE_FUNCTIONS = {
@@ -4116,7 +4408,8 @@ function applicableGates(input) {
4116
4408
  "PG-03",
4117
4409
  "PG-05",
4118
4410
  "PG-09",
4119
- "PG-08"
4411
+ "PG-08",
4412
+ "PG-11"
4120
4413
  ];
4121
4414
  case "submitReview": return [
4122
4415
  ...always,
@@ -4127,7 +4420,10 @@ function applicableGates(input) {
4127
4420
  case "runCheck": return [
4128
4421
  ...always,
4129
4422
  "PG-04",
4423
+ "PG-03",
4130
4424
  "PG-05",
4425
+ "PG-06",
4426
+ "PG-07",
4131
4427
  "PG-08",
4132
4428
  "PG-09",
4133
4429
  "PG-10",
@@ -4220,6 +4516,11 @@ function projectSummary(snapshot) {
4220
4516
  revision: snapshot.revision,
4221
4517
  totalWords: snapshot.totalWords,
4222
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 },
4223
4524
  phases: snapshot.phases,
4224
4525
  blocking: snapshot.blocking,
4225
4526
  finished: snapshot.finished,
@@ -4230,9 +4531,11 @@ function projectSummary(snapshot) {
4230
4531
  };
4231
4532
  }
4232
4533
  function projectChapters(snapshot) {
4233
- const rows = snapshot.chapters.map(projectChapter);
4234
4534
  const draft = snapshot.draft;
4235
- 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({
4236
4539
  num: draft.num,
4237
4540
  title: draft.title,
4238
4541
  wordCount: draft.wordCount,
@@ -4363,7 +4666,20 @@ const STAGE_RANK = {
4363
4666
  ready_to_archive: 6,
4364
4667
  done: 7
4365
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
+ };
4366
4681
  const MUTABLE_STATE = {
4682
+ [PROJECT_FILE]: "project",
4367
4683
  [joinRel(STATE_DIR, STATE_FILES.pipeline)]: "pipeline",
4368
4684
  [joinRel(STATE_DIR, STATE_FILES.chapters)]: "chapters",
4369
4685
  [joinRel(STATE_DIR, STATE_FILES.characters)]: "characters",
@@ -4383,6 +4699,8 @@ var EngineService = class {
4383
4699
  stores = /* @__PURE__ */ new Map();
4384
4700
  mutex = new KeyedMutex();
4385
4701
  replay = /* @__PURE__ */ new Map();
4702
+ /** Status, panel and gates ask for the same stage context repeatedly. */
4703
+ briefContextCache = /* @__PURE__ */ new WeakMap();
4386
4704
  briefs;
4387
4705
  now;
4388
4706
  /** The project this process last touched; the panel follows it without a workspace hint. */
@@ -4518,12 +4836,50 @@ var EngineService = class {
4518
4836
  emptyAllowed: true
4519
4837
  });
4520
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
+ }
4521
4872
  async getStatus(input = {}) {
4522
4873
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4523
4874
  const snapshot = await this.load(input.root);
4524
4875
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4525
4876
  const metrics = await this.metricsFor(snapshot, chapter);
4526
- 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);
4527
4883
  return {
4528
4884
  data: {
4529
4885
  project: {
@@ -4536,9 +4892,18 @@ var EngineService = class {
4536
4892
  active_chapter: snapshot.pipeline.chapter,
4537
4893
  stage: snapshot.pipeline.stage,
4538
4894
  rounds: { ...snapshot.pipeline.rounds },
4539
- blocking: this.effectiveBlocking(snapshot),
4895
+ blocking: [...this.effectiveBlocking(snapshot), ...journal],
4540
4896
  state_errors: this.stateErrors(snapshot),
4541
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
+ })(),
4542
4907
  next
4543
4908
  },
4544
4909
  revision: snapshot.pipeline.revision,
@@ -4564,17 +4929,50 @@ var EngineService = class {
4564
4929
  const snapshot = await this.load(input.root);
4565
4930
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4566
4931
  const store = this.storeFor(snapshot.root);
4567
- switch (input.kind) {
4568
- case "pipeline": return {
4569
- 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
+ },
4570
4942
  revision: snapshot.pipeline.revision,
4571
4943
  warnings: []
4572
4944
  };
4573
- case "chapters": return {
4574
- data: snapshot.chapters,
4945
+ }
4946
+ switch (input.kind) {
4947
+ case "pipeline": return {
4948
+ data: snapshot.pipeline,
4575
4949
  revision: snapshot.pipeline.revision,
4576
4950
  warnings: []
4577
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
+ }
4578
4976
  case "characters": return {
4579
4977
  data: snapshot.characters,
4580
4978
  revision: snapshot.pipeline.revision,
@@ -4610,30 +5008,79 @@ var EngineService = class {
4610
5008
  revision: snapshot.pipeline.revision,
4611
5009
  warnings: []
4612
5010
  };
4613
- case "review": return {
4614
- data: chapter === snapshot.pipeline.chapter ? snapshot.review : await store.readDoc(reviewFile(chapter), "review"),
4615
- revision: snapshot.pipeline.revision,
4616
- warnings: []
4617
- };
4618
- case "check": return {
4619
- data: chapter === snapshot.pipeline.chapter ? snapshot.check : await store.readDoc(checkFile(chapter), "check"),
4620
- revision: snapshot.pipeline.revision,
4621
- warnings: []
4622
- };
4623
- case "novel": return {
4624
- data: snapshot.project,
4625
- revision: snapshot.pipeline.revision,
4626
- warnings: []
4627
- };
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
+ };
4628
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
+ }
4629
5068
  const raw = await store.readText(planWorldFile(chapter));
4630
5069
  return {
4631
- data: raw === null ? null : parsePlanYaml(raw, planWorldFile(chapter)),
5070
+ data: raw === null ? null : narrow("worldPlan", parsePlanYaml(raw, planWorldFile(chapter))),
4632
5071
  revision: snapshot.pipeline.revision,
4633
5072
  warnings: []
4634
5073
  };
4635
5074
  }
4636
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
+ }
4637
5084
  const raw = await store.readText(planChapterFile(chapter));
4638
5085
  return {
4639
5086
  data: raw === null ? null : parsePlanYaml(raw, planChapterFile(chapter)),
@@ -4646,7 +5093,12 @@ var EngineService = class {
4646
5093
  }
4647
5094
  async advise(input = {}) {
4648
5095
  const snapshot = await this.load(input.root);
4649
- 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);
4650
5102
  return {
4651
5103
  data: {
4652
5104
  stage: snapshot.pipeline.stage,
@@ -4654,7 +5106,7 @@ var EngineService = class {
4654
5106
  tool: next.tool,
4655
5107
  args: next.args,
4656
5108
  reason: next.reason,
4657
- blocking: this.effectiveBlocking(snapshot),
5109
+ blocking: [...this.effectiveBlocking(snapshot), ...journal],
4658
5110
  state_errors: this.stateErrors(snapshot)
4659
5111
  },
4660
5112
  revision: snapshot.pipeline.revision,
@@ -4690,6 +5142,7 @@ var EngineService = class {
4690
5142
  /** P4 must apply exclusions before its arithmetic closes. */
4691
5143
  score_exclusions: this.rulepack.exclusions,
4692
5144
  next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
5145
+ ...snapshot.worldPlanError === null ? {} : { world_plan_error: snapshot.worldPlanError },
4693
5146
  ...snapshot.planError === null ? {} : { plan_error: snapshot.planError },
4694
5147
  ...snapshot.reviewError === null ? {} : { review_error: snapshot.reviewError },
4695
5148
  ...snapshot.checkError === null ? {} : { check_error: snapshot.checkError }
@@ -4735,6 +5188,13 @@ var EngineService = class {
4735
5188
  if (typeof input.objective !== "string" || input.objective.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "world plan objective must not be empty");
4736
5189
  const constraints = stringList(input.constraints, "constraints");
4737
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
+ }
4738
5198
  const characters = objectList(input.characters, "characters");
4739
5199
  for (const [index, character] of characters.entries()) {
4740
5200
  requireText(character, "name", `characters[${index}].name`);
@@ -4795,9 +5255,11 @@ var EngineService = class {
4795
5255
  }
4796
5256
  async submitChapterPlan(input) {
4797
5257
  if (typeof input.title !== "string" || input.title.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan title must not be empty");
4798
- const titleBody = input.title.replace(/^第\s*\d+\s*章\s*/, "").trim();
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();
4799
5261
  const titleLength = Array.from(titleBody).length;
4800
- if (titleLength < 4 || titleLength > 8) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan title body must be 4..8 characters, got ${titleLength}`, "use the same 4..8 character body the P3 title rule will judge");
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");
4801
5263
  if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan narrator must not be empty");
4802
5264
  const beats = stringList(input.beats, "beats");
4803
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");
@@ -4812,12 +5274,14 @@ var EngineService = class {
4812
5274
  key: (snapshot) => `plan:${snapshot.pipeline.chapter}:${sha256(stableJson(input))}`,
4813
5275
  run: async (snapshot, context) => {
4814
5276
  await this.assertGates(snapshot, "submitChapterPlan", { briefStage: "P2" });
5277
+ const cast = normalizeCast(input.cast, snapshot);
4815
5278
  const chapter = snapshot.pipeline.chapter;
4816
5279
  const path = planChapterFile(chapter);
4817
5280
  const plan = {
4818
5281
  ...input,
4819
5282
  chapter,
4820
5283
  beats,
5284
+ ...cast === void 0 ? {} : { cast },
4821
5285
  schema_version: 1
4822
5286
  };
4823
5287
  delete plan.root;
@@ -4846,6 +5310,7 @@ var EngineService = class {
4846
5310
  async submitDraft(input) {
4847
5311
  if (typeof input.content !== "string" || input.content.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "draft content must not be empty");
4848
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");
4849
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(", ")}`);
4850
5315
  return this.mutate({
4851
5316
  root: input.root,
@@ -4872,18 +5337,16 @@ var EngineService = class {
4872
5337
  summary: input.summary
4873
5338
  });
4874
5339
  const chapter = input.chapter;
4875
- const planPath = planChapterFile(chapter);
4876
- 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");
4877
5342
  const mergedPlan = {
4878
- ...(planText === null ? null : parsePlanYaml(planText, planPath)) ?? {
4879
- schema_version: 1,
4880
- chapter
4881
- },
5343
+ ...plan,
4882
5344
  title: input.chapter_title,
4883
5345
  scene_type: input.scene_type,
4884
5346
  narrator: input.narrator,
4885
5347
  summary: input.summary
4886
5348
  };
5349
+ const planPath = planChapterFile(chapter);
4887
5350
  const nextPipeline = pipelineWith(snapshot, context.revision, {
4888
5351
  stage: "P4",
4889
5352
  blocking: [],
@@ -5097,7 +5560,7 @@ var EngineService = class {
5097
5560
  });
5098
5561
  if (input.summary === void 0 && snapshot.plan?.summary !== void 0) {
5099
5562
  const opening = snapshot.plan.summary.opening;
5100
- 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");
5101
5564
  }
5102
5565
  const machine = this.machine(snapshot, input.content, { summary: input.summary ?? snapshot.plan?.summary ?? null });
5103
5566
  const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
@@ -5185,17 +5648,18 @@ var EngineService = class {
5185
5648
  semantic: { items: semantic },
5186
5649
  verdict
5187
5650
  };
5188
- 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";
5189
5653
  const blockingItems = verdict === "fail" ? [...machineBlocking.map((violation) => ({
5190
5654
  code: violation.code,
5191
5655
  scope: "check",
5192
5656
  detail: violation.detail
5193
5657
  })), ...semanticResult.blocking] : [];
5194
5658
  const nextPipeline = pipelineWith(snapshot, context.revision, {
5195
- stage: nextStage,
5659
+ stage: nextStage$2,
5196
5660
  rounds: {
5197
5661
  ...snapshot.pipeline.rounds,
5198
- check: snapshot.pipeline.rounds.check + 1
5662
+ check: snapshot.pipeline.rounds.check + (verdict === "fail" || fullScope ? 1 : 0)
5199
5663
  },
5200
5664
  blocking: blockingItems,
5201
5665
  last_action: action("novel_check", this.now(), sha256(text))
@@ -5213,19 +5677,23 @@ var EngineService = class {
5213
5677
  verdict,
5214
5678
  violations: scopedMachine.violations.length,
5215
5679
  metrics: scopedMachine.metrics,
5216
- stage: nextStage
5680
+ stage: nextStage$2
5217
5681
  },
5218
- warnings: machineWarnings(scopedMachine)
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
+ }] : []]
5219
5686
  };
5220
5687
  }
5221
5688
  });
5222
5689
  }
5223
5690
  async archiveChapter(input) {
5224
- const archiveDigest = sha256(stableJson({
5691
+ const handoffOverride = normalizeHandoffOverride(input.handoff);
5692
+ const archiveDigest = `v2:${sha256(stableJson({
5225
5693
  chapter: input.chapter,
5226
5694
  state_delta: input.state_delta,
5227
- handoff: input.handoff
5228
- }));
5695
+ handoff: handoffOverride
5696
+ }))}`;
5229
5697
  return this.mutate({
5230
5698
  root: input.root,
5231
5699
  op: "archiveChapter",
@@ -5237,7 +5705,7 @@ var EngineService = class {
5237
5705
  const already = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
5238
5706
  if (already !== void 0) {
5239
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");
5240
- 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");
5241
5709
  const archivedText = await this.readChapter(snapshot, input.chapter);
5242
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");
5243
5711
  return {
@@ -5280,7 +5748,7 @@ var EngineService = class {
5280
5748
  const metrics = countWordMetrics(text);
5281
5749
  const score = snapshot.review?.total ?? 0;
5282
5750
  const chapter = input.chapter;
5283
- const storedTitle = (snapshot.plan?.title ?? "").replace(/^第\s*\d+\s*章\s*/, "").trim();
5751
+ const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
5284
5752
  const record = {
5285
5753
  num: chapter,
5286
5754
  title: storedTitle === "" ? `第${chapter}章` : storedTitle,
@@ -5315,18 +5783,22 @@ var EngineService = class {
5315
5783
  for (const item of foreshadows.items) if (item.status !== "resolved" && chapter - item.last_touched >= this.rulepack.crosschapter.foreshadow_dormant_after) item.status = "dormant";
5316
5784
  const chapters = structuredClone(snapshot.chapters);
5317
5785
  chapters.chapters.push(record);
5318
- const totalChapters = snapshot.project.targets.total_chapters;
5319
- const derivedVolume = Math.max(1, Math.ceil(chapter / 20));
5786
+ const position = advanceHandoff(snapshot.handoff, chapter, handoffOverride);
5320
5787
  const handoff = {
5321
5788
  schema_version: 1,
5322
5789
  current_chapter: chapter,
5323
- volume: input.handoff?.volume ?? derivedVolume,
5324
- volume_name: input.handoff?.volume_name ?? `第${derivedVolume}卷`,
5325
- act: input.handoff?.act ?? `第${Math.max(1, Math.ceil((chapter - 1) % 20 / 5) + 1)}幕`,
5326
- 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,
5327
5794
  next_chapter: chapter + 1,
5328
5795
  last_summary: summary.events.join(";").slice(0, 200),
5329
- 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) => ({
5330
5802
  id: item.id,
5331
5803
  title: item.title
5332
5804
  })),
@@ -5354,6 +5826,20 @@ var EngineService = class {
5354
5826
  ["relationships", ledger.relationships],
5355
5827
  ["handoff", handoff]
5356
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;
5357
5843
  return {
5358
5844
  files: [
5359
5845
  {
@@ -5395,6 +5881,10 @@ var EngineService = class {
5395
5881
  {
5396
5882
  path: joinRel(STATE_DIR, STATE_FILES.relationships),
5397
5883
  text: yaml(ledger.relationships)
5884
+ },
5885
+ {
5886
+ path: ARCHIVE_DIGESTS,
5887
+ text: `${JSON.stringify(archiveDigests, null, 2)}\n`
5398
5888
  }
5399
5889
  ],
5400
5890
  backup: true,
@@ -5423,20 +5913,72 @@ var EngineService = class {
5423
5913
  if (typeof input.reason !== "string" || input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason");
5424
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");
5425
5915
  const resolvedRoot = await this.resolveRoot(input.root);
5426
- const absolute = this.storeFor(resolvedRoot).path(input.path);
5916
+ const store = this.storeFor(resolvedRoot);
5917
+ const absolute = store.path(input.path);
5427
5918
  const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
5428
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
+ }
5429
5925
  return this.mutate({
5430
5926
  root: resolvedRoot,
5431
5927
  op: "amend",
5928
+ ...repairPaths.length === 0 ? {} : { repairPaths },
5432
5929
  key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
5433
5930
  run: async (snapshot, context) => {
5434
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");
5435
5932
  if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
5436
- 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, {
5437
5946
  ...input.content,
5438
5947
  schema_version: input.content.schema_version ?? 1
5439
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");
5440
5982
  const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_amend", this.now(), shortDigest(input.reason)) });
5441
5983
  return {
5442
5984
  files: [{
@@ -5555,8 +6097,14 @@ var EngineService = class {
5555
6097
  root: input.root,
5556
6098
  op: "export",
5557
6099
  key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
6100
+ chapter: snapshot.pipeline.chapter,
5558
6101
  text: sha256(snapshot.text ?? ""),
5559
- 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
+ })),
5560
6108
  title: snapshot.project.title
5561
6109
  }))}`,
5562
6110
  run: async (snapshot, context) => {
@@ -5699,6 +6247,9 @@ var EngineService = class {
5699
6247
  op: "amend",
5700
6248
  key: () => `import:${input.kind}:${sha256(stableJson(input.content))}`,
5701
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");
5702
6253
  const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import", this.now(), shortDigest(input.kind)) });
5703
6254
  return {
5704
6255
  files: [{
@@ -5727,7 +6278,7 @@ var EngineService = class {
5727
6278
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
5728
6279
  const draft = archivedCurrent || snapshot.pipeline.stage === "done" ? null : {
5729
6280
  num: snapshot.pipeline.chapter,
5730
- title: (snapshot.plan?.title ?? "").replace(/^第\s*\d+\s*章\s*/, "").trim() || `第${snapshot.pipeline.chapter}章`,
6281
+ title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
5731
6282
  wordCount: draftWords,
5732
6283
  score: snapshot.review?.total ?? 0,
5733
6284
  status: this.effectiveBlocking(snapshot).length > 0 ? "blocked" : "draft",
@@ -5743,19 +6294,85 @@ var EngineService = class {
5743
6294
  revision: snapshot.pipeline.revision,
5744
6295
  rounds: snapshot.pipeline.rounds,
5745
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
+ })(),
5746
6306
  next: finished ? void 0 : this.nextStep(snapshot),
5747
6307
  finished,
5748
6308
  chapters: archived,
5749
6309
  draft,
5750
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,
5751
6312
  phases: phaseStrip(snapshot.pipeline.stage, finished)
5752
6313
  };
5753
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
+ }
5754
6365
  nextStep(snapshot) {
5755
6366
  const stage = snapshot.pipeline.stage;
5756
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
+ };
5757
6373
  if (this.stateErrors(snapshot).length > 0) {
5758
6374
  const recovery = this.recoveryFor(snapshot);
6375
+ if (recovery.tool === "novel_amend") return recovery;
5759
6376
  const target = recovery.args.to_stage;
5760
6377
  if (STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[stage]) return recovery;
5761
6378
  }
@@ -5796,11 +6413,25 @@ var EngineService = class {
5796
6413
  },
5797
6414
  reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
5798
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
+ };
5799
6421
  if (stage === "P6" && snapshot.checkError !== null) return {
5800
6422
  tool: "novel_check",
5801
6423
  args: this.nextArgs("P6", chapter),
5802
6424
  reason: `CHECK_UNREADABLE@${checkFile(chapter)}; repair the check with novel_check before any stale revision blocker`
5803
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
+ };
5804
6435
  if (snapshot.pipeline.blocking.length > 0) {
5805
6436
  if (stage === "P5" || stage === "P6") return {
5806
6437
  tool: "novel_revise",
@@ -5827,14 +6458,17 @@ var EngineService = class {
5827
6458
  "P5",
5828
6459
  "P6"
5829
6460
  ].includes(stage)) return null;
5830
- const counter = stage === "P4" ? "review" : stage === "P5" ? "revise" : "check";
5831
- if (!roundsExhausted(snapshot.pipeline.rounds, counter)) return null;
5832
- return {
6461
+ const block = (counter) => ({
5833
6462
  code: "PG-11",
5834
6463
  scope: "rounds",
5835
6464
  detail: `round limit reached for ${counter} (review ${snapshot.pipeline.rounds.review}, revise ${snapshot.pipeline.rounds.revise}, check ${snapshot.pipeline.rounds.check})`,
5836
6465
  hint: "call novel_rollback with to_stage P4; the manuscript is kept for reference"
5837
- };
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;
5838
6472
  }
5839
6473
  /** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
5840
6474
  effectiveBlocking(snapshot) {
@@ -5844,10 +6478,19 @@ var EngineService = class {
5844
6478
  detail: `${error.code}: ${error.message}`,
5845
6479
  hint: this.recoveryFor(snapshot).reason
5846
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
+ }] : [];
5847
6487
  const extra = this.roundsLimitBlock(snapshot);
5848
6488
  const reviewDigest = this.reviewDigestProblem(snapshot);
6489
+ const archive = this.archivePreflight(snapshot);
5849
6490
  const base = [
5850
6491
  ...state,
6492
+ ...active,
6493
+ ...archive === null ? [] : [archive.item],
5851
6494
  ...reviewDigest === null ? [] : [reviewDigest],
5852
6495
  ...snapshot.pipeline.blocking
5853
6496
  ];
@@ -5879,8 +6522,18 @@ var EngineService = class {
5879
6522
  return null;
5880
6523
  }
5881
6524
  stateErrors(snapshot) {
5882
- const errors = [];
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
+ });
5883
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
+ });
5884
6537
  if (snapshot.planError !== null) errors.push({
5885
6538
  path: planChapterFile(chapter),
5886
6539
  code: "PLAN_UNREADABLE",
@@ -5902,7 +6555,26 @@ var EngineService = class {
5902
6555
  recoveryFor(snapshot) {
5903
6556
  const chapter = snapshot.pipeline.chapter;
5904
6557
  const errors = this.stateErrors(snapshot);
5905
- const desired = snapshot.planError !== null ? "P2" : snapshot.reviewError !== null ? "P4" : "P6";
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";
5906
6578
  const target = this.clampRollbackTarget(snapshot.pipeline.stage, desired);
5907
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`;
5908
6580
  return {
@@ -5937,13 +6609,16 @@ var EngineService = class {
5937
6609
  "runCheck",
5938
6610
  "archiveChapter"
5939
6611
  ];
6612
+ const worldPlanSensitive = ["submitChapterPlan", ...planSensitive];
5940
6613
  const reviewSensitive = [
5941
6614
  "submitRevision",
5942
6615
  "runCheck",
5943
6616
  "archiveChapter"
5944
6617
  ];
5945
6618
  const checkSensitive = ["archiveChapter"];
5946
- const relevant = [];
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"));
5947
6622
  if (snapshot.planError !== null && planSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PLAN_UNREADABLE"));
5948
6623
  if (snapshot.reviewError !== null && reviewSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "REVIEW_UNREADABLE"));
5949
6624
  if (snapshot.checkError !== null && checkSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "CHECK_UNREADABLE"));
@@ -6002,7 +6677,7 @@ var EngineService = class {
6002
6677
  }
6003
6678
  if (chapters.length === 0 && snapshot.text !== null && (chapter === void 0 || chapter === snapshot.pipeline.chapter)) chapters.push({
6004
6679
  num: snapshot.pipeline.chapter,
6005
- title: snapshot.plan?.title ?? "未命名",
6680
+ title: stripTitlePrefix(snapshot.plan?.title ?? "") || "未命名",
6006
6681
  text: snapshot.text
6007
6682
  });
6008
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");
@@ -6011,7 +6686,7 @@ var EngineService = class {
6011
6686
  machine(snapshot, text, extra) {
6012
6687
  return runMachineChecks(text, {
6013
6688
  chapter: snapshot.pipeline.chapter,
6014
- 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),
6015
6690
  genre: snapshot.project.genre,
6016
6691
  sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
6017
6692
  rulepack: this.rulepack,
@@ -6029,12 +6704,20 @@ var EngineService = class {
6029
6704
  });
6030
6705
  }
6031
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;
6032
6715
  const machine = [
6033
6716
  "P4",
6034
6717
  "P5",
6035
6718
  "P6"
6036
6719
  ].includes(stage) && snapshot.text !== null ? this.machine(snapshot, snapshot.text, { summary: snapshot.plan?.summary ?? null }) : void 0;
6037
- return briefContext({
6720
+ const context = briefContext({
6038
6721
  stage,
6039
6722
  chapter,
6040
6723
  project: snapshot.project,
@@ -6046,6 +6729,8 @@ var EngineService = class {
6046
6729
  foreshadows: snapshot.foreshadows,
6047
6730
  timeline: snapshot.timeline,
6048
6731
  relationships: snapshot.relationships,
6732
+ worldPlan: snapshot.worldPlan,
6733
+ world_plan_error: snapshot.worldPlanError,
6049
6734
  plan: snapshot.plan,
6050
6735
  plan_error: snapshot.planError,
6051
6736
  review: snapshot.review,
@@ -6057,6 +6742,8 @@ var EngineService = class {
6057
6742
  previousText: snapshot.previousText,
6058
6743
  handoff: snapshot.handoff
6059
6744
  });
6745
+ cache.set(cacheKey, context);
6746
+ return context;
6060
6747
  }
6061
6748
  briefDigest(stage, snapshot, context) {
6062
6749
  const briefHash = this.briefs.render(stage, this.briefVariables(snapshot, snapshot.pipeline.chapter)).contentHash;
@@ -6084,17 +6771,19 @@ var EngineService = class {
6084
6771
  round: snapshot.pipeline.stage === "P5" ? snapshot.pipeline.rounds.revise + 1 : snapshot.pipeline.rounds.review + 1
6085
6772
  };
6086
6773
  }
6087
- 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 = {}) {
6088
6780
  const text = extras.text ?? snapshot.text ?? "";
6089
- 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");
6090
- const stateBlocking = this.stateGateBlock(snapshot, operation);
6091
- if (stateBlocking.length > 0) throw new EngineBlockedError(ERROR_CODES.GATE_BLOCKED, `${operation} blocked by unreadable state: ${stateBlocking[0].detail}`, stateBlocking, this.recoveryFor(snapshot).reason);
6092
6781
  const archive = operation === "archiveChapter" ? {
6093
6782
  chapterExists: snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter),
6094
6783
  checkPassed: snapshot.check?.verdict === "pass",
6095
6784
  hashMatches: snapshot.check !== null && snapshot.text !== null && snapshot.check.content_hash === sha256(snapshot.text)
6096
6785
  } : null;
6097
- const result = evaluateGates({
6786
+ return {
6098
6787
  operation,
6099
6788
  stage: snapshot.pipeline.stage,
6100
6789
  text,
@@ -6104,7 +6793,7 @@ var EngineService = class {
6104
6793
  chapters: snapshot.chapters,
6105
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))),
6106
6795
  briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
6107
- 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),
6108
6797
  sceneType: extras.sceneType ?? snapshot.plan?.scene_type ?? null,
6109
6798
  summary: extras.summary ?? snapshot.plan?.summary ?? null,
6110
6799
  summaryProvided: extras.summaryProvided,
@@ -6126,7 +6815,13 @@ var EngineService = class {
6126
6815
  previousText: snapshot.previousText,
6127
6816
  previousTexts: snapshot.previousTexts,
6128
6817
  priorSceneTypes: snapshot.chapters.chapters.map((record) => record.scene_type)
6129
- }, {
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), {
6130
6825
  rulepack: this.rulepack,
6131
6826
  minScore: this.config.quality.minScore
6132
6827
  });
@@ -6153,36 +6848,76 @@ var EngineService = class {
6153
6848
  }
6154
6849
  async load(root, retries = 1) {
6155
6850
  const store = this.storeFor(await this.resolveRoot(root));
6156
- 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");
6157
- const project = await store.readDoc(PROJECT_FILE, "project");
6158
- 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
+ }
6159
6879
  const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
6160
6880
  if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
6161
- const chapters = await store.readDoc(joinRel("state", STATE_FILES.chapters), "chapters") ?? emptyChapters();
6162
- const characters = await store.readDoc(joinRel("state", STATE_FILES.characters), "characters") ?? emptyCharacters();
6163
- const world = await store.readDoc(joinRel("state", STATE_FILES.world), "world") ?? emptyWorld();
6164
- 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", {
6165
6900
  schema_version: 1,
6166
6901
  locked_at_chapter: 0,
6167
6902
  characters: {},
6168
6903
  locations: {}
6169
- };
6170
- 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", {
6171
6906
  schema_version: 1,
6172
6907
  next_seq: 1,
6173
6908
  items: []
6174
- };
6175
- 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", {
6176
6911
  schema_version: 1,
6177
6912
  next_seq: 1,
6178
6913
  items: []
6179
- };
6180
- 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", {
6181
6916
  schema_version: 1,
6182
6917
  next_seq: 1,
6183
6918
  items: []
6184
- };
6185
- 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);
6186
6921
  const chapter = pipeline.chapter;
6187
6922
  const text = await store.readText(chapterFile(chapter));
6188
6923
  const previousText = chapter > 1 ? await store.readText(chapterFile(chapter - 1)) : null;
@@ -6202,12 +6937,29 @@ var EngineService = class {
6202
6937
  if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
6203
6938
  planError = errorMessage(error);
6204
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;
6948
+ } catch (error) {
6949
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
6950
+ worldPlanError = errorMessage(error);
6951
+ }
6205
6952
  const reviewRead = await this.recoverableDoc(store, reviewFile(chapter), "review");
6206
6953
  const checkRead = await this.recoverableDoc(store, checkFile(chapter), "check");
6207
6954
  const review = reviewRead.value;
6208
- const reviewError = reviewRead.error;
6955
+ let reviewError = reviewRead.error;
6209
6956
  const check = checkRead.value;
6210
- const checkError = checkRead.error;
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`;
6211
6963
  const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
6212
6964
  if (latest === null || latest.revision !== pipeline.revision) {
6213
6965
  if (retries > 0) return this.load(store.root, retries - 1);
@@ -6217,6 +6969,7 @@ var EngineService = class {
6217
6969
  return {
6218
6970
  root: store.root,
6219
6971
  project,
6972
+ projectError,
6220
6973
  pipeline,
6221
6974
  chapters,
6222
6975
  characters,
@@ -6226,17 +6979,31 @@ var EngineService = class {
6226
6979
  foreshadows,
6227
6980
  relationships,
6228
6981
  handoff,
6982
+ worldPlan,
6983
+ ledgerErrors,
6229
6984
  text,
6230
6985
  previousText,
6231
6986
  previousTexts,
6232
6987
  plan,
6233
6988
  planError,
6989
+ worldPlanError,
6234
6990
  review,
6235
6991
  reviewError,
6236
6992
  check,
6237
6993
  checkError
6238
6994
  };
6239
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
+ }
6240
7007
  /** Read a review/check without letting one corrupt file wedge every read. */
6241
7008
  async recoverableDoc(store, path, kind) {
6242
7009
  try {
@@ -6313,7 +7080,7 @@ var EngineService = class {
6313
7080
  };
6314
7081
  }
6315
7082
  }
6316
- await assertJournalConsistent(store);
7083
+ await assertJournalConsistent(store, options.repairPaths ?? []);
6317
7084
  const snapshot = await this.load(root);
6318
7085
  const key = options.key?.(snapshot);
6319
7086
  if (key !== void 0) {
@@ -6374,6 +7141,7 @@ var EngineService = class {
6374
7141
  return {
6375
7142
  root,
6376
7143
  project: { ...ENGINE_PROJECT_INITIAL },
7144
+ projectError: null,
6377
7145
  pipeline: emptyPipeline({
6378
7146
  revision: 1,
6379
7147
  chapter: 1,
@@ -6404,11 +7172,14 @@ var EngineService = class {
6404
7172
  items: []
6405
7173
  },
6406
7174
  handoff: null,
7175
+ worldPlan: null,
7176
+ ledgerErrors: [],
6407
7177
  text: null,
6408
7178
  previousText: null,
6409
7179
  previousTexts: [],
6410
7180
  plan: null,
6411
7181
  planError: null,
7182
+ worldPlanError: null,
6412
7183
  review: null,
6413
7184
  reviewError: null,
6414
7185
  check: null,
@@ -6488,20 +7259,362 @@ const machineWarnings = (machine) => machine.violations.filter((violation) => vi
6488
7259
  code: violation.code,
6489
7260
  message: `${violation.detail} (paragraph ${violation.location.paragraph})`
6490
7261
  }));
6491
- /** Titles are stored as bodies; the format rule judges the rendered heading. */
6492
- const composeTitle = (chapter, title) => {
6493
- if (title === null) return null;
6494
- return /^第\s*\d+\s*章/.test(title) ? title : `第${chapter}章 ${title}`;
6495
- };
6496
- const stableJson = (value) => {
7262
+ /** 0.1.2 archive digests used localeCompare; keep that comparison only for upgrades. */
7263
+ const legacyStableJson = (value) => {
6497
7264
  const normalize = (input) => {
6498
7265
  if (Array.isArray(input)) return input.map(normalize);
6499
- 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)]));
6500
7267
  return input;
6501
7268
  };
6502
- 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}`;
6503
7303
  };
6504
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
+ };
6505
7618
  /** Validate the shape a stored chapter plan must have before any stage uses it. */
6506
7619
  const validateStoredChapterPlan = (plan, path) => {
6507
7620
  const fail = (detail) => {
@@ -6526,7 +7639,14 @@ const validateStoredChapterPlan = (plan, path) => {
6526
7639
  if (!SCENE_TYPES.includes(plan.scene_type)) fail(`unknown scene_type "${String(plan.scene_type)}"`);
6527
7640
  if (!Array.isArray(plan.beats) || plan.beats.some((beat) => typeof beat !== "string")) fail("beats must be a string array");
6528
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");
6529
- if (plan.summary !== void 0 && (typeof plan.summary !== "object" || plan.summary === null)) fail("summary must be an object when present");
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
+ }
6530
7650
  };
6531
7651
  const CHECK_SCOPES = ["content", "integrity"];
6532
7652
  const normalizeCheckScope = (value) => {
@@ -6823,6 +7943,10 @@ function createNovelTools(engine) {
6823
7943
  type: "array",
6824
7944
  items: { type: "integer" }
6825
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
+ },
6826
7950
  project_root: root
6827
7951
  },
6828
7952
  output: jsonOutput,
@@ -6835,6 +7959,7 @@ function createNovelTools(engine) {
6835
7959
  opening: args.opening ?? "",
6836
7960
  ending: args.ending ?? "",
6837
7961
  target_words: [args.target_words?.[0] ?? 2e3, args.target_words?.[1] ?? 3e3],
7962
+ ...args.cast === void 0 ? {} : { cast: asJson(args.cast, "cast") },
6838
7963
  root: args.project_root
6839
7964
  }))
6840
7965
  }),
@@ -7046,12 +8171,23 @@ function createNovelTools(engine) {
7046
8171
  ]
7047
8172
  },
7048
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
+ },
7049
8183
  project_root: root
7050
8184
  },
7051
8185
  output: jsonOutput,
7052
8186
  execute: guarded(async (args) => engine.queryState({
7053
8187
  kind: args.kind,
7054
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 },
7055
8191
  root: args.project_root
7056
8192
  }))
7057
8193
  }),
@@ -7063,6 +8199,7 @@ function createNovelTools(engine) {
7063
8199
  type: "string",
7064
8200
  required: true,
7065
8201
  enum: [
8202
+ "novel.yaml",
7066
8203
  "state/chapters.yaml",
7067
8204
  "state/characters.yaml",
7068
8205
  "state/world.yaml",