dsh-novel-write-engine 0.1.0 → 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
@@ -350,7 +350,7 @@ function applyExclusions(dimensions, exclusions) {
350
350
  const canonical = (location) => {
351
351
  if (typeof location !== "string") return "";
352
352
  const paragraph = /第\s*(\d+)\s*段/.exec(location);
353
- return paragraph === null ? location.trim() : `paragraph:${paragraph[1]}`;
353
+ return paragraph === null ? location.trim() : `paragraph:${Number(paragraph[1])}`;
354
354
  };
355
355
  const deductionsOf = (dimension) => {
356
356
  if (typeof dimension !== "object" || dimension === null) return [];
@@ -648,27 +648,28 @@ function nextStage(stage, op, openBlockers) {
648
648
  function availableOps(stage) {
649
649
  return TRANSITIONS.filter((transition) => transition.from === stage).map((transition) => transition.op);
650
650
  }
651
- /** Whether the review/revise retry budget is spent. */
652
- function roundsExhausted(rounds) {
651
+ /** Whether one retry budget (or, without a counter, any budget) is spent. */
652
+ function roundsExhausted(rounds, counter) {
653
+ if (counter !== void 0) return rounds[counter] >= LIMITS.maxRounds;
653
654
  return rounds.review >= LIMITS.maxRounds || rounds.revise >= LIMITS.maxRounds || rounds.check >= LIMITS.maxRounds;
654
655
  }
655
656
  /** The operation a stage expects next, used by `advise` and the status `next` block. */
656
657
  const STAGE_NEXT = {
657
658
  idle: {
658
659
  tool: "novel_plan_world",
659
- reason: "call novel_brief P1, then submit the world plan"
660
+ reason: "submit the world plan"
660
661
  },
661
662
  P2: {
662
663
  tool: "novel_plan_chapter",
663
- reason: "call novel_brief P2, then submit the chapter plan"
664
+ reason: "submit the chapter plan"
664
665
  },
665
666
  P3: {
666
667
  tool: "novel_write_draft",
667
- reason: "call novel_brief P3, then submit the draft"
668
+ reason: "submit the draft"
668
669
  },
669
670
  P4: {
670
671
  tool: "novel_review",
671
- reason: "call novel_brief P4, then submit the scored review"
672
+ reason: "submit the scored review"
672
673
  },
673
674
  P5: {
674
675
  tool: "novel_revise",
@@ -676,11 +677,11 @@ const STAGE_NEXT = {
676
677
  },
677
678
  P6: {
678
679
  tool: "novel_check",
679
- reason: "run the consistency check, then archive"
680
+ reason: "run the consistency check"
680
681
  },
681
682
  ready_to_archive: {
682
- tool: "novel_brief",
683
- reason: "all gates pass; call novel_brief P8, then archive the chapter"
683
+ tool: "novel_archive",
684
+ reason: "all gates pass; archive the chapter with its state_delta"
684
685
  },
685
686
  done: {
686
687
  tool: "novel_plan_world",
@@ -1022,10 +1023,16 @@ const adjectiveDensity = (ctx) => {
1022
1023
  return [v(SEMANTIC_RULES.ADJECTIVE_DENSITY, "advisory", 1, `adjectives every ${(100 / density).toFixed(1)} chars; limit is ${max}%`)];
1023
1024
  };
1024
1025
  const slidingWindows = (text, size) => {
1025
- 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];
1026
1029
  const windows = [];
1027
- for (let start = 0; start + size <= text.length; start += 1) windows.push(text.slice(start, start + size));
1028
- 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]);
1029
1036
  if (windows[windows.length - 1] !== tail) windows.push(tail);
1030
1037
  return windows;
1031
1038
  };
@@ -1063,7 +1070,23 @@ const duplicateParagraph = (ctx) => {
1063
1070
  const previous = ctx.evidence.previousTexts.length > 0 ? ctx.evidence.previousTexts : ctx.evidence.previousText === null ? [] : [ctx.evidence.previousText];
1064
1071
  if (previous.length === 0) return [];
1065
1072
  const minChars = ctx.rulepack.crosschapter.duplicate_min_chars;
1066
- const longParagraphs = (text) => splitParagraphs(text).filter((paragraph) => Array.from(paragraph.replace(/\s+/g, "")).length >= minChars).join("\n\n");
1073
+ const longParagraphs = (text) => {
1074
+ const units = [];
1075
+ let buffer = "";
1076
+ for (const paragraph of splitParagraphs(text)) {
1077
+ buffer += paragraph;
1078
+ if (Array.from(buffer.replace(/\s+/g, "")).length >= minChars) {
1079
+ units.push(buffer);
1080
+ buffer = "";
1081
+ }
1082
+ }
1083
+ if (buffer !== "") {
1084
+ if (units.length === 0) {
1085
+ if (Array.from(buffer.replace(/\s+/g, "")).length >= minChars) units.push(buffer);
1086
+ } else units[units.length - 1] += buffer;
1087
+ }
1088
+ return units.join("\n\n");
1089
+ };
1067
1090
  const best = maxParagraphSimilarity(longParagraphs(ctx.text), previous.map((text) => longParagraphs(text)).filter((text) => text !== ""));
1068
1091
  if (best < ctx.rulepack.crosschapter.duplicate_advisory) return [];
1069
1092
  const severity = best >= ctx.rulepack.crosschapter.duplicate_similarity ? "blocker" : "advisory";
@@ -1087,10 +1110,18 @@ const revisitedTooSoon = (ctx) => {
1087
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})`));
1088
1111
  };
1089
1112
  const resourceMismatch = (ctx) => {
1090
- 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) => {
1091
1117
  if (change.delta === 0) return [];
1092
- const magnitude = String(Math.abs(change.delta));
1093
- 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`)];
1094
1125
  });
1095
1126
  };
1096
1127
  const timeRegression = (ctx) => {
@@ -1246,6 +1277,11 @@ const lexiconCodes = /* @__PURE__ */ new Set([
1246
1277
  "GOD_VIEW",
1247
1278
  "CONCLUSION_FIRST"
1248
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
+ }
1249
1285
  /**
1250
1286
  * Run the deterministic pass. Format and punctuation run before the lexicon so
1251
1287
  * the report reads in the order an author fixes things; the lexicon runs last
@@ -1340,7 +1376,7 @@ function resolveInside(root, relativePath) {
1340
1376
  const absoluteRoot = resolve(root);
1341
1377
  const absolute = resolve(absoluteRoot, relativePath);
1342
1378
  const inside = relative(absoluteRoot, absolute);
1343
- if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": escapes project root`);
1379
+ if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": escapes project root`);
1344
1380
  return absolute;
1345
1381
  }
1346
1382
  // src/store/migrate.ts
@@ -1415,6 +1451,7 @@ const SCHEMAS = {
1415
1451
  rounds: req("object"),
1416
1452
  briefs: req("object"),
1417
1453
  blocking: req("array"),
1454
+ review_digest: f("string"),
1418
1455
  last_action: f("any")
1419
1456
  },
1420
1457
  chapters: { chapters: req("array") },
@@ -1726,10 +1763,11 @@ function narrow(kind, value) {
1726
1763
  if (typeof version !== "number" || !Number.isInteger(version)) return invalid$1(kind, "missing schema_version");
1727
1764
  if (version > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `refusing ${kind}: schema_version ${version} is newer than supported 1`, "upgrade the plugin; the file was left untouched");
1728
1765
  if (version !== 1) invalid$1(kind, `unsupported schema_version ${version}`);
1766
+ if (!Object.hasOwn(SCHEMAS, kind)) invalid$1(kind, `unknown document kind "${String(kind)}"`);
1729
1767
  const schema = SCHEMAS[kind];
1730
1768
  for (const key of Object.keys(record)) {
1731
1769
  if (key === "schema_version") continue;
1732
- if (!(key in schema)) invalid$1(kind, `unknown key "${key}"`);
1770
+ if (!Object.hasOwn(schema, key)) invalid$1(kind, `unknown key "${key}"`);
1733
1771
  }
1734
1772
  for (const [key, field] of Object.entries(schema)) {
1735
1773
  const fieldValue = record[key];
@@ -1836,7 +1874,12 @@ var FsStore = class {
1836
1874
  async readDoc(relativePath, kind) {
1837
1875
  const text = await this.readText(relativePath);
1838
1876
  if (text === null) return null;
1839
- const parsed = parse(text);
1877
+ let parsed;
1878
+ try {
1879
+ parsed = parse(text);
1880
+ } catch (error) {
1881
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${relativePath} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix the YAML syntax; the engine will not rewrite a document it cannot parse", { cause: error });
1882
+ }
1840
1883
  return narrow(kind, (typeof parsed === "object" && parsed !== null && typeof parsed.schema_version === "number" ? parsed.schema_version : 1) < 1 ? migrateDocument(parsed, MIGRATIONS, 1) : parsed);
1841
1884
  }
1842
1885
  /** Write text atomically: temp file, fsync, rename, directory fsync. */
@@ -2080,6 +2123,9 @@ async function recoverPending(store) {
2080
2123
  /** Throw before a write when the journal or the last commit is inconsistent. */
2081
2124
  async function assertJournalConsistent(store) {
2082
2125
  await recoverPending(store);
2126
+ const { records } = await readJournal(store);
2127
+ const committed = new Set(records.filter((record) => record.status === "committed" && record.op !== "lock_reclaimed").map((record) => record.txid));
2128
+ for (const entry of await store.list(STAGING_DIR)) if (committed.has(entry)) await store.removeTree(joinRel(STAGING_DIR, entry)).catch(() => void 0);
2083
2129
  const { malformed, mismatches } = await reconcileJournal(store);
2084
2130
  assertJournalHealthy(malformed);
2085
2131
  if (mismatches.length > 0) {
@@ -2113,6 +2159,8 @@ var KeyedMutex = class {
2113
2159
  return this.tails.size;
2114
2160
  }
2115
2161
  };
2162
+ /** Nonces this process currently holds; same-pid locks not in this set are stale. */
2163
+ const liveNonces = /* @__PURE__ */ new Set();
2116
2164
  const isLockRecord = (value) => typeof value === "object" && value !== null && typeof value.pid === "number" && typeof value.startedAt === "string" && (value.nonce === void 0 || typeof value.nonce === "string");
2117
2165
  const processAlive = (pid) => {
2118
2166
  if (pid === process.pid) return true;
@@ -2155,6 +2203,7 @@ var FileLock = class {
2155
2203
  }
2156
2204
  await handle.close();
2157
2205
  this.held = record;
2206
+ if (record.nonce !== void 0) liveNonces.add(record.nonce);
2158
2207
  return;
2159
2208
  } catch (error) {
2160
2209
  if (error.code !== "EEXIST") throw error;
@@ -2168,7 +2217,8 @@ var FileLock = class {
2168
2217
  const record = await this.read();
2169
2218
  const held = this.held;
2170
2219
  this.held = null;
2171
- if (record !== null && held !== null && record.pid === held.pid && record.nonce === held.nonce) await promises.rm(this.absolutePath, { force: true });
2220
+ if (held?.nonce !== void 0) liveNonces.delete(held.nonce);
2221
+ if (record !== null && held !== null && record.pid === held.pid && record.nonce === held.nonce) await promises.rm(this.absolutePath, { force: true }).catch(() => void 0);
2172
2222
  }
2173
2223
  /** Read the current lock record, or null when absent/held by a foreign process. */
2174
2224
  async read() {
@@ -2199,30 +2249,25 @@ var FileLock = class {
2199
2249
  }
2200
2250
  if (record === null) {
2201
2251
  if (Date.now() - mtimeMs <= this.staleMs) return false;
2202
- } else {
2203
- const age = Date.now() - Date.parse(record.startedAt);
2204
- if (!(!Number.isFinite(age) || !processAlive(record.pid))) return false;
2205
- }
2252
+ } else if (record.pid === process.pid) {
2253
+ if (record.nonce !== void 0 && liveNonces.has(record.nonce)) return false;
2254
+ } else if (processAlive(record.pid)) return false;
2206
2255
  const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
2207
2256
  try {
2208
- await promises.rename(this.absolutePath, claimPath);
2257
+ await promises.link(this.absolutePath, claimPath);
2209
2258
  } catch (error) {
2210
2259
  if (error.code === "ENOENT") return true;
2211
2260
  throw error;
2212
2261
  }
2213
- let moved = null;
2214
- moved = await this.readAt(claimPath);
2262
+ const moved = await this.readAt(claimPath);
2215
2263
  const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
2216
2264
  if (!(record === null ? moved === null && Date.now() - claimMtime > this.staleMs : moved !== null && moved.pid === record.pid && moved.startedAt === record.startedAt && moved.nonce === record.nonce)) {
2217
- try {
2218
- await promises.link(claimPath, this.absolutePath);
2219
- await promises.rm(claimPath, { force: true });
2220
- } catch {
2221
- await promises.rm(claimPath, { force: true }).catch(() => void 0);
2222
- }
2265
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
2223
2266
  return false;
2224
2267
  }
2225
- await promises.rm(claimPath, { force: true });
2268
+ const current = await this.readAt(this.absolutePath);
2269
+ if (record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce) await promises.rm(this.absolutePath, { force: true }).catch(() => void 0);
2270
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
2226
2271
  await this.onReclaim?.(record);
2227
2272
  return true;
2228
2273
  }
@@ -2294,6 +2339,12 @@ async function restoreTargets(store, renamed, before) {
2294
2339
  * that terminal row).
2295
2340
  */
2296
2341
  async function runTransaction(store, input) {
2342
+ const staged = /* @__PURE__ */ new Set();
2343
+ for (const file of input.files) {
2344
+ if (staged.has(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `transaction ${input.txid} stages ${file.path} more than once`, "each target may appear once per transaction; merge the writes before staging");
2345
+ if (file.text !== void 0 && file.bytes !== void 0) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `transaction ${input.txid} stages both text and bytes for ${file.path}`, "choose one representation per staged file");
2346
+ staged.add(file.path);
2347
+ }
2297
2348
  const staging = stagingDir(input.txid);
2298
2349
  const expected = /* @__PURE__ */ new Map();
2299
2350
  const before = /* @__PURE__ */ new Map();
@@ -2416,8 +2467,13 @@ var ProjectRegistry = class {
2416
2467
  async read() {
2417
2468
  const text = await this.store.readText(REGISTRY_FILE);
2418
2469
  if (text === null) return empty();
2419
- const raw = parse(text);
2420
- if (typeof raw !== "object" || raw === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
2470
+ let raw;
2471
+ try {
2472
+ raw = parse(text);
2473
+ } catch (error) {
2474
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix or delete the registry file; projects on disk are unaffected", { cause: error });
2475
+ }
2476
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
2421
2477
  const record = raw;
2422
2478
  if (record.schema_version !== 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} has an unsupported schema_version`);
2423
2479
  return {
@@ -2786,11 +2842,7 @@ var BriefLibrary = class {
2786
2842
  };
2787
2843
  }
2788
2844
  };
2789
- const hash = (text) => {
2790
- let value = 0;
2791
- for (let index = 0; index < text.length; index += 1) value = Math.imul(31, value) + text.charCodeAt(index) | 0;
2792
- return `fnv:${(value >>> 0).toString(16)}`;
2793
- };
2845
+ const hash = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
2794
2846
  // src/engine/context.ts
2795
2847
  const compactSummary = (row) => ({
2796
2848
  num: row.num,
@@ -2813,7 +2865,7 @@ const pendingForeshadows = (ledger) => ledger.items.filter((item) => item.status
2813
2865
  * pack is the minimum project state the model needs to apply them without
2814
2866
  * guessing who exists, what was locked, or what the previous chapter ended on.
2815
2867
  */
2816
- function briefContext(input) {
2868
+ function unbudgetedBriefContext(input) {
2817
2869
  const previousEnding = input.previousText === null ? "" : input.previousText.trim().slice(-400);
2818
2870
  const base = {
2819
2871
  project: {
@@ -2825,7 +2877,10 @@ function briefContext(input) {
2825
2877
  chapter: input.chapter,
2826
2878
  stage: input.pipeline.stage,
2827
2879
  scene_distribution: sceneDistribution(input.chapters),
2828
- previous_ending: previousEnding
2880
+ previous_ending: previousEnding,
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 }
2829
2884
  };
2830
2885
  switch (input.stage) {
2831
2886
  case "P1": return {
@@ -2853,6 +2908,7 @@ function briefContext(input) {
2853
2908
  ...base,
2854
2909
  text: input.text ?? "",
2855
2910
  summary: input.plan?.summary ?? null,
2911
+ plan: input.plan,
2856
2912
  machine: input.machine ?? null,
2857
2913
  characters: input.characters.characters,
2858
2914
  check: input.check,
@@ -2864,6 +2920,7 @@ function briefContext(input) {
2864
2920
  case "P5": return {
2865
2921
  ...base,
2866
2922
  text: input.text ?? "",
2923
+ plan: input.plan,
2867
2924
  machine: input.machine ?? null,
2868
2925
  review: input.review,
2869
2926
  issues: input.review?.issues ?? []
@@ -2871,6 +2928,7 @@ function briefContext(input) {
2871
2928
  case "P6": return {
2872
2929
  ...base,
2873
2930
  text: input.text ?? "",
2931
+ plan: input.plan,
2874
2932
  machine: input.machine ?? null,
2875
2933
  characters: input.characters.characters,
2876
2934
  world: input.world,
@@ -2888,6 +2946,7 @@ function briefContext(input) {
2888
2946
  };
2889
2947
  case "P8": return {
2890
2948
  ...base,
2949
+ plan: input.plan,
2891
2950
  check: input.check,
2892
2951
  review: input.review,
2893
2952
  summary: input.plan?.summary ?? null,
@@ -2897,6 +2956,238 @@ function briefContext(input) {
2897
2956
  default: return base;
2898
2957
  }
2899
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
+ }
2900
3191
  // src/engine/state-delta.ts
2901
3192
  const SECTIONS = [
2902
3193
  "characters",
@@ -2911,6 +3202,15 @@ const ACTIONS = [
2911
3202
  "no_change"
2912
3203
  ];
2913
3204
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3205
+ /** Define an own property even for names such as `__proto__` or `constructor`. */
3206
+ const setOwn = (record, key, value) => {
3207
+ Object.defineProperty(record, key, {
3208
+ value,
3209
+ enumerable: true,
3210
+ configurable: true,
3211
+ writable: true
3212
+ });
3213
+ };
2914
3214
  function fail$1(detail) {
2915
3215
  throw new EngineInputError(ERROR_CODES.DELTA_UNVERIFIED, `state_delta is incomplete: ${detail}`, "declare every entity the chapter touched as created, updated, or no_change with a reason");
2916
3216
  }
@@ -2961,7 +3261,7 @@ function applyStateDelta(input) {
2961
3261
  const foreshadows = structuredClone(input.foreshadows);
2962
3262
  const timeline = structuredClone(input.timeline);
2963
3263
  const relationships = structuredClone(input.relationships);
2964
- const assigned = {};
3264
+ const assigned = Object.create(null);
2965
3265
  const appeared = [...new Set(summary.characters_appeared)];
2966
3266
  const byName = new Map(characters.characters.map((character) => [character.name, character]));
2967
3267
  const narrator = input.narrator ?? "";
@@ -2973,7 +3273,7 @@ function applyStateDelta(input) {
2973
3273
  };
2974
3274
  for (const name of appeared) {
2975
3275
  if (!appearsInManuscript(name)) fail$1(`character "${name}" is listed in characters_appeared but does not appear in the manuscript`);
2976
- if (delta.characters[name] === void 0) fail$1(`character "${name}" appeared in the chapter but is not declared in characters`);
3276
+ if (!Object.hasOwn(delta.characters, name)) fail$1(`character "${name}" appeared in the chapter but is not declared in characters`);
2977
3277
  }
2978
3278
  for (const [name, raw] of Object.entries(delta.characters)) {
2979
3279
  const declared = entry(raw, `characters.${name}`);
@@ -3020,7 +3320,7 @@ function applyStateDelta(input) {
3020
3320
  }
3021
3321
  for (const change of summary.resource_changes) {
3022
3322
  if (!isRecord(change) || typeof change.character !== "string" || change.character.trim() === "" || typeof change.resource !== "string" || change.resource.trim() === "" || !Number.isInteger(change.delta) || typeof change.reason !== "string" || change.reason.trim() === "") fail$1("every resource_changes entry must declare character/resource/delta/reason");
3023
- if (delta.characters[change.character] === void 0) fail$1(`resource change for "${change.character}" has no character declaration`);
3323
+ if (!Object.hasOwn(delta.characters, change.character)) fail$1(`resource change for "${change.character}" has no character declaration`);
3024
3324
  if (delta.characters[change.character].action === "no_change") fail$1(`resource change for "${change.character}" conflicts with a no_change declaration`);
3025
3325
  const character = byName.get(change.character);
3026
3326
  if (character === void 0) fail$1(`resource change names unknown character "${change.character}"`);
@@ -3067,8 +3367,8 @@ function applyStateDelta(input) {
3067
3367
  ["artifact", summary.new_elements.artifacts]
3068
3368
  ]) for (const name of names) {
3069
3369
  if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
3370
+ if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
3070
3371
  const declared = delta.world[name];
3071
- if (declared === void 0) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
3072
3372
  if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
3073
3373
  if (declared.kind !== void 0 && declared.kind !== kind) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${kind}`);
3074
3374
  }
@@ -3086,9 +3386,19 @@ function applyStateDelta(input) {
3086
3386
  }
3087
3387
  const existing = findWorld(name);
3088
3388
  if (declared.action === "created") {
3389
+ if (name.trim() === "") fail$1("a created world entity has an empty name");
3089
3390
  if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
3391
+ if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
3090
3392
  const kind = declared.kind ?? expected;
3091
3393
  if (kind === null) fail$1(`world.${name} must declare kind (location|ability|artifact|faction|rule)`);
3394
+ if (![
3395
+ "location",
3396
+ "ability",
3397
+ "artifact",
3398
+ "faction",
3399
+ "rule"
3400
+ ].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
3401
+ if (expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
3092
3402
  if (kind === "location") world.locations.push({
3093
3403
  name,
3094
3404
  first_appearance: chapter,
@@ -3099,10 +3409,10 @@ function applyStateDelta(input) {
3099
3409
  changes: []
3100
3410
  });
3101
3411
  else world[kind === "ability" ? "abilities" : kind === "artifact" ? "artifacts" : kind === "faction" ? "factions" : "rules"].push({
3412
+ ...declared.detail ?? {},
3102
3413
  name,
3103
3414
  first_appearance: chapter,
3104
- description: declared.description ?? "",
3105
- ...declared.detail ?? {}
3415
+ description: declared.description ?? ""
3106
3416
  });
3107
3417
  continue;
3108
3418
  }
@@ -3124,35 +3434,45 @@ function applyStateDelta(input) {
3124
3434
  }
3125
3435
  }
3126
3436
  }
3127
- for (const name of appeared) if (locks.characters[name] === void 0) {
3437
+ for (const name of appeared) if (!Object.hasOwn(locks.characters, name)) {
3128
3438
  const character = byName.get(name);
3129
- locks.characters[name] = {
3439
+ setOwn(locks.characters, name, {
3130
3440
  first_appearance: character.first_appearance,
3131
3441
  locked_at: chapter,
3132
3442
  appearance: appearanceOf(input.text, name, name === narratorName ? narratorAliases : [])
3133
- };
3443
+ });
3134
3444
  }
3135
- for (const location of world.locations) if (locks.locations[location.name] === void 0) locks.locations[location.name] = {
3445
+ for (const location of world.locations) if (!Object.hasOwn(locks.locations, location.name)) setOwn(locks.locations, location.name, {
3136
3446
  first_appearance: location.first_appearance,
3137
3447
  locked_at: chapter,
3138
3448
  layout: location.layout,
3139
3449
  capacity: location.capacity
3140
- };
3450
+ });
3141
3451
  locks.locked_at_chapter = Math.max(locks.locked_at_chapter, chapter);
3142
3452
  const byId = new Map(foreshadows.items.map((item) => [item.id, item]));
3143
3453
  const plantedKeys = /* @__PURE__ */ new Set();
3144
3454
  for (const plant of summary.foreshadows.planted) {
3145
3455
  if (plant.key.trim() === "") fail$1("a planted foreshadow has an empty key");
3456
+ if (byId.has(plant.key)) fail$1(`foreshadow key "${plant.key}" collides with an existing id`);
3146
3457
  if (plantedKeys.has(plant.key)) fail$1(`foreshadow key "${plant.key}" is planted twice`);
3147
3458
  plantedKeys.add(plant.key);
3459
+ if (!Object.hasOwn(delta.foreshadows, plant.key)) fail$1(`foreshadows.${plant.key} is not declared`);
3148
3460
  const declared = entry(delta.foreshadows[plant.key], `foreshadows.${plant.key}`);
3149
3461
  requireReason(declared, `foreshadows.${plant.key}`);
3150
3462
  if (declared.action !== "created") fail$1(`foreshadows.${plant.key} is planted; its action must be created`);
3463
+ if (typeof plant.title !== "string" || plant.title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty summary title`);
3464
+ if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" does not appear in the manuscript`);
3465
+ if (declared.title !== void 0 && typeof declared.title !== "string") fail$1(`foreshadows.${plant.key}.title must be a string`);
3466
+ const title = declared.title ?? plant.title;
3467
+ if (title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty title`);
3468
+ if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" does not appear in the manuscript`);
3151
3469
  const allocated = allocateIds("FS", foreshadows.next_seq, 1);
3470
+ const id = allocated.ids[0];
3471
+ if (byId.has(id)) fail$1(`foreshadows.next_seq is behind the ledger; ${id} already exists`);
3152
3472
  foreshadows.next_seq = allocated.nextSeq;
3153
3473
  const item = {
3154
- id: allocated.ids[0],
3155
- title: declared.title ?? plant.title,
3474
+ id,
3475
+ title,
3156
3476
  planted: chapter,
3157
3477
  last_touched: chapter,
3158
3478
  status: "planted",
@@ -3165,12 +3485,14 @@ function applyStateDelta(input) {
3165
3485
  assigned[plant.key] = item.id;
3166
3486
  }
3167
3487
  for (const advance of summary.foreshadows.advanced) {
3488
+ if (!Object.hasOwn(delta.foreshadows, advance.key)) fail$1(`foreshadows.${advance.key} is not declared`);
3168
3489
  const declared = entry(delta.foreshadows[advance.key], `foreshadows.${advance.key}`);
3169
3490
  requireReason(declared, `foreshadows.${advance.key}`);
3170
3491
  if (declared.action !== "updated") fail$1(`foreshadows.${advance.key} is advanced; its action must be updated`);
3171
3492
  const id = assigned[advance.key] ?? advance.key;
3172
3493
  const item = byId.get(id);
3173
3494
  if (item === void 0) fail$1(`foreshadow "${advance.key}" does not exist; no fallback is allowed`);
3495
+ if (item.status === "resolved") fail$1(`foreshadow "${advance.key}" is already resolved`);
3174
3496
  item.last_touched = chapter;
3175
3497
  item.status = "advanced";
3176
3498
  item.advance_log.push({
@@ -3181,12 +3503,14 @@ function applyStateDelta(input) {
3181
3503
  assigned[advance.key] = item.id;
3182
3504
  }
3183
3505
  for (const key of summary.foreshadows.resolved) {
3506
+ if (!Object.hasOwn(delta.foreshadows, key)) fail$1(`foreshadows.${key} is not declared`);
3184
3507
  const declared = entry(delta.foreshadows[key], `foreshadows.${key}`);
3185
3508
  requireReason(declared, `foreshadows.${key}`);
3186
3509
  if (declared.action !== "updated") fail$1(`foreshadows.${key} is resolved; its action must be updated`);
3187
3510
  const id = assigned[key] ?? key;
3188
3511
  const item = byId.get(id);
3189
3512
  if (item === void 0) fail$1(`foreshadow "${key}" does not exist; no fallback is allowed`);
3513
+ if (item.status === "resolved") fail$1(`foreshadow "${key}" is already resolved`);
3190
3514
  item.status = "resolved";
3191
3515
  item.last_touched = chapter;
3192
3516
  item.resolved_at = chapter;
@@ -3218,8 +3542,9 @@ function applyStateDelta(input) {
3218
3542
  const declared = entry(delta.timeline[key], `timeline.${key}`);
3219
3543
  if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
3220
3544
  const allocated = allocateIds("TE", timeline.next_seq, 1);
3221
- timeline.next_seq = allocated.nextSeq;
3222
3545
  const id = allocated.ids[0];
3546
+ if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
3547
+ timeline.next_seq = allocated.nextSeq;
3223
3548
  timeline.items.push({
3224
3549
  id,
3225
3550
  chapter,
@@ -3246,8 +3571,9 @@ function applyStateDelta(input) {
3246
3571
  if (existing !== void 0) fail$1(`relationship ${pairKey(a, b)} already exists`);
3247
3572
  if (typeof declared.type !== "string" || declared.type.trim() === "") fail$1(`relationships.${key} is created but has no type`);
3248
3573
  const allocated = allocateIds("RL", relationships.next_seq, 1);
3249
- relationships.next_seq = allocated.nextSeq;
3250
3574
  const id = allocated.ids[0];
3575
+ if (relationships.items.some((item) => item.id === id)) fail$1(`relationships.next_seq is behind the ledger; ${id} already exists`);
3576
+ relationships.next_seq = allocated.nextSeq;
3251
3577
  relationships.items.push({
3252
3578
  id,
3253
3579
  a,
@@ -3301,6 +3627,23 @@ function evidenceQuote(value) {
3301
3627
  candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
3302
3628
  return candidate;
3303
3629
  }
3630
+ /** Sentence spans, including the terminating punctuation, for evidence de-duplication. */
3631
+ function sentenceSpans(text) {
3632
+ const spans = [];
3633
+ let start = 0;
3634
+ for (let index = 0; index < text.length; index += 1) if ("。!?!?".includes(text[index])) {
3635
+ spans.push({
3636
+ start,
3637
+ end: index + 1
3638
+ });
3639
+ start = index + 1;
3640
+ }
3641
+ if (start < text.length) spans.push({
3642
+ start,
3643
+ end: text.length
3644
+ });
3645
+ return spans;
3646
+ }
3304
3647
  /**
3305
3648
  * Validate the model-supplied L2 semantic items. Evidence must be a real
3306
3649
  * substring of the manuscript; a failed verdict is itself a blocking finding.
@@ -3309,6 +3652,10 @@ function evidenceQuote(value) {
3309
3652
  function evaluateSemantic(text, items) {
3310
3653
  const blocking = [];
3311
3654
  const seen = /* @__PURE__ */ new Set();
3655
+ const seenQuotes = /* @__PURE__ */ new Set();
3656
+ const spans = [];
3657
+ const sentences = sentenceSpans(text);
3658
+ const usedSentences = /* @__PURE__ */ new Set();
3312
3659
  for (const [index, item] of items.entries()) {
3313
3660
  const where = `semantic[${index}]`;
3314
3661
  if (typeof item !== "object" || item === null) {
@@ -3353,11 +3700,11 @@ function evaluateSemantic(text, items) {
3353
3700
  continue;
3354
3701
  }
3355
3702
  const quote = evidenceQuote(item.evidence);
3356
- if (Array.from(quote).length < 4) {
3703
+ if (Array.from(quote).length < 8) {
3357
3704
  blocking.push({
3358
3705
  code: "SEMANTIC_EVIDENCE_INVALID",
3359
3706
  scope: "check",
3360
- detail: `${item.id} quotes fewer than 4 characters`
3707
+ detail: `${item.id} quotes fewer than 8 characters`
3361
3708
  });
3362
3709
  continue;
3363
3710
  }
@@ -3369,6 +3716,40 @@ function evaluateSemantic(text, items) {
3369
3716
  });
3370
3717
  continue;
3371
3718
  }
3719
+ if (seenQuotes.has(quote)) {
3720
+ blocking.push({
3721
+ code: "SEMANTIC_EVIDENCE_INVALID",
3722
+ scope: "check",
3723
+ detail: `${item.id} repeats another semantic item's quote; each check needs distinct evidence`
3724
+ });
3725
+ continue;
3726
+ }
3727
+ let start = text.indexOf(quote);
3728
+ let sentence = -1;
3729
+ while (start !== -1) {
3730
+ const end = start + quote.length;
3731
+ const overlaps = spans.some((span) => start < span.end && span.start < end);
3732
+ const sentenceIndex = sentences.findIndex((candidate) => start >= candidate.start && end <= candidate.end);
3733
+ if (!overlaps && sentenceIndex !== -1 && !usedSentences.has(sentenceIndex)) {
3734
+ sentence = sentenceIndex;
3735
+ break;
3736
+ }
3737
+ start = text.indexOf(quote, start + 1);
3738
+ }
3739
+ if (start === -1 || sentence === -1) {
3740
+ blocking.push({
3741
+ code: "SEMANTIC_EVIDENCE_INVALID",
3742
+ scope: "check",
3743
+ detail: `${item.id} cites overlapping text or the same sentence as another check; each check needs a distinct passage`
3744
+ });
3745
+ continue;
3746
+ }
3747
+ spans.push({
3748
+ start,
3749
+ end: start + quote.length
3750
+ });
3751
+ usedSentences.add(sentence);
3752
+ seenQuotes.add(quote);
3372
3753
  if (item.verdict === "fail") blocking.push({
3373
3754
  code: "SEMANTIC_CHECK_FAILED",
3374
3755
  scope: "check",
@@ -3421,6 +3802,7 @@ const wordCount = (input) => {
3421
3802
  };
3422
3803
  /** PG-04: format blockers must be zero before length is judged. */
3423
3804
  const format = (input, options) => {
3805
+ if (input.scope !== void 0 && !input.scope.includes("content")) return [];
3424
3806
  const violations = runRules([
3425
3807
  "MD_CONTAMINATION",
3426
3808
  "ILLEGAL_QUOTE",
@@ -3501,6 +3883,7 @@ const summarySchema = (input) => {
3501
3883
  if (!isText(summary.opening)) problems.push("opening must be text");
3502
3884
  else {
3503
3885
  const opening = summary.opening;
3886
+ if (Array.from(opening).length < LIMITS.minQuoteChars) problems.push(`opening must be at least ${LIMITS.minQuoteChars} characters`);
3504
3887
  if (!splitParagraphs(input.text).slice(0, 2).some((paragraph) => paragraph.includes(opening))) problems.push("opening must be quoted from the first two paragraphs");
3505
3888
  }
3506
3889
  const continuity = summary.continuity;
@@ -3537,6 +3920,7 @@ const score = (input, options) => {
3537
3920
  if (review === null) return [block("PG-07", "no review document supplied")];
3538
3921
  if (!Array.isArray(review.dimensions)) return [block("PG-07", "review dimensions must be an array")];
3539
3922
  const problems = reviewArithmeticProblems(review.dimensions, review.total);
3923
+ if (applyExclusions(review.dimensions, options.rulepack.exclusions).dropped > 0) problems.push("review contains mutually exclusive or duplicate deductions; resubmit it through novel_review");
3540
3924
  if (review.content_hash !== sha256(input.text)) problems.push("review content_hash does not match the current manuscript");
3541
3925
  const paragraphCount = splitParagraphs(input.text).length;
3542
3926
  for (const dimension of review.dimensions) {
@@ -3616,18 +4000,22 @@ const readingProof = (input) => {
3616
4000
  ending
3617
4001
  ]) if (problem !== null) problems.push(problem);
3618
4002
  const paragraphs = splitParagraphs(input.text).length;
3619
- if (input.proof.opening.paragraph > 2) problems.push("the opening quote must come from the first two paragraphs");
3620
- if (input.proof.ending.paragraph < Math.max(1, paragraphs - 1)) problems.push("the ending quote must come from the last two paragraphs");
3621
4003
  const actuals = [
3622
4004
  input.proof.opening,
3623
4005
  input.proof.middle,
3624
4006
  input.proof.ending
3625
4007
  ].map((reference) => actualParagraph(input.text, reference));
4008
+ if (actuals[0] > 2) problems.push("the opening quote must come from the first two paragraphs");
4009
+ const middleMin = paragraphs <= 4 ? 2 : 3;
4010
+ const middleMax = paragraphs <= 4 ? Math.max(2, paragraphs - 1) : paragraphs - 2;
4011
+ if (actuals[1] > 0 && (actuals[1] < middleMin || actuals[1] > middleMax)) problems.push("the middle quote must come from the body of the chapter, not its first or last edges");
4012
+ if (actuals[2] > 0 && actuals[2] < Math.max(1, paragraphs - 1)) problems.push("the ending quote must come from the last two paragraphs");
3626
4013
  if (new Set(actuals.filter((index) => index > 0)).size < 3) problems.push("the three quotes must come from three distinct paragraphs");
3627
4014
  return problems.length === 0 ? [] : [block("PG-09", problems.join("; "), "quote at least 12 characters from the real paragraph you read")];
3628
4015
  };
3629
4016
  /** PG-10: cross-chapter reconciliation must be clean. */
3630
4017
  const consistency = (input, options) => {
4018
+ if (input.scope !== void 0 && !input.scope.includes("integrity")) return [];
3631
4019
  const violations = runRules([
3632
4020
  "DUPLICATE_PARAGRAPH",
3633
4021
  "FORESHADOW_DORMANT",
@@ -3662,8 +4050,9 @@ const consistency = (input, options) => {
3662
4050
  /** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
3663
4051
  const rounds = (input) => {
3664
4052
  if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
3665
- if (!roundsExhausted(input.rounds)) return [];
3666
- return [block("PG-11", `round limits reached (review ${input.rounds.review}, revise ${input.rounds.revise}, check ${input.rounds.check})`, "call novel_rollback to restart the chapter; the manuscript is kept for reference")];
4053
+ const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
4054
+ if (!roundsExhausted(input.rounds, counter)) return [];
4055
+ return [block("PG-11", `round limits reached for ${counter} (review ${input.rounds.review}, revise ${input.rounds.revise}, check ${input.rounds.check})`, "call novel_rollback to restart the chapter; the manuscript is kept for reference")];
3667
4056
  };
3668
4057
  /** PG-12: the archive transaction's preconditions, checked before staging. */
3669
4058
  const archiveAtomic = (input) => {
@@ -3753,6 +4142,7 @@ function applicableGates(input) {
3753
4142
  "PG-06",
3754
4143
  "PG-07",
3755
4144
  "PG-08",
4145
+ "PG-09",
3756
4146
  "PG-10",
3757
4147
  "PG-12"
3758
4148
  ];
@@ -3784,7 +4174,7 @@ const STAGE_TO_PHASE = {
3784
4174
  P4: "P4",
3785
4175
  P5: "P5",
3786
4176
  P6: "P6",
3787
- ready_to_archive: "P8",
4177
+ ready_to_archive: "P7",
3788
4178
  done: "P9"
3789
4179
  };
3790
4180
  /** The nine-phase progress strip: phases before the active one are done. */
@@ -3818,23 +4208,6 @@ function projectChapter(record) {
3818
4208
  details: record.summary.events.join(";").slice(0, 240)
3819
4209
  };
3820
4210
  }
3821
- /** Status report used by the tool and by commands; one function, three callers. */
3822
- function projectStatus(snapshot, next) {
3823
- return {
3824
- project: {
3825
- title: snapshot.title,
3826
- genre: snapshot.genre,
3827
- platform: snapshot.platform,
3828
- root: snapshot.root
3829
- },
3830
- chapter: snapshot.chapter,
3831
- stage: snapshot.stage,
3832
- rounds: { ...snapshot.rounds },
3833
- blocking: snapshot.blocking,
3834
- metrics: null,
3835
- next
3836
- };
3837
- }
3838
4211
  function projectSummary(snapshot) {
3839
4212
  return {
3840
4213
  schemaVersion: 1,
@@ -3979,6 +4352,17 @@ const ROUND_TRANSITIONS = {
3979
4352
  P5: "P5",
3980
4353
  P6: "P6"
3981
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
+ };
3982
4366
  const MUTABLE_STATE = {
3983
4367
  [joinRel(STATE_DIR, STATE_FILES.pipeline)]: "pipeline",
3984
4368
  [joinRel(STATE_DIR, STATE_FILES.chapters)]: "chapters",
@@ -4013,6 +4397,20 @@ var EngineService = class {
4013
4397
  /** Compiled on first use so `apply()` performs no IO. */
4014
4398
  rulepackSource;
4015
4399
  compiled = null;
4400
+ disposed = false;
4401
+ /**
4402
+ * Release in-process caches on host teardown. The engine owns no file
4403
+ * handles, so this is deliberately synchronous and idempotent; it prevents a
4404
+ * disposed instance from keeping project stores and replay results alive.
4405
+ */
4406
+ dispose() {
4407
+ if (this.disposed) return;
4408
+ this.disposed = true;
4409
+ this.stores.clear();
4410
+ this.replay.clear();
4411
+ this.lastRoot = null;
4412
+ this.compiled = null;
4413
+ }
4016
4414
  get rulepack() {
4017
4415
  this.compiled ??= this.rulepackSource();
4018
4416
  return this.compiled;
@@ -4052,6 +4450,8 @@ var EngineService = class {
4052
4450
  async initProject(input) {
4053
4451
  const title = typeof input.title === "string" ? input.title.trim() : "";
4054
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");
4055
4455
  if (!GENRES.includes(input.genre)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown genre "${String(input.genre)}"`, `allowed: ${GENRES.join(", ")}`);
4056
4456
  if (!PLATFORMS.includes(input.platform)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown platform "${String(input.platform)}"`, `allowed: ${PLATFORMS.join(", ")}`);
4057
4457
  if (input.words_per_chapter !== void 0) {
@@ -4119,6 +4519,7 @@ var EngineService = class {
4119
4519
  });
4120
4520
  }
4121
4521
  async getStatus(input = {}) {
4522
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4122
4523
  const snapshot = await this.load(input.root);
4123
4524
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4124
4525
  const metrics = await this.metricsFor(snapshot, chapter);
@@ -4132,9 +4533,11 @@ var EngineService = class {
4132
4533
  root: snapshot.root
4133
4534
  },
4134
4535
  chapter,
4536
+ active_chapter: snapshot.pipeline.chapter,
4135
4537
  stage: snapshot.pipeline.stage,
4136
4538
  rounds: { ...snapshot.pipeline.rounds },
4137
- blocking: snapshot.pipeline.blocking,
4539
+ blocking: this.effectiveBlocking(snapshot),
4540
+ state_errors: this.stateErrors(snapshot),
4138
4541
  metrics,
4139
4542
  next
4140
4543
  },
@@ -4148,6 +4551,7 @@ var EngineService = class {
4148
4551
  revision: 1,
4149
4552
  warnings: []
4150
4553
  };
4554
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4151
4555
  const snapshot = await this.load(input.root);
4152
4556
  return {
4153
4557
  data: countWordMetrics((input.chapter === void 0 ? snapshot.text : await this.readChapter(snapshot, input.chapter)) ?? ""),
@@ -4156,6 +4560,7 @@ var EngineService = class {
4156
4560
  };
4157
4561
  }
4158
4562
  async queryState(input) {
4563
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4159
4564
  const snapshot = await this.load(input.root);
4160
4565
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4161
4566
  const store = this.storeFor(snapshot.root);
@@ -4206,12 +4611,12 @@ var EngineService = class {
4206
4611
  warnings: []
4207
4612
  };
4208
4613
  case "review": return {
4209
- data: snapshot.review,
4614
+ data: chapter === snapshot.pipeline.chapter ? snapshot.review : await store.readDoc(reviewFile(chapter), "review"),
4210
4615
  revision: snapshot.pipeline.revision,
4211
4616
  warnings: []
4212
4617
  };
4213
4618
  case "check": return {
4214
- data: snapshot.check,
4619
+ data: chapter === snapshot.pipeline.chapter ? snapshot.check : await store.readDoc(checkFile(chapter), "check"),
4215
4620
  revision: snapshot.pipeline.revision,
4216
4621
  warnings: []
4217
4622
  };
@@ -4223,7 +4628,7 @@ var EngineService = class {
4223
4628
  case "planWorld": {
4224
4629
  const raw = await store.readText(planWorldFile(chapter));
4225
4630
  return {
4226
- data: raw === null ? null : parseYamlLoose(raw),
4631
+ data: raw === null ? null : parsePlanYaml(raw, planWorldFile(chapter)),
4227
4632
  revision: snapshot.pipeline.revision,
4228
4633
  warnings: []
4229
4634
  };
@@ -4231,7 +4636,7 @@ var EngineService = class {
4231
4636
  case "planChapter": {
4232
4637
  const raw = await store.readText(planChapterFile(chapter));
4233
4638
  return {
4234
- data: raw === null ? null : parseYamlLoose(raw),
4639
+ data: raw === null ? null : parsePlanYaml(raw, planChapterFile(chapter)),
4235
4640
  revision: snapshot.pipeline.revision,
4236
4641
  warnings: []
4237
4642
  };
@@ -4249,59 +4654,65 @@ var EngineService = class {
4249
4654
  tool: next.tool,
4250
4655
  args: next.args,
4251
4656
  reason: next.reason,
4252
- blocking: snapshot.pipeline.blocking
4657
+ blocking: this.effectiveBlocking(snapshot),
4658
+ state_errors: this.stateErrors(snapshot)
4253
4659
  },
4254
4660
  revision: snapshot.pipeline.revision,
4255
4661
  warnings: []
4256
4662
  };
4257
4663
  }
4258
4664
  async getBrief(input) {
4259
- const snapshot = await this.load(input.root);
4260
- const chapter = input.chapter ?? snapshot.pipeline.chapter;
4261
- const context = this.briefContextFor(input.stage, snapshot, chapter);
4262
- const digest = this.briefDigest(input.stage, snapshot, context);
4263
- const variables = this.briefVariables(snapshot, chapter);
4264
- const rendered = this.briefs.render(input.stage, variables);
4265
- const fields = {
4266
- stage: input.stage,
4267
- chapter,
4268
- digest,
4269
- min_words: snapshot.project.targets.words_per_chapter[0],
4270
- min_score: this.config.quality.minScore,
4271
- scene_types: [
4272
- "战斗/动作章",
4273
- "日常过渡章",
4274
- "情感爆发章",
4275
- "纯叙事章"
4276
- ],
4277
- platform_style: this.rulepack.platform[snapshot.project.platform],
4278
- genre_guide: this.rulepack.genres[snapshot.project.genre],
4279
- /** P4 must apply exclusions before its arithmetic closes. */
4280
- score_exclusions: this.rulepack.exclusions,
4281
- next_tool: NEXT_TOOL_BY_BRIEF[input.stage]
4282
- };
4283
- const existing = snapshot.pipeline.briefs[input.stage];
4284
- if (existing !== void 0 && existing.digest === digest) return {
4285
- data: {
4286
- stage: input.stage,
4287
- chapter,
4288
- digest,
4289
- title: rendered.title,
4290
- content: rendered.content,
4291
- content_hash: rendered.contentHash,
4292
- fields,
4293
- context
4294
- },
4295
- revision: snapshot.pipeline.revision,
4296
- warnings: []
4297
- };
4665
+ await this.load(input.root);
4298
4666
  return this.mutate({
4299
4667
  root: input.root,
4300
4668
  op: "getBrief",
4301
- run: async (fresh, ctx) => {
4302
- const nextPipeline = pipelineWith(fresh, ctx.revision, {
4669
+ run: async (snapshot, context) => {
4670
+ const chapter = input.chapter ?? snapshot.pipeline.chapter;
4671
+ if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `brief ${input.stage} belongs to active chapter ${snapshot.pipeline.chapter}, not ${chapter}`, `omit chapter or pass ${snapshot.pipeline.chapter}`);
4672
+ const contextPack = this.briefContextFor(input.stage, snapshot, chapter);
4673
+ const digest = this.briefDigest(input.stage, snapshot, contextPack);
4674
+ const variables = this.briefVariables(snapshot, chapter);
4675
+ const rendered = this.briefs.render(input.stage, variables);
4676
+ const fields = {
4677
+ stage: input.stage,
4678
+ chapter,
4679
+ digest,
4680
+ min_words: snapshot.project.targets.words_per_chapter[0],
4681
+ min_score: this.config.quality.minScore,
4682
+ scene_types: [
4683
+ "战斗/动作章",
4684
+ "日常过渡章",
4685
+ "情感爆发章",
4686
+ "纯叙事章"
4687
+ ],
4688
+ platform_style: this.rulepack.platform[snapshot.project.platform],
4689
+ genre_guide: this.rulepack.genres[snapshot.project.genre],
4690
+ /** P4 must apply exclusions before its arithmetic closes. */
4691
+ score_exclusions: this.rulepack.exclusions,
4692
+ next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
4693
+ ...snapshot.planError === null ? {} : { plan_error: snapshot.planError },
4694
+ ...snapshot.reviewError === null ? {} : { review_error: snapshot.reviewError },
4695
+ ...snapshot.checkError === null ? {} : { check_error: snapshot.checkError }
4696
+ };
4697
+ const data = {
4698
+ stage: input.stage,
4699
+ chapter,
4700
+ digest,
4701
+ title: rendered.title,
4702
+ content: rendered.content,
4703
+ content_hash: rendered.contentHash,
4704
+ fields,
4705
+ context: contextPack
4706
+ };
4707
+ const existing = snapshot.pipeline.briefs[input.stage];
4708
+ if (existing !== void 0 && existing.digest === digest) return {
4709
+ files: [],
4710
+ data,
4711
+ replay: true
4712
+ };
4713
+ const nextPipeline = pipelineWith(snapshot, context.revision, {
4303
4714
  briefs: {
4304
- ...fresh.pipeline.briefs,
4715
+ ...snapshot.pipeline.briefs,
4305
4716
  [input.stage]: {
4306
4717
  issued_at: this.now().toISOString(),
4307
4718
  digest
@@ -4315,22 +4726,27 @@ var EngineService = class {
4315
4726
  text: yaml(nextPipeline)
4316
4727
  }],
4317
4728
  backup: false,
4318
- data: {
4319
- stage: input.stage,
4320
- chapter,
4321
- digest,
4322
- title: rendered.title,
4323
- content: rendered.content,
4324
- content_hash: rendered.contentHash,
4325
- fields,
4326
- context
4327
- }
4729
+ data
4328
4730
  };
4329
4731
  }
4330
4732
  });
4331
4733
  }
4332
4734
  async submitWorldPlan(input) {
4333
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
+ }
4334
4750
  return this.mutate({
4335
4751
  root: input.root,
4336
4752
  op: "submitWorldPlan",
@@ -4341,10 +4757,10 @@ var EngineService = class {
4341
4757
  const plan = {
4342
4758
  chapter,
4343
4759
  objective: input.objective,
4344
- constraints: input.constraints,
4345
- characters: input.characters,
4346
- locations: input.locations,
4347
- continuity_notes: input.continuity_notes
4760
+ constraints,
4761
+ characters,
4762
+ locations,
4763
+ continuity_notes
4348
4764
  };
4349
4765
  const path = planWorldFile(chapter);
4350
4766
  const nextPipeline = pipelineWith(snapshot, context.revision, {
@@ -4379,6 +4795,13 @@ var EngineService = class {
4379
4795
  }
4380
4796
  async submitChapterPlan(input) {
4381
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`);
4382
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(", ")}`);
4383
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");
4384
4807
  const [min, max] = input.target_words;
@@ -4394,6 +4817,7 @@ var EngineService = class {
4394
4817
  const plan = {
4395
4818
  ...input,
4396
4819
  chapter,
4820
+ beats,
4397
4821
  schema_version: 1
4398
4822
  };
4399
4823
  delete plan.root;
@@ -4451,7 +4875,7 @@ var EngineService = class {
4451
4875
  const planPath = planChapterFile(chapter);
4452
4876
  const planText = await context.store.readText(planPath);
4453
4877
  const mergedPlan = {
4454
- ...(planText === null ? null : parseYamlLoose(planText)) ?? {
4878
+ ...(planText === null ? null : parsePlanYaml(planText, planPath)) ?? {
4455
4879
  schema_version: 1,
4456
4880
  chapter
4457
4881
  },
@@ -4527,6 +4951,11 @@ var EngineService = class {
4527
4951
  "fix"
4528
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");
4529
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} 段`);
4530
4959
  }
4531
4960
  const review = {
4532
4961
  schema_version: 1,
@@ -4559,6 +4988,7 @@ var EngineService = class {
4559
4988
  detail: `score ${input.total} is below the minimum ${this.config.quality.minScore}`,
4560
4989
  hint: "revise the chapter and submit a new P4 review"
4561
4990
  });
4991
+ const reviewDigest = sha256(stableJson(review));
4562
4992
  const nextPipeline = pipelineWith(snapshot, context.revision, {
4563
4993
  stage: next,
4564
4994
  rounds: {
@@ -4566,7 +4996,8 @@ var EngineService = class {
4566
4996
  review: snapshot.pipeline.rounds.review + 1
4567
4997
  },
4568
4998
  blocking,
4569
- last_action: action("novel_review", this.now(), sha256(stableJson(review)))
4999
+ review_digest: reviewDigest,
5000
+ last_action: action("novel_review", this.now(), reviewDigest)
4570
5001
  });
4571
5002
  return {
4572
5003
  files: [{
@@ -4629,7 +5060,15 @@ var EngineService = class {
4629
5060
  seen.add(submitted.id);
4630
5061
  const before = byId.get(submitted.id);
4631
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");
4632
- 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");
4633
5072
  const index = issues.findIndex((issue) => issue.id === submitted.id);
4634
5073
  issues[index] = {
4635
5074
  ...before,
@@ -4669,11 +5108,12 @@ var EngineService = class {
4669
5108
  revise: snapshot.pipeline.rounds.revise + 1
4670
5109
  },
4671
5110
  blocking: [],
5111
+ ...patchedReview === null ? {} : { review_digest: sha256(stableJson(patchedReview)) },
4672
5112
  last_action: action("novel_revise", this.now(), sha256(input.content))
4673
5113
  });
4674
5114
  const planPath = planChapterFile(input.chapter);
4675
5115
  const planText = await context.store.readText(planPath);
4676
- const plan = planText === null ? null : parseYamlLoose(planText);
5116
+ const plan = planText === null ? null : parsePlanYaml(planText, planPath);
4677
5117
  const files = [{
4678
5118
  path: chapterFile(input.chapter),
4679
5119
  text: input.content
@@ -4706,12 +5146,13 @@ var EngineService = class {
4706
5146
  });
4707
5147
  }
4708
5148
  async runCheck(input) {
5149
+ const scope = normalizeCheckScope(input.scope);
4709
5150
  return this.mutate({
4710
5151
  root: input.root,
4711
5152
  op: "runCheck",
4712
5153
  key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
4713
5154
  semantic: input.semantic ?? [],
4714
- scope: input.scope ?? []
5155
+ scope
4715
5156
  }))}`,
4716
5157
  run: async (snapshot, context) => {
4717
5158
  this.assertChapter(snapshot, input.chapter);
@@ -4721,20 +5162,25 @@ var EngineService = class {
4721
5162
  briefStage: "P6",
4722
5163
  text,
4723
5164
  proof: snapshot.review?.proof ?? null,
4724
- semantic
5165
+ semantic,
5166
+ scope
4725
5167
  });
4726
5168
  const machine = this.machine(snapshot, text, { summary: snapshot.plan?.summary ?? null });
4727
- 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");
4728
5174
  const semanticResult = evaluateSemantic(text, semantic);
4729
5175
  const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
4730
5176
  const check = {
4731
5177
  schema_version: 1,
4732
5178
  chapter: input.chapter,
4733
- scope: input.scope ?? ["content", "integrity"],
5179
+ scope,
4734
5180
  content_hash: sha256(text),
4735
5181
  machine: {
4736
- violations: machine.violations,
4737
- metrics: machine.metrics
5182
+ violations: scopedMachine.violations,
5183
+ metrics: scopedMachine.metrics
4738
5184
  },
4739
5185
  semantic: { items: semantic },
4740
5186
  verdict
@@ -4745,7 +5191,7 @@ var EngineService = class {
4745
5191
  scope: "check",
4746
5192
  detail: violation.detail
4747
5193
  })), ...semanticResult.blocking] : [];
4748
- let nextPipeline = pipelineWith(snapshot, context.revision, {
5194
+ const nextPipeline = pipelineWith(snapshot, context.revision, {
4749
5195
  stage: nextStage,
4750
5196
  rounds: {
4751
5197
  ...snapshot.pipeline.rounds,
@@ -4754,24 +5200,6 @@ var EngineService = class {
4754
5200
  blocking: blockingItems,
4755
5201
  last_action: action("novel_check", this.now(), sha256(text))
4756
5202
  });
4757
- if (verdict === "fail" && nextPipeline.briefs.P6 !== void 0) {
4758
- const postCheck = {
4759
- ...snapshot,
4760
- check,
4761
- pipeline: nextPipeline
4762
- };
4763
- const digest = this.briefDigest("P6", postCheck, this.briefContextFor("P6", postCheck, input.chapter));
4764
- nextPipeline = {
4765
- ...nextPipeline,
4766
- briefs: {
4767
- ...nextPipeline.briefs,
4768
- P6: {
4769
- ...nextPipeline.briefs.P6,
4770
- digest
4771
- }
4772
- }
4773
- };
4774
- }
4775
5203
  return {
4776
5204
  files: [{
4777
5205
  path: checkFile(input.chapter),
@@ -4783,11 +5211,11 @@ var EngineService = class {
4783
5211
  data: {
4784
5212
  chapter: input.chapter,
4785
5213
  verdict,
4786
- violations: machine.violations.length,
4787
- metrics: machine.metrics,
5214
+ violations: scopedMachine.violations.length,
5215
+ metrics: scopedMachine.metrics,
4788
5216
  stage: nextStage
4789
5217
  },
4790
- warnings: machineWarnings(machine)
5218
+ warnings: machineWarnings(scopedMachine)
4791
5219
  };
4792
5220
  }
4793
5221
  });
@@ -4808,7 +5236,8 @@ var EngineService = class {
4808
5236
  run: async (snapshot, context) => {
4809
5237
  const already = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
4810
5238
  if (already !== void 0) {
4811
- if (already.archive_digest !== void 0 && 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");
5239
+ 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");
4812
5241
  const archivedText = await this.readChapter(snapshot, input.chapter);
4813
5242
  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");
4814
5243
  return {
@@ -4827,11 +5256,14 @@ var EngineService = class {
4827
5256
  const text = snapshot.text ?? "";
4828
5257
  const summary = snapshot.plan?.summary;
4829
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");
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");
4830
5261
  await this.assertGates(snapshot, "archiveChapter", {
4831
5262
  briefStage: "P8",
4832
5263
  text,
4833
5264
  summary,
4834
- review: snapshot.review
5265
+ review: snapshot.review,
5266
+ proof: snapshot.review?.proof ?? null
4835
5267
  });
4836
5268
  if (text === "") throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${input.chapter} has no draft`);
4837
5269
  const archiveBlockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
@@ -4992,7 +5424,7 @@ var EngineService = class {
4992
5424
  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");
4993
5425
  const resolvedRoot = await this.resolveRoot(input.root);
4994
5426
  const absolute = this.storeFor(resolvedRoot).path(input.path);
4995
- const kind = MUTABLE_STATE[input.path];
5427
+ const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
4996
5428
  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");
4997
5429
  return this.mutate({
4998
5430
  root: resolvedRoot,
@@ -5041,6 +5473,7 @@ var EngineService = class {
5041
5473
  this.assertChapter(snapshot, input.chapter);
5042
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(", ")}`);
5043
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");
5044
5477
  const nextPipeline = pipelineWith(snapshot, context.revision, {
5045
5478
  chapter: input.chapter,
5046
5479
  stage,
@@ -5259,6 +5692,7 @@ var EngineService = class {
5259
5692
  revision: snapshot.pipeline.revision,
5260
5693
  warnings: []
5261
5694
  };
5695
+ if (!Object.hasOwn(STATE_FILES, input.kind)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown state document "${String(input.kind)}"`, `allowed documents: ${Object.values(STATE_FILES).join(", ")}`);
5262
5696
  const path = joinRel(STATE_DIR, STATE_FILES[input.kind]);
5263
5697
  return await this.mutate({
5264
5698
  root: input.root,
@@ -5296,7 +5730,7 @@ var EngineService = class {
5296
5730
  title: (snapshot.plan?.title ?? "").replace(/^第\s*\d+\s*章\s*/, "").trim() || `第${snapshot.pipeline.chapter}章`,
5297
5731
  wordCount: draftWords,
5298
5732
  score: snapshot.review?.total ?? 0,
5299
- status: snapshot.pipeline.blocking.length > 0 ? "blocked" : "draft",
5733
+ status: this.effectiveBlocking(snapshot).length > 0 ? "blocked" : "draft",
5300
5734
  details: (snapshot.plan?.summary?.events ?? []).join(";").slice(0, 240)
5301
5735
  };
5302
5736
  return {
@@ -5308,7 +5742,7 @@ var EngineService = class {
5308
5742
  stage: snapshot.pipeline.stage,
5309
5743
  revision: snapshot.pipeline.revision,
5310
5744
  rounds: snapshot.pipeline.rounds,
5311
- blocking: snapshot.pipeline.blocking,
5745
+ blocking: this.effectiveBlocking(snapshot),
5312
5746
  next: finished ? void 0 : this.nextStep(snapshot),
5313
5747
  finished,
5314
5748
  chapters: archived,
@@ -5318,31 +5752,224 @@ var EngineService = class {
5318
5752
  };
5319
5753
  }
5320
5754
  nextStep(snapshot) {
5321
- if (snapshot.pipeline.blocking.length > 0) {
5322
- const first = snapshot.pipeline.blocking[0];
5323
- return {
5324
- tool: "novel_status",
5325
- args: {},
5326
- reason: `${first.code}: ${first.detail}${first.hint === void 0 ? "" : ` (${first.hint})`}`
5327
- };
5755
+ const stage = snapshot.pipeline.stage;
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;
5328
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
+ };
5771
+ const step = STAGE_NEXT[stage];
5329
5772
  const target = snapshot.project.targets.total_chapters;
5330
- if (snapshot.pipeline.stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
5773
+ if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
5331
5774
  tool: "novel_export",
5332
5775
  args: { kind: "epub" },
5333
5776
  reason: "全书已完成;可导出成书,或结束本书"
5334
5777
  };
5335
- const step = STAGE_NEXT[snapshot.pipeline.stage];
5336
- const args = snapshot.pipeline.stage === "ready_to_archive" ? {
5337
- stage: "P8",
5338
- chapter: snapshot.pipeline.chapter
5339
- } : { chapter: snapshot.pipeline.chapter };
5778
+ const roundsBlock = this.roundsLimitBlock(snapshot);
5779
+ if (roundsBlock !== null) return {
5780
+ tool: "novel_rollback",
5781
+ args: {
5782
+ chapter,
5783
+ to_stage: "P4"
5784
+ },
5785
+ reason: `${roundsBlock.code}: ${roundsBlock.detail}; ${roundsBlock.hint ?? ""}`.trim()
5786
+ };
5787
+ const first = this.effectiveBlocking(snapshot)[0];
5788
+ const detail = first === void 0 ? "" : `${first.code}: ${first.detail}${first.hint === void 0 ? "" : ` (${first.hint})`}`;
5789
+ const briefStage = STATUS_BRIEF[stage];
5790
+ const followUp = (stage === "P5" || stage === "P6") && snapshot.pipeline.blocking.length > 0 ? "novel_revise" : step.tool;
5791
+ if (briefStage !== null && !this.briefIsCurrent(snapshot, briefStage)) return {
5792
+ tool: "novel_brief",
5793
+ args: {
5794
+ stage: briefStage,
5795
+ chapter
5796
+ },
5797
+ reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
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
+ };
5804
+ if (snapshot.pipeline.blocking.length > 0) {
5805
+ if (stage === "P5" || stage === "P6") return {
5806
+ tool: "novel_revise",
5807
+ args: { chapter },
5808
+ reason: `${detail}; fix it with novel_revise`
5809
+ };
5810
+ return {
5811
+ tool: step.tool,
5812
+ args: this.nextArgs(stage, chapter),
5813
+ reason: `${detail}; ${step.reason}`
5814
+ };
5815
+ }
5340
5816
  return {
5341
5817
  tool: step.tool,
5342
- args,
5818
+ args: this.nextArgs(stage, chapter),
5343
5819
  reason: step.reason
5344
5820
  };
5345
5821
  }
5822
+ /** A computed blocker for the retry budget the next operation would consume. */
5823
+ roundsLimitBlock(snapshot) {
5824
+ const stage = snapshot.pipeline.stage;
5825
+ if (![
5826
+ "P4",
5827
+ "P5",
5828
+ "P6"
5829
+ ].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 {
5833
+ code: "PG-11",
5834
+ scope: "rounds",
5835
+ detail: `round limit reached for ${counter} (review ${snapshot.pipeline.rounds.review}, revise ${snapshot.pipeline.rounds.revise}, check ${snapshot.pipeline.rounds.check})`,
5836
+ hint: "call novel_rollback with to_stage P4; the manuscript is kept for reference"
5837
+ };
5838
+ }
5839
+ /** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
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
+ }));
5847
+ const extra = this.roundsLimitBlock(snapshot);
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
+ }));
5956
+ }
5957
+ /** Whether the stored brief still signs the current snapshot. */
5958
+ briefIsCurrent(snapshot, stage) {
5959
+ const record = snapshot.pipeline.briefs[stage];
5960
+ if (record === void 0) return false;
5961
+ const context = this.briefContextFor(stage, snapshot, snapshot.pipeline.chapter);
5962
+ return record.digest === this.briefDigest(stage, snapshot, context);
5963
+ }
5964
+ /** Arguments valid for the tool `STAGE_NEXT` recommends at this stage. */
5965
+ nextArgs(stage, chapter) {
5966
+ switch (stage) {
5967
+ case "idle":
5968
+ case "P2":
5969
+ case "done": return {};
5970
+ default: return { chapter };
5971
+ }
5972
+ }
5346
5973
  async metricsFor(snapshot, chapter) {
5347
5974
  if (chapter === snapshot.pipeline.chapter && snapshot.text !== null) return countWordMetrics(snapshot.text);
5348
5975
  const text = await this.readChapter(snapshot, chapter);
@@ -5360,7 +5987,8 @@ var EngineService = class {
5360
5987
  }
5361
5988
  async chaptersForExport(snapshot, chapter) {
5362
5989
  const records = snapshot.chapters.chapters;
5363
- if (chapter !== void 0 && !records.some((record) => record.num === chapter)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not in the archive index`, "archive the chapter first, or export all archived chapters");
5990
+ const activeDraft = chapter !== void 0 && chapter === snapshot.pipeline.chapter && snapshot.text !== null;
5991
+ if (chapter !== void 0 && !records.some((record) => record.num === chapter) && !activeDraft) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not archived and is not the active draft`, "archive the chapter first, or pass the active chapter to export its draft");
5364
5992
  const selected = chapter === void 0 ? records : records.filter((record) => record.num === chapter);
5365
5993
  const chapters = [];
5366
5994
  for (const record of selected) {
@@ -5372,7 +6000,7 @@ var EngineService = class {
5372
6000
  text
5373
6001
  });
5374
6002
  }
5375
- if (chapters.length === 0 && snapshot.text !== null && chapter === void 0) chapters.push({
6003
+ if (chapters.length === 0 && snapshot.text !== null && (chapter === void 0 || chapter === snapshot.pipeline.chapter)) chapters.push({
5376
6004
  num: snapshot.pipeline.chapter,
5377
6005
  title: snapshot.plan?.title ?? "未命名",
5378
6006
  text: snapshot.text
@@ -5419,8 +6047,11 @@ var EngineService = class {
5419
6047
  timeline: snapshot.timeline,
5420
6048
  relationships: snapshot.relationships,
5421
6049
  plan: snapshot.plan,
6050
+ plan_error: snapshot.planError,
5422
6051
  review: snapshot.review,
6052
+ review_error: snapshot.reviewError,
5423
6053
  check: snapshot.check,
6054
+ check_error: snapshot.checkError,
5424
6055
  text: snapshot.text,
5425
6056
  machine,
5426
6057
  previousText: snapshot.previousText,
@@ -5435,6 +6066,7 @@ var EngineService = class {
5435
6066
  chapter: snapshot.pipeline.chapter,
5436
6067
  pipelineStage: snapshot.pipeline.stage,
5437
6068
  rounds: snapshot.pipeline.rounds,
6069
+ min_score: this.config.quality.minScore,
5438
6070
  rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
5439
6071
  context
5440
6072
  }));
@@ -5455,6 +6087,8 @@ var EngineService = class {
5455
6087
  async assertGates(snapshot, operation, extras) {
5456
6088
  const text = extras.text ?? snapshot.text ?? "";
5457
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);
5458
6092
  const archive = operation === "archiveChapter" ? {
5459
6093
  chapterExists: snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter),
5460
6094
  checkPassed: snapshot.check?.verdict === "pass",
@@ -5478,6 +6112,7 @@ var EngineService = class {
5478
6112
  check: extras.check ?? snapshot.check,
5479
6113
  semantic: extras.semantic ?? [],
5480
6114
  proof: extras.proof ?? null,
6115
+ scope: extras.scope,
5481
6116
  rounds: snapshot.pipeline.rounds,
5482
6117
  archive,
5483
6118
  evidence: {
@@ -5509,8 +6144,11 @@ var EngineService = class {
5509
6144
  throw new EngineBlockedError(result.blocking.every((item) => item.code === "PG-09") ? ERROR_CODES.READING_PROOF_INVALID : ERROR_CODES.GATE_BLOCKED, `${operation} blocked by ${result.blocking.length} gate(s), first: ${first.code} ${first.detail}`, result.blocking, result.blocking.map((item) => `${item.code}: ${item.hint ?? item.detail}`).join(" | "));
5510
6145
  }
5511
6146
  }
5512
- assertChapter(snapshot, chapter) {
6147
+ assertChapterParameter(chapter) {
5513
6148
  if (!Number.isInteger(chapter) || chapter < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter must be a positive integer (got ${String(chapter)})`);
6149
+ }
6150
+ assertChapter(snapshot, chapter) {
6151
+ this.assertChapterParameter(chapter);
5514
6152
  if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not the active chapter (${snapshot.pipeline.chapter})`, "archive the active chapter before starting the next one");
5515
6153
  }
5516
6154
  async load(root, retries = 1) {
@@ -5554,9 +6192,22 @@ var EngineService = class {
5554
6192
  if (previous !== null) previousTexts.push(previous);
5555
6193
  }
5556
6194
  const planRaw = await store.readText(planChapterFile(chapter));
5557
- const plan = planRaw === null ? null : parseYamlLoose(planRaw);
5558
- const review = await store.readDoc(reviewFile(chapter), "review");
5559
- const check = await store.readDoc(checkFile(chapter), "check");
6195
+ let plan = null;
6196
+ let planError = null;
6197
+ if (planRaw !== null) try {
6198
+ const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
6199
+ validateStoredChapterPlan(parsed, planChapterFile(chapter));
6200
+ plan = parsed;
6201
+ } catch (error) {
6202
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
6203
+ planError = errorMessage(error);
6204
+ }
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;
5560
6211
  const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
5561
6212
  if (latest === null || latest.revision !== pipeline.revision) {
5562
6213
  if (retries > 0) return this.load(store.root, retries - 1);
@@ -5579,10 +6230,28 @@ var EngineService = class {
5579
6230
  previousText,
5580
6231
  previousTexts,
5581
6232
  plan,
6233
+ planError,
5582
6234
  review,
5583
- check
6235
+ reviewError,
6236
+ check,
6237
+ checkError
5584
6238
  };
5585
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
+ }
5586
6255
  async mutate(options) {
5587
6256
  const root = await this.resolveRoot(options.root);
5588
6257
  const signal = this.signalContext.getStore();
@@ -5739,8 +6408,11 @@ var EngineService = class {
5739
6408
  previousText: null,
5740
6409
  previousTexts: [],
5741
6410
  plan: null,
6411
+ planError: null,
5742
6412
  review: null,
5743
- check: null
6413
+ reviewError: null,
6414
+ check: null,
6415
+ checkError: null
5744
6416
  };
5745
6417
  }
5746
6418
  };
@@ -5753,6 +6425,17 @@ const NEXT_TOOL_BY_BRIEF = {
5753
6425
  P6: "novel_check",
5754
6426
  P8: "novel_archive"
5755
6427
  };
6428
+ /** The task book each stage must see before its next write operation. */
6429
+ const STATUS_BRIEF = {
6430
+ idle: "P1",
6431
+ P2: "P2",
6432
+ P3: "P3",
6433
+ P4: "P4",
6434
+ P5: "P5",
6435
+ P6: "P6",
6436
+ ready_to_archive: "P8",
6437
+ done: "P1"
6438
+ };
5756
6439
  const yaml = (value) => stringify(value, { lineWidth: 0 });
5757
6440
  const emptyChapters = () => ({
5758
6441
  schema_version: 1,
@@ -5778,6 +6461,7 @@ const pipelineWith = (snapshot, revision, patch) => ({
5778
6461
  rounds: snapshot.pipeline.rounds,
5779
6462
  briefs: snapshot.pipeline.briefs,
5780
6463
  blocking: snapshot.pipeline.blocking,
6464
+ review_digest: snapshot.pipeline.review_digest,
5781
6465
  last_action: snapshot.pipeline.last_action,
5782
6466
  ...patch
5783
6467
  });
@@ -5817,7 +6501,65 @@ const stableJson = (value) => {
5817
6501
  };
5818
6502
  return JSON.stringify(normalize(value));
5819
6503
  };
5820
- const parseYamlLoose = (text) => parse(text);
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
+ };
6552
+ /** Parse a plan file with a machine code instead of leaking a YAML parser error. */
6553
+ const parsePlanYaml = (text, path) => {
6554
+ let parsed;
6555
+ try {
6556
+ parsed = parse(text);
6557
+ } catch (error) {
6558
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix the plan YAML; the engine will not silently discard a corrupt plan", { cause: error });
6559
+ }
6560
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} must be a YAML mapping`, "rewrite the plan as a mapping (title, scene_type, narrator, ...)");
6561
+ return parsed;
6562
+ };
5821
6563
  /** Line diff with a shared prefix/suffix, enough for revision review. */
5822
6564
  function diffLines(before, after) {
5823
6565
  const left = before === "" ? [] : before.split("\n");
@@ -5867,7 +6609,10 @@ const asJson = (value, field) => {
5867
6609
  };
5868
6610
  /** Turn an engine failure into the message shape tools surface. */
5869
6611
  const fail = (error) => {
5870
- if (error instanceof EngineError) throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`);
6612
+ if (error instanceof EngineError) {
6613
+ const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
6614
+ throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}`);
6615
+ }
5871
6616
  throw error;
5872
6617
  };
5873
6618
  /** Wrap an execute body with cancellation and a machine-code error. */
@@ -5921,7 +6666,7 @@ const stringArray = {
5921
6666
  };
5922
6667
  const root = {
5923
6668
  type: "string",
5924
- description: "Project root; defaults to the configured root."
6669
+ description: "Project root. When omitted, resolves to the last active project, then the registry active root, then the configured projectRoot; pass it explicitly when several projects exist."
5925
6670
  };
5926
6671
  /**
5927
6672
  * The eighteen tools are thin adapters: narrow parameters, call one service
@@ -5980,8 +6725,11 @@ function createNovelTools(engine) {
5980
6725
  schema: { type: "json" },
5981
6726
  render: (_args, raw) => {
5982
6727
  const value = payload(raw);
6728
+ const active = value.active_chapter ?? value.chapter;
6729
+ const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
5983
6730
  return text([
5984
- `《${value.project.title}》 第 ${value.chapter} 章 · ${value.stage}`,
6731
+ `《${value.project.title}》 ${where} · ${value.stage}`,
6732
+ ...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
5985
6733
  value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
5986
6734
  ...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
5987
6735
  `下一步:${value.next.tool} — ${value.next.reason}`
@@ -6017,7 +6765,7 @@ function createNovelTools(engine) {
6017
6765
  schema: { type: "json" },
6018
6766
  render: (_args, raw) => {
6019
6767
  const value = payload(raw);
6020
- return text(`${value.content}\n\n---\nbrief digest: ${value.digest}${value.context === void 0 ? "" : `\ncontext:\n${JSON.stringify(value.context, null, 2)}`}`);
6768
+ return text(`${value.content}\n\n---\nbrief digest: ${value.digest}${value.fields === void 0 ? "" : `\nfields:\n${JSON.stringify(value.fields)}`}${value.context === void 0 ? "" : `\ncontext:\n${JSON.stringify(value.context)}`}`);
6021
6769
  }
6022
6770
  },
6023
6771
  execute: guarded(async (args) => engine.getBrief({
@@ -6440,6 +7188,11 @@ function createNovelTools(engine) {
6440
7188
  ];
6441
7189
  }
6442
7190
  // src/commands/admin.ts
7191
+ /** Registry commands need an explicit root; do not pass undefined into path.resolve. */
7192
+ const rootArg = (args) => {
7193
+ if (typeof args.root !== "string" || args.root.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "registry operation requires a non-empty root", "pass {\"root\":\"/absolute/project/path\"}");
7194
+ return args.root;
7195
+ };
6443
7196
  /**
6444
7197
  * The ten tools hidden when `tools.mode=core`, plus the explicit
6445
7198
  * registry/settings-pack operations that are command-only by design (§6.8).
@@ -6471,9 +7224,9 @@ const OPERATIONS$1 = {
6471
7224
  novel_pipeline: (engine, args) => engine.advise(args),
6472
7225
  novel_state: (engine, args) => engine.queryState(args),
6473
7226
  novel_registry_list: (engine) => engine.listProjects(),
6474
- novel_registry_register: (engine, args) => engine.registerProject({ root: args.root }),
6475
- novel_registry_activate: (engine, args) => engine.setActiveProject({ root: args.root }),
6476
- novel_registry_forget: (engine, args) => engine.forgetProject({ root: args.root }),
7227
+ novel_registry_register: (engine, args) => engine.registerProject({ root: rootArg(args) }),
7228
+ novel_registry_activate: (engine, args) => engine.setActiveProject({ root: rootArg(args) }),
7229
+ novel_registry_forget: (engine, args) => engine.forgetProject({ root: rootArg(args) }),
6477
7230
  novel_import: (engine, args) => engine.importPack(args)
6478
7231
  };
6479
7232
  const ADMIN_OPERATION_NAMES = Object.keys(OPERATIONS$1);
@@ -6500,7 +7253,7 @@ function parseAdminArguments(raw, operation) {
6500
7253
  }
6501
7254
  /** Run one hidden operation and render its payload for the command result. */
6502
7255
  async function runAdminOperation(engine, operation, rawArguments) {
6503
- const handler = OPERATIONS$1[operation];
7256
+ const handler = Object.hasOwn(OPERATIONS$1, operation) ? OPERATIONS$1[operation] : void 0;
6504
7257
  if (handler === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, `未知子命令 "${operation}"`, `available: ${ADMIN_OPERATION_NAMES.join(", ")}`);
6505
7258
  const parsed = parseAdminArguments(rawArguments, operation);
6506
7259
  const result = await handler(engine, parsed.root === void 0 && typeof parsed.project_root === "string" ? {
@@ -7200,6 +7953,7 @@ function apply(ctx, config) {
7200
7953
  ctx.emit?.(event.name, event.payload);
7201
7954
  };
7202
7955
  const engine = new EngineService(engineConfig, () => loadRulepack(), { onEvent: emit });
7956
+ ctx.effect(() => () => engine.dispose(), `${name}: engine dispose`);
7203
7957
  ctx.provide?.("novelEngine", engine);
7204
7958
  for (const tool of createNovelTools(engine)) {
7205
7959
  if (toolsMode === "core" && !CORE_TOOLS.includes(tool.name)) continue;
@@ -7241,4 +7995,4 @@ function connectionFence(scope) {
7241
7995
  }
7242
7996
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
7243
7997
  }
7244
- export { BriefLibrary, Config, ERROR_CODES, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, OPERATIONS, PLATFORMS, SCENE_TYPES, SCHEMA_VERSION, applicableGates, apply, blockedError, briefStageFor, chapterHeading, evaluateGates, faultError, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, projectChapter, projectChapters, projectGates, projectStatus, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, verifyReference };
7998
+ export { BriefLibrary, Config, ERROR_CODES, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, OPERATIONS, PLATFORMS, SCENE_TYPES, SCHEMA_VERSION, applicableGates, apply, blockedError, briefStageFor, chapterHeading, evaluateGates, faultError, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, verifyReference };