dsh-novel-write-engine 0.1.1 → 0.1.2

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
@@ -1023,10 +1023,16 @@ const adjectiveDensity = (ctx) => {
1023
1023
  return [v(SEMANTIC_RULES.ADJECTIVE_DENSITY, "advisory", 1, `adjectives every ${(100 / density).toFixed(1)} chars; limit is ${max}%`)];
1024
1024
  };
1025
1025
  const slidingWindows = (text, size) => {
1026
- if (text.length <= size) return [text];
1026
+ const positions = [];
1027
+ for (let index = 0; index < text.length; index += 1) if (/\S/.test(text[index])) positions.push(index);
1028
+ if (positions.length <= size) return [text];
1027
1029
  const windows = [];
1028
- for (let start = 0; start + size <= text.length; start += 1) windows.push(text.slice(start, start + size));
1029
- const tail = text.slice(text.length - size);
1030
+ for (let startIndex = 0; startIndex + size <= positions.length; startIndex += 1) {
1031
+ const start = positions[startIndex];
1032
+ const end = startIndex + size < positions.length ? positions[startIndex + size] : text.length;
1033
+ windows.push(text.slice(start, end));
1034
+ }
1035
+ const tail = text.slice(positions[positions.length - size]);
1030
1036
  if (windows[windows.length - 1] !== tail) windows.push(tail);
1031
1037
  return windows;
1032
1038
  };
@@ -1104,10 +1110,18 @@ const revisitedTooSoon = (ctx) => {
1104
1110
  }).map((item) => v(CROSS_RULES.FS_REVISITED_TOO_SOON, "advisory", 1, `foreshadow ${item.id} reappears after ${ctx.chapter - item.last_touched} chapters (minimum ${min})`));
1105
1111
  };
1106
1112
  const resourceMismatch = (ctx) => {
1107
- return (ctx.summary?.resource_changes ?? []).flatMap((change) => {
1113
+ const changes = ctx.summary?.resource_changes ?? [];
1114
+ const positive = /(增加|多了|得到|获得|赚|加|上升|增|\+)/;
1115
+ const negative = /(减少|少了|失去|消耗|花|损失|下降|减|-|−)/;
1116
+ return changes.flatMap((change) => {
1108
1117
  if (change.delta === 0) return [];
1109
- const magnitude = String(Math.abs(change.delta));
1110
- return ctx.text.includes(change.character) && ctx.text.includes(magnitude) ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", 1, `${change.character}'s ${change.resource} change (${change.delta}) is not stated in the text`)];
1118
+ const magnitude = Math.abs(change.delta);
1119
+ return [...ctx.text.matchAll(/\d+/g)].some((match) => {
1120
+ if (Number(match[0]) !== magnitude) return false;
1121
+ const index = match.index ?? 0;
1122
+ const window = ctx.text.slice(Math.max(0, index - 80), Math.min(ctx.text.length, index + match[0].length + 80));
1123
+ return window.includes(change.character) && window.includes(change.resource) && (change.delta > 0 ? positive : negative).test(window);
1124
+ }) ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", 1, `${change.character}'s ${change.resource} change (${change.delta}) is not stated with the right sign in the text`)];
1111
1125
  });
1112
1126
  };
1113
1127
  const timeRegression = (ctx) => {
@@ -1263,6 +1277,11 @@ const lexiconCodes = /* @__PURE__ */ new Set([
1263
1277
  "GOD_VIEW",
1264
1278
  "CONCLUSION_FIRST"
1265
1279
  ]);
1280
+ const integrityCodes = new Set(Object.values(CROSS_RULES));
1281
+ /** Which runCheck scope owns one machine finding. */
1282
+ function scopeOfRule(code) {
1283
+ return integrityCodes.has(code) ? "integrity" : "content";
1284
+ }
1266
1285
  /**
1267
1286
  * Run the deterministic pass. Format and punctuation run before the lexicon so
1268
1287
  * the report reads in the order an author fixes things; the lexicon runs last
@@ -2846,7 +2865,7 @@ const pendingForeshadows = (ledger) => ledger.items.filter((item) => item.status
2846
2865
  * pack is the minimum project state the model needs to apply them without
2847
2866
  * guessing who exists, what was locked, or what the previous chapter ended on.
2848
2867
  */
2849
- function briefContext(input) {
2868
+ function unbudgetedBriefContext(input) {
2850
2869
  const previousEnding = input.previousText === null ? "" : input.previousText.trim().slice(-400);
2851
2870
  const base = {
2852
2871
  project: {
@@ -2859,7 +2878,9 @@ function briefContext(input) {
2859
2878
  stage: input.pipeline.stage,
2860
2879
  scene_distribution: sceneDistribution(input.chapters),
2861
2880
  previous_ending: previousEnding,
2862
- ...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error }
2881
+ ...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error },
2882
+ ...input.review_error === void 0 || input.review_error === null ? {} : { review_error: input.review_error },
2883
+ ...input.check_error === void 0 || input.check_error === null ? {} : { check_error: input.check_error }
2863
2884
  };
2864
2885
  switch (input.stage) {
2865
2886
  case "P1": return {
@@ -2887,6 +2908,7 @@ function briefContext(input) {
2887
2908
  ...base,
2888
2909
  text: input.text ?? "",
2889
2910
  summary: input.plan?.summary ?? null,
2911
+ plan: input.plan,
2890
2912
  machine: input.machine ?? null,
2891
2913
  characters: input.characters.characters,
2892
2914
  check: input.check,
@@ -2898,6 +2920,7 @@ function briefContext(input) {
2898
2920
  case "P5": return {
2899
2921
  ...base,
2900
2922
  text: input.text ?? "",
2923
+ plan: input.plan,
2901
2924
  machine: input.machine ?? null,
2902
2925
  review: input.review,
2903
2926
  issues: input.review?.issues ?? []
@@ -2905,6 +2928,7 @@ function briefContext(input) {
2905
2928
  case "P6": return {
2906
2929
  ...base,
2907
2930
  text: input.text ?? "",
2931
+ plan: input.plan,
2908
2932
  machine: input.machine ?? null,
2909
2933
  characters: input.characters.characters,
2910
2934
  world: input.world,
@@ -2922,6 +2946,7 @@ function briefContext(input) {
2922
2946
  };
2923
2947
  case "P8": return {
2924
2948
  ...base,
2949
+ plan: input.plan,
2925
2950
  check: input.check,
2926
2951
  review: input.review,
2927
2952
  summary: input.plan?.summary ?? null,
@@ -2931,6 +2956,238 @@ function briefContext(input) {
2931
2956
  default: return base;
2932
2957
  }
2933
2958
  }
2959
+ /** Per-stage context budgets, measured in JSON characters (metadata excluded). */
2960
+ const CONTEXT_BUDGETS = {
2961
+ P1: 6e3,
2962
+ P2: 8e3,
2963
+ P3: 16e3,
2964
+ P4: 24e3,
2965
+ P5: 24e3,
2966
+ P6: 32e3,
2967
+ P8: 12e3
2968
+ };
2969
+ const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2970
+ const asArray = (value) => Array.isArray(value) ? value : null;
2971
+ const jsonSize = (value) => Array.from(JSON.stringify(value)).length;
2972
+ const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(value) ? 1 : 0;
2973
+ /**
2974
+ * Apply the stage budget after the stage-specific pack exists. Protected keys
2975
+ * are never candidates; optional ledgers are trimmed round-robin so one large
2976
+ * list cannot starve every other kind of context.
2977
+ */
2978
+ function withContextBudget(stage, raw) {
2979
+ const context = structuredClone(raw);
2980
+ const budget = CONTEXT_BUDGETS[stage] ?? 8e3;
2981
+ const protectedKeys = [
2982
+ "project",
2983
+ "chapter",
2984
+ "stage",
2985
+ "previous_ending",
2986
+ "text",
2987
+ "plan",
2988
+ "precheck",
2989
+ "review",
2990
+ "check",
2991
+ "machine",
2992
+ "summary"
2993
+ ];
2994
+ const mentioned = [
2995
+ 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 ?? "")
3000
+ ].join("\n");
3001
+ const omitted = {};
3002
+ const truncated = /* @__PURE__ */ new Set();
3003
+ const candidates = [];
3004
+ const stableKey = (value) => {
3005
+ const row = asRecord(value);
3006
+ if (row === null) return JSON.stringify(value) ?? "";
3007
+ for (const key of [
3008
+ "id",
3009
+ "name",
3010
+ "title"
3011
+ ]) {
3012
+ const item = row[key];
3013
+ if (typeof item === "string" && item !== "") return item;
3014
+ }
3015
+ return JSON.stringify(row);
3016
+ };
3017
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
3018
+ const addArray = (key, array, rank, tie = stableKey) => {
3019
+ let prepared = false;
3020
+ const sort = () => {
3021
+ if (prepared) return;
3022
+ prepared = true;
3023
+ array()?.sort((left, right) => {
3024
+ const byRank = rank(right) - rank(left);
3025
+ return byRank !== 0 ? byRank : compareText(tie(left), tie(right));
3026
+ });
3027
+ };
3028
+ candidates.push({
3029
+ key,
3030
+ count: () => array()?.length ?? 0,
3031
+ trim: () => {
3032
+ sort();
3033
+ const list = array();
3034
+ if (list === null || list.length === 0) return false;
3035
+ list.pop();
3036
+ return true;
3037
+ },
3038
+ prepare: sort
3039
+ });
3040
+ };
3041
+ const addObject = (key, map, rank) => {
3042
+ let keys = null;
3043
+ const ensure = () => {
3044
+ const target = map();
3045
+ keys ??= target === null ? [] : Object.keys(target);
3046
+ keys.sort((left, right) => {
3047
+ const byRank = rank(right, map()?.[right]) - rank(left, map()?.[left]);
3048
+ return byRank !== 0 ? byRank : compareText(left, right);
3049
+ });
3050
+ return keys;
3051
+ };
3052
+ candidates.push({
3053
+ key,
3054
+ count: () => Object.keys(map() ?? {}).length,
3055
+ trim: () => {
3056
+ const target = map();
3057
+ if (target === null) return false;
3058
+ const keyToDelete = ensure().pop();
3059
+ if (keyToDelete === void 0 || !Object.hasOwn(target, keyToDelete)) return false;
3060
+ delete target[keyToDelete];
3061
+ return true;
3062
+ },
3063
+ prepare: ensure
3064
+ });
3065
+ };
3066
+ const chapter = (value) => asRecord(value)?.num ?? 0;
3067
+ addArray("characters", () => asArray(context.characters), (value) => {
3068
+ const row = asRecord(value);
3069
+ const name = typeof row?.name === "string" ? row.name : "";
3070
+ return mentionScore(mentioned, name) * 1e3 + Number(row?.first_appearance ?? 0);
3071
+ }, (value) => {
3072
+ const row = asRecord(value);
3073
+ return typeof row?.name === "string" ? row.name : stableKey(value);
3074
+ });
3075
+ const world = asRecord(context.world);
3076
+ for (const key of [
3077
+ "locations",
3078
+ "abilities",
3079
+ "artifacts",
3080
+ "factions",
3081
+ "rules"
3082
+ ]) addArray(`world.${key}`, () => asArray(world?.[key]), (value) => {
3083
+ const row = asRecord(value);
3084
+ const name = typeof row?.name === "string" ? row.name : "";
3085
+ return mentionScore(mentioned, name) * 1e3 + Number(row?.first_appearance ?? 0);
3086
+ }, (value) => {
3087
+ const row = asRecord(value);
3088
+ return typeof row?.name === "string" ? row.name : stableKey(value);
3089
+ });
3090
+ const locks = asRecord(context.locks);
3091
+ for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => mentionScore(mentioned, name) * 1e3);
3092
+ addArray("open_foreshadows", () => asArray(context.open_foreshadows), (value) => {
3093
+ const row = asRecord(value);
3094
+ const title = typeof row?.title === "string" ? row.title : "";
3095
+ return mentionScore(mentioned, title) * 1e3 - Number(row?.last_touched ?? 0);
3096
+ }, (value) => {
3097
+ const row = asRecord(value);
3098
+ return `${typeof row?.title === "string" ? row.title : ""}\u0000${typeof row?.id === "string" ? row.id : ""}`;
3099
+ });
3100
+ const foreshadows = asRecord(context.foreshadows);
3101
+ addArray("foreshadows", () => asArray(foreshadows?.items), (value) => {
3102
+ const row = asRecord(value);
3103
+ const title = typeof row?.title === "string" ? row.title : "";
3104
+ const id = typeof row?.id === "string" ? row.id : "";
3105
+ return Math.max(mentionScore(mentioned, title), mentionScore(mentioned, id)) * 1e3 - Number(row?.last_touched ?? 0);
3106
+ }, (value) => {
3107
+ const row = asRecord(value);
3108
+ return `${typeof row?.id === "string" ? row.id : ""}\u0000${typeof row?.title === "string" ? row.title : ""}`;
3109
+ });
3110
+ addArray("timeline", () => asArray(context.timeline), (value) => {
3111
+ const row = asRecord(value);
3112
+ const text = `${String(row?.description ?? "")}${String(row?.location ?? "")}`;
3113
+ return mentionScore(mentioned, text) * 1e3 + Number(row?.chapter ?? 0);
3114
+ }, (value) => {
3115
+ const row = asRecord(value);
3116
+ return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.chapter ?? "")}`;
3117
+ });
3118
+ const relationships = asRecord(context.relationships);
3119
+ addArray("relationships", () => asArray(relationships?.items), (value) => {
3120
+ const row = asRecord(value);
3121
+ const text = `${String(row?.a ?? "")}${String(row?.b ?? "")}`;
3122
+ return mentionScore(mentioned, text) * 1e3 + Number(row?.since ?? 0);
3123
+ }, (value) => {
3124
+ const row = asRecord(value);
3125
+ return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.a ?? "")}\u0000${String(row?.b ?? "")}`;
3126
+ });
3127
+ addArray("recent_summaries", () => asArray(context.recent_summaries), (value) => chapter(value), (value) => String(chapter(value)).padStart(8, "0"));
3128
+ addArray("score_history", () => asArray(context.score_history), (value) => Number(asRecord(value)?.num ?? 0), (value) => String(Number(asRecord(value)?.num ?? 0)).padStart(8, "0"));
3129
+ for (const candidate of candidates) candidate.prepare();
3130
+ const used = () => {
3131
+ const measure = { ...context };
3132
+ delete measure.context_meta;
3133
+ return jsonSize(measure);
3134
+ };
3135
+ const buildMeta = (finalUsed) => ({
3136
+ version: 1,
3137
+ stage,
3138
+ budget,
3139
+ used: finalUsed,
3140
+ over_budget: false,
3141
+ protected: protectedKeys,
3142
+ truncated: [...truncated].sort(),
3143
+ omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => left.localeCompare(right))),
3144
+ strategy: "stage-v1"
3145
+ });
3146
+ const totalWith = (finalUsed, meta) => jsonSize({
3147
+ ...context,
3148
+ context_meta: {
3149
+ ...meta,
3150
+ used: finalUsed
3151
+ }
3152
+ });
3153
+ let finalUsed = used();
3154
+ let metaBase = buildMeta(finalUsed);
3155
+ while (totalWith(finalUsed, metaBase) > budget) {
3156
+ let changed = false;
3157
+ for (const candidate of candidates) {
3158
+ finalUsed = used();
3159
+ metaBase = buildMeta(finalUsed);
3160
+ if (totalWith(finalUsed, metaBase) <= budget) break;
3161
+ if (candidate.count() === 0) continue;
3162
+ candidate.prepare();
3163
+ if (candidate.trim()) {
3164
+ omitted[candidate.key] = (omitted[candidate.key] ?? 0) + 1;
3165
+ truncated.add(candidate.key);
3166
+ changed = true;
3167
+ }
3168
+ }
3169
+ finalUsed = used();
3170
+ metaBase = buildMeta(finalUsed);
3171
+ if (!changed) break;
3172
+ }
3173
+ const overBudget = totalWith(finalUsed, metaBase) > budget;
3174
+ const meta = {
3175
+ ...metaBase,
3176
+ over_budget: overBudget
3177
+ };
3178
+ return {
3179
+ ...context,
3180
+ context_meta: meta
3181
+ };
3182
+ }
3183
+ /**
3184
+ * Build the stage-specific context pack plus the deterministic budget report.
3185
+ * The budget report is part of the brief digest; a truncated pack cannot be
3186
+ * silently mistaken for the full ledger set.
3187
+ */
3188
+ function briefContext(input) {
3189
+ return withContextBudget(input.stage, unbudgetedBriefContext(input));
3190
+ }
2934
3191
  // src/engine/state-delta.ts
2935
3192
  const SECTIONS = [
2936
3193
  "characters",
@@ -3545,6 +3802,7 @@ const wordCount = (input) => {
3545
3802
  };
3546
3803
  /** PG-04: format blockers must be zero before length is judged. */
3547
3804
  const format = (input, options) => {
3805
+ if (input.scope !== void 0 && !input.scope.includes("content")) return [];
3548
3806
  const violations = runRules([
3549
3807
  "MD_CONTAMINATION",
3550
3808
  "ILLEGAL_QUOTE",
@@ -3757,6 +4015,7 @@ const readingProof = (input) => {
3757
4015
  };
3758
4016
  /** PG-10: cross-chapter reconciliation must be clean. */
3759
4017
  const consistency = (input, options) => {
4018
+ if (input.scope !== void 0 && !input.scope.includes("integrity")) return [];
3760
4019
  const violations = runRules([
3761
4020
  "DUPLICATE_PARAGRAPH",
3762
4021
  "FORESHADOW_DORMANT",
@@ -3915,7 +4174,7 @@ const STAGE_TO_PHASE = {
3915
4174
  P4: "P4",
3916
4175
  P5: "P5",
3917
4176
  P6: "P6",
3918
- ready_to_archive: "P8",
4177
+ ready_to_archive: "P7",
3919
4178
  done: "P9"
3920
4179
  };
3921
4180
  /** The nine-phase progress strip: phases before the active one are done. */
@@ -4093,6 +4352,17 @@ const ROUND_TRANSITIONS = {
4093
4352
  P5: "P5",
4094
4353
  P6: "P6"
4095
4354
  };
4355
+ /** Forward order of stages; rollback may only move to an equal or earlier rank. */
4356
+ const STAGE_RANK = {
4357
+ idle: 0,
4358
+ P2: 1,
4359
+ P3: 2,
4360
+ P4: 3,
4361
+ P5: 4,
4362
+ P6: 5,
4363
+ ready_to_archive: 6,
4364
+ done: 7
4365
+ };
4096
4366
  const MUTABLE_STATE = {
4097
4367
  [joinRel(STATE_DIR, STATE_FILES.pipeline)]: "pipeline",
4098
4368
  [joinRel(STATE_DIR, STATE_FILES.chapters)]: "chapters",
@@ -4180,6 +4450,8 @@ var EngineService = class {
4180
4450
  async initProject(input) {
4181
4451
  const title = typeof input.title === "string" ? input.title.trim() : "";
4182
4452
  if (title === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "title must not be empty");
4453
+ if (input.author !== void 0 && typeof input.author !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "author must be a string");
4454
+ if (input.description !== void 0 && typeof input.description !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "description must be a string");
4183
4455
  if (!GENRES.includes(input.genre)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown genre "${String(input.genre)}"`, `allowed: ${GENRES.join(", ")}`);
4184
4456
  if (!PLATFORMS.includes(input.platform)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown platform "${String(input.platform)}"`, `allowed: ${PLATFORMS.join(", ")}`);
4185
4457
  if (input.words_per_chapter !== void 0) {
@@ -4265,6 +4537,7 @@ var EngineService = class {
4265
4537
  stage: snapshot.pipeline.stage,
4266
4538
  rounds: { ...snapshot.pipeline.rounds },
4267
4539
  blocking: this.effectiveBlocking(snapshot),
4540
+ state_errors: this.stateErrors(snapshot),
4268
4541
  metrics,
4269
4542
  next
4270
4543
  },
@@ -4381,7 +4654,8 @@ var EngineService = class {
4381
4654
  tool: next.tool,
4382
4655
  args: next.args,
4383
4656
  reason: next.reason,
4384
- blocking: this.effectiveBlocking(snapshot)
4657
+ blocking: this.effectiveBlocking(snapshot),
4658
+ state_errors: this.stateErrors(snapshot)
4385
4659
  },
4386
4660
  revision: snapshot.pipeline.revision,
4387
4661
  warnings: []
@@ -4416,7 +4690,9 @@ var EngineService = class {
4416
4690
  /** P4 must apply exclusions before its arithmetic closes. */
4417
4691
  score_exclusions: this.rulepack.exclusions,
4418
4692
  next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
4419
- ...snapshot.planError === null ? {} : { plan_error: snapshot.planError }
4693
+ ...snapshot.planError === null ? {} : { plan_error: snapshot.planError },
4694
+ ...snapshot.reviewError === null ? {} : { review_error: snapshot.reviewError },
4695
+ ...snapshot.checkError === null ? {} : { check_error: snapshot.checkError }
4420
4696
  };
4421
4697
  const data = {
4422
4698
  stage: input.stage,
@@ -4457,6 +4733,20 @@ var EngineService = class {
4457
4733
  }
4458
4734
  async submitWorldPlan(input) {
4459
4735
  if (typeof input.objective !== "string" || input.objective.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "world plan objective must not be empty");
4736
+ const constraints = stringList(input.constraints, "constraints");
4737
+ const continuity_notes = stringList(input.continuity_notes, "continuity_notes");
4738
+ const characters = objectList(input.characters, "characters");
4739
+ for (const [index, character] of characters.entries()) {
4740
+ requireText(character, "name", `characters[${index}].name`);
4741
+ requireText(character, "role", `characters[${index}].role`);
4742
+ requireText(character, "current_state", `characters[${index}].current_state`);
4743
+ }
4744
+ const locations = objectList(input.locations, "locations");
4745
+ for (const [index, location] of locations.entries()) {
4746
+ requireText(location, "name", `locations[${index}].name`);
4747
+ requireText(location, "layout", `locations[${index}].layout`);
4748
+ if (typeof location.capacity !== "number" || !Number.isFinite(location.capacity)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a finite number`);
4749
+ }
4460
4750
  return this.mutate({
4461
4751
  root: input.root,
4462
4752
  op: "submitWorldPlan",
@@ -4467,10 +4757,10 @@ var EngineService = class {
4467
4757
  const plan = {
4468
4758
  chapter,
4469
4759
  objective: input.objective,
4470
- constraints: input.constraints,
4471
- characters: input.characters,
4472
- locations: input.locations,
4473
- continuity_notes: input.continuity_notes
4760
+ constraints,
4761
+ characters,
4762
+ locations,
4763
+ continuity_notes
4474
4764
  };
4475
4765
  const path = planWorldFile(chapter);
4476
4766
  const nextPipeline = pipelineWith(snapshot, context.revision, {
@@ -4505,6 +4795,13 @@ var EngineService = class {
4505
4795
  }
4506
4796
  async submitChapterPlan(input) {
4507
4797
  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();
4799
+ 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");
4801
+ if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan narrator must not be empty");
4802
+ const beats = stringList(input.beats, "beats");
4803
+ 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");
4804
+ for (const [field, value] of [["opening", input.opening], ["ending", input.ending]]) if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan ${field} must not be empty`);
4508
4805
  if (!SCENE_TYPES.includes(input.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown scene_type "${String(input.scene_type)}"`, `allowed: ${SCENE_TYPES.join(", ")}`);
4509
4806
  if (!Array.isArray(input.target_words) || input.target_words.length !== 2) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "target_words must be [min, max] positive integers with min <= max");
4510
4807
  const [min, max] = input.target_words;
@@ -4520,6 +4817,7 @@ var EngineService = class {
4520
4817
  const plan = {
4521
4818
  ...input,
4522
4819
  chapter,
4820
+ beats,
4523
4821
  schema_version: 1
4524
4822
  };
4525
4823
  delete plan.root;
@@ -4653,6 +4951,11 @@ var EngineService = class {
4653
4951
  "fix"
4654
4952
  ]) if (typeof issue[field] !== "string" || issue[field].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} has no ${field}`, "every issue needs id, severity, status, dimension, location, problem and fix");
4655
4953
  if (issue.evidence !== void 0 && typeof issue.evidence !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} evidence must be a string`, "quote the revised text as evidence, or omit the field");
4954
+ if (issue.severity === "blocker" && issue.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} is a blocker and cannot be waived`, "fix the issue and quote the revised text, or keep it open");
4955
+ if (this.rulepack.quality.dimensions.find((item) => item.id === issue.dimension) === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} references unknown dimension "${issue.dimension}"`, `use one of: ${this.rulepack.quality.dimensions.map((item) => item.id).join(", ")}`);
4956
+ const paragraph = parseParagraphLocation(issue.location);
4957
+ const paragraphCount = splitParagraphs(text).length;
4958
+ if (paragraph === null || paragraph < 1 || paragraph > paragraphCount) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} location "${issue.location}" is outside the chapter`, `use 第 1 段 .. 第 ${paragraphCount} 段`);
4656
4959
  }
4657
4960
  const review = {
4658
4961
  schema_version: 1,
@@ -4757,7 +5060,15 @@ var EngineService = class {
4757
5060
  seen.add(submitted.id);
4758
5061
  const before = byId.get(submitted.id);
4759
5062
  if (before === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `revision references unknown issue ${submitted.id}`, "only update issues from the current review; new issues belong in the next review");
4760
- if (before.severity === "blocker" && submitted.severity !== "blocker") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be downgraded`, "keep severity blocker and mark it fixed with evidence");
5063
+ for (const field of [
5064
+ "dimension",
5065
+ "location",
5066
+ "problem",
5067
+ "fix"
5068
+ ]) if (submitted[field] !== void 0 && submitted[field] !== before[field]) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change its ${field} during revision`, "only status and evidence may change; new findings belong in the next review");
5069
+ if (submitted.severity !== void 0 && submitted.severity !== before.severity) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change severity during revision`, "only status and evidence may change; a blocker stays a blocker until re-reviewed");
5070
+ if (before.severity === "blocker" && submitted.severity !== void 0 && submitted.severity !== "blocker") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be downgraded`, "keep severity blocker and mark it fixed with evidence");
5071
+ if (before.severity === "blocker" && submitted.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be waived`, "keep severity blocker and mark it fixed with evidence");
4761
5072
  const index = issues.findIndex((issue) => issue.id === submitted.id);
4762
5073
  issues[index] = {
4763
5074
  ...before,
@@ -4835,12 +5146,13 @@ var EngineService = class {
4835
5146
  });
4836
5147
  }
4837
5148
  async runCheck(input) {
5149
+ const scope = normalizeCheckScope(input.scope);
4838
5150
  return this.mutate({
4839
5151
  root: input.root,
4840
5152
  op: "runCheck",
4841
5153
  key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
4842
5154
  semantic: input.semantic ?? [],
4843
- scope: input.scope ?? []
5155
+ scope
4844
5156
  }))}`,
4845
5157
  run: async (snapshot, context) => {
4846
5158
  this.assertChapter(snapshot, input.chapter);
@@ -4850,20 +5162,25 @@ var EngineService = class {
4850
5162
  briefStage: "P6",
4851
5163
  text,
4852
5164
  proof: snapshot.review?.proof ?? null,
4853
- semantic
5165
+ semantic,
5166
+ scope
4854
5167
  });
4855
5168
  const machine = this.machine(snapshot, text, { summary: snapshot.plan?.summary ?? null });
4856
- const machineBlocking = machine.violations.filter((violation) => violation.severity === "blocker");
5169
+ const scopedMachine = {
5170
+ ...machine,
5171
+ violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
5172
+ };
5173
+ const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
4857
5174
  const semanticResult = evaluateSemantic(text, semantic);
4858
5175
  const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
4859
5176
  const check = {
4860
5177
  schema_version: 1,
4861
5178
  chapter: input.chapter,
4862
- scope: input.scope ?? ["content", "integrity"],
5179
+ scope,
4863
5180
  content_hash: sha256(text),
4864
5181
  machine: {
4865
- violations: machine.violations,
4866
- metrics: machine.metrics
5182
+ violations: scopedMachine.violations,
5183
+ metrics: scopedMachine.metrics
4867
5184
  },
4868
5185
  semantic: { items: semantic },
4869
5186
  verdict
@@ -4894,11 +5211,11 @@ var EngineService = class {
4894
5211
  data: {
4895
5212
  chapter: input.chapter,
4896
5213
  verdict,
4897
- violations: machine.violations.length,
4898
- metrics: machine.metrics,
5214
+ violations: scopedMachine.violations.length,
5215
+ metrics: scopedMachine.metrics,
4899
5216
  stage: nextStage
4900
5217
  },
4901
- warnings: machineWarnings(machine)
5218
+ warnings: machineWarnings(scopedMachine)
4902
5219
  };
4903
5220
  }
4904
5221
  });
@@ -4939,6 +5256,7 @@ var EngineService = class {
4939
5256
  const text = snapshot.text ?? "";
4940
5257
  const summary = snapshot.plan?.summary;
4941
5258
  if (summary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the draft has no summary; resubmit the draft with a summary", "call novel_write_draft with the full summary block");
5259
+ if (snapshot.review !== null && snapshot.pipeline.review_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review has no review_digest; it predates digest-bound reviews and cannot be archived blindly", "re-run novel_review for this chapter, then re-run the archive");
4942
5260
  if (snapshot.review !== null && snapshot.pipeline.review_digest !== void 0 && snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review changed after submission; its digest no longer matches", "re-run novel_review instead of editing reviews/ directly");
4943
5261
  await this.assertGates(snapshot, "archiveChapter", {
4944
5262
  briefStage: "P8",
@@ -5155,6 +5473,7 @@ var EngineService = class {
5155
5473
  this.assertChapter(snapshot, input.chapter);
5156
5474
  if (!Object.prototype.hasOwnProperty.call(ROUND_TRANSITIONS, input.toStage)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown rollback target "${String(input.toStage)}"`, `allowed targets: ${Object.keys(ROUND_TRANSITIONS).join(", ")}`);
5157
5475
  const stage = ROUND_TRANSITIONS[input.toStage];
5476
+ if (STAGE_RANK[stage] > STAGE_RANK[snapshot.pipeline.stage]) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `cannot roll back from ${snapshot.pipeline.stage} to later stage ${stage}`, "rollback only moves to the current stage or an earlier one; use the normal stage tools to advance");
5158
5477
  const nextPipeline = pipelineWith(snapshot, context.revision, {
5159
5478
  chapter: input.chapter,
5160
5479
  stage,
@@ -5411,7 +5730,7 @@ var EngineService = class {
5411
5730
  title: (snapshot.plan?.title ?? "").replace(/^第\s*\d+\s*章\s*/, "").trim() || `第${snapshot.pipeline.chapter}章`,
5412
5731
  wordCount: draftWords,
5413
5732
  score: snapshot.review?.total ?? 0,
5414
- status: snapshot.pipeline.blocking.length > 0 ? "blocked" : "draft",
5733
+ status: this.effectiveBlocking(snapshot).length > 0 ? "blocked" : "draft",
5415
5734
  details: (snapshot.plan?.summary?.events ?? []).join(";").slice(0, 240)
5416
5735
  };
5417
5736
  return {
@@ -5435,6 +5754,20 @@ var EngineService = class {
5435
5754
  nextStep(snapshot) {
5436
5755
  const stage = snapshot.pipeline.stage;
5437
5756
  const chapter = snapshot.pipeline.chapter;
5757
+ if (this.stateErrors(snapshot).length > 0) {
5758
+ const recovery = this.recoveryFor(snapshot);
5759
+ const target = recovery.args.to_stage;
5760
+ if (STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[stage]) return recovery;
5761
+ }
5762
+ const reviewDigest = this.reviewDigestProblem(snapshot);
5763
+ if (reviewDigest !== null) return {
5764
+ tool: "novel_rollback",
5765
+ args: {
5766
+ chapter,
5767
+ to_stage: "P4"
5768
+ },
5769
+ reason: `${reviewDigest.code}: ${reviewDigest.detail}; ${reviewDigest.hint}`
5770
+ };
5438
5771
  const step = STAGE_NEXT[stage];
5439
5772
  const target = snapshot.project.targets.total_chapters;
5440
5773
  if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
@@ -5463,6 +5796,11 @@ var EngineService = class {
5463
5796
  },
5464
5797
  reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
5465
5798
  };
5799
+ if (stage === "P6" && snapshot.checkError !== null) return {
5800
+ tool: "novel_check",
5801
+ args: this.nextArgs("P6", chapter),
5802
+ reason: `CHECK_UNREADABLE@${checkFile(chapter)}; repair the check with novel_check before any stale revision blocker`
5803
+ };
5466
5804
  if (snapshot.pipeline.blocking.length > 0) {
5467
5805
  if (stage === "P5" || stage === "P6") return {
5468
5806
  tool: "novel_revise",
@@ -5500,9 +5838,121 @@ var EngineService = class {
5500
5838
  }
5501
5839
  /** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
5502
5840
  effectiveBlocking(snapshot) {
5841
+ const state = this.stateErrors(snapshot).map((error) => ({
5842
+ code: "STATE_UNREADABLE",
5843
+ scope: "state",
5844
+ detail: `${error.code}: ${error.message}`,
5845
+ hint: this.recoveryFor(snapshot).reason
5846
+ }));
5503
5847
  const extra = this.roundsLimitBlock(snapshot);
5504
- if (extra === null) return snapshot.pipeline.blocking;
5505
- return snapshot.pipeline.blocking.some((item) => item.code === extra.code) ? snapshot.pipeline.blocking : [...snapshot.pipeline.blocking, extra];
5848
+ const reviewDigest = this.reviewDigestProblem(snapshot);
5849
+ const base = [
5850
+ ...state,
5851
+ ...reviewDigest === null ? [] : [reviewDigest],
5852
+ ...snapshot.pipeline.blocking
5853
+ ];
5854
+ if (extra === null) return base;
5855
+ return base.some((item) => item.code === extra.code) ? base : [...base, extra];
5856
+ }
5857
+ /**
5858
+ * A review that exists but cannot be tied to the pipeline digest would let a
5859
+ * hand-edited review pass archive. Archive already fails closed; status and
5860
+ * next must surface the same repair path instead of pointing at the archive
5861
+ * command forever.
5862
+ */
5863
+ reviewDigestProblem(snapshot) {
5864
+ if (snapshot.pipeline.stage !== "ready_to_archive" || snapshot.review === null) return null;
5865
+ const chapter = snapshot.pipeline.chapter;
5866
+ const hint = "call novel_rollback with to_stage P4, then re-run novel_review before archiving";
5867
+ if (snapshot.pipeline.review_digest === void 0) return {
5868
+ code: "REVIEW_DIGEST_MISSING",
5869
+ scope: "state",
5870
+ detail: `review ${reviewFile(chapter)} has no pipeline.review_digest; it predates digest-bound reviews`,
5871
+ hint
5872
+ };
5873
+ if (snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) return {
5874
+ code: "REVIEW_DIGEST_MISMATCH",
5875
+ scope: "state",
5876
+ detail: `review ${reviewFile(chapter)} changed after submission; its digest no longer matches`,
5877
+ hint
5878
+ };
5879
+ return null;
5880
+ }
5881
+ stateErrors(snapshot) {
5882
+ const errors = [];
5883
+ const chapter = snapshot.pipeline.chapter;
5884
+ if (snapshot.planError !== null) errors.push({
5885
+ path: planChapterFile(chapter),
5886
+ code: "PLAN_UNREADABLE",
5887
+ message: snapshot.planError
5888
+ });
5889
+ if (snapshot.reviewError !== null) errors.push({
5890
+ path: reviewFile(chapter),
5891
+ code: "REVIEW_UNREADABLE",
5892
+ message: snapshot.reviewError
5893
+ });
5894
+ if (snapshot.checkError !== null) errors.push({
5895
+ path: checkFile(chapter),
5896
+ code: "CHECK_UNREADABLE",
5897
+ message: snapshot.checkError
5898
+ });
5899
+ return errors;
5900
+ }
5901
+ /** The earliest rebuild point for the current state damage. */
5902
+ recoveryFor(snapshot) {
5903
+ const chapter = snapshot.pipeline.chapter;
5904
+ const errors = this.stateErrors(snapshot);
5905
+ const desired = snapshot.planError !== null ? "P2" : snapshot.reviewError !== null ? "P4" : "P6";
5906
+ const target = this.clampRollbackTarget(snapshot.pipeline.stage, desired);
5907
+ 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
+ return {
5909
+ tool: "novel_rollback",
5910
+ args: {
5911
+ chapter,
5912
+ to_stage: target
5913
+ },
5914
+ reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; ${action}`
5915
+ };
5916
+ }
5917
+ /** Never point at a stage later than the current one; rollback only moves backward. */
5918
+ clampRollbackTarget(stage, desired) {
5919
+ if (STAGE_RANK[ROUND_TRANSITIONS[desired]] <= STAGE_RANK[stage]) return desired;
5920
+ return {
5921
+ idle: "P1",
5922
+ P2: "P2",
5923
+ P3: "P3",
5924
+ P4: "P4",
5925
+ P5: "P5",
5926
+ P6: "P6",
5927
+ ready_to_archive: "P6",
5928
+ done: "P6"
5929
+ }[stage];
5930
+ }
5931
+ /** State damage that makes this operation unsafe to start. */
5932
+ stateGateBlock(snapshot, operation) {
5933
+ const planSensitive = [
5934
+ "submitDraft",
5935
+ "submitReview",
5936
+ "submitRevision",
5937
+ "runCheck",
5938
+ "archiveChapter"
5939
+ ];
5940
+ const reviewSensitive = [
5941
+ "submitRevision",
5942
+ "runCheck",
5943
+ "archiveChapter"
5944
+ ];
5945
+ const checkSensitive = ["archiveChapter"];
5946
+ const relevant = [];
5947
+ if (snapshot.planError !== null && planSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PLAN_UNREADABLE"));
5948
+ if (snapshot.reviewError !== null && reviewSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "REVIEW_UNREADABLE"));
5949
+ if (snapshot.checkError !== null && checkSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "CHECK_UNREADABLE"));
5950
+ return relevant.map((error) => ({
5951
+ code: "STATE_UNREADABLE",
5952
+ scope: "state",
5953
+ detail: `${error.code}: ${error.message}`,
5954
+ hint: this.recoveryFor(snapshot).reason
5955
+ }));
5506
5956
  }
5507
5957
  /** Whether the stored brief still signs the current snapshot. */
5508
5958
  briefIsCurrent(snapshot, stage) {
@@ -5599,7 +6049,9 @@ var EngineService = class {
5599
6049
  plan: snapshot.plan,
5600
6050
  plan_error: snapshot.planError,
5601
6051
  review: snapshot.review,
6052
+ review_error: snapshot.reviewError,
5602
6053
  check: snapshot.check,
6054
+ check_error: snapshot.checkError,
5603
6055
  text: snapshot.text,
5604
6056
  machine,
5605
6057
  previousText: snapshot.previousText,
@@ -5635,6 +6087,8 @@ var EngineService = class {
5635
6087
  async assertGates(snapshot, operation, extras) {
5636
6088
  const text = extras.text ?? snapshot.text ?? "";
5637
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);
5638
6092
  const archive = operation === "archiveChapter" ? {
5639
6093
  chapterExists: snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter),
5640
6094
  checkPassed: snapshot.check?.verdict === "pass",
@@ -5658,6 +6112,7 @@ var EngineService = class {
5658
6112
  check: extras.check ?? snapshot.check,
5659
6113
  semantic: extras.semantic ?? [],
5660
6114
  proof: extras.proof ?? null,
6115
+ scope: extras.scope,
5661
6116
  rounds: snapshot.pipeline.rounds,
5662
6117
  archive,
5663
6118
  evidence: {
@@ -5740,12 +6195,19 @@ var EngineService = class {
5740
6195
  let plan = null;
5741
6196
  let planError = null;
5742
6197
  if (planRaw !== null) try {
5743
- plan = parsePlanYaml(planRaw, planChapterFile(chapter));
6198
+ const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
6199
+ validateStoredChapterPlan(parsed, planChapterFile(chapter));
6200
+ plan = parsed;
5744
6201
  } catch (error) {
5745
- planError = error instanceof Error ? error.message : String(error);
6202
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
6203
+ planError = errorMessage(error);
5746
6204
  }
5747
- const review = await store.readDoc(reviewFile(chapter), "review");
5748
- const check = await store.readDoc(checkFile(chapter), "check");
6205
+ const reviewRead = await this.recoverableDoc(store, reviewFile(chapter), "review");
6206
+ const checkRead = await this.recoverableDoc(store, checkFile(chapter), "check");
6207
+ const review = reviewRead.value;
6208
+ const reviewError = reviewRead.error;
6209
+ const check = checkRead.value;
6210
+ const checkError = checkRead.error;
5749
6211
  const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
5750
6212
  if (latest === null || latest.revision !== pipeline.revision) {
5751
6213
  if (retries > 0) return this.load(store.root, retries - 1);
@@ -5770,9 +6232,26 @@ var EngineService = class {
5770
6232
  plan,
5771
6233
  planError,
5772
6234
  review,
5773
- check
6235
+ reviewError,
6236
+ check,
6237
+ checkError
5774
6238
  };
5775
6239
  }
6240
+ /** Read a review/check without letting one corrupt file wedge every read. */
6241
+ async recoverableDoc(store, path, kind) {
6242
+ try {
6243
+ return {
6244
+ value: await store.readDoc(path, kind),
6245
+ error: null
6246
+ };
6247
+ } catch (error) {
6248
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
6249
+ return {
6250
+ value: null,
6251
+ error: errorMessage(error)
6252
+ };
6253
+ }
6254
+ }
5776
6255
  async mutate(options) {
5777
6256
  const root = await this.resolveRoot(options.root);
5778
6257
  const signal = this.signalContext.getStore();
@@ -5931,7 +6410,9 @@ var EngineService = class {
5931
6410
  plan: null,
5932
6411
  planError: null,
5933
6412
  review: null,
5934
- check: null
6413
+ reviewError: null,
6414
+ check: null,
6415
+ checkError: null
5935
6416
  };
5936
6417
  }
5937
6418
  };
@@ -6020,6 +6501,54 @@ const stableJson = (value) => {
6020
6501
  };
6021
6502
  return JSON.stringify(normalize(value));
6022
6503
  };
6504
+ const errorMessage = (error) => error instanceof Error ? `${error.message}${error.cause instanceof Error ? `: ${error.cause.message}` : ""}` : String(error);
6505
+ /** Validate the shape a stored chapter plan must have before any stage uses it. */
6506
+ const validateStoredChapterPlan = (plan, path) => {
6507
+ const fail = (detail) => {
6508
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not a usable chapter plan: ${detail}`);
6509
+ };
6510
+ const rawVersion = plan.schema_version;
6511
+ if (rawVersion !== void 0) {
6512
+ const version = typeof rawVersion === "number" ? rawVersion : null;
6513
+ if (version === null) fail("schema_version must be a positive integer");
6514
+ const checkedVersion = version;
6515
+ if (!Number.isInteger(checkedVersion) || checkedVersion < 1) fail("schema_version must be a positive integer");
6516
+ if (checkedVersion > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `${path} uses schema_version ${checkedVersion}, newer than supported 1`, "upgrade the plugin; the plan was left untouched");
6517
+ if (checkedVersion !== 1) fail(`schema_version must be 1`);
6518
+ }
6519
+ if (!Number.isInteger(plan.chapter) || plan.chapter < 1) fail("chapter must be a positive integer");
6520
+ for (const key of [
6521
+ "title",
6522
+ "narrator",
6523
+ "opening",
6524
+ "ending"
6525
+ ]) if (typeof plan[key] !== "string" || plan[key].trim() === "") fail(`${key} must be non-empty`);
6526
+ if (!SCENE_TYPES.includes(plan.scene_type)) fail(`unknown scene_type "${String(plan.scene_type)}"`);
6527
+ if (!Array.isArray(plan.beats) || plan.beats.some((beat) => typeof beat !== "string")) fail("beats must be a string array");
6528
+ 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");
6530
+ };
6531
+ const CHECK_SCOPES = ["content", "integrity"];
6532
+ const normalizeCheckScope = (value) => {
6533
+ if (value === void 0) return [...CHECK_SCOPES];
6534
+ if (!Array.isArray(value) || value.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "scope must contain at least one of content, integrity");
6535
+ const unique = [...new Set(value)];
6536
+ for (const scope of unique) if (scope !== "content" && scope !== "integrity") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown check scope "${String(scope)}"`, `allowed: ${CHECK_SCOPES.join(", ")}`);
6537
+ return CHECK_SCOPES.filter((scope) => unique.includes(scope));
6538
+ };
6539
+ const stringList = (value, field) => {
6540
+ if (value === void 0) return [];
6541
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of non-empty strings`);
6542
+ return value;
6543
+ };
6544
+ const objectList = (value, field) => {
6545
+ if (value === void 0) return [];
6546
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "object" || item === null || Array.isArray(item))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of objects`);
6547
+ return value;
6548
+ };
6549
+ const requireText = (object, key, path) => {
6550
+ if (typeof object[key] !== "string" || object[key].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${path} must be a non-empty string`);
6551
+ };
6023
6552
  /** Parse a plan file with a machine code instead of leaking a YAML parser error. */
6024
6553
  const parsePlanYaml = (text, path) => {
6025
6554
  let parsed;