dsh-novel-write-engine 0.2.5 → 0.2.7

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
@@ -14,7 +14,7 @@ const PLUGIN_NAME = "dsh-novel-write-engine";
14
14
  const PANEL_ID = "novel-workshop";
15
15
  const PANEL_ROUTE = "/api/novel-write-engine/panel";
16
16
  const SKILL_NAME = "novel-write-engine";
17
- const REMOTE_VERSION = 5;
17
+ const REMOTE_VERSION = 6;
18
18
  /** The panel is a recent-history view; the wire payload and UI use one cap. */
19
19
  const PANEL_CHAPTER_LIMIT = 30;
20
20
  /** Project-relative locations. Every write goes through these constants. */
@@ -523,9 +523,61 @@ function applyExclusions(dimensions, exclusions) {
523
523
  function splitParagraphs(text) {
524
524
  return text.split(/\n+/).map((line) => line.trim()).filter((line) => line !== "");
525
525
  }
526
+ const SENTENCE_TERMINATOR = /[。!?!?]+|…+|\.(?=\s|$)/g;
527
+ const SENTENCE_CLOSERS = /* @__PURE__ */ new Set([
528
+ "」",
529
+ "』",
530
+ "”",
531
+ "\"",
532
+ "’",
533
+ "'",
534
+ ")",
535
+ ")",
536
+ "】",
537
+ "]"
538
+ ]);
539
+ /**
540
+ * Sentence boundaries have one owner. This offset-preserving variant is used
541
+ * by semantic evidence and any caller that must map a quote back to a
542
+ * paragraph; `splitSentences` is the string-only convenience view of it.
543
+ */
544
+ function splitSentenceSpans(text) {
545
+ const spans = [];
546
+ let paragraph = 1;
547
+ let lineStart = 0;
548
+ while (lineStart <= text.length) {
549
+ const newline = text.indexOf("\n", lineStart);
550
+ const lineEnd = newline === -1 ? text.length : newline;
551
+ const line = text.slice(lineStart, lineEnd);
552
+ if (line.trim() !== "") {
553
+ let cursor = 0;
554
+ while (cursor < line.length) {
555
+ SENTENCE_TERMINATOR.lastIndex = cursor;
556
+ const match = SENTENCE_TERMINATOR.exec(line);
557
+ let rawEnd = match === null ? line.length : match.index + match[0].length;
558
+ while (rawEnd < line.length && SENTENCE_CLOSERS.has(line[rawEnd])) rawEnd += 1;
559
+ let start = cursor;
560
+ while (start < rawEnd && /\s/.test(line[start])) start += 1;
561
+ const body = line.slice(start, rawEnd).trimEnd();
562
+ const end = start + body.length;
563
+ if (body !== "") spans.push({
564
+ start: lineStart + start,
565
+ end: lineStart + end,
566
+ paragraph,
567
+ text: body
568
+ });
569
+ cursor = rawEnd;
570
+ }
571
+ paragraph += 1;
572
+ }
573
+ if (newline === -1) break;
574
+ lineStart = newline + 1;
575
+ }
576
+ return spans;
577
+ }
526
578
  /** Sentence splitting uses Chinese and English terminators; empty runs collapse. */
527
579
  function splitSentences(text) {
528
- return text.split(/[。!?!?]+|…+|\.(?=\s|$)/).map((sentence) => sentence.trim()).filter((sentence) => sentence !== "");
580
+ return splitSentenceSpans(text).map((span) => span.text.replace(/([。!?!?]+|…+|\.)$/u, "").replace(/[」』”"'’))】\]]+$/u, "").trim()).filter((sentence) => sentence !== "");
529
581
  }
530
582
  /** Character length of each sentence, in document order. */
531
583
  function sentenceLengths(sentences) {
@@ -627,6 +679,30 @@ function similarity(a, b) {
627
679
  function textSimilarity(a, b) {
628
680
  return similarity(simhash(a), simhash(b));
629
681
  }
682
+ /** Character-bigram Dice similarity; unlike SimHash this has a ~0 baseline for unrelated short text. */
683
+ function bigramSimilarity(a, b) {
684
+ const grams = (text) => {
685
+ const chars = Array.from(text.replace(/\s+/g, ""));
686
+ if (chars.length < 2) return chars.length === 0 ? [] : [chars[0]];
687
+ const result = [];
688
+ for (let index = 0; index + 1 < chars.length; index += 1) result.push(chars[index] + chars[index + 1]);
689
+ return result;
690
+ };
691
+ const left = grams(a);
692
+ const right = grams(b);
693
+ if (left.length === 0 || right.length === 0) return 0;
694
+ const counts = /* @__PURE__ */ new Map();
695
+ for (const gram of left) counts.set(gram, (counts.get(gram) ?? 0) + 1);
696
+ let intersection = 0;
697
+ for (const gram of right) {
698
+ const count = counts.get(gram) ?? 0;
699
+ if (count > 0) {
700
+ counts.set(gram, count - 1);
701
+ intersection += 1;
702
+ }
703
+ }
704
+ return 2 * intersection / (left.length + right.length);
705
+ }
630
706
  // src/domain/transitions.ts
631
707
  /**
632
708
  * The single definition of allowed progress. The engine queries this table
@@ -1968,31 +2044,31 @@ const SCHEMAS = {
1968
2044
  themes: req("array")
1969
2045
  }
1970
2046
  };
1971
- const invalid$1 = (kind, detail) => {
2047
+ const invalid$2 = (kind, detail) => {
1972
2048
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `invalid ${kind} document: ${detail}`);
1973
2049
  };
1974
2050
  const objectAt = (kind, value, path) => {
1975
- if (typeof value !== "object" || value === null || Array.isArray(value)) return invalid$1(kind, `${path} must be an object`);
2051
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return invalid$2(kind, `${path} must be an object`);
1976
2052
  return value;
1977
2053
  };
1978
2054
  const listAt = (kind, value, path) => {
1979
- if (!Array.isArray(value)) return invalid$1(kind, `${path} must be an array`);
2055
+ if (!Array.isArray(value)) return invalid$2(kind, `${path} must be an array`);
1980
2056
  return value;
1981
2057
  };
1982
2058
  const stringAt = (kind, value, path) => {
1983
- if (typeof value !== "string") return invalid$1(kind, `${path} must be a string`);
2059
+ if (typeof value !== "string") return invalid$2(kind, `${path} must be a string`);
1984
2060
  return value;
1985
2061
  };
1986
2062
  const numberAt = (kind, value, path) => {
1987
- if (typeof value !== "number" || !Number.isFinite(value)) return invalid$1(kind, `${path} must be a number`);
2063
+ if (typeof value !== "number" || !Number.isFinite(value)) return invalid$2(kind, `${path} must be a number`);
1988
2064
  return value;
1989
2065
  };
1990
2066
  const integerAt = (kind, value, path) => {
1991
- if (typeof value !== "number" || !Number.isInteger(value)) return invalid$1(kind, `${path} must be an integer`);
2067
+ if (typeof value !== "number" || !Number.isInteger(value)) return invalid$2(kind, `${path} must be an integer`);
1992
2068
  return value;
1993
2069
  };
1994
2070
  const booleanAt = (kind, value, path) => {
1995
- if (typeof value !== "boolean") return invalid$1(kind, `${path} must be a boolean`);
2071
+ if (typeof value !== "boolean") return invalid$2(kind, `${path} must be a boolean`);
1996
2072
  return value;
1997
2073
  };
1998
2074
  const optionalString = (kind, value, path) => {
@@ -2015,10 +2091,19 @@ const duplicateNames = (kind, rows, path, nameOf) => {
2015
2091
  for (const row of rows) {
2016
2092
  const name = nameOf(row);
2017
2093
  if (typeof name !== "string" || name === "") continue;
2018
- if (seen.has(name)) invalid$1(kind, `${path} contains duplicate name "${name}"`);
2094
+ if (seen.has(name)) invalid$2(kind, `${path} contains duplicate name "${name}"`);
2019
2095
  seen.add(name);
2020
2096
  }
2021
2097
  };
2098
+ const validateAnchorList = (kind, value, path) => {
2099
+ if (value === void 0) return;
2100
+ const anchors = listAt(kind, value, path);
2101
+ if (anchors.length > 8) invalid$2(kind, `${path} may contain at most 8 anchors`);
2102
+ for (const [index, anchor] of anchors.entries()) {
2103
+ const text = stringAt(kind, anchor, `${path}[${index}]`).trim();
2104
+ if (text.length < 2 || Array.from(text).length > 120) invalid$2(kind, `${path}[${index}] must contain 2..120 characters`);
2105
+ }
2106
+ };
2022
2107
  const validateSummary = (kind, value, path) => {
2023
2108
  const summary = objectAt(kind, value, path);
2024
2109
  for (const key of [
@@ -2033,6 +2118,18 @@ const validateSummary = (kind, value, path) => {
2033
2118
  "resolved"
2034
2119
  ]) listAt(kind, foreshadows[key], `${path}.foreshadows.${key}`);
2035
2120
  objectAt(kind, summary.new_elements, `${path}.new_elements`);
2121
+ if (summary.new_element_anchors !== void 0) {
2122
+ const anchorMap = objectAt(kind, summary.new_element_anchors, `${path}.new_element_anchors`);
2123
+ for (const group of Object.keys(anchorMap)) {
2124
+ if (![
2125
+ "locations",
2126
+ "abilities",
2127
+ "artifacts"
2128
+ ].includes(group)) invalid$2(kind, `${path}.new_element_anchors has unknown group "${group}"`);
2129
+ const entries = objectAt(kind, anchorMap[group], `${path}.new_element_anchors.${group}`);
2130
+ for (const [name, anchors] of Object.entries(entries)) validateAnchorList(kind, anchors, `${path}.new_element_anchors.${group}.${name}`);
2131
+ }
2132
+ }
2036
2133
  listAt(kind, summary.resource_changes, `${path}.resource_changes`);
2037
2134
  stringAt(kind, summary.opening, `${path}.opening`);
2038
2135
  const continuity = objectAt(kind, summary.continuity, `${path}.continuity`);
@@ -2047,7 +2144,7 @@ const validateNested = (kind, record) => {
2047
2144
  const targets = objectAt(kind, record.targets, "targets");
2048
2145
  for (const key of ["words_per_chapter", "dialogue_ratio"]) {
2049
2146
  const pair = listAt(kind, targets[key], `targets.${key}`);
2050
- if (pair.length !== 2) invalid$1(kind, `targets.${key} must contain two numbers`);
2147
+ if (pair.length !== 2) invalid$2(kind, `targets.${key} must contain two numbers`);
2051
2148
  for (const [index, item] of pair.entries()) numberAt(kind, item, `targets.${key}[${index}]`);
2052
2149
  }
2053
2150
  if (targets.total_chapters !== null) integerAt(kind, targets.total_chapters, "targets.total_chapters");
@@ -2123,13 +2220,14 @@ const validateNested = (kind, record) => {
2123
2220
  stringAt(kind, row.layout, `${path}.layout`);
2124
2221
  numberAt(kind, row.capacity, `${path}.capacity`);
2125
2222
  listAt(kind, row.changes, `${path}.changes`);
2223
+ validateAnchorList(kind, row.anchors, `${path}.anchors`);
2126
2224
  });
2127
2225
  for (const key of [
2128
2226
  "abilities",
2129
2227
  "artifacts",
2130
2228
  "factions",
2131
2229
  "rules"
2132
- ]) listAt(kind, record[key], key);
2230
+ ]) for (const [index, row] of listAt(kind, record[key], key).entries()) if (typeof row === "object" && row !== null && !Array.isArray(row)) validateAnchorList(kind, row.anchors, `${key}[${index}].anchors`);
2133
2231
  {
2134
2232
  const names = [];
2135
2233
  for (const key of [
@@ -2142,7 +2240,7 @@ const validateNested = (kind, record) => {
2142
2240
  const rows = Array.isArray(record[key]) ? record[key] : [];
2143
2241
  for (const row of rows) if (typeof row.name === "string") names.push(row.name);
2144
2242
  }
2145
- if (new Set(names).size !== names.length) invalid$1(kind, "world entities must have unique names");
2243
+ if (new Set(names).size !== names.length) invalid$2(kind, "world entities must have unique names");
2146
2244
  }
2147
2245
  return;
2148
2246
  case "locks": {
@@ -2213,7 +2311,7 @@ const validateNested = (kind, record) => {
2213
2311
  validateRecords(kind, semantic.items, "semantic.items", (row, path) => {
2214
2312
  stringAt(kind, row.id, `${path}.id`);
2215
2313
  const verdict = stringAt(kind, row.verdict, `${path}.verdict`);
2216
- if (verdict !== "pass" && verdict !== "fail") invalid$1(kind, `${path}.verdict must be pass|fail`);
2314
+ if (verdict !== "pass" && verdict !== "fail") invalid$2(kind, `${path}.verdict must be pass|fail`);
2217
2315
  stringAt(kind, row.evidence, `${path}.evidence`);
2218
2316
  if (row.note !== void 0) stringAt(kind, row.note, `${path}.note`);
2219
2317
  });
@@ -2262,11 +2360,11 @@ const validateNested = (kind, record) => {
2262
2360
  "notes",
2263
2361
  "status"
2264
2362
  ]) stringAt(kind, row[key], `${path}.${key}`);
2265
- if (!SCENE_TYPES.includes(row.scene_type)) invalid$1(kind, `${path}.scene_type must be one of ${SCENE_TYPES.join(", ")}`);
2266
- if (row.status !== "planned" && row.status !== "archived") invalid$1(kind, `${path}.status must be planned|archived`);
2363
+ if (!SCENE_TYPES.includes(row.scene_type)) invalid$2(kind, `${path}.scene_type must be one of ${SCENE_TYPES.join(", ")}`);
2364
+ if (row.status !== "planned" && row.status !== "archived") invalid$2(kind, `${path}.status must be planned|archived`);
2267
2365
  for (const [index, name] of listAt(kind, row.cast, `${path}.cast`).entries()) stringAt(kind, name, `${path}.cast[${index}]`);
2268
2366
  const target = listAt(kind, row.target_words, `${path}.target_words`);
2269
- if (target.length !== 2) invalid$1(kind, `${path}.target_words must contain two integers`);
2367
+ if (target.length !== 2) invalid$2(kind, `${path}.target_words must contain two integers`);
2270
2368
  for (const [index, item] of target.entries()) integerAt(kind, item, `${path}.target_words[${index}]`);
2271
2369
  nullableString(kind, row.archived_at, `${path}.archived_at`);
2272
2370
  nullableNumber(kind, row.word_count, `${path}.word_count`);
@@ -2323,9 +2421,9 @@ const validateNested = (kind, record) => {
2323
2421
  validateRecords(kind, record.issues, "issues", (row, path) => {
2324
2422
  stringAt(kind, row.id, `${path}.id`);
2325
2423
  const severity = stringAt(kind, row.severity, `${path}.severity`);
2326
- if (severity !== "blocker" && severity !== "advisory") invalid$1(kind, `${path}.severity must be blocker|advisory`);
2424
+ if (severity !== "blocker" && severity !== "advisory") invalid$2(kind, `${path}.severity must be blocker|advisory`);
2327
2425
  const status = stringAt(kind, row.status, `${path}.status`);
2328
- if (status !== "open" && status !== "fixed" && status !== "waived") invalid$1(kind, `${path}.status must be open|fixed|waived`);
2426
+ if (status !== "open" && status !== "fixed" && status !== "waived") invalid$2(kind, `${path}.status must be open|fixed|waived`);
2329
2427
  for (const field of [
2330
2428
  "dimension",
2331
2429
  "location",
@@ -2358,26 +2456,26 @@ const matchesType = (value, field) => {
2358
2456
  * user upgrades instead of the engine rewriting their data.
2359
2457
  */
2360
2458
  function narrow(kind, value) {
2361
- if (typeof value !== "object" || value === null || Array.isArray(value)) invalid$1(kind, "not an object");
2459
+ if (typeof value !== "object" || value === null || Array.isArray(value)) invalid$2(kind, "not an object");
2362
2460
  const record = value;
2363
2461
  const version = record.schema_version;
2364
- if (typeof version !== "number" || !Number.isInteger(version)) return invalid$1(kind, "missing schema_version");
2462
+ if (typeof version !== "number" || !Number.isInteger(version)) return invalid$2(kind, "missing schema_version");
2365
2463
  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");
2366
- if (version !== 1) invalid$1(kind, `unsupported schema_version ${version}`);
2367
- if (!Object.hasOwn(SCHEMAS, kind)) invalid$1(kind, `unknown document kind "${String(kind)}"`);
2464
+ if (version !== 1) invalid$2(kind, `unsupported schema_version ${version}`);
2465
+ if (!Object.hasOwn(SCHEMAS, kind)) invalid$2(kind, `unknown document kind "${String(kind)}"`);
2368
2466
  const schema = SCHEMAS[kind];
2369
2467
  for (const key of Object.keys(record)) {
2370
2468
  if (key === "schema_version") continue;
2371
- if (!Object.hasOwn(schema, key)) invalid$1(kind, `unknown key "${key}"`);
2469
+ if (!Object.hasOwn(schema, key)) invalid$2(kind, `unknown key "${key}"`);
2372
2470
  }
2373
2471
  for (const [key, field] of Object.entries(schema)) {
2374
2472
  const fieldValue = record[key];
2375
2473
  if (fieldValue === void 0) {
2376
- if (field.required === true) invalid$1(kind, `missing required key "${key}"`);
2474
+ if (field.required === true) invalid$2(kind, `missing required key "${key}"`);
2377
2475
  continue;
2378
2476
  }
2379
- if (!matchesType(fieldValue, field)) invalid$1(kind, `"${key}" must be ${field.type}`);
2380
- if (field.enum !== void 0 && !field.enum.includes(fieldValue)) invalid$1(kind, `"${key}" must be one of ${field.enum.join(", ")}`);
2477
+ if (!matchesType(fieldValue, field)) invalid$2(kind, `"${key}" must be ${field.type}`);
2478
+ if (field.enum !== void 0 && !field.enum.includes(fieldValue)) invalid$2(kind, `"${key}" must be one of ${field.enum.join(", ")}`);
2381
2479
  }
2382
2480
  validateNested(kind, record);
2383
2481
  return record;
@@ -3944,7 +4042,7 @@ const wordPair = (value, where) => {
3944
4042
  if (!Number.isInteger(min) || !Number.isInteger(max) || min <= 0 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be [min, max] positive integers with min <= max`);
3945
4043
  return [min, max];
3946
4044
  };
3947
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4045
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3948
4046
  /** Remove a leading 第N章 prefix; stored titles are bodies. */
3949
4047
  function stripChapterPrefix(title) {
3950
4048
  return title.replace(LENIENT_TITLE_PREFIX$1, "").trim();
@@ -4046,7 +4144,7 @@ const BOOK_PATCH_KEYS = /* @__PURE__ */ new Set([
4046
4144
  /** Apply a partial book patch; omitted fields keep their existing value. */
4047
4145
  function mergeBookPlan(existing, patch) {
4048
4146
  if (patch === void 0) return { ...existing };
4049
- if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
4147
+ if (!isRecord$3(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
4050
4148
  for (const key of Object.keys(patch)) if (!BOOK_PATCH_KEYS.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `book has unknown field "${key}"`, `allowed: ${[...BOOK_PATCH_KEYS].join(", ")}`);
4051
4149
  const result = { ...existing };
4052
4150
  for (const key of [
@@ -4074,7 +4172,7 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
4074
4172
  if (!Array.isArray(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
4075
4173
  if (input.length > LIMITS.maxOutlineVolumes) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains ${input.length} items; limit is ${LIMITS.maxOutlineVolumes}`);
4076
4174
  const volumes = input.map((raw, index) => {
4077
- if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
4175
+ if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
4078
4176
  const start = positiveInteger$1(raw.start_chapter, `volumes[${index}].start_chapter`);
4079
4177
  const end = positiveInteger$1(raw.end_chapter, `volumes[${index}].end_chapter`);
4080
4178
  if (end < start) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] end_chapter ${end} is before start_chapter ${start}`);
@@ -4191,7 +4289,7 @@ function normalizeOutlineChapters(input, options) {
4191
4289
  const archived = new Map(options.archived.map((record) => [record.num, record]));
4192
4290
  const normalized = /* @__PURE__ */ new Map();
4193
4291
  for (const [index, raw] of input.entries()) {
4194
- if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4292
+ if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4195
4293
  const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4196
4294
  if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`, "raise total_chapters with novel_blueprint before planning a longer book");
4197
4295
  if (normalized.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
@@ -4265,7 +4363,7 @@ function normalizeOutlineChapters(input, options) {
4265
4363
  }
4266
4364
  /** Validate a document loaded from disk. Throws a fault, not an input error. */
4267
4365
  function validateOutlineDocument(value, options) {
4268
- if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
4366
+ if (!isRecord$3(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
4269
4367
  try {
4270
4368
  if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml schema_version must be 1, got ${String(value.schema_version)}`);
4271
4369
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4277,7 +4375,7 @@ function validateOutlineDocument(value, options) {
4277
4375
  ]);
4278
4376
  for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml has unknown key "${key}"`);
4279
4377
  if (typeof value.updated_at !== "string" || value.updated_at.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml updated_at must be a string");
4280
- if (!isRecord$2(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
4378
+ if (!isRecord$3(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
4281
4379
  const allowedBook = /* @__PURE__ */ new Set([
4282
4380
  "premise",
4283
4381
  "logline",
@@ -4301,7 +4399,7 @@ function validateOutlineDocument(value, options) {
4301
4399
  const chapters = [];
4302
4400
  const seen = /* @__PURE__ */ new Set();
4303
4401
  for (const [index, raw] of value.chapters.entries()) {
4304
- if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4402
+ if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4305
4403
  const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4306
4404
  if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`);
4307
4405
  if (seen.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
@@ -4367,7 +4465,7 @@ function mergeWorldbook(existing, patch, now) {
4367
4465
  ...existing,
4368
4466
  updated_at: now
4369
4467
  };
4370
- if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
4468
+ if (!isRecord$3(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
4371
4469
  const result = {
4372
4470
  ...existing,
4373
4471
  updated_at: now,
@@ -4387,7 +4485,7 @@ function mergeWorldbook(existing, patch, now) {
4387
4485
  if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} must be an array`);
4388
4486
  if (value.length > LIMITS.maxWorldbookEntries) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} contains ${value.length} items; limit is ${LIMITS.maxWorldbookEntries}`);
4389
4487
  return value.map((raw, index) => {
4390
- if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
4488
+ if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
4391
4489
  return raw;
4392
4490
  });
4393
4491
  };
@@ -4467,7 +4565,7 @@ function mergeWorldbook(existing, patch, now) {
4467
4565
  }
4468
4566
  /** Validate a worldbook loaded from disk. */
4469
4567
  function validateWorldbookDocument(value, now) {
4470
- if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
4568
+ if (!isRecord$3(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
4471
4569
  try {
4472
4570
  if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml schema_version must be 1, got ${String(value.schema_version)}`);
4473
4571
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4878,7 +4976,7 @@ function parseOutlineData(content, format) {
4878
4976
  } catch (error) {
4879
4977
  throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} is invalid: ${error instanceof Error ? error.message : String(error)}`);
4880
4978
  }
4881
- if (!isRecord$2(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
4979
+ if (!isRecord$3(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
4882
4980
  if (parsed.schema_version !== void 0 && parsed.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} schema_version must be 1, got ${String(parsed.schema_version)}`);
4883
4981
  const warnings = [];
4884
4982
  const allowed = /* @__PURE__ */ new Set([
@@ -4909,19 +5007,19 @@ function parseOutlineInput(content, format) {
4909
5007
  };
4910
5008
  }
4911
5009
  /** One-line Markdown field; a newline would turn into an unknown import line. */
4912
- const oneLine = (value) => value.replace(/\s+/g, " ").trim();
5010
+ const oneLine$1 = (value) => value.replace(/\s+/g, " ").trim();
4913
5011
  /** Export helper: a human-readable outline in the import template's shape. */
4914
5012
  function renderOutlineMarkdown(project, document, chapters) {
4915
- const parts = [`# ${oneLine(project.title)}`, ""];
4916
- if (document.book.logline !== "") parts.push(`> ${oneLine(document.book.logline)}`, "");
5013
+ const parts = [`# ${oneLine$1(project.title)}`, ""];
5014
+ if (document.book.logline !== "") parts.push(`> ${oneLine$1(document.book.logline)}`, "");
4917
5015
  const fields = [
4918
- ["前提", oneLine(document.book.premise)],
4919
- ["主题", document.book.themes.map(oneLine).join("、")],
4920
- ["基调", oneLine(document.book.tone)],
4921
- ["视角", oneLine(document.book.pov)],
4922
- ["读者", oneLine(document.book.audience)],
4923
- ["卖点", document.book.selling_points.map(oneLine).join("、")],
4924
- ["结局", oneLine(document.book.ending)],
5016
+ ["前提", oneLine$1(document.book.premise)],
5017
+ ["主题", document.book.themes.map(oneLine$1).join("、")],
5018
+ ["基调", oneLine$1(document.book.tone)],
5019
+ ["视角", oneLine$1(document.book.pov)],
5020
+ ["读者", oneLine$1(document.book.audience)],
5021
+ ["卖点", document.book.selling_points.map(oneLine$1).join("、")],
5022
+ ["结局", oneLine$1(document.book.ending)],
4925
5023
  ["严格", document.book.strict ? "true" : "false"]
4926
5024
  ];
4927
5025
  for (const [key, value] of fields) if (value !== "") parts.push(`${key}:${value}`);
@@ -4930,12 +5028,12 @@ function renderOutlineMarkdown(project, document, chapters) {
4930
5028
  const outside = document.chapters.filter((card) => !document.volumes.some((volume) => card.num >= volume.start_chapter && card.num <= volume.end_chapter));
4931
5029
  for (const card of outside) parts.push(...renderCard(card, archived.get(card.num)));
4932
5030
  for (const volume of document.volumes) {
4933
- parts.push(`## ${oneLine(volume.title)}(第${volume.start_chapter}-${volume.end_chapter}章)`);
4934
- parts.push(`编号:${oneLine(volume.id)}`);
5031
+ parts.push(`## ${oneLine$1(volume.title)}(第${volume.start_chapter}-${volume.end_chapter}章)`);
5032
+ parts.push(`编号:${oneLine$1(volume.id)}`);
4935
5033
  parts.push(`状态:${volume.status}`);
4936
- if (volume.goal !== "") parts.push(`目标:${oneLine(volume.goal)}`);
4937
- if (volume.arc !== "") parts.push(`弧线:${oneLine(volume.arc)}`);
4938
- if (volume.climax !== "") parts.push(`高潮:${oneLine(volume.climax)}`);
5034
+ if (volume.goal !== "") parts.push(`目标:${oneLine$1(volume.goal)}`);
5035
+ if (volume.arc !== "") parts.push(`弧线:${oneLine$1(volume.arc)}`);
5036
+ if (volume.climax !== "") parts.push(`高潮:${oneLine$1(volume.climax)}`);
4939
5037
  parts.push("");
4940
5038
  const cards = document.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
4941
5039
  for (const card of cards) parts.push(...renderCard(card, archived.get(card.num)));
@@ -4944,15 +5042,15 @@ function renderOutlineMarkdown(project, document, chapters) {
4944
5042
  }
4945
5043
  const renderCard = (card, record) => {
4946
5044
  const lines = [
4947
- `### 第${card.num}章 ${oneLine(card.title)}`,
5045
+ `### 第${card.num}章 ${oneLine$1(card.title)}`,
4948
5046
  `场景:${card.scene_type}`,
4949
5047
  `字数:${card.target_words[0]}-${card.target_words[1]}`,
4950
- `角色:${card.cast.map(oneLine).join("、")}`,
4951
- `目标:${oneLine(card.goal)}`,
4952
- `冲突:${oneLine(card.conflict)}`,
4953
- `钩子:${oneLine(card.hook)}`
5048
+ `角色:${card.cast.map(oneLine$1).join("、")}`,
5049
+ `目标:${oneLine$1(card.goal)}`,
5050
+ `冲突:${oneLine$1(card.conflict)}`,
5051
+ `钩子:${oneLine$1(card.hook)}`
4954
5052
  ];
4955
- if (card.notes !== "") lines.push(`备注:${oneLine(card.notes)}`);
5053
+ if (card.notes !== "") lines.push(`备注:${oneLine$1(card.notes)}`);
4956
5054
  if (card.status === "archived" && record !== void 0) lines.push(`归档:${record.word_count} 字 / ${record.score} 分`);
4957
5055
  lines.push("");
4958
5056
  return lines;
@@ -4964,20 +5062,20 @@ function renderWorldbookMarkdown(project, document) {
4964
5062
  if (lines.length === 0) return;
4965
5063
  parts.push(`## ${title}`, "", ...lines, "");
4966
5064
  };
4967
- if (document.premise !== "") section("一句话", [oneLine(document.premise)]);
4968
- if (document.setting !== "") section("世界设定", [oneLine(document.setting)]);
4969
- if (document.themes.length > 0) section("主题", [document.themes.map(oneLine).join("、")]);
4970
- section("规则", document.rules.map((entry) => `- **${oneLine(entry.name)}**:${oneLine(entry.description)}`));
4971
- section("势力", document.factions.map((entry) => entry.goal === "" ? `- **${oneLine(entry.name)}**:${oneLine(entry.description)}` : `- **${oneLine(entry.name)}**:${oneLine(entry.description)}(目标:${oneLine(entry.goal)})`));
4972
- section("地点", document.locations.map((entry) => `- **${oneLine(entry.name)}**:${oneLine(entry.description)}${entry.layout === "" ? "" : `;布局:${oneLine(entry.layout)}`}${entry.capacity === 0 ? "" : `;容量:${entry.capacity}`}`));
4973
- section("角色", document.characters.map((entry) => `- **${oneLine(entry.name)}**(${oneLine(entry.role)}):${oneLine(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine(entry.arc)}`}`));
4974
- section("术语", document.glossary.map((entry) => `- **${oneLine(entry.term)}**:${oneLine(entry.definition)}`));
4975
- section("前史", document.backstory.map((entry) => `- ${oneLine(entry.time)}:${oneLine(entry.event)}`));
4976
- section("数值事实", (document.facts ?? []).map((fact) => `- **${oneLine(fact.alias)}**:${fact.value}${oneLine(fact.unit)}${fact.tolerance === void 0 ? "" : `(容差 ${fact.tolerance})`}${fact.note === void 0 || fact.note === "" ? "" : `;${oneLine(fact.note)}`}`));
5065
+ if (document.premise !== "") section("一句话", [oneLine$1(document.premise)]);
5066
+ if (document.setting !== "") section("世界设定", [oneLine$1(document.setting)]);
5067
+ if (document.themes.length > 0) section("主题", [document.themes.map(oneLine$1).join("、")]);
5068
+ section("规则", document.rules.map((entry) => `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}`));
5069
+ section("势力", document.factions.map((entry) => entry.goal === "" ? `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}` : `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}(目标:${oneLine$1(entry.goal)})`));
5070
+ section("地点", document.locations.map((entry) => `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}${entry.layout === "" ? "" : `;布局:${oneLine$1(entry.layout)}`}${entry.capacity === 0 ? "" : `;容量:${entry.capacity}`}`));
5071
+ section("角色", document.characters.map((entry) => `- **${oneLine$1(entry.name)}**(${oneLine$1(entry.role)}):${oneLine$1(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine$1(entry.arc)}`}`));
5072
+ section("术语", document.glossary.map((entry) => `- **${oneLine$1(entry.term)}**:${oneLine$1(entry.definition)}`));
5073
+ section("前史", document.backstory.map((entry) => `- ${oneLine$1(entry.time)}:${oneLine$1(entry.event)}`));
5074
+ section("数值事实", (document.facts ?? []).map((fact) => `- **${oneLine$1(fact.alias)}**:${fact.value}${oneLine$1(fact.unit)}${fact.tolerance === void 0 ? "" : `(容差 ${fact.tolerance})`}${fact.note === void 0 || fact.note === "" ? "" : `;${oneLine$1(fact.note)}`}`));
4977
5075
  return parts.join("\n");
4978
5076
  }
4979
5077
  // src/engine/outline-merge.ts
4980
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5078
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4981
5079
  const text$1 = (value, where) => {
4982
5080
  if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
4983
5081
  return value.trim();
@@ -5012,7 +5110,7 @@ const submittedNumbers = (input) => {
5012
5110
  const result = /* @__PURE__ */ new Set();
5013
5111
  if (!Array.isArray(input)) return result;
5014
5112
  for (const raw of input) {
5015
- if (!isRecord$1(raw) || !Number.isInteger(raw.num) || raw.num < 1) continue;
5113
+ if (!isRecord$2(raw) || !Number.isInteger(raw.num) || raw.num < 1) continue;
5016
5114
  result.add(raw.num);
5017
5115
  }
5018
5116
  return result;
@@ -5051,7 +5149,7 @@ const mergeVolumes = (options) => {
5051
5149
  };
5052
5150
  const submitted = /* @__PURE__ */ new Set();
5053
5151
  for (const [index, raw] of (options.volumes ?? []).entries()) {
5054
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
5152
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
5055
5153
  const title = text$1(raw.title, `volumes[${index}].title`);
5056
5154
  if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
5057
5155
  submitted.add(title);
@@ -5205,6 +5303,120 @@ const mergeOutline = (options) => {
5205
5303
  volumes_removed: volumeChanges.removed
5206
5304
  };
5207
5305
  };
5306
+ // src/engine/entity-anchors.ts
5307
+ /** The three `summary.new_elements` groups that may carry anchors. */
5308
+ const NEW_ELEMENT_GROUPS = [
5309
+ "locations",
5310
+ "abilities",
5311
+ "artifacts"
5312
+ ];
5313
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5314
+ const invalid$1 = (detail) => {
5315
+ throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, detail, "new_element_anchors maps each new entity name to 1..8 verbatim anchors of 2..120 characters");
5316
+ };
5317
+ const emptyAnchorMap = () => ({
5318
+ locations: {},
5319
+ abilities: {},
5320
+ artifacts: {}
5321
+ });
5322
+ /** Trim, de-duplicate and validate one explicit anchor list. */
5323
+ function normalizeAnchors(value, where) {
5324
+ if (!Array.isArray(value)) invalid$1(`${where} must be an array of strings`);
5325
+ const list = value;
5326
+ const result = [];
5327
+ const seen = /* @__PURE__ */ new Set();
5328
+ for (const [index, raw] of list.entries()) {
5329
+ if (typeof raw !== "string") invalid$1(`${where}[${index}] must be a string`);
5330
+ const anchor = raw.trim();
5331
+ const length = Array.from(anchor).length;
5332
+ if (length < 2 || length > 120) invalid$1(`${where}[${index}] must contain 2..120 characters`);
5333
+ if (seen.has(anchor)) continue;
5334
+ seen.add(anchor);
5335
+ result.push(anchor);
5336
+ }
5337
+ if (result.length > 8) invalid$1(`${where} contains ${result.length} unique anchors; limit is 8`);
5338
+ return result;
5339
+ }
5340
+ /**
5341
+ * Parse the optional chapter-level anchor map. Keys must name an entity in the
5342
+ * matching `new_elements` group; an unknown key is a schema error, not a
5343
+ * silently ignored instruction.
5344
+ */
5345
+ function normalizeNewElementAnchors(value, namesByGroup) {
5346
+ const result = emptyAnchorMap();
5347
+ if (value === void 0) return result;
5348
+ if (!isRecord$1(value)) invalid$1("new_element_anchors must be an object");
5349
+ const record = value;
5350
+ for (const key of Object.keys(record)) if (!NEW_ELEMENT_GROUPS.includes(key)) invalid$1(`new_element_anchors has unknown group "${key}"`);
5351
+ for (const group of NEW_ELEMENT_GROUPS) {
5352
+ const rawGroup = record[group];
5353
+ if (rawGroup === void 0) continue;
5354
+ if (!isRecord$1(rawGroup)) invalid$1(`new_element_anchors.${group} must be an object`);
5355
+ const groupRecord = rawGroup;
5356
+ for (const [name, rawAnchors] of Object.entries(groupRecord)) {
5357
+ if (!namesByGroup[group].includes(name)) invalid$1(`new_element_anchors.${group} names "${name}", but new_elements.${group} has no such entity`);
5358
+ const anchors = normalizeAnchors(rawAnchors, `new_element_anchors.${group}.${name}`);
5359
+ if (anchors.length > 0) result[group][name] = anchors;
5360
+ }
5361
+ }
5362
+ return result;
5363
+ }
5364
+ /** Read the declared new-element names from a summary without trusting its shape. */
5365
+ const newElementNames = (summary) => {
5366
+ const record = isRecord$1(summary) ? summary : {};
5367
+ const elements = isRecord$1(record.new_elements) ? record.new_elements : {};
5368
+ const names = (group) => Array.isArray(elements[group]) ? elements[group].filter((name) => typeof name === "string") : [];
5369
+ return {
5370
+ locations: names("locations"),
5371
+ abilities: names("abilities"),
5372
+ artifacts: names("artifacts")
5373
+ };
5374
+ };
5375
+ /** Normalize anchors for one summary; returns an empty map when omitted. */
5376
+ function anchorsFromSummary(summary) {
5377
+ if (!isRecord$1(summary)) return emptyAnchorMap();
5378
+ return normalizeNewElementAnchors(summary.new_element_anchors, newElementNames(summary));
5379
+ }
5380
+ function anchorsForGroup(anchors, group, name) {
5381
+ return anchors[group][name] ?? [];
5382
+ }
5383
+ /**
5384
+ * The single name/anchor matcher used by PG-05 and state-delta. Explicit
5385
+ * anchors win; only an entity with no anchors falls back to its readable name.
5386
+ */
5387
+ function matchEntityText(text, name, anchors = []) {
5388
+ const attempted = anchors.length > 0 ? [...anchors] : [name];
5389
+ const matched = attempted.find((anchor) => anchor !== "" && text.includes(anchor)) ?? null;
5390
+ return {
5391
+ ok: matched !== null,
5392
+ matched,
5393
+ attempted,
5394
+ usedFallback: anchors.length === 0
5395
+ };
5396
+ }
5397
+ function entityMatchHint(name, anchors) {
5398
+ if (anchors.length === 0) return `provide summary.new_element_anchors for "${name}", or write the name verbatim in the manuscript`;
5399
+ return `none of the anchors for "${name}" appears verbatim (${anchors.join(" / ")}); add a matching anchor or use a scene name that is actually in the manuscript`;
5400
+ }
5401
+ /** Persist anchors on a world row without overwriting old anchors that are not repeated. */
5402
+ function mergeEntityAnchors(existing, incoming) {
5403
+ const old = Array.isArray(existing) ? existing.filter((anchor) => typeof anchor === "string") : [];
5404
+ const merged = [];
5405
+ const seen = /* @__PURE__ */ new Set();
5406
+ for (const anchor of [...old, ...incoming]) {
5407
+ const value = anchor.trim();
5408
+ if (value === "" || seen.has(value)) continue;
5409
+ seen.add(value);
5410
+ merged.push(value);
5411
+ if (merged.length === 8) break;
5412
+ }
5413
+ return merged;
5414
+ }
5415
+ /** A shape-safe name reader for world rows; `[null]` must never crash a gate. */
5416
+ function worldEntityName(row) {
5417
+ if (!isRecord$1(row) || typeof row.name !== "string" || row.name.trim() === "") return null;
5418
+ return row.name.trim();
5419
+ }
5208
5420
  // src/engine/state-delta.ts
5209
5421
  const SECTIONS = [
5210
5422
  "characters",
@@ -5336,6 +5548,12 @@ function applyStateDelta(input) {
5336
5548
  "advanced",
5337
5549
  "resolved"
5338
5550
  ]) if (!Array.isArray(summary.foreshadows[key])) fail$1(`summary.foreshadows.${key} must be an array`);
5551
+ let anchors;
5552
+ try {
5553
+ anchors = anchorsFromSummary(summary);
5554
+ } catch (error) {
5555
+ fail$1(error instanceof Error ? error.message : "summary.new_element_anchors is invalid");
5556
+ }
5339
5557
  const characters = structuredClone(input.characters);
5340
5558
  const world = structuredClone(input.world);
5341
5559
  const locks = structuredClone(input.locks);
@@ -5377,7 +5595,7 @@ function applyStateDelta(input) {
5377
5595
  "profile",
5378
5596
  "current_state",
5379
5597
  "growth"
5380
- ]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"港币\":3},\"status\":\"...\"}" : field === "profile" ? "{\"age\":31,\"occupation\":\"...\"}" : "{\"change\":\"...\",\"status_change\":\"...\"}"}`);
5598
+ ]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"资源名\":3},\"status\":\"...\"}" : field === "profile" ? "{\"age\":31,\"occupation\":\"...\"}" : "{\"change\":\"...\",\"status_change\":\"...\"}"}`);
5381
5599
  if (declared.growth !== void 0) {
5382
5600
  const growth = declared.growth;
5383
5601
  for (const field of ["change", "status_change"]) if (typeof growth[field] !== "string" || growth[field].trim() === "") fail$1(`characters.${name}.growth.${field} must be a non-empty string`);
@@ -5472,12 +5690,23 @@ function applyStateDelta(input) {
5472
5690
  if (summary.new_elements.artifacts.includes(name)) return "artifact";
5473
5691
  return null;
5474
5692
  };
5693
+ const anchorsForKind = (kind, name) => {
5694
+ if (kind === "location") return anchorsForGroup(anchors, "locations", name);
5695
+ if (kind === "ability") return anchorsForGroup(anchors, "abilities", name);
5696
+ if (kind === "artifact") return anchorsForGroup(anchors, "artifacts", name);
5697
+ return [];
5698
+ };
5699
+ const requireText = (name, kind, context) => {
5700
+ const candidates = anchorsForKind(kind, name);
5701
+ if (!matchEntityText(input.text, name, candidates).ok) fail$1(`${context} ${entityMatchHint(name, candidates)}`);
5702
+ return candidates;
5703
+ };
5475
5704
  for (const [kind, names] of [
5476
5705
  ["location", summary.new_elements.locations],
5477
5706
  ["ability", summary.new_elements.abilities],
5478
5707
  ["artifact", summary.new_elements.artifacts]
5479
5708
  ]) for (const name of names) {
5480
- if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
5709
+ requireText(name, kind, `summary lists new ${kind} "${name}" but the manuscript never mentions it;`);
5481
5710
  if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
5482
5711
  const declared = delta.world[name];
5483
5712
  if (declared.kind !== void 0 && declared.kind !== kind) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${kind}`);
@@ -5496,6 +5725,7 @@ function applyStateDelta(input) {
5496
5725
  const expected = expectedKind(name);
5497
5726
  if (expected !== null && declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
5498
5727
  const existing = findWorld(name);
5728
+ let entityAnchors = anchorsForKind(declared.kind ?? expected, name);
5499
5729
  let action = declared.action;
5500
5730
  if (action === "created" && existing !== null) {
5501
5731
  if (declared.kind !== void 0 && declared.kind !== existing.kind) fail$1(`world.${name} is declared as ${declared.kind} but the ledger has ${existing.kind}; kind cannot change`);
@@ -5503,12 +5733,13 @@ function applyStateDelta(input) {
5503
5733
  action = "updated";
5504
5734
  warnReclass(name, "created", "updated", "existing-created-as-updated");
5505
5735
  } else if (action === "updated" && existing === null) {
5506
- if ((declared.kind ?? expected) === null) fail$1(`world.${name} does not exist; declare kind (location|ability|artifact|faction|rule) or list it in summary.new_elements`);
5507
- if (!input.text.includes(name)) fail$1(`world entity "${name}" is new but does not appear in the manuscript`);
5736
+ const kind = declared.kind ?? expected;
5737
+ if (kind === null) fail$1(`world.${name} does not exist; declare kind (location|ability|artifact|faction|rule) or list it in summary.new_elements`);
5738
+ entityAnchors = requireText(name, kind, `world entity "${name}" is new but does not appear in the manuscript;`);
5508
5739
  action = "created";
5509
5740
  warnReclass(name, "updated", "created", "missing-updated-as-created");
5510
5741
  } else if (action === "no_change" && existing === null && expected !== null) {
5511
- if (!input.text.includes(name)) fail$1(`world entity "${name}" is new but does not appear in the manuscript`);
5742
+ entityAnchors = requireText(name, expected, `world entity "${name}" is new but does not appear in the manuscript;`);
5512
5743
  action = "created";
5513
5744
  warnReclass(name, "no_change", "created", "missing-no-change-as-created");
5514
5745
  }
@@ -5522,9 +5753,9 @@ function applyStateDelta(input) {
5522
5753
  if (action === "created") {
5523
5754
  if (name.trim() === "") fail$1("a created world entity has an empty name");
5524
5755
  if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
5525
- if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
5526
5756
  const kind = declared.kind ?? expected;
5527
5757
  if (kind === null) fail$1(`world.${name} must declare kind (location|ability|artifact|faction|rule)`);
5758
+ entityAnchors = requireText(name, kind, `world entity "${name}" is created but does not appear in the manuscript;`);
5528
5759
  if (![
5529
5760
  "location",
5530
5761
  "ability",
@@ -5540,13 +5771,15 @@ function applyStateDelta(input) {
5540
5771
  description: declared.description ?? "",
5541
5772
  layout: declared.layout ?? "",
5542
5773
  capacity: declared.capacity ?? 0,
5774
+ ...entityAnchors.length === 0 ? {} : { anchors: entityAnchors },
5543
5775
  changes: []
5544
5776
  });
5545
5777
  else world[kind === "ability" ? "abilities" : kind === "artifact" ? "artifacts" : kind === "faction" ? "factions" : "rules"].push({
5546
5778
  ...declared.detail ?? {},
5547
5779
  name,
5548
5780
  first_appearance: chapter,
5549
- description: declared.description ?? ""
5781
+ description: declared.description ?? "",
5782
+ ...entityAnchors.length === 0 ? {} : { anchors: entityAnchors }
5550
5783
  });
5551
5784
  continue;
5552
5785
  }
@@ -5558,6 +5791,7 @@ function applyStateDelta(input) {
5558
5791
  if (declared.description !== void 0) location.description = declared.description;
5559
5792
  if (declared.layout !== void 0) location.layout = declared.layout;
5560
5793
  if (declared.capacity !== void 0) location.capacity = declared.capacity;
5794
+ if (entityAnchors.length > 0) location.anchors = mergeEntityAnchors(location.anchors, entityAnchors);
5561
5795
  location.changes.push({
5562
5796
  chapter,
5563
5797
  change: declared.reason ?? declared.description ?? "updated",
@@ -5566,6 +5800,7 @@ function applyStateDelta(input) {
5566
5800
  } else if (isRecord(existing.value)) {
5567
5801
  if (declared.description !== void 0 && "description" in existing.value) existing.value.description = declared.description;
5568
5802
  if (declared.detail !== void 0) Object.assign(existing.value, declared.detail);
5803
+ if (entityAnchors.length > 0) existing.value.anchors = mergeEntityAnchors(existing.value.anchors, entityAnchors);
5569
5804
  }
5570
5805
  }
5571
5806
  }
@@ -5775,6 +6010,30 @@ const median = (values) => {
5775
6010
  const middle = Math.floor(sorted.length / 2);
5776
6011
  return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
5777
6012
  };
6013
+ /** Persisted anchors exposed by the report; top-level caps keep the JSON small. */
6014
+ const worldAnchors = (world) => {
6015
+ if (world === void 0) return [];
6016
+ const groups = [
6017
+ ["location", world.locations],
6018
+ ["ability", world.abilities],
6019
+ ["artifact", world.artifacts],
6020
+ ["faction", world.factions],
6021
+ ["rule", world.rules]
6022
+ ];
6023
+ const out = [];
6024
+ for (const [kind, entries] of groups) for (const entry of entries) {
6025
+ if (typeof entry !== "object" || entry === null) continue;
6026
+ const record = entry;
6027
+ if (typeof record.name !== "string" || !Array.isArray(record.anchors)) continue;
6028
+ const anchors = record.anchors.filter((anchor) => typeof anchor === "string").slice(0, 8);
6029
+ if (anchors.length > 0) out.push({
6030
+ name: record.name,
6031
+ kind,
6032
+ anchors
6033
+ });
6034
+ }
6035
+ return out.slice(0, 10);
6036
+ };
5778
6037
  /** Build the whole-book report from one committed snapshot. No IO. */
5779
6038
  function buildBookReport(input) {
5780
6039
  const records = [...input.chapters].sort((left, right) => left.num - right.num);
@@ -5890,6 +6149,7 @@ function buildBookReport(input) {
5890
6149
  };
5891
6150
  });
5892
6151
  if (records.some((record) => record.rounds === void 0 || record.archived_at === void 0)) warnings.push("部分旧章缺少归档 effort 元数据;报告已按事务日志回填");
6152
+ const persistedAnchors = worldAnchors(input.world);
5893
6153
  return {
5894
6154
  progress: {
5895
6155
  archived: records.length,
@@ -5917,7 +6177,10 @@ function buildBookReport(input) {
5917
6177
  volumes: volumeCounts,
5918
6178
  drift
5919
6179
  },
5920
- worldbook: worldbookCounts(input.worldbook),
6180
+ worldbook: {
6181
+ ...worldbookCounts(input.worldbook),
6182
+ ...persistedAnchors.length === 0 ? {} : { anchors: persistedAnchors }
6183
+ },
5921
6184
  quality: {
5922
6185
  average: Math.round(qualityAverage * 10) / 10,
5923
6186
  median: Math.round(median(scores) * 10) / 10,
@@ -6319,6 +6582,173 @@ function searchSnapshot(input) {
6319
6582
  results
6320
6583
  };
6321
6584
  }
6585
+ const oneLine = (value) => value.replace(/\s+/g, " ").trim();
6586
+ const ledgerWorldEntries = (world, kind) => {
6587
+ const rows = kind === "location" ? world.locations : kind === "faction" ? world.factions : world.rules;
6588
+ const entries = [];
6589
+ for (const row of rows) {
6590
+ if (typeof row !== "object" || row === null) continue;
6591
+ const record = row;
6592
+ if (typeof record.name !== "string" || record.name.trim() === "") continue;
6593
+ const anchors = Array.isArray(record.anchors) ? record.anchors.filter((anchor) => typeof anchor === "string") : [];
6594
+ entries.push({
6595
+ name: record.name,
6596
+ description: typeof record.description === "string" ? record.description : "",
6597
+ ...anchors.length === 0 ? {} : { anchors }
6598
+ });
6599
+ }
6600
+ return entries;
6601
+ };
6602
+ const nameDescription = (entries, world, kind = "location") => {
6603
+ const merged = /* @__PURE__ */ new Map();
6604
+ for (const entry of [...entries, ...ledgerWorldEntries(world, kind)]) {
6605
+ const name = oneLine(entry.name);
6606
+ if (name === "") continue;
6607
+ const existing = merged.get(name);
6608
+ if (existing === void 0) {
6609
+ merged.set(name, {
6610
+ name,
6611
+ description: oneLine(entry.description),
6612
+ ...entry.anchors === void 0 || entry.anchors.length === 0 ? {} : { anchors: [...entry.anchors] }
6613
+ });
6614
+ continue;
6615
+ }
6616
+ if (existing.anchors === void 0 && entry.anchors !== void 0 && entry.anchors.length > 0) existing.anchors = [...entry.anchors];
6617
+ }
6618
+ return [...merged.values()].slice(0, 5);
6619
+ };
6620
+ /**
6621
+ * Build the v6 panel book view from the canonical `BookReport`. This function
6622
+ * only truncates and renames; it never recomputes a quality/pacing aggregate.
6623
+ */
6624
+ function buildPanelBookData(input) {
6625
+ const report = input.report;
6626
+ const remainingBasis = report.progress.target === null ? "none" : report.progress.archived === 0 ? "minimum_target" : "actual_average";
6627
+ const remaining = remainingBasis === "actual_average" ? report.progress.remaining_words_average : remainingBasis === "minimum_target" ? report.progress.remaining_words_min : null;
6628
+ const card = input.outline.chapters.find((candidate) => candidate.num === input.currentChapter);
6629
+ const currentCard = card === void 0 ? null : {
6630
+ num: card.num,
6631
+ title: card.title,
6632
+ scene_type: card.scene_type,
6633
+ goal: card.goal,
6634
+ conflict: card.conflict,
6635
+ hook: card.hook,
6636
+ cast: [...card.cast],
6637
+ target_words: [card.target_words[0], card.target_words[1]],
6638
+ status: card.status
6639
+ };
6640
+ const quality = report.quality;
6641
+ const counts = {
6642
+ present: report.worldbook.present,
6643
+ characters: report.worldbook.characters,
6644
+ locations: report.worldbook.locations,
6645
+ factions: report.worldbook.factions,
6646
+ rules: report.worldbook.rules,
6647
+ glossary: report.worldbook.glossary,
6648
+ backstory: report.worldbook.backstory,
6649
+ ...report.worldbook.facts === void 0 ? {} : { facts: report.worldbook.facts }
6650
+ };
6651
+ return {
6652
+ identity: {
6653
+ author: input.project.author,
6654
+ description: input.project.description,
6655
+ created_at: input.project.created_at,
6656
+ targets: {
6657
+ words_per_chapter: [input.project.targets.words_per_chapter[0], input.project.targets.words_per_chapter[1]],
6658
+ total_chapters: input.project.targets.total_chapters,
6659
+ dialogue_ratio: [input.project.targets.dialogue_ratio[0], input.project.targets.dialogue_ratio[1]]
6660
+ }
6661
+ },
6662
+ blueprint: {
6663
+ premise: input.outline.book.premise,
6664
+ logline: input.outline.book.logline,
6665
+ themes: [...input.outline.book.themes],
6666
+ tone: input.outline.book.tone,
6667
+ pov: input.outline.book.pov,
6668
+ audience: input.outline.book.audience,
6669
+ selling_points: [...input.outline.book.selling_points],
6670
+ ending: input.outline.book.ending
6671
+ },
6672
+ progress: {
6673
+ archived: report.progress.archived,
6674
+ target: report.progress.target,
6675
+ words_archived: report.progress.words,
6676
+ words_with_draft: report.progress.words + input.draftWords,
6677
+ average_words: report.progress.average_words,
6678
+ remaining_words: remaining,
6679
+ remaining_words_basis: remainingBasis,
6680
+ percent: report.progress.percent
6681
+ },
6682
+ current: {
6683
+ card: currentCard,
6684
+ chapter: input.currentChapter,
6685
+ stage: input.stage,
6686
+ rounds: { ...input.rounds },
6687
+ manuscript_hash: input.manuscriptHash,
6688
+ metrics: input.metrics
6689
+ },
6690
+ worldbook: {
6691
+ counts,
6692
+ locations: nameDescription(input.worldbook.locations, input.world),
6693
+ factions: nameDescription(input.worldbook.factions, input.world, "faction"),
6694
+ rules: nameDescription(input.worldbook.rules, input.world, "rule"),
6695
+ facts: (input.worldbook.facts ?? []).slice(0, 5).map((fact) => ({
6696
+ alias: fact.alias,
6697
+ value: fact.value,
6698
+ unit: fact.unit
6699
+ }))
6700
+ },
6701
+ cast: {
6702
+ total: report.cast.total,
6703
+ top: report.cast.top.slice(0, 5).map((entry) => ({ ...entry }))
6704
+ },
6705
+ relationships: {
6706
+ total: report.relationships.total,
6707
+ types: { ...report.relationships.types }
6708
+ },
6709
+ foreshadows: {
6710
+ ...report.foreshadows,
6711
+ oldest: report.foreshadows.oldest.slice(0, 5).map((entry) => ({ ...entry }))
6712
+ },
6713
+ timeline: { ...report.timeline },
6714
+ outline: { drift: report.outline.drift.slice(0, 10).map((entry) => ({ ...entry })) },
6715
+ quality: {
6716
+ average: quality.average,
6717
+ median: quality.median,
6718
+ min: quality.min,
6719
+ max: quality.max,
6720
+ last_ten_average: input.lastTenAverage,
6721
+ trend: quality.trend,
6722
+ trend_basis: quality.trend_basis,
6723
+ samples: quality.samples,
6724
+ attempts: quality.attempts,
6725
+ dimensions: quality.dimensions.slice(0, 15).map((entry) => ({ ...entry }))
6726
+ },
6727
+ pacing: {
6728
+ scene_types: { ...report.pacing.scene_types },
6729
+ hook_strength: { ...report.pacing.hook_strength },
6730
+ plot_advancement: { ...report.pacing.plot_advancement },
6731
+ longest: report.pacing.longest === null ? null : { ...report.pacing.longest },
6732
+ shortest: report.pacing.shortest === null ? null : { ...report.pacing.shortest }
6733
+ },
6734
+ effort: {
6735
+ ...report.effort,
6736
+ chapters: report.effort.chapters.slice(-30).map((entry) => ({ ...entry }))
6737
+ },
6738
+ health: {
6739
+ state_errors: input.stateErrors.slice(0, 10).map((entry) => ({ ...entry })),
6740
+ outline: {
6741
+ present: input.outlinePresent,
6742
+ error: input.outlineError
6743
+ },
6744
+ worldbook: {
6745
+ present: input.worldbookPresent,
6746
+ error: input.worldbookError
6747
+ },
6748
+ pending: input.pending === null ? null : { ...input.pending }
6749
+ }
6750
+ };
6751
+ }
6322
6752
  // src/engine/history.ts
6323
6753
  const PATTERN = /^ch(?<chapter>\d{3,})\.r(?<revision>\d+)\.(?<suffix>review\.yaml|check\.yaml|chapter\.yaml|md)$/;
6324
6754
  const kindOf = (directory, suffix) => {
@@ -7100,6 +7530,14 @@ const SEMANTIC_CHECK_IDS = [
7100
7530
  "L2-VOICE",
7101
7531
  "L2-POV"
7102
7532
  ];
7533
+ /** A longer evidence interval must share at least this share of the shorter quote. */
7534
+ const SEMANTIC_OVERLAP_RATIO = .5;
7535
+ const conflictSummary = (itemId, conflict) => {
7536
+ const pair = `${itemId}↔${conflict.other_id}`;
7537
+ if (conflict.kind === "same_sentence") return `${pair} same sentence${conflict.sentence === void 0 ? "" : ` at paragraph ${conflict.sentence.paragraph} sentence ${conflict.sentence.id}`}`;
7538
+ if (conflict.kind === "duplicate") return `${pair} exact duplicate`;
7539
+ return `${pair} overlaps ${conflict.overlap_chars ?? 0} chars${conflict.overlap_ratio === void 0 ? "" : ` (${Math.round(conflict.overlap_ratio * 100)}%)`}${conflict.paragraphs === void 0 || conflict.paragraphs.length === 0 ? "" : ` at paragraph ${conflict.paragraphs.join("/")}`}`;
7540
+ };
7103
7541
  /**
7104
7542
  * A caller supplied evidence that cannot be evaluated as a content judgement.
7105
7543
  * It must never be persisted as a failed check or consume a content round.
@@ -7108,16 +7546,26 @@ var CheckSubmissionError = class extends EngineInputError {
7108
7546
  items;
7109
7547
  passed;
7110
7548
  failed;
7549
+ conflicts;
7550
+ blocking;
7111
7551
  rounds_consumed = 0;
7112
7552
  failure_class = "submission";
7113
- constructor(items) {
7553
+ constructor(items, blocking) {
7114
7554
  const passed = items.filter((item) => item.state === "pass").length;
7115
7555
  const failed = items.filter((item) => item.state !== "pass" && item.state !== "fail").length;
7116
7556
  const first = items.find((item) => item.state !== "pass" && item.state !== "fail");
7117
- super(ERROR_CODES.CHECK_SUBMISSION_INVALID, `semantic submission is invalid: ${first?.id ?? "semantic"} ${first?.state ?? "invalid"}${first?.detail === void 0 ? "" : ` (${first.detail})`}; ${passed} passed, ${failed} failed; no check was written and no round was consumed`, "resubmit novel_check with all five semantic items; each needs a distinct verbatim quote of at least 8 characters");
7557
+ const conflicts = items.flatMap((item) => item.conflicts ?? []);
7558
+ const summaries = items.flatMap((item) => (item.conflicts ?? []).map((conflict) => conflictSummary(item.id, conflict))).slice(0, 5);
7559
+ super(ERROR_CODES.CHECK_SUBMISSION_INVALID, `semantic submission is invalid: ${first?.id ?? "semantic"} ${first?.state ?? "invalid"}${first?.detail === void 0 ? "" : ` (${first.detail})`}${summaries.length === 0 ? "" : `; conflicts: ${summaries.join("; ")}`}; ${passed} passed, ${failed} failed; no check was written and no round was consumed`, "resubmit novel_check with all five semantic items; use distinct quotes from different sentences and paragraphs, and never reuse a sentence for two dimensions");
7118
7560
  this.items = items;
7119
7561
  this.passed = passed;
7120
7562
  this.failed = failed;
7563
+ this.conflicts = conflicts;
7564
+ this.blocking = blocking ?? items.filter((item) => submissionState(item.state)).map((item) => ({
7565
+ code: codeForState(item.state),
7566
+ scope: "check",
7567
+ detail: detailFor(item)
7568
+ }));
7121
7569
  }
7122
7570
  };
7123
7571
  /** Extract the quoted part of an evidence string, tolerating "第 N 段:" labels. */
@@ -7129,75 +7577,18 @@ function evidenceQuote(value) {
7129
7577
  candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
7130
7578
  return candidate;
7131
7579
  }
7132
- const CLOSING_PUNCTUATION = /* @__PURE__ */ new Set([
7133
- "」",
7134
- "』",
7135
- "”",
7136
- "\"",
7137
- "’",
7138
- "'",
7139
- ")",
7140
- ")",
7141
- "】",
7142
- "]"
7143
- ]);
7144
- /**
7145
- * Split text into sentence spans while treating every non-empty line as a
7146
- * paragraph boundary. A quote may cover multiple sentences or paragraphs; the
7147
- * spans are only used to prove that no two evidence items reuse the same
7148
- * passage. Trailing closing quotes belong to the sentence they terminate.
7149
- */
7150
- function sentenceSpans(text) {
7151
- const spans = [];
7152
- let paragraph = 1;
7153
- let lineStart = 0;
7154
- let sentenceStart = 0;
7155
- for (let index = 0; index < text.length; index += 1) {
7156
- const char = text[index];
7157
- if (char === "\n") {
7158
- if (text.slice(lineStart, index).trim() !== "") {
7159
- if (index > sentenceStart) spans.push({
7160
- start: sentenceStart,
7161
- end: index,
7162
- paragraph
7163
- });
7164
- paragraph += 1;
7165
- }
7166
- lineStart = index + 1;
7167
- sentenceStart = index + 1;
7168
- continue;
7169
- }
7170
- if ("。!?!?".includes(char)) {
7171
- let end = index + 1;
7172
- while (end < text.length && CLOSING_PUNCTUATION.has(text[end])) end += 1;
7173
- spans.push({
7174
- start: sentenceStart,
7175
- end,
7176
- paragraph
7177
- });
7178
- sentenceStart = end;
7179
- index = end - 1;
7180
- }
7181
- }
7182
- if (sentenceStart < text.length) spans.push({
7183
- start: sentenceStart,
7184
- end: text.length,
7185
- paragraph
7186
- });
7187
- return spans;
7188
- }
7189
7580
  function nearestParagraph(text, quote) {
7190
7581
  const paragraphs = splitParagraphs(text);
7191
7582
  let best = null;
7192
7583
  for (const [index, paragraph] of paragraphs.entries()) {
7193
- const score = textSimilarity(paragraph, quote);
7584
+ const score = bigramSimilarity(paragraph, quote);
7194
7585
  if (best === null || score > best.score) best = {
7195
7586
  index,
7196
7587
  paragraph,
7197
7588
  score
7198
7589
  };
7199
7590
  }
7200
- if (best === null) return null;
7591
+ if (best === null || best.score < .5) return null;
7201
7592
  return {
7202
7593
  paragraph: best.index + 1,
7203
7594
  quote: Array.from(best.paragraph).slice(0, 120).join(""),
@@ -7230,18 +7621,41 @@ const detailFor = (result) => {
7230
7621
  default: return `${result.id} is invalid`;
7231
7622
  }
7232
7623
  };
7624
+ const sentenceIdsFor = (sentences, start, end) => sentences.map((sentence, index) => ({
7625
+ sentence,
7626
+ index
7627
+ })).filter(({ sentence }) => start < sentence.end && sentence.start < end).map(({ index }) => index);
7628
+ const paragraphsFor = (sentences, start, end) => {
7629
+ const values = /* @__PURE__ */ new Set();
7630
+ for (const sentence of sentences) if (start < sentence.end && sentence.start < end) values.add(sentence.paragraph);
7631
+ return [...values].sort((left, right) => left - right);
7632
+ };
7633
+ const intervalOverlap = (start, end, other) => {
7634
+ const overlapStart = Math.max(start, other.start);
7635
+ const overlapEnd = Math.min(end, other.end);
7636
+ const overlapChars = Math.max(0, overlapEnd - overlapStart);
7637
+ const minLength = Math.min(end - start, other.end - other.start);
7638
+ return {
7639
+ start: overlapStart,
7640
+ end: overlapEnd,
7641
+ chars: overlapChars,
7642
+ ratio: minLength === 0 ? 0 : overlapChars / minLength,
7643
+ contains: start <= other.start && end >= other.end || other.start <= start && other.end >= end
7644
+ };
7645
+ };
7233
7646
  /**
7234
- * Validate and evaluate all five L2 semantic items.
7647
+ * Evaluate all five L2 semantic items.
7235
7648
  *
7236
- * Evidence is located by character range, not by requiring one sentence. Two
7237
- * items may not overlap or reuse a sentence; a quote that exists but fails
7238
- * those rules is reported as OVERLAP/SAME_SENTENCE instead of the old generic
7239
- * "overlapping text or the same sentence" message.
7649
+ * Sentence boundaries come from the domain owner. A failure never consumes an
7650
+ * interval or a sentence, so one bad quote cannot cascade into the next item.
7651
+ * Exact duplicate, same-sentence reuse and meaningful interval overlap are
7652
+ * distinct, pair-level conflicts; merely sharing a few words in different
7653
+ * sentences is not a conflict.
7240
7654
  */
7241
7655
  function evaluateSemantic(text, input) {
7242
7656
  const items = [];
7243
- const spans = [];
7244
- const sentences = sentenceSpans(text);
7657
+ const accepted = [];
7658
+ const sentences = splitSentenceSpans(text);
7245
7659
  const usedSentences = /* @__PURE__ */ new Map();
7246
7660
  const seenIds = /* @__PURE__ */ new Set();
7247
7661
  const acceptedQuotes = /* @__PURE__ */ new Map();
@@ -7306,13 +7720,26 @@ function evaluateSemantic(text, input) {
7306
7720
  });
7307
7721
  continue;
7308
7722
  }
7723
+ if (!SEMANTIC_CHECK_IDS.includes(id)) {
7724
+ items.push({
7725
+ id,
7726
+ state: "invalid",
7727
+ detail: `${id} is not one of the required P6 semantic checks`
7728
+ });
7729
+ continue;
7730
+ }
7309
7731
  const duplicate = acceptedQuotes.get(quote);
7310
7732
  if (duplicate !== void 0) {
7733
+ const conflict = {
7734
+ kind: "duplicate",
7735
+ other_id: duplicate
7736
+ };
7311
7737
  items.push({
7312
7738
  id,
7313
7739
  state: "duplicate",
7314
7740
  quote,
7315
7741
  conflict_with: duplicate,
7742
+ conflicts: [conflict],
7316
7743
  detail: `${id} repeats ${duplicate}'s quote exactly`
7317
7744
  });
7318
7745
  continue;
@@ -7334,63 +7761,79 @@ function evaluateSemantic(text, input) {
7334
7761
  });
7335
7762
  continue;
7336
7763
  }
7337
- let accepted = null;
7338
- let overlapWith;
7339
- let sameSentenceWith;
7340
- let boundary = false;
7764
+ let acceptedSpan = null;
7765
+ const conflicts = [];
7341
7766
  for (const start of occurrences) {
7342
7767
  const end = start + quote.length;
7343
- const conflict = spans.find((span) => start < span.end && span.start < end);
7344
- if (conflict !== void 0) {
7345
- overlapWith ??= conflict.id;
7346
- continue;
7768
+ const sentenceIds = sentenceIdsFor(sentences, start, end);
7769
+ if (sentenceIds.length === 0) continue;
7770
+ let rejected = false;
7771
+ for (const other of accepted) {
7772
+ const overlap = intervalOverlap(start, end, other);
7773
+ const minLength = Math.min(quote.length, other.quote.length);
7774
+ const threshold = Math.max(8, Math.ceil(SEMANTIC_OVERLAP_RATIO * minLength));
7775
+ if (overlap.chars > 0 && (overlap.contains || overlap.chars >= threshold)) {
7776
+ conflicts.push({
7777
+ kind: "interval_overlap",
7778
+ other_id: other.id,
7779
+ overlap_text: Array.from(text.slice(overlap.start, overlap.end)).slice(0, 120).join(""),
7780
+ overlap_chars: overlap.chars,
7781
+ overlap_ratio: Math.round(overlap.ratio * 1e3) / 1e3,
7782
+ paragraphs: paragraphsFor(sentences, overlap.start, overlap.end)
7783
+ });
7784
+ rejected = true;
7785
+ }
7347
7786
  }
7348
- const covered = sentences.map((sentence, sentenceIndex) => ({
7349
- sentence,
7350
- sentenceIndex
7351
- })).filter(({ sentence }) => start < sentence.end && sentence.start < end);
7352
- if (covered.length === 0) {
7353
- boundary = true;
7354
- continue;
7787
+ if (!rejected) for (const sentenceId of sentenceIds) {
7788
+ const owner = usedSentences.get(sentenceId);
7789
+ if (owner !== void 0) {
7790
+ const sentence = sentences[sentenceId];
7791
+ conflicts.push({
7792
+ kind: "same_sentence",
7793
+ other_id: owner,
7794
+ sentence: {
7795
+ id: sentenceId + 1,
7796
+ text: sentence.text,
7797
+ paragraph: sentence.paragraph
7798
+ }
7799
+ });
7800
+ rejected = true;
7801
+ }
7355
7802
  }
7356
- const used = covered.find(({ sentenceIndex }) => usedSentences.has(sentenceIndex));
7357
- if (used !== void 0) {
7358
- sameSentenceWith ??= usedSentences.get(used.sentenceIndex);
7359
- continue;
7803
+ if (!rejected) {
7804
+ acceptedSpan = {
7805
+ start,
7806
+ end,
7807
+ id,
7808
+ quote,
7809
+ sentenceIds
7810
+ };
7811
+ break;
7360
7812
  }
7361
- accepted = {
7362
- start,
7363
- end,
7364
- id
7365
- };
7366
- for (const { sentenceIndex } of covered) usedSentences.set(sentenceIndex, id);
7367
- break;
7368
7813
  }
7369
- if (accepted === null) {
7370
- if (overlapWith !== void 0) items.push({
7371
- id,
7372
- state: "overlap",
7373
- quote,
7374
- conflict_with: overlapWith,
7375
- detail: `${id} overlaps ${overlapWith}'s accepted evidence interval`
7376
- });
7377
- else if (sameSentenceWith !== void 0) items.push({
7378
- id,
7379
- state: "same_sentence",
7380
- quote,
7381
- conflict_with: sameSentenceWith,
7382
- detail: `${id} reuses a sentence already cited by ${sameSentenceWith}`
7383
- });
7384
- else items.push({
7814
+ if (acceptedSpan === null) {
7815
+ const unique = /* @__PURE__ */ new Map();
7816
+ for (const conflict of conflicts) {
7817
+ const key = `${conflict.kind}:${conflict.other_id}:${conflict.overlap_chars ?? ""}:${conflict.sentence?.id ?? ""}`;
7818
+ unique.set(key, conflict);
7819
+ }
7820
+ const all = [...unique.values()];
7821
+ const overlap = all.find((conflict) => conflict.kind === "interval_overlap");
7822
+ const same = all.find((conflict) => conflict.kind === "same_sentence");
7823
+ const chosen = overlap ?? same;
7824
+ const state = overlap !== void 0 ? "overlap" : same !== void 0 ? "same_sentence" : "boundary";
7825
+ items.push({
7385
7826
  id,
7386
- state: boundary ? "boundary" : "same_sentence",
7827
+ state,
7387
7828
  quote,
7388
- ...boundary ? {} : { conflict_with: id },
7389
- detail: boundary ? `${id} cannot be mapped onto a sentence boundary` : `${id} reuses a sentence already cited by another item`
7829
+ ...chosen === void 0 ? {} : { conflict_with: chosen.other_id },
7830
+ ...all.length === 0 ? {} : { conflicts: all },
7831
+ detail: chosen === void 0 ? `${id} cannot be mapped onto a sentence boundary` : `${id} ${chosen.kind} with ${chosen.other_id}`
7390
7832
  });
7391
7833
  continue;
7392
7834
  }
7393
- spans.push(accepted);
7835
+ accepted.push(acceptedSpan);
7836
+ for (const sentenceId of acceptedSpan.sentenceIds) usedSentences.set(sentenceId, id);
7394
7837
  acceptedQuotes.set(quote, id);
7395
7838
  items.push({
7396
7839
  id,
@@ -7404,24 +7847,29 @@ function evaluateSemantic(text, input) {
7404
7847
  state: "missing",
7405
7848
  detail: `${id} is required by the P6 task book`
7406
7849
  });
7407
- for (const id of seenIds) if (!SEMANTIC_CHECK_IDS.includes(id)) items.push({
7408
- id,
7409
- state: "invalid",
7410
- detail: `${id} is not one of the required P6 semantic checks`
7411
- });
7850
+ const conflicts = items.flatMap((item) => item.conflicts ?? []);
7412
7851
  const findings = items.filter((item) => item.state === "fail").map((item) => ({
7413
7852
  code: "SEMANTIC_CHECK_FAILED",
7414
7853
  scope: "check",
7415
7854
  detail: item.detail ?? `${item.id}: semantic check failed`
7416
7855
  }));
7417
7856
  const submissionItems = items.filter((item) => submissionState(item.state));
7418
- const blocking = [...submissionItems.map((item) => ({
7419
- code: codeForState(item.state),
7420
- scope: "check",
7421
- detail: detailFor(item)
7422
- })), ...findings];
7857
+ const blocking = [
7858
+ ...submissionItems.map((item) => ({
7859
+ code: codeForState(item.state),
7860
+ scope: "check",
7861
+ detail: detailFor(item)
7862
+ })),
7863
+ ...submissionItems.flatMap((item) => (item.conflicts ?? []).slice(0, 5).map((conflict) => ({
7864
+ code: codeForState(item.state),
7865
+ scope: "check",
7866
+ detail: `${item.id}: ${conflictSummary(item.id, conflict)}`
7867
+ }))),
7868
+ ...findings
7869
+ ];
7423
7870
  return {
7424
7871
  items,
7872
+ conflicts,
7425
7873
  findings,
7426
7874
  blocking,
7427
7875
  submission_failed: submissionItems.length > 0,
@@ -7432,7 +7880,7 @@ function evaluateSemantic(text, input) {
7432
7880
  /** Throw a typed submission error when shape/evidence errors exist. */
7433
7881
  function validateSemanticSubmission(text, input) {
7434
7882
  const evaluation = evaluateSemantic(text, input);
7435
- if (evaluation.submission_failed) throw new CheckSubmissionError(evaluation.items);
7883
+ if (evaluation.submission_failed) throw new CheckSubmissionError(evaluation.items, evaluation.blocking);
7436
7884
  return evaluation;
7437
7885
  }
7438
7886
  // src/engine/gates.ts
@@ -7537,23 +7985,32 @@ const summarySchema = (input) => {
7537
7985
  }
7538
7986
  const newElements = summary.new_elements;
7539
7987
  if (typeof newElements !== "object" || newElements === null) problems.push("new_elements must be an object");
7540
- else for (const key of [
7541
- "locations",
7542
- "abilities",
7543
- "artifacts"
7544
- ]) {
7545
- if (!textArray(newElements[key])) problems.push(`new_elements.${key} must be a string array`);
7546
- for (const name of textArray(newElements[key]) ? newElements[key] : []) {
7547
- if (!input.text.includes(name)) problems.push(`new_elements.${key} lists "${name}", but the manuscript never mentions it`);
7548
- if (input.operation !== "archiveChapter") {
7549
- const ledger = input.evidence.world;
7550
- if (ledger !== null && [
7551
- ledger.locations,
7552
- ledger.abilities,
7553
- ledger.artifacts,
7554
- ledger.factions,
7555
- ledger.rules
7556
- ].some((rows) => rows.some((row) => row.name === name))) problems.push(`new_elements.${key} lists "${name}", but state/world.yaml already has it; remove it or use updated/no_change`);
7988
+ else {
7989
+ let anchorMap = null;
7990
+ try {
7991
+ anchorMap = anchorsFromSummary(summary);
7992
+ } catch (error) {
7993
+ problems.push(error instanceof Error ? error.message : "new_element_anchors is invalid");
7994
+ }
7995
+ for (const key of [
7996
+ "locations",
7997
+ "abilities",
7998
+ "artifacts"
7999
+ ]) {
8000
+ if (!textArray(newElements[key])) problems.push(`new_elements.${key} must be a string array`);
8001
+ for (const name of textArray(newElements[key]) ? newElements[key] : []) {
8002
+ const anchors = anchorMap === null ? [] : anchorsForGroup(anchorMap, key, name);
8003
+ if (!matchEntityText(input.text, name, anchors).ok) problems.push(`new_elements.${key} lists "${name}", but ${entityMatchHint(name, anchors)}`);
8004
+ if (input.operation !== "archiveChapter") {
8005
+ const ledger = input.evidence.world;
8006
+ if (ledger !== null && [
8007
+ ledger.locations,
8008
+ ledger.abilities,
8009
+ ledger.artifacts,
8010
+ ledger.factions,
8011
+ ledger.rules
8012
+ ].some((rows) => rows.some((row) => worldEntityName(row) === name))) problems.push(`new_elements.${key} lists "${name}", but state/world.yaml already has it; remove it or use updated/no_change`);
8013
+ }
7557
8014
  }
7558
8015
  }
7559
8016
  }
@@ -7676,12 +8133,18 @@ const issuesCleared = (input) => {
7676
8133
  }
7677
8134
  return problems.length === 0 ? [] : [block("PG-08", problems.join("; "), "mark every blocker fixed and quote the revised text as evidence")];
7678
8135
  };
7679
- /** Paragraph index (1-based) that contains the quote, or -1. */
8136
+ /** Paragraph index (1-based) nearest the reference when the quote repeats, or -1. */
7680
8137
  function actualParagraph(text, reference) {
7681
8138
  if (typeof reference !== "object" || reference === null || typeof reference.quote !== "string") return -1;
7682
8139
  const quote = reference.quote.trim();
7683
8140
  if (Array.from(quote).length < LIMITS.minQuoteChars) return -1;
7684
- return splitParagraphs(text).findIndex((paragraph) => paragraph.includes(quote)) + 1;
8141
+ const matches = splitParagraphs(text).map((paragraph, index) => ({
8142
+ paragraph,
8143
+ index
8144
+ })).filter(({ paragraph }) => paragraph.includes(quote));
8145
+ if (matches.length === 0) return -1;
8146
+ const target = Number.isInteger(reference.paragraph) && reference.paragraph > 0 ? reference.paragraph : 1;
8147
+ return matches.reduce((best, match) => Math.abs(match.index + 1 - target) < Math.abs(best.index + 1 - target) ? match : best).index + 1;
7685
8148
  }
7686
8149
  /** Verify one reading-proof reference against the manuscript. */
7687
8150
  function verifyReference(text, reference) {
@@ -7690,9 +8153,13 @@ function verifyReference(text, reference) {
7690
8153
  if (!Number.isInteger(reference.paragraph) || reference.paragraph < 1) return "reading proof paragraph must be a positive integer";
7691
8154
  const quote = reference.quote.trim();
7692
8155
  if (Array.from(quote).length < LIMITS.minQuoteChars) return `quote "${reference.quote}" is shorter than ${LIMITS.minQuoteChars} characters`;
7693
- const actual = splitParagraphs(text).findIndex((paragraph) => paragraph.includes(quote));
7694
- if (actual === -1) return `quote "${quote}" does not appear in the text`;
7695
- if (Math.abs(actual + 1 - reference.paragraph) > 1) return `quote "${quote}" is at paragraph ${actual + 1}, not ${reference.paragraph}`;
8156
+ const matches = splitParagraphs(text).map((paragraph, index) => ({
8157
+ paragraph,
8158
+ index
8159
+ })).filter(({ paragraph }) => paragraph.includes(quote));
8160
+ if (matches.length === 0) return `quote "${quote}" does not appear in the text`;
8161
+ const nearest = matches.reduce((best, match) => Math.abs(match.index + 1 - reference.paragraph) < Math.abs(best.index + 1 - reference.paragraph) ? match : best);
8162
+ if (Math.abs(nearest.index + 1 - reference.paragraph) > 1) return `quote "${quote}" is at paragraph ${nearest.index + 1}, not ${reference.paragraph}`;
7696
8163
  return null;
7697
8164
  }
7698
8165
  /** PG-09: three verifiable quotes from three distinct paragraphs. */
@@ -7945,12 +8412,21 @@ function projectChapter(record) {
7945
8412
  score: record.score,
7946
8413
  stage: record.status === "reviewed" ? "done" : "P3",
7947
8414
  status: record.status === "reviewed" ? "passed" : "draft",
7948
- details: record.summary.events.join(";").slice(0, 240)
8415
+ details: record.summary.events.join(";").slice(0, 240),
8416
+ sceneType: record.scene_type,
8417
+ dialogueRatio: record.dialogue_ratio,
8418
+ hookStrength: record.summary.hook_strength,
8419
+ finalCheck: record.final_check ?? null,
8420
+ reviews: record.rounds?.review ?? 0,
8421
+ revisions: record.rounds?.revise ?? 0,
8422
+ checks: record.rounds?.check ?? 0,
8423
+ rollbacks: record.rounds?.rollbacks ?? 0,
8424
+ archivedAt: record.archived_at ?? null
7949
8425
  };
7950
8426
  }
7951
8427
  function projectSummary(snapshot) {
7952
8428
  return {
7953
- schemaVersion: 5,
8429
+ schemaVersion: 6,
7954
8430
  panelId: PANEL_ID,
7955
8431
  root: snapshot.root,
7956
8432
  title: snapshot.title,
@@ -7972,6 +8448,8 @@ function projectSummary(snapshot) {
7972
8448
  ...snapshot.book === void 0 ? {} : { book: snapshot.book },
7973
8449
  ...snapshot.quality === void 0 ? {} : { quality: snapshot.quality },
7974
8450
  ...snapshot.health === void 0 ? {} : { health: snapshot.health },
8451
+ ...snapshot.bookData === void 0 ? {} : { bookData: snapshot.bookData },
8452
+ ...snapshot.pending === void 0 ? {} : { pending: snapshot.pending },
7975
8453
  ...snapshot.activity === void 0 ? {} : { activity: snapshot.activity },
7976
8454
  ...snapshot.advisories === void 0 ? {} : { advisories: snapshot.advisories },
7977
8455
  ...snapshot.acknowledgedAdvisories === void 0 ? {} : { acknowledgedAdvisories: snapshot.acknowledgedAdvisories },
@@ -7997,14 +8475,14 @@ function projectChapters(snapshot) {
7997
8475
  details: draft.details
7998
8476
  });
7999
8477
  return {
8000
- schemaVersion: 5,
8478
+ schemaVersion: 6,
8001
8479
  panelId: PANEL_ID,
8002
8480
  rows
8003
8481
  };
8004
8482
  }
8005
8483
  function projectGates(snapshot) {
8006
8484
  return {
8007
- schemaVersion: 5,
8485
+ schemaVersion: 6,
8008
8486
  panelId: PANEL_ID,
8009
8487
  stage: snapshot.stage,
8010
8488
  blocking: snapshot.blocking,
@@ -8406,6 +8884,8 @@ var EngineService = class {
8406
8884
  exportArtifactEpoch = /* @__PURE__ */ new Map();
8407
8885
  /** Status, panel and gates ask for the same stage context repeatedly. */
8408
8886
  briefContextCache = /* @__PURE__ */ new WeakMap();
8887
+ /** The 15s panel poll reuses the heavy book projection until revision/damage changes. */
8888
+ panelCache = /* @__PURE__ */ new Map();
8409
8889
  briefs;
8410
8890
  now;
8411
8891
  metadataMutex = new KeyedMutex();
@@ -9418,14 +9898,51 @@ var EngineService = class {
9418
9898
  if (typeof input.txid !== "string" || !/^[0-9a-f-]{36}$/i.test(input.txid)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore txid must be a transaction UUID");
9419
9899
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
9420
9900
  if (input.paths !== void 0 && (!Array.isArray(input.paths) || input.paths.length === 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore paths must be a non-empty array");
9901
+ const pipelinePath = joinRel(STATE_DIR, STATE_FILES.pipeline);
9902
+ const pipelineOnly = input.paths?.length === 1 && input.paths[0] === pipelinePath;
9421
9903
  return this.mutate({
9422
9904
  root: input.root,
9423
9905
  op: "restoreBackup",
9424
9906
  chapter: input.chapter,
9425
9907
  reason: `restore backup ${input.txid}`,
9908
+ ...pipelineOnly ? {
9909
+ allowPipelineRecovery: true,
9910
+ repairPaths: [pipelinePath]
9911
+ } : {},
9426
9912
  run: async (snapshot, context) => {
9427
9913
  const manifest = await readBackupManifest(context.store, input.txid);
9428
9914
  if (manifest === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} has no readable manifest`, "list backups with novel_state {kind:\"backups\"}; only marked engine backups can be restored");
9915
+ if (pipelineOnly) {
9916
+ if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring state/pipeline.yaml does not accept a chapter", "omit chapter when paths is exactly [state/pipeline.yaml]");
9917
+ if (!manifest.files.includes(pipelinePath)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${pipelinePath}`, "choose a backup created by a stage operation or amend that contains the pipeline");
9918
+ if (snapshot.pipelineError === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is readable; refusing a blind pipeline restore", "use the normal stage operation, or repair a genuinely unreadable pipeline");
9919
+ const text = await context.store.readText(`backups/${input.txid}/${pipelinePath}`);
9920
+ if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${pipelinePath}`, "choose another backup with a pipeline pre-image");
9921
+ let backupPipeline;
9922
+ try {
9923
+ backupPipeline = narrow("pipeline", parse(text));
9924
+ } catch (error) {
9925
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid pipeline: ${errorMessage(error)}`, "choose a different backup or repair the file manually");
9926
+ }
9927
+ const nextPipeline = {
9928
+ ...backupPipeline,
9929
+ revision: context.revision,
9930
+ last_action: action("novel_restore", this.now(), shortDigest(input.txid))
9931
+ };
9932
+ return {
9933
+ files: [{
9934
+ path: pipelinePath,
9935
+ text: yaml(nextPipeline)
9936
+ }],
9937
+ backup: true,
9938
+ data: {
9939
+ txid: input.txid,
9940
+ chapter: nextPipeline.chapter,
9941
+ restored: [pipelinePath]
9942
+ },
9943
+ warnings: []
9944
+ };
9945
+ }
9429
9946
  if (input.paths?.length === 1 && input.paths[0] === "outline/outline.yaml") {
9430
9947
  this.assertArchiveIndexReadable(snapshot, "novel_restore");
9431
9948
  if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring outline/outline.yaml does not accept a chapter", "omit chapter when paths is exactly [outline/outline.yaml]");
@@ -9441,6 +9958,8 @@ var EngineService = class {
9441
9958
  } catch (error) {
9442
9959
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid outline: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
9443
9960
  }
9961
+ assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, document.volumes, "novel_restore");
9962
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, document.chapters, document.volumes, "novel_restore");
9444
9963
  const restoredCards = new Map(document.chapters.map((card) => [card.num, card]));
9445
9964
  const archivedNums = new Set(snapshot.chapters.chapters.map((record) => record.num));
9446
9965
  for (const record of snapshot.chapters.chapters) {
@@ -10131,7 +10650,7 @@ var EngineService = class {
10131
10650
  } catch {}
10132
10651
  if (oldChecks > 0) issues.push({
10133
10652
  code: "LEGACY_CHECK_FORMAT",
10134
- detail: `${oldChecks} 章 check 是 0.2.0 旧格式(advisory 混在 violations);重新跑 novel_check 或按 docs/0.2.3-plan.md 标记`
10653
+ detail: `${oldChecks} 章 check 是 0.2.0 旧格式(advisory 混在 violations);重新跑 novel_check 或按 0.2.3 迁移说明标记`
10135
10654
  });
10136
10655
  }
10137
10656
  const hints = /* @__PURE__ */ new Map();
@@ -10239,7 +10758,7 @@ var EngineService = class {
10239
10758
  severity: "error",
10240
10759
  title: "状态文档不可读",
10241
10760
  detail: `${error.code}: ${error.message}`,
10242
- hint: `repair ${error.path} with novel_amend`
10761
+ hint: error.code === "PIPELINE_UNREADABLE" ? "restore a valid pipeline backup with novel_restore { paths: [\"state/pipeline.yaml\"], txid }" : `repair ${error.path} with novel_amend`
10243
10762
  });
10244
10763
  add({
10245
10764
  id: "outline",
@@ -10424,7 +10943,7 @@ var EngineService = class {
10424
10943
  severity: "warning",
10425
10944
  title: "旧数据升级提示",
10426
10945
  detail: `${legacy.length} 类旧数据需要解释或人工补齐:${legacy.slice(0, 3).map((issue) => `${issue.code}(${issue.detail})`).join(";")}`,
10427
- hint: "这是只读审计,不会自动改历史;按 docs/0.2.3-plan.md WP0 处理,补 anchor/role 前先核对原文"
10946
+ hint: "这是只读审计,不会自动改历史;按 0.2.3 的 WP0 处理,补 anchor/role 前先核对原文"
10428
10947
  });
10429
10948
  const locked = await store.exists(LOCK_FILE);
10430
10949
  add({
@@ -10517,6 +11036,7 @@ var EngineService = class {
10517
11036
  worldbook: snapshot.worldbook,
10518
11037
  worldbookPresent: snapshot.worldbookPresent,
10519
11038
  worldbookError: snapshot.worldbookError,
11039
+ world: snapshot.world,
10520
11040
  chapters: snapshot.chapters.chapters,
10521
11041
  characters: snapshot.characters,
10522
11042
  timeline: snapshot.timeline,
@@ -10715,7 +11235,7 @@ var EngineService = class {
10715
11235
  reason: "no_change 必填;created/updated 建议写",
10716
11236
  role: "protagonist | supporting | antagonist | extra(可选)",
10717
11237
  profile: "object(可选)",
10718
- current_state: "object(可选,如 {\"resources\":{\"港币\":-3}})"
11238
+ current_state: "object(可选,如 {\"resources\":{\"资源名\":-3}})"
10719
11239
  } },
10720
11240
  foreshadows: { "<summary.planted.key 或 FS-xxx>": {
10721
11241
  action: "created | updated | no_change",
@@ -10745,27 +11265,27 @@ var EngineService = class {
10745
11265
  },
10746
11266
  examples: [{
10747
11267
  summary_excerpt: {
10748
- characters_appeared: ["陈嘉树"],
11268
+ characters_appeared: ["角色甲"],
10749
11269
  foreshadows: {
10750
11270
  planted: [],
10751
11271
  advanced: ["FS-001"],
10752
11272
  resolved: []
10753
11273
  }
10754
11274
  },
10755
- characters: { 陈嘉树: {
11275
+ characters: { 角色甲: {
10756
11276
  action: "updated",
10757
- current_state: { resources: { 港币: -3 } }
11277
+ current_state: { resources: { 资源名: -3 } }
10758
11278
  } },
10759
11279
  foreshadows: { "FS-001": {
10760
11280
  action: "updated",
10761
- note: "欠条被收回",
11281
+ note: "债务已结清",
10762
11282
  new_info: true
10763
11283
  } }
10764
11284
  }, {
10765
11285
  summary_excerpt: {
10766
11286
  events: ["收到信"],
10767
11287
  new_elements: {
10768
- locations: ["旧码头"],
11288
+ locations: ["地点甲"],
10769
11289
  abilities: [],
10770
11290
  artifacts: []
10771
11291
  }
@@ -10775,31 +11295,31 @@ var EngineService = class {
10775
11295
  story_time: "Day 3 清晨",
10776
11296
  description: "收到信"
10777
11297
  } },
10778
- world: { 旧码头: {
11298
+ world: { 地点甲: {
10779
11299
  action: "created",
10780
11300
  kind: "location",
10781
- description: "退潮时露出石阶"
11301
+ description: "地点说明"
10782
11302
  } }
10783
11303
  }],
10784
11304
  linked_example: {
10785
11305
  summary: {
10786
- characters_appeared: ["陈嘉树"],
11306
+ characters_appeared: ["角色甲"],
10787
11307
  new_elements: {
10788
- locations: ["旧码头"],
11308
+ locations: ["地点甲"],
10789
11309
  abilities: [],
10790
11310
  artifacts: []
10791
11311
  },
10792
11312
  events: ["收到信"]
10793
11313
  },
10794
11314
  delta: {
10795
- characters: { 陈嘉树: {
11315
+ characters: { 角色甲: {
10796
11316
  action: "no_change",
10797
11317
  reason: "本章无变化"
10798
11318
  } },
10799
- world: { 旧码头: {
11319
+ world: { 地点甲: {
10800
11320
  action: "created",
10801
11321
  kind: "location",
10802
- description: "退潮时露出石阶"
11322
+ description: "地点说明"
10803
11323
  } },
10804
11324
  timeline: { "0": {
10805
11325
  action: "created",
@@ -12888,15 +13408,18 @@ var EngineService = class {
12888
13408
  const target = snapshot.project.targets.total_chapters;
12889
13409
  const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
12890
13410
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
12891
- const journal = await readJournal(this.storeFor(snapshot.root));
12892
13411
  const pending = await inspectPending(this.storeFor(snapshot.root));
13412
+ const stateErrorCount = this.stateErrors(snapshot).length;
13413
+ const panelCacheKey = `${snapshot.root}:${snapshot.pipeline.revision}:${pending.state}:${stateErrorCount}:${snapshot.outlineError ?? ""}:${snapshot.worldbookError ?? ""}:${JSON.stringify(snapshot.briefs)}`;
13414
+ const cachedPanel = this.panelCache.get(snapshot.root);
13415
+ if (cachedPanel?.key === panelCacheKey) return cachedPanel.snapshot;
13416
+ const journal = await readJournal(this.storeFor(snapshot.root));
12893
13417
  const journalBlocking = await this.journalBlocking(snapshot);
12894
13418
  const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
12895
13419
  const counts = worldbookCounts(snapshot.worldbook);
12896
13420
  const scores = archived.slice(-10).map((chapter) => chapter.score);
12897
13421
  const blocking = [...this.effectiveBlocking(snapshot), ...journalBlocking];
12898
13422
  const healthBlocking = blocking.length;
12899
- const stateErrorCount = this.stateErrors(snapshot).length;
12900
13423
  const book = {
12901
13424
  premise: snapshot.outline.book.premise,
12902
13425
  logline: snapshot.outline.book.logline,
@@ -12940,11 +13463,16 @@ var EngineService = class {
12940
13463
  const store = this.storeFor(snapshot.root);
12941
13464
  const reviewNames = new Set(await store.list(REVIEW_DIR));
12942
13465
  const sampleChapters = /* @__PURE__ */ new Set();
13466
+ const currentReviews = [];
12943
13467
  for (const record of archived) {
12944
13468
  const name = reviewFile(record.num).slice(`${REVIEW_DIR}/`.length);
12945
13469
  if (!reviewNames.has(name)) continue;
12946
13470
  try {
12947
- if (await store.readDoc(reviewFile(record.num), "review") !== null) sampleChapters.add(record.num);
13471
+ const review = await store.readDoc(reviewFile(record.num), "review");
13472
+ if (review !== null) {
13473
+ sampleChapters.add(record.num);
13474
+ currentReviews.push(review);
13475
+ }
12948
13476
  } catch {}
12949
13477
  }
12950
13478
  const samples = sampleChapters.size;
@@ -12975,15 +13503,66 @@ var EngineService = class {
12975
13503
  chapter: row.chapter,
12976
13504
  status: row.status
12977
13505
  }));
13506
+ const draftMetrics = snapshot.text === null ? null : countWordMetrics(snapshot.text);
13507
+ const bookData = buildPanelBookData({
13508
+ report: buildBookReport({
13509
+ project: snapshot.project,
13510
+ outline: snapshot.outline,
13511
+ outlinePresent: snapshot.outlinePresent,
13512
+ outlineError: snapshot.outlineError,
13513
+ worldbook: snapshot.worldbook,
13514
+ worldbookPresent: snapshot.worldbookPresent,
13515
+ worldbookError: snapshot.worldbookError,
13516
+ world: snapshot.world,
13517
+ chapters: archived,
13518
+ characters: snapshot.characters,
13519
+ timeline: snapshot.timeline,
13520
+ foreshadows: snapshot.foreshadows,
13521
+ relationships: snapshot.relationships,
13522
+ reviews: currentReviews,
13523
+ activity: journal.rows.slice(-8),
13524
+ journal: journal.rows,
13525
+ reviewAttempts: attempts,
13526
+ finalChecks: Object.fromEntries(archived.filter((record) => record.final_check === "pass" || record.final_check === "fail").map((record) => [record.num, record.final_check])),
13527
+ targetChapters: target,
13528
+ minScore: this.config.quality.minScore,
13529
+ foreshadowDormantAfter: this.rulepack.crosschapter.foreshadow_dormant_after
13530
+ }),
13531
+ project: snapshot.project,
13532
+ worldbook: snapshot.worldbook,
13533
+ world: snapshot.world,
13534
+ outline: snapshot.outline,
13535
+ outlinePresent: snapshot.outlinePresent,
13536
+ worldbookPresent: snapshot.worldbookPresent,
13537
+ currentChapter: snapshot.pipeline.chapter,
13538
+ stage: snapshot.pipeline.stage,
13539
+ rounds: snapshot.pipeline.rounds,
13540
+ draftWords,
13541
+ lastTenAverage: scores.length === 0 ? 0 : scores.reduce((total, score) => total + score, 0) / scores.length,
13542
+ manuscriptHash: snapshot.pipeline.draft?.content_hash ?? (snapshot.text === null ? null : sha256(snapshot.text)),
13543
+ metrics: draftMetrics,
13544
+ stateErrors: this.stateErrors(snapshot).map((error) => ({
13545
+ path: error.path,
13546
+ code: error.code,
13547
+ message: error.message
13548
+ })),
13549
+ pending: pending.state === "none" ? null : {
13550
+ state: pending.state,
13551
+ txid: pending.txid ?? "",
13552
+ detail: pending.detail ?? ""
13553
+ },
13554
+ outlineError: snapshot.outlineError,
13555
+ worldbookError: snapshot.worldbookError
13556
+ });
12978
13557
  const draft = archivedCurrent || snapshot.pipeline.stage === "done" ? null : {
12979
13558
  num: snapshot.pipeline.chapter,
12980
13559
  title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
12981
13560
  wordCount: draftWords,
12982
13561
  score: snapshot.review?.total ?? 0,
12983
13562
  status: blocking.length > 0 ? "blocked" : "draft",
12984
- details: (snapshot.plan?.summary?.events ?? []).join(";").slice(0, 240)
13563
+ details: (snapshot.pipeline.draft?.summary?.events ?? []).join(";").slice(0, 240)
12985
13564
  };
12986
- return {
13565
+ const result = {
12987
13566
  root: snapshot.root,
12988
13567
  title: snapshot.project.title,
12989
13568
  genre: snapshot.project.genre,
@@ -13014,11 +13593,13 @@ var EngineService = class {
13014
13593
  averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
13015
13594
  phases: phaseStrip(snapshot.pipeline.stage, finished),
13016
13595
  book,
13596
+ bookData,
13017
13597
  quality,
13018
13598
  health: {
13019
- ok: healthBlocking === 0 && stateErrorCount === 0,
13599
+ ok: healthBlocking === 0 && stateErrorCount === 0 && pending.state === "none",
13020
13600
  blocking: healthBlocking,
13021
- stateErrors: stateErrorCount
13601
+ stateErrors: stateErrorCount,
13602
+ pending: pending.state !== "none"
13022
13603
  },
13023
13604
  activity,
13024
13605
  ...advisories.length === 0 ? {} : { advisories },
@@ -13030,6 +13611,15 @@ var EngineService = class {
13030
13611
  detail: pending.detail ?? ""
13031
13612
  } }
13032
13613
  };
13614
+ this.panelCache.set(snapshot.root, {
13615
+ key: panelCacheKey,
13616
+ snapshot: result
13617
+ });
13618
+ if (this.panelCache.size > 16) {
13619
+ const oldest = this.panelCache.keys().next().value;
13620
+ if (oldest !== void 0) this.panelCache.delete(oldest);
13621
+ }
13622
+ return result;
13033
13623
  }
13034
13624
  /**
13035
13625
  * The archive stage can be left by a direct revision (the 0.2.3 workflow) or
@@ -13324,7 +13914,7 @@ var EngineService = class {
13324
13914
  code: "PG-13",
13325
13915
  scope: "outline",
13326
13916
  detail: `chapter ${snapshot.pipeline.chapter} has no outline card while strict mode is on`,
13327
- hint: "call novel_outline to add the card, or novel_blueprint {\"strict\":false} to turn strict mode off"
13917
+ hint: "call novel_outline to add the missing card; do not turn strict mode off just to pass this gate"
13328
13918
  };
13329
13919
  }
13330
13920
  const plan = snapshot.plan;
@@ -13376,6 +13966,11 @@ var EngineService = class {
13376
13966
  code: "PROJECT_UNREADABLE",
13377
13967
  message: snapshot.projectError
13378
13968
  });
13969
+ if (snapshot.pipelineError !== null) errors.push({
13970
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
13971
+ code: "PIPELINE_UNREADABLE",
13972
+ message: snapshot.pipelineError
13973
+ });
13379
13974
  const chapter = snapshot.pipeline.chapter;
13380
13975
  if (snapshot.worldPlanError !== null) errors.push({
13381
13976
  path: planWorldFile(chapter),
@@ -13411,6 +14006,11 @@ var EngineService = class {
13411
14006
  }
13412
14007
  /** The earliest rebuild point for the current state damage. */
13413
14008
  recoveryFor(snapshot) {
14009
+ if (snapshot.pipelineError !== null) return {
14010
+ tool: "novel_restore",
14011
+ args: { paths: [joinRel(STATE_DIR, STATE_FILES.pipeline)] },
14012
+ reason: `PIPELINE_UNREADABLE: ${snapshot.pipelineError}; choose a backup with a pipeline pre-image and pass its txid`
14013
+ };
13414
14014
  const chapter = snapshot.pipeline.chapter;
13415
14015
  const errors = this.stateErrors(snapshot);
13416
14016
  if (snapshot.projectError !== null) return {
@@ -13759,7 +14359,7 @@ var EngineService = class {
13759
14359
  summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
13760
14360
  summaryProvided: extras.summaryProvided,
13761
14361
  summaryOnly: extras.summaryOnly,
13762
- summaryOnlyRevision: extras.summaryOnlyRevision ?? snapshot.pipeline.last_action?.op === "novel_revise_summary",
14362
+ summaryOnlyRevision: extras.summaryOnlyRevision ?? (operation === "runCheck" && snapshot.pipeline.stage === "P6" && snapshot.pipeline.last_action?.op === "novel_revise_summary"),
13763
14363
  review: extras.review ?? snapshot.review,
13764
14364
  check: extras.check ?? snapshot.check,
13765
14365
  semantic: extras.semantic ?? [],
@@ -13886,8 +14486,22 @@ var EngineService = class {
13886
14486
  actualTitle: project.title
13887
14487
  });
13888
14488
  }
13889
- const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
13890
- if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
14489
+ const pipelinePath = joinRel(STATE_DIR, STATE_FILES.pipeline);
14490
+ let pipeline = null;
14491
+ let pipelineError = null;
14492
+ try {
14493
+ pipeline = await store.readDoc(pipelinePath, "pipeline");
14494
+ if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
14495
+ } catch (error) {
14496
+ pipelineError = errorMessage(error);
14497
+ const backup = await this.latestBackup(store, pipelinePath);
14498
+ if (backup === null) throw error;
14499
+ try {
14500
+ pipeline = narrow("pipeline", parse(backup));
14501
+ } catch {
14502
+ throw error;
14503
+ }
14504
+ }
13891
14505
  let briefs = pipeline.briefs;
13892
14506
  const briefsRaw = await store.readText(BRIEF_RECEIPTS_FILE);
13893
14507
  if (briefsRaw !== null) {
@@ -14026,10 +14640,12 @@ var EngineService = class {
14026
14640
  if (planRaw === null && planError === null && cycleRank >= STAGE_RANK.P3) planError = `${planChapterFile(chapter)} is missing`;
14027
14641
  if (review === null && reviewError === null && cycleRank >= STAGE_RANK.P5) reviewError = `${reviewFile(chapter)} is missing`;
14028
14642
  if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
14029
- const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
14030
- if (latest === null || latest.revision !== pipeline.revision) {
14031
- if (retries > 0) return this.loadAt(selection, retries - 1, allowLock);
14032
- throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project changed while its snapshot was being read", "retry the read; if it keeps moving, wait for the writer to finish");
14643
+ if (pipelineError === null) {
14644
+ const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
14645
+ if (latest === null || latest.revision !== pipeline.revision) {
14646
+ if (retries > 0) return this.loadAt(selection, retries - 1, allowLock);
14647
+ throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project changed while its snapshot was being read", "retry the read; if it keeps moving, wait for the writer to finish");
14648
+ }
14033
14649
  }
14034
14650
  if (!allowLock && await this.lockIsLive(store)) {
14035
14651
  if (retries > 0) return this.loadAt(selection, retries - 1, false);
@@ -14040,6 +14656,7 @@ var EngineService = class {
14040
14656
  selection,
14041
14657
  project,
14042
14658
  projectError,
14659
+ pipelineError,
14043
14660
  pipeline,
14044
14661
  briefs,
14045
14662
  chapters,
@@ -14194,6 +14811,7 @@ var EngineService = class {
14194
14811
  }
14195
14812
  await assertJournalConsistent(store, options.repairPaths ?? []);
14196
14813
  const snapshot = await this.loadAt(selection, 1, true);
14814
+ if (snapshot.pipelineError !== null && options.allowPipelineRecovery !== true) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `state/pipeline.yaml is unreadable: ${snapshot.pipelineError}`, "restore a valid pipeline backup with novel_restore { paths: [\"state/pipeline.yaml\"], txid: ... }; other writes are blocked until then");
14197
14815
  if (selection.source === "session" && selection.created_at !== void 0 && selection.created_at !== "" && snapshot.projectError !== null) throw identityMismatchError({
14198
14816
  selection,
14199
14817
  actualTitle: snapshot.project.title
@@ -14242,7 +14860,8 @@ var EngineService = class {
14242
14860
  },
14243
14861
  ...error instanceof EngineError ? { code: error.code } : {},
14244
14862
  ...error instanceof EngineError && error.hint !== void 0 ? { hint: error.hint } : {},
14245
- ...error instanceof EngineBlockedError ? { blocking: error.blocking } : {}
14863
+ ...error instanceof EngineBlockedError ? { blocking: error.blocking } : {},
14864
+ ...error instanceof CheckSubmissionError ? { blocking: error.blocking.slice(0, 20) } : {}
14246
14865
  }).catch(() => void 0);
14247
14866
  throw error;
14248
14867
  }
@@ -14309,6 +14928,7 @@ var EngineService = class {
14309
14928
  selection,
14310
14929
  project: { ...ENGINE_PROJECT_INITIAL },
14311
14930
  projectError: null,
14931
+ pipelineError: null,
14312
14932
  pipeline: emptyPipeline({
14313
14933
  revision: 1,
14314
14934
  chapter: 1,
@@ -16006,7 +16626,7 @@ function createNovelTools(engine, options = {}) {
16006
16626
  }),
16007
16627
  defineTool({
16008
16628
  name: "novel_restore",
16009
- description: "Restore an active chapter file, or outline/outline.yaml, from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
16629
+ description: "Restore an active chapter file, outline/outline.yaml, or a damaged state/pipeline.yaml from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
16010
16630
  parameters: {
16011
16631
  txid: {
16012
16632
  type: "string",
@@ -16016,7 +16636,7 @@ function createNovelTools(engine, options = {}) {
16016
16636
  paths: {
16017
16637
  type: "array",
16018
16638
  items: { type: "string" },
16019
- description: arraySemanticsDescription("replace", "Project-relative chapter files, or exactly [outline/outline.yaml]; omitted restores every restorable chapter file the manifest records.")
16639
+ description: arraySemanticsDescription("replace", "Project-relative chapter files, exactly [outline/outline.yaml], or exactly [state/pipeline.yaml] when the pipeline is unreadable; omitted restores every restorable chapter file the manifest records.")
16020
16640
  },
16021
16641
  project_root: root
16022
16642
  },
@@ -16511,7 +17131,7 @@ function createSkill() {
16511
17131
  async function panelPayload(engine, root) {
16512
17132
  return {
16513
17133
  ...await engine.panelBundle(root === void 0 ? {} : { root }),
16514
- schemaVersion: 5
17134
+ schemaVersion: 6
16515
17135
  };
16516
17136
  }
16517
17137
  const queryRoot = (url) => {
@@ -16561,7 +17181,7 @@ function panelHandler(engine, guard = denyAll) {
16561
17181
  response.setHeader("content-type", "application/json; charset=utf-8");
16562
17182
  response.setHeader("cache-control", "no-store");
16563
17183
  response.end(JSON.stringify({
16564
- schemaVersion: 5,
17184
+ schemaVersion: 6,
16565
17185
  projects: [],
16566
17186
  selection: {
16567
17187
  root: null,
@@ -16576,7 +17196,7 @@ function panelHandler(engine, guard = denyAll) {
16576
17196
  response.setHeader("content-type", "application/json; charset=utf-8");
16577
17197
  response.setHeader("cache-control", "no-store");
16578
17198
  response.end(JSON.stringify({
16579
- schemaVersion: 5,
17199
+ schemaVersion: 6,
16580
17200
  projects: [],
16581
17201
  selection: {
16582
17202
  root: null,