dsh-novel-write-engine 0.2.4 → 0.2.6

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. */
@@ -245,6 +245,9 @@ const ERROR_CODES = {
245
245
  /** The exact same keyed submission already failed in the same context. */
246
246
  RETRY_UNCHANGED: "ENGINE_RETRY_UNCHANGED",
247
247
  GATE_BLOCKED: "ENGINE_GATE_BLOCKED",
248
+ CHECK_SUBMISSION_INVALID: "ENGINE_CHECK_SUBMISSION_INVALID",
249
+ PENDING_PARTIAL: "ENGINE_PENDING_PARTIAL",
250
+ IO_PERMISSION: "ENGINE_IO_PERMISSION",
248
251
  READING_PROOF_INVALID: "ENGINE_READING_PROOF_INVALID",
249
252
  SCORE_MISMATCH: "ENGINE_SCORE_MISMATCH",
250
253
  /** A revision tried to rewrite reviewer-owned issue fields. */
@@ -520,9 +523,61 @@ function applyExclusions(dimensions, exclusions) {
520
523
  function splitParagraphs(text) {
521
524
  return text.split(/\n+/).map((line) => line.trim()).filter((line) => line !== "");
522
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
+ }
523
578
  /** Sentence splitting uses Chinese and English terminators; empty runs collapse. */
524
579
  function splitSentences(text) {
525
- 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 !== "");
526
581
  }
527
582
  /** Character length of each sentence, in document order. */
528
583
  function sentenceLengths(sentences) {
@@ -624,6 +679,30 @@ function similarity(a, b) {
624
679
  function textSimilarity(a, b) {
625
680
  return similarity(simhash(a), simhash(b));
626
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
+ }
627
706
  // src/domain/transitions.ts
628
707
  /**
629
708
  * The single definition of allowed progress. The engine queries this table
@@ -1393,7 +1472,7 @@ const duplicateParagraph = (ctx) => {
1393
1472
  }
1394
1473
  const blocker = ctx.rulepack.crosschapter.duplicate_similarity;
1395
1474
  const severity = best.score >= blocker ? "blocker" : "advisory";
1396
- return [v(CROSS_RULES.DUPLICATE_PARAGRAPH, severity, currentParagraph + 1, `paragraph ${currentParagraph + 1} reuses ${Math.round(best.score * 100)}% of paragraph ${previousParagraph - matched.start + 1} from previous source ${matched.source} (advisory ≥${Math.round(advisory * 100)}%, blocker ≥${Math.round(blocker * 100)}%)`)];
1475
+ return [v(CROSS_RULES.DUPLICATE_PARAGRAPH, severity, currentParagraph + 1, `paragraph ${currentParagraph + 1} reuses ${Math.round(best.score * 100)}% of paragraph ${previousParagraph + 1} from previous source ${matched.source} (advisory ≥${Math.round(advisory * 100)}%, blocker ≥${Math.round(blocker * 100)}%)`)];
1397
1476
  };
1398
1477
  const foreshadowDormant = (ctx) => {
1399
1478
  const ledger = ctx.evidence.foreshadows;
@@ -1508,11 +1587,12 @@ const timeRegression = (ctx) => {
1508
1587
  const ordered = [...ledger.items].sort((left, right) => left.chapter - right.chapter);
1509
1588
  const last = ordered[ordered.length - 1];
1510
1589
  const previous = ordered.length >= 2 ? ordered[ordered.length - 2] : null;
1511
- const currentFromText = dayOf(ctx.text);
1590
+ const currentMatch = /(?:Day\s*(\d+)|第\s*(\d+)\s*[天日])/i.exec(ctx.text);
1591
+ const currentFromText = currentMatch === null ? null : Number(currentMatch[1] ?? currentMatch[2]);
1512
1592
  const current = currentFromText ?? dayOf(last.story_time);
1513
1593
  const before = previous === null ? null : dayOf(previous.story_time);
1514
1594
  if (current === null) return [];
1515
- if (currentFromText !== null && dayOf(last.story_time) !== null && currentFromText < dayOf(last.story_time)) return [v(CROSS_RULES.TIME_REGRESSION, "advisory", ctx.chapter, `story time goes back from ${last.story_time} to Day ${currentFromText}`)];
1595
+ if (currentFromText !== null && dayOf(last.story_time) !== null && currentFromText < dayOf(last.story_time)) return [v(CROSS_RULES.TIME_REGRESSION, "advisory", paragraphOfIndex(ctx.text, currentMatch.index), `story time goes back from ${last.story_time} to Day ${currentFromText}`)];
1516
1596
  if (before === null || current >= before) return [];
1517
1597
  return [v(CROSS_RULES.TIME_REGRESSION, "advisory", last.chapter, `story time goes back from ${previous.story_time} to ${last.story_time}`)];
1518
1598
  };
@@ -1922,6 +2002,8 @@ const SCHEMAS = {
1922
2002
  machine: req("object"),
1923
2003
  semantic: req("object"),
1924
2004
  acknowledged: f("array"),
2005
+ failure_class: f("string", { enum: ["submission", "content"] }),
2006
+ counted: f("boolean"),
1925
2007
  verdict: req("string", { enum: ["pass", "fail"] })
1926
2008
  },
1927
2009
  worldPlan: {
@@ -1962,31 +2044,31 @@ const SCHEMAS = {
1962
2044
  themes: req("array")
1963
2045
  }
1964
2046
  };
1965
- const invalid$1 = (kind, detail) => {
2047
+ const invalid$2 = (kind, detail) => {
1966
2048
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `invalid ${kind} document: ${detail}`);
1967
2049
  };
1968
2050
  const objectAt = (kind, value, path) => {
1969
- 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`);
1970
2052
  return value;
1971
2053
  };
1972
2054
  const listAt = (kind, value, path) => {
1973
- 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`);
1974
2056
  return value;
1975
2057
  };
1976
2058
  const stringAt = (kind, value, path) => {
1977
- 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`);
1978
2060
  return value;
1979
2061
  };
1980
2062
  const numberAt = (kind, value, path) => {
1981
- 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`);
1982
2064
  return value;
1983
2065
  };
1984
2066
  const integerAt = (kind, value, path) => {
1985
- 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`);
1986
2068
  return value;
1987
2069
  };
1988
2070
  const booleanAt = (kind, value, path) => {
1989
- 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`);
1990
2072
  return value;
1991
2073
  };
1992
2074
  const optionalString = (kind, value, path) => {
@@ -2004,6 +2086,24 @@ const nullableInteger = (kind, value, path) => {
2004
2086
  const validateRecords = (kind, value, path, validate) => {
2005
2087
  for (const [index, raw] of listAt(kind, value, path).entries()) validate(objectAt(kind, raw, `${path}[${index}]`), `${path}[${index}]`);
2006
2088
  };
2089
+ const duplicateNames = (kind, rows, path, nameOf) => {
2090
+ const seen = /* @__PURE__ */ new Set();
2091
+ for (const row of rows) {
2092
+ const name = nameOf(row);
2093
+ if (typeof name !== "string" || name === "") continue;
2094
+ if (seen.has(name)) invalid$2(kind, `${path} contains duplicate name "${name}"`);
2095
+ seen.add(name);
2096
+ }
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
+ };
2007
2107
  const validateSummary = (kind, value, path) => {
2008
2108
  const summary = objectAt(kind, value, path);
2009
2109
  for (const key of [
@@ -2018,6 +2118,18 @@ const validateSummary = (kind, value, path) => {
2018
2118
  "resolved"
2019
2119
  ]) listAt(kind, foreshadows[key], `${path}.foreshadows.${key}`);
2020
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
+ }
2021
2133
  listAt(kind, summary.resource_changes, `${path}.resource_changes`);
2022
2134
  stringAt(kind, summary.opening, `${path}.opening`);
2023
2135
  const continuity = objectAt(kind, summary.continuity, `${path}.continuity`);
@@ -2032,7 +2144,7 @@ const validateNested = (kind, record) => {
2032
2144
  const targets = objectAt(kind, record.targets, "targets");
2033
2145
  for (const key of ["words_per_chapter", "dialogue_ratio"]) {
2034
2146
  const pair = listAt(kind, targets[key], `targets.${key}`);
2035
- 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`);
2036
2148
  for (const [index, item] of pair.entries()) numberAt(kind, item, `targets.${key}[${index}]`);
2037
2149
  }
2038
2150
  if (targets.total_chapters !== null) integerAt(kind, targets.total_chapters, "targets.total_chapters");
@@ -2097,6 +2209,7 @@ const validateNested = (kind, record) => {
2097
2209
  listAt(kind, row.growth, `${path}.growth`);
2098
2210
  integerAt(kind, row.updated_at, `${path}.updated_at`);
2099
2211
  });
2212
+ duplicateNames(kind, record.characters, "characters", (row) => row.name);
2100
2213
  return;
2101
2214
  case "world":
2102
2215
  validateRecords(kind, record.locations, "locations", (row, path) => {
@@ -2107,13 +2220,28 @@ const validateNested = (kind, record) => {
2107
2220
  stringAt(kind, row.layout, `${path}.layout`);
2108
2221
  numberAt(kind, row.capacity, `${path}.capacity`);
2109
2222
  listAt(kind, row.changes, `${path}.changes`);
2223
+ validateAnchorList(kind, row.anchors, `${path}.anchors`);
2110
2224
  });
2111
2225
  for (const key of [
2112
2226
  "abilities",
2113
2227
  "artifacts",
2114
2228
  "factions",
2115
2229
  "rules"
2116
- ]) 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`);
2231
+ {
2232
+ const names = [];
2233
+ for (const key of [
2234
+ "locations",
2235
+ "abilities",
2236
+ "artifacts",
2237
+ "factions",
2238
+ "rules"
2239
+ ]) {
2240
+ const rows = Array.isArray(record[key]) ? record[key] : [];
2241
+ for (const row of rows) if (typeof row.name === "string") names.push(row.name);
2242
+ }
2243
+ if (new Set(names).size !== names.length) invalid$2(kind, "world entities must have unique names");
2244
+ }
2117
2245
  return;
2118
2246
  case "locks": {
2119
2247
  integerAt(kind, record.locked_at_chapter, "locked_at_chapter");
@@ -2183,10 +2311,16 @@ const validateNested = (kind, record) => {
2183
2311
  validateRecords(kind, semantic.items, "semantic.items", (row, path) => {
2184
2312
  stringAt(kind, row.id, `${path}.id`);
2185
2313
  const verdict = stringAt(kind, row.verdict, `${path}.verdict`);
2186
- 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`);
2187
2315
  stringAt(kind, row.evidence, `${path}.evidence`);
2188
2316
  if (row.note !== void 0) stringAt(kind, row.note, `${path}.note`);
2189
2317
  });
2318
+ if (semantic.findings !== void 0) validateRecords(kind, semantic.findings, "semantic.findings", (row, path) => {
2319
+ stringAt(kind, row.code, `${path}.code`);
2320
+ stringAt(kind, row.detail, `${path}.detail`);
2321
+ if (row.scope !== void 0) stringAt(kind, row.scope, `${path}.scope`);
2322
+ if (row.hint !== void 0) stringAt(kind, row.hint, `${path}.hint`);
2323
+ });
2190
2324
  return;
2191
2325
  }
2192
2326
  case "outline": {
@@ -2226,11 +2360,11 @@ const validateNested = (kind, record) => {
2226
2360
  "notes",
2227
2361
  "status"
2228
2362
  ]) stringAt(kind, row[key], `${path}.${key}`);
2229
- if (!SCENE_TYPES.includes(row.scene_type)) invalid$1(kind, `${path}.scene_type must be one of ${SCENE_TYPES.join(", ")}`);
2230
- 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`);
2231
2365
  for (const [index, name] of listAt(kind, row.cast, `${path}.cast`).entries()) stringAt(kind, name, `${path}.cast[${index}]`);
2232
2366
  const target = listAt(kind, row.target_words, `${path}.target_words`);
2233
- 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`);
2234
2368
  for (const [index, item] of target.entries()) integerAt(kind, item, `${path}.target_words[${index}]`);
2235
2369
  nullableString(kind, row.archived_at, `${path}.archived_at`);
2236
2370
  nullableNumber(kind, row.word_count, `${path}.word_count`);
@@ -2287,9 +2421,9 @@ const validateNested = (kind, record) => {
2287
2421
  validateRecords(kind, record.issues, "issues", (row, path) => {
2288
2422
  stringAt(kind, row.id, `${path}.id`);
2289
2423
  const severity = stringAt(kind, row.severity, `${path}.severity`);
2290
- 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`);
2291
2425
  const status = stringAt(kind, row.status, `${path}.status`);
2292
- 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`);
2293
2427
  for (const field of [
2294
2428
  "dimension",
2295
2429
  "location",
@@ -2322,26 +2456,26 @@ const matchesType = (value, field) => {
2322
2456
  * user upgrades instead of the engine rewriting their data.
2323
2457
  */
2324
2458
  function narrow(kind, value) {
2325
- 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");
2326
2460
  const record = value;
2327
2461
  const version = record.schema_version;
2328
- 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");
2329
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");
2330
- if (version !== 1) invalid$1(kind, `unsupported schema_version ${version}`);
2331
- 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)}"`);
2332
2466
  const schema = SCHEMAS[kind];
2333
2467
  for (const key of Object.keys(record)) {
2334
2468
  if (key === "schema_version") continue;
2335
- if (!Object.hasOwn(schema, key)) invalid$1(kind, `unknown key "${key}"`);
2469
+ if (!Object.hasOwn(schema, key)) invalid$2(kind, `unknown key "${key}"`);
2336
2470
  }
2337
2471
  for (const [key, field] of Object.entries(schema)) {
2338
2472
  const fieldValue = record[key];
2339
2473
  if (fieldValue === void 0) {
2340
- if (field.required === true) invalid$1(kind, `missing required key "${key}"`);
2474
+ if (field.required === true) invalid$2(kind, `missing required key "${key}"`);
2341
2475
  continue;
2342
2476
  }
2343
- if (!matchesType(fieldValue, field)) invalid$1(kind, `"${key}" must be ${field.type}`);
2344
- 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(", ")}`);
2345
2479
  }
2346
2480
  validateNested(kind, record);
2347
2481
  return record;
@@ -2386,7 +2520,7 @@ var FsStore = class {
2386
2520
  if ((await promises.lstat(current)).isSymbolicLink()) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": "${segment}" is a symlink`);
2387
2521
  } catch (error) {
2388
2522
  if (isMissing(error)) break;
2389
- throw error;
2523
+ throw ioFailure(error);
2390
2524
  }
2391
2525
  }
2392
2526
  return absolute;
@@ -2403,7 +2537,7 @@ var FsStore = class {
2403
2537
  return true;
2404
2538
  } catch (error) {
2405
2539
  if (isMissing(error)) return false;
2406
- throw error;
2540
+ throw ioFailure(error);
2407
2541
  }
2408
2542
  }
2409
2543
  async stat(relativePath) {
@@ -2416,7 +2550,7 @@ var FsStore = class {
2416
2550
  };
2417
2551
  } catch (error) {
2418
2552
  if (isMissing(error)) return null;
2419
- throw error;
2553
+ throw ioFailure(error);
2420
2554
  }
2421
2555
  }
2422
2556
  async list(relativeDir) {
@@ -2424,7 +2558,7 @@ var FsStore = class {
2424
2558
  return (await promises.readdir(await this.safePath(relativeDir))).sort();
2425
2559
  } catch (error) {
2426
2560
  if (isMissing(error)) return [];
2427
- throw error;
2561
+ throw ioFailure(error);
2428
2562
  }
2429
2563
  }
2430
2564
  /**
@@ -2441,7 +2575,7 @@ var FsStore = class {
2441
2575
  })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
2442
2576
  } catch (error) {
2443
2577
  if (isMissing(error)) return [];
2444
- throw error;
2578
+ throw ioFailure(error);
2445
2579
  }
2446
2580
  }
2447
2581
  /**
@@ -2459,7 +2593,7 @@ var FsStore = class {
2459
2593
  };
2460
2594
  } catch (error) {
2461
2595
  if (isMissing(error)) return null;
2462
- throw error;
2596
+ throw ioFailure(error);
2463
2597
  }
2464
2598
  }
2465
2599
  /**
@@ -2479,7 +2613,7 @@ var FsStore = class {
2479
2613
  };
2480
2614
  } catch (error) {
2481
2615
  if (isMissing(error)) return null;
2482
- throw error;
2616
+ throw ioFailure(error);
2483
2617
  }
2484
2618
  }
2485
2619
  async readText(relativePath) {
@@ -2487,7 +2621,7 @@ var FsStore = class {
2487
2621
  return await promises.readFile(await this.safePath(relativePath), "utf8");
2488
2622
  } catch (error) {
2489
2623
  if (isMissing(error)) return null;
2490
- throw error;
2624
+ throw ioFailure(error);
2491
2625
  }
2492
2626
  }
2493
2627
  /** Read and narrow one of the twelve engine documents. */
@@ -2521,7 +2655,7 @@ var FsStore = class {
2521
2655
  await promises.mkdir(current);
2522
2656
  } catch (error) {
2523
2657
  if (error.code === "EEXIST") continue;
2524
- throw error;
2658
+ throw ioFailure(error);
2525
2659
  }
2526
2660
  }
2527
2661
  }
@@ -2542,7 +2676,7 @@ var FsStore = class {
2542
2676
  await syncDirectory(dirname(absolute));
2543
2677
  } catch (error) {
2544
2678
  await promises.rm(temp, { force: true }).catch(() => void 0);
2545
- throw error;
2679
+ throw ioFailure(error);
2546
2680
  }
2547
2681
  }
2548
2682
  async writeDoc(relativePath, document) {
@@ -2587,7 +2721,7 @@ var FsStore = class {
2587
2721
  return await promises.readFile(await this.safePath(relativePath));
2588
2722
  } catch (error) {
2589
2723
  if (isMissing(error)) return null;
2590
- throw error;
2724
+ throw ioFailure(error);
2591
2725
  }
2592
2726
  }
2593
2727
  async writeBytes(relativePath, bytes) {
@@ -2606,7 +2740,7 @@ var FsStore = class {
2606
2740
  await syncDirectory(dirname(absolute));
2607
2741
  } catch (error) {
2608
2742
  await promises.rm(temp, { force: true }).catch(() => void 0);
2609
- throw error;
2743
+ throw ioFailure(error);
2610
2744
  }
2611
2745
  }
2612
2746
  /** Content hash of a file, or null when missing. */
@@ -2634,6 +2768,12 @@ async function syncDirectory(directory) {
2634
2768
  function isMissing(error) {
2635
2769
  return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
2636
2770
  }
2771
+ function ioFailure(error) {
2772
+ if (error instanceof EngineFaultError) return error;
2773
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
2774
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") return new EngineFaultError(ERROR_CODES.IO_PERMISSION, `filesystem permission denied (${code}): ${error instanceof Error ? error.message : String(error)}`, "fix the project directory permissions, or restore a readable copy before retrying", { cause: error });
2775
+ return error;
2776
+ }
2637
2777
  /** Join project-relative segments with forward slashes. */
2638
2778
  function joinRel(...parts) {
2639
2779
  return parts.filter((part) => part !== "").join("/");
@@ -2757,21 +2897,18 @@ async function reconcileJournal(store) {
2757
2897
  };
2758
2898
  }
2759
2899
  /**
2760
- * Resolve a pending transaction left by a crash. The pending row is written
2761
- * after the pre-images are captured and before the first rename, so it is the
2762
- * only record that can distinguish "renamed but not journaled" from "crashed
2763
- * before touching a target". A fully matching pending transaction is promoted
2764
- * to committed; a transaction whose targets all still match their pre-images
2765
- * is closed as aborted; any mixture is refused as a partial commit.
2900
+ * Read-only classification of the last pending transaction. Status/doctor/
2901
+ * audit must never call recoverPending: recovery writes a terminal journal row,
2902
+ * and a read command is not allowed to mutate the project.
2766
2903
  */
2767
- async function recoverPending(store) {
2904
+ async function inspectPending(store) {
2768
2905
  const { records } = await readJournal(store);
2769
2906
  const last = [...records].reverse().find((record) => record.op !== "lock_reclaimed");
2770
- if (last?.status !== "pending") return;
2907
+ if (last?.status !== "pending") return { state: "none" };
2771
2908
  const matchesExpected = async () => {
2772
2909
  if (last.hashes === void 0) return false;
2773
2910
  for (const [path, expected] of Object.entries(last.hashes)) if (await store.hashBytes(path) !== expected) return false;
2774
- return true;
2911
+ return Object.keys(last.hashes).length > 0;
2775
2912
  };
2776
2913
  const matchesBefore = async () => {
2777
2914
  if (last.before === void 0) return false;
@@ -2779,9 +2916,45 @@ async function recoverPending(store) {
2779
2916
  const actual = await store.hashBytes(path);
2780
2917
  if (expected === null ? actual !== null : actual !== expected) return false;
2781
2918
  }
2782
- return true;
2919
+ return Object.keys(last.before).length > 0;
2783
2920
  };
2784
- if (await matchesExpected()) {
2921
+ if (await matchesExpected()) return {
2922
+ state: "complete",
2923
+ txid: last.txid,
2924
+ files: last.files,
2925
+ detail: "all target files match the pending hashes; the next write can promote this transaction",
2926
+ stagingDir: joinRel(STAGING_DIR, last.txid)
2927
+ };
2928
+ if (await matchesBefore()) return {
2929
+ state: "untouched",
2930
+ txid: last.txid,
2931
+ files: last.files,
2932
+ detail: "no target changed; the next write can close this transaction as aborted",
2933
+ stagingDir: joinRel(STAGING_DIR, last.txid)
2934
+ };
2935
+ return {
2936
+ state: "partial",
2937
+ txid: last.txid,
2938
+ files: last.files,
2939
+ detail: "some targets changed and others did not; automatic recovery is unsafe",
2940
+ stagingDir: joinRel(STAGING_DIR, last.txid)
2941
+ };
2942
+ }
2943
+ /**
2944
+ * Resolve a pending transaction left by a crash. The pending row is written
2945
+ * after the pre-images are captured and before the first rename, so it is the
2946
+ * only record that can distinguish "renamed but not journaled" from "crashed
2947
+ * before touching a target". A fully matching pending transaction is promoted
2948
+ * to committed; a transaction whose targets all still match their pre-images
2949
+ * is closed as aborted; any mixture is refused as a partial commit.
2950
+ */
2951
+ async function recoverPending(store) {
2952
+ const inspection = await inspectPending(store);
2953
+ if (inspection.state === "none" || inspection.txid === void 0) return;
2954
+ const { records } = await readJournal(store);
2955
+ const last = [...records].reverse().find((record) => record.txid === inspection.txid);
2956
+ if (last === void 0 || last.status !== "pending") return;
2957
+ if (inspection.state === "complete") {
2785
2958
  await appendJournal(store, {
2786
2959
  ...last,
2787
2960
  status: "committed"
@@ -2789,7 +2962,7 @@ async function recoverPending(store) {
2789
2962
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
2790
2963
  return;
2791
2964
  }
2792
- if (await matchesBefore()) {
2965
+ if (inspection.state === "untouched") {
2793
2966
  await appendJournal(store, {
2794
2967
  ...last,
2795
2968
  status: "aborted",
@@ -2799,7 +2972,7 @@ async function recoverPending(store) {
2799
2972
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
2800
2973
  return;
2801
2974
  }
2802
- throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `pending transaction ${last.txid} partially changed the project; automatic recovery is unsafe`, `restore from backups/ or ${STAGING_DIR}/${last.txid}/before/ before retrying`);
2975
+ throw new EngineFaultError(ERROR_CODES.PENDING_PARTIAL, `pending transaction ${last.txid} partially changed the project; automatic recovery is unsafe`, `inspect ${STAGING_DIR}/${last.txid}/before/ and state/journal.jsonl before restoring anything`);
2803
2976
  }
2804
2977
  /**
2805
2978
  * A crash during append can leave one JSONL fragment without a newline. Since
@@ -2991,22 +3164,43 @@ var FileLock = class {
2991
3164
  if (Date.now() - mtimeMs <= this.staleMs) return false;
2992
3165
  } else if (lockRecordIsLive(record)) return false;
2993
3166
  const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
3167
+ let claimByRename = false;
2994
3168
  try {
2995
3169
  await promises.link(this.absolutePath, claimPath);
2996
3170
  } catch (error) {
2997
3171
  const code = error.code;
2998
3172
  if (code === "ENOENT") return true;
2999
3173
  if (code === "EPERM" || code === "EACCES") return false;
3000
- throw error;
3174
+ if ([
3175
+ "ENOSYS",
3176
+ "ENOTSUP",
3177
+ "EOPNOTSUPP",
3178
+ "EXDEV",
3179
+ "EINVAL"
3180
+ ].includes(code ?? "")) try {
3181
+ await promises.rename(this.absolutePath, claimPath);
3182
+ claimByRename = true;
3183
+ } catch (renameError) {
3184
+ if (renameError.code === "ENOENT") return true;
3185
+ throw renameError;
3186
+ }
3187
+ else throw error;
3001
3188
  }
3002
3189
  const moved = await this.readAt(claimPath);
3003
3190
  const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
3004
3191
  if (!(record === null ? moved === null && Date.now() - claimMtime > this.staleMs : moved !== null && moved.pid === record.pid && moved.startedAt === record.startedAt && moved.nonce === record.nonce)) {
3005
- await promises.rm(claimPath, { force: true }).catch(() => void 0);
3192
+ if (claimByRename) await promises.access(this.absolutePath).then(async () => {
3193
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
3194
+ }, async () => {
3195
+ await promises.rename(claimPath, this.absolutePath).catch(async () => {
3196
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
3197
+ });
3198
+ });
3199
+ else await promises.rm(claimPath, { force: true }).catch(() => void 0);
3006
3200
  return false;
3007
3201
  }
3008
- const current = await this.readAt(this.absolutePath);
3009
- if (!(record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce)) {
3202
+ const current = claimByRename ? null : await this.readAt(this.absolutePath);
3203
+ if (!(claimByRename || (record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce))) {
3010
3204
  await promises.rm(claimPath, { force: true }).catch(() => void 0);
3011
3205
  return false;
3012
3206
  }
@@ -3025,6 +3219,7 @@ const BACKUP_MANIFEST = "manifest.json";
3025
3219
  async function listBackups(store) {
3026
3220
  const entries = [];
3027
3221
  for (const name of await store.list(BACKUP_DIR)) {
3222
+ if (name === "migrations") continue;
3028
3223
  const info = await store.stat(joinRel(BACKUP_DIR, name));
3029
3224
  if (info === null || !info.isDirectory) continue;
3030
3225
  if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
@@ -3847,7 +4042,7 @@ const wordPair = (value, where) => {
3847
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`);
3848
4043
  return [min, max];
3849
4044
  };
3850
- 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);
3851
4046
  /** Remove a leading 第N章 prefix; stored titles are bodies. */
3852
4047
  function stripChapterPrefix(title) {
3853
4048
  return title.replace(LENIENT_TITLE_PREFIX$1, "").trim();
@@ -3949,7 +4144,7 @@ const BOOK_PATCH_KEYS = /* @__PURE__ */ new Set([
3949
4144
  /** Apply a partial book patch; omitted fields keep their existing value. */
3950
4145
  function mergeBookPlan(existing, patch) {
3951
4146
  if (patch === void 0) return { ...existing };
3952
- 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");
3953
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(", ")}`);
3954
4149
  const result = { ...existing };
3955
4150
  for (const key of [
@@ -3977,7 +4172,7 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
3977
4172
  if (!Array.isArray(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
3978
4173
  if (input.length > LIMITS.maxOutlineVolumes) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains ${input.length} items; limit is ${LIMITS.maxOutlineVolumes}`);
3979
4174
  const volumes = input.map((raw, index) => {
3980
- 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`);
3981
4176
  const start = positiveInteger$1(raw.start_chapter, `volumes[${index}].start_chapter`);
3982
4177
  const end = positiveInteger$1(raw.end_chapter, `volumes[${index}].end_chapter`);
3983
4178
  if (end < start) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] end_chapter ${end} is before start_chapter ${start}`);
@@ -4094,7 +4289,7 @@ function normalizeOutlineChapters(input, options) {
4094
4289
  const archived = new Map(options.archived.map((record) => [record.num, record]));
4095
4290
  const normalized = /* @__PURE__ */ new Map();
4096
4291
  for (const [index, raw] of input.entries()) {
4097
- 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`);
4098
4293
  const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4099
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");
4100
4295
  if (normalized.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
@@ -4118,7 +4313,7 @@ function normalizeOutlineChapters(input, options) {
4118
4313
  notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
4119
4314
  volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
4120
4315
  status: "archived",
4121
- archived_at: base.archived_at ?? options.now,
4316
+ archived_at: base.archived_at ?? record.archived_at ?? null,
4122
4317
  word_count: record.word_count,
4123
4318
  score: record.score
4124
4319
  });
@@ -4159,7 +4354,7 @@ function normalizeOutlineChapters(input, options) {
4159
4354
  ...base,
4160
4355
  volume: volumeForChapter(options.volumes, record.num, base.volume),
4161
4356
  status: "archived",
4162
- archived_at: base.archived_at ?? options.now,
4357
+ archived_at: base.archived_at ?? record.archived_at ?? null,
4163
4358
  word_count: record.word_count,
4164
4359
  score: record.score
4165
4360
  });
@@ -4168,7 +4363,7 @@ function normalizeOutlineChapters(input, options) {
4168
4363
  }
4169
4364
  /** Validate a document loaded from disk. Throws a fault, not an input error. */
4170
4365
  function validateOutlineDocument(value, options) {
4171
- 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");
4172
4367
  try {
4173
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)}`);
4174
4369
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4180,7 +4375,7 @@ function validateOutlineDocument(value, options) {
4180
4375
  ]);
4181
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}"`);
4182
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");
4183
- 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");
4184
4379
  const allowedBook = /* @__PURE__ */ new Set([
4185
4380
  "premise",
4186
4381
  "logline",
@@ -4204,7 +4399,7 @@ function validateOutlineDocument(value, options) {
4204
4399
  const chapters = [];
4205
4400
  const seen = /* @__PURE__ */ new Set();
4206
4401
  for (const [index, raw] of value.chapters.entries()) {
4207
- 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`);
4208
4403
  const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4209
4404
  if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`);
4210
4405
  if (seen.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
@@ -4270,7 +4465,7 @@ function mergeWorldbook(existing, patch, now) {
4270
4465
  ...existing,
4271
4466
  updated_at: now
4272
4467
  };
4273
- 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");
4274
4469
  const result = {
4275
4470
  ...existing,
4276
4471
  updated_at: now,
@@ -4290,7 +4485,7 @@ function mergeWorldbook(existing, patch, now) {
4290
4485
  if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} must be an array`);
4291
4486
  if (value.length > LIMITS.maxWorldbookEntries) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} contains ${value.length} items; limit is ${LIMITS.maxWorldbookEntries}`);
4292
4487
  return value.map((raw, index) => {
4293
- 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`);
4294
4489
  return raw;
4295
4490
  });
4296
4491
  };
@@ -4370,7 +4565,7 @@ function mergeWorldbook(existing, patch, now) {
4370
4565
  }
4371
4566
  /** Validate a worldbook loaded from disk. */
4372
4567
  function validateWorldbookDocument(value, now) {
4373
- 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");
4374
4569
  try {
4375
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)}`);
4376
4571
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4781,7 +4976,7 @@ function parseOutlineData(content, format) {
4781
4976
  } catch (error) {
4782
4977
  throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} is invalid: ${error instanceof Error ? error.message : String(error)}`);
4783
4978
  }
4784
- 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`);
4785
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)}`);
4786
4981
  const warnings = [];
4787
4982
  const allowed = /* @__PURE__ */ new Set([
@@ -4812,19 +5007,19 @@ function parseOutlineInput(content, format) {
4812
5007
  };
4813
5008
  }
4814
5009
  /** One-line Markdown field; a newline would turn into an unknown import line. */
4815
- const oneLine = (value) => value.replace(/\s+/g, " ").trim();
5010
+ const oneLine$1 = (value) => value.replace(/\s+/g, " ").trim();
4816
5011
  /** Export helper: a human-readable outline in the import template's shape. */
4817
5012
  function renderOutlineMarkdown(project, document, chapters) {
4818
- const parts = [`# ${oneLine(project.title)}`, ""];
4819
- 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)}`, "");
4820
5015
  const fields = [
4821
- ["前提", oneLine(document.book.premise)],
4822
- ["主题", document.book.themes.map(oneLine).join("、")],
4823
- ["基调", oneLine(document.book.tone)],
4824
- ["视角", oneLine(document.book.pov)],
4825
- ["读者", oneLine(document.book.audience)],
4826
- ["卖点", document.book.selling_points.map(oneLine).join("、")],
4827
- ["结局", 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)],
4828
5023
  ["严格", document.book.strict ? "true" : "false"]
4829
5024
  ];
4830
5025
  for (const [key, value] of fields) if (value !== "") parts.push(`${key}:${value}`);
@@ -4833,12 +5028,12 @@ function renderOutlineMarkdown(project, document, chapters) {
4833
5028
  const outside = document.chapters.filter((card) => !document.volumes.some((volume) => card.num >= volume.start_chapter && card.num <= volume.end_chapter));
4834
5029
  for (const card of outside) parts.push(...renderCard(card, archived.get(card.num)));
4835
5030
  for (const volume of document.volumes) {
4836
- parts.push(`## ${oneLine(volume.title)}(第${volume.start_chapter}-${volume.end_chapter}章)`);
4837
- 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)}`);
4838
5033
  parts.push(`状态:${volume.status}`);
4839
- if (volume.goal !== "") parts.push(`目标:${oneLine(volume.goal)}`);
4840
- if (volume.arc !== "") parts.push(`弧线:${oneLine(volume.arc)}`);
4841
- 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)}`);
4842
5037
  parts.push("");
4843
5038
  const cards = document.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
4844
5039
  for (const card of cards) parts.push(...renderCard(card, archived.get(card.num)));
@@ -4847,15 +5042,15 @@ function renderOutlineMarkdown(project, document, chapters) {
4847
5042
  }
4848
5043
  const renderCard = (card, record) => {
4849
5044
  const lines = [
4850
- `### 第${card.num}章 ${oneLine(card.title)}`,
5045
+ `### 第${card.num}章 ${oneLine$1(card.title)}`,
4851
5046
  `场景:${card.scene_type}`,
4852
5047
  `字数:${card.target_words[0]}-${card.target_words[1]}`,
4853
- `角色:${card.cast.map(oneLine).join("、")}`,
4854
- `目标:${oneLine(card.goal)}`,
4855
- `冲突:${oneLine(card.conflict)}`,
4856
- `钩子:${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)}`
4857
5052
  ];
4858
- if (card.notes !== "") lines.push(`备注:${oneLine(card.notes)}`);
5053
+ if (card.notes !== "") lines.push(`备注:${oneLine$1(card.notes)}`);
4859
5054
  if (card.status === "archived" && record !== void 0) lines.push(`归档:${record.word_count} 字 / ${record.score} 分`);
4860
5055
  lines.push("");
4861
5056
  return lines;
@@ -4867,19 +5062,20 @@ function renderWorldbookMarkdown(project, document) {
4867
5062
  if (lines.length === 0) return;
4868
5063
  parts.push(`## ${title}`, "", ...lines, "");
4869
5064
  };
4870
- if (document.premise !== "") section("一句话", [oneLine(document.premise)]);
4871
- if (document.setting !== "") section("世界设定", [oneLine(document.setting)]);
4872
- if (document.themes.length > 0) section("主题", [document.themes.map(oneLine).join("、")]);
4873
- section("规则", document.rules.map((entry) => `- **${oneLine(entry.name)}**:${oneLine(entry.description)}`));
4874
- section("势力", document.factions.map((entry) => entry.goal === "" ? `- **${oneLine(entry.name)}**:${oneLine(entry.description)}` : `- **${oneLine(entry.name)}**:${oneLine(entry.description)}(目标:${oneLine(entry.goal)})`));
4875
- section("地点", document.locations.map((entry) => `- **${oneLine(entry.name)}**:${oneLine(entry.description)}${entry.layout === "" ? "" : `;布局:${oneLine(entry.layout)}`}${entry.capacity === 0 ? "" : `;容量:${entry.capacity}`}`));
4876
- section("角色", document.characters.map((entry) => `- **${oneLine(entry.name)}**(${oneLine(entry.role)}):${oneLine(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine(entry.arc)}`}`));
4877
- section("术语", document.glossary.map((entry) => `- **${oneLine(entry.term)}**:${oneLine(entry.definition)}`));
4878
- section("前史", document.backstory.map((entry) => `- ${oneLine(entry.time)}:${oneLine(entry.event)}`));
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)}`}`));
4879
5075
  return parts.join("\n");
4880
5076
  }
4881
5077
  // src/engine/outline-merge.ts
4882
- 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);
4883
5079
  const text$1 = (value, where) => {
4884
5080
  if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
4885
5081
  return value.trim();
@@ -4914,7 +5110,7 @@ const submittedNumbers = (input) => {
4914
5110
  const result = /* @__PURE__ */ new Set();
4915
5111
  if (!Array.isArray(input)) return result;
4916
5112
  for (const raw of input) {
4917
- 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;
4918
5114
  result.add(raw.num);
4919
5115
  }
4920
5116
  return result;
@@ -4953,7 +5149,7 @@ const mergeVolumes = (options) => {
4953
5149
  };
4954
5150
  const submitted = /* @__PURE__ */ new Set();
4955
5151
  for (const [index, raw] of (options.volumes ?? []).entries()) {
4956
- 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`);
4957
5153
  const title = text$1(raw.title, `volumes[${index}].title`);
4958
5154
  if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
4959
5155
  submitted.add(title);
@@ -5003,18 +5199,30 @@ const remapVolume = (card, oldVolumes, newVolumes) => {
5003
5199
  volume: card.num >= next.start_chapter && card.num <= next.end_chapter ? nextIndex + 1 : null
5004
5200
  };
5005
5201
  };
5006
- const assertArchivedVolumes = (options, volumes, removedTitles) => {
5007
- const removed = new Set(removedTitles);
5008
- const oldByTitle = volumeByTitle(options.existingVolumes);
5009
- const newByTitle = volumeByTitle(volumes);
5010
- for (const record of options.archivedChapters) {
5011
- const card = options.existing.find((entry) => entry.num === record.num);
5012
- if (card === void 0 || card.volume === null) continue;
5013
- const previous = options.existingVolumes[card.volume - 1];
5202
+ const assertArchivedCardVolumes = (archivedChapters, cards, oldVolumes, newVolumes, operation = "outline write") => {
5203
+ const byNum = new Map(cards.map((card) => [card.num, card]));
5204
+ const newByTitle = volumeByTitle(newVolumes);
5205
+ for (const record of archivedChapters) {
5206
+ const card = byNum.get(record.num);
5207
+ const previous = card !== void 0 && card.volume !== null && card.volume !== void 0 ? oldVolumes[card.volume - 1] : oldVolumes.find((volume) => record.num >= volume.start_chapter && record.num <= volume.end_chapter);
5014
5208
  if (previous === void 0) continue;
5015
- if (removed.has(previous.title) || !oldByTitle.has(previous.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${previous.title}": archived chapter ${record.num} belongs to it`, "archived cards are evidence; keep the volume or reopen the chapter first");
5016
5209
  const next = newByTitle.get(previous.title);
5017
- if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${previous.title}" no longer covers archived chapter ${record.num}`, "keep archived chapter ranges inside their existing volume");
5210
+ if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} would leave archived chapter ${record.num} outside its volume "${previous.title}"`, "keep the volume/range that covers archived cards, or call novel_reopen first");
5211
+ }
5212
+ };
5213
+ /**
5214
+ * Final-document guard: every archived chapter with a volume-aware outline
5215
+ * must point at a volume that actually covers it. The old-card guard above
5216
+ * only proves the volume list did not drop the old range; an amend can still
5217
+ * write a card with `volume:null` or a stale index.
5218
+ */
5219
+ const assertArchivedCardsInVolumes = (archivedChapters, cards, volumes, operation = "outline write") => {
5220
+ if (volumes.length === 0) return;
5221
+ const byNum = new Map(cards.map((card) => [card.num, card]));
5222
+ for (const record of archivedChapters) {
5223
+ const card = byNum.get(record.num);
5224
+ const volume = card !== void 0 && card.volume !== null && card.volume !== void 0 ? volumes[card.volume - 1] : void 0;
5225
+ if (volume === void 0 || record.num < volume.start_chapter || record.num > volume.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} would leave archived chapter ${record.num} without a covering volume`, "keep a volume/range that covers every archived card, or call novel_reopen first");
5018
5226
  }
5019
5227
  };
5020
5228
  /**
@@ -5026,7 +5234,7 @@ const mergeOutline = (options) => {
5026
5234
  const submitted = submittedNumbers(options.chapters);
5027
5235
  const removed = parseChapterNumbers(options.remove, "remove");
5028
5236
  const volumeChanges = mergeVolumes(options);
5029
- assertArchivedVolumes(options, volumeChanges.volumes, volumeChanges.removed);
5237
+ assertArchivedCardVolumes(options.archivedChapters, options.existing, options.existingVolumes, volumeChanges.volumes, "novel_outline");
5030
5238
  const existingByNum = new Map(options.existing.map((card) => [card.num, card]));
5031
5239
  const archivedByNum = new Map(options.archivedChapters.map((record) => [record.num, record]));
5032
5240
  const archivedNums = new Set(archivedByNum.keys());
@@ -5036,9 +5244,10 @@ const mergeOutline = (options) => {
5036
5244
  if (archivedByNum.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is archived`, "archived cards are evidence; call novel_reopen first");
5037
5245
  if (existingByNum.get(num)?.status !== "planned") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is not a planned card`);
5038
5246
  }
5247
+ const existingForNormalize = options.existing.map((card) => archivedNums.has(card.num) ? remapVolume(card, options.existingVolumes, volumeChanges.volumes) : card);
5039
5248
  const normalized = normalizeOutlineChapters(options.chapters, {
5040
5249
  volumes: volumeChanges.volumes,
5041
- existing: options.existing,
5250
+ existing: existingForNormalize,
5042
5251
  archived: options.archivedChapters,
5043
5252
  titleRule: options.titleRule,
5044
5253
  totalChapters: options.totalChapters,
@@ -5048,6 +5257,7 @@ const mergeOutline = (options) => {
5048
5257
  const byNum = new Map(normalized.map((card) => [card.num, card]));
5049
5258
  const updated = /* @__PURE__ */ new Set();
5050
5259
  const added = /* @__PURE__ */ new Set();
5260
+ const reindexed = /* @__PURE__ */ new Set();
5051
5261
  for (const card of normalized) {
5052
5262
  if (!submitted.has(card.num)) continue;
5053
5263
  const before = existingByNum.get(card.num);
@@ -5062,6 +5272,8 @@ const mergeOutline = (options) => {
5062
5272
  ...card,
5063
5273
  status: "archived"
5064
5274
  });
5275
+ const current = byNum.get(card.num);
5276
+ if (stableJson(card) !== stableJson(current)) reindexed.add(card.num);
5065
5277
  continue;
5066
5278
  }
5067
5279
  const remapped = remapVolume(card, options.existingVolumes, volumeChanges.volumes);
@@ -5069,25 +5281,142 @@ const mergeOutline = (options) => {
5069
5281
  const current = byNum.get(card.num);
5070
5282
  if (stableJson(current) !== stableJson(remapped)) {
5071
5283
  byNum.set(card.num, remapped);
5072
- if (card.status === "planned") updated.add(card.num);
5284
+ reindexed.add(card.num);
5073
5285
  }
5074
5286
  } else {
5075
5287
  byNum.set(card.num, remapped);
5076
- if (card.status === "planned" && stableJson(card) !== stableJson(remapped)) updated.add(card.num);
5288
+ if (stableJson(card) !== stableJson(remapped)) reindexed.add(card.num);
5077
5289
  }
5078
5290
  }
5079
5291
  for (const num of removed) byNum.delete(num);
5292
+ const chapters = [...byNum.values()].sort((left, right) => left.num - right.num);
5293
+ assertArchivedCardsInVolumes(options.archivedChapters, chapters, volumeChanges.volumes, "novel_outline");
5080
5294
  return {
5081
- chapters: [...byNum.values()].sort((left, right) => left.num - right.num),
5295
+ chapters,
5082
5296
  volumes: volumeChanges.volumes,
5083
5297
  added: [...added].sort((left, right) => left - right),
5084
5298
  updated: [...updated].sort((left, right) => left - right),
5299
+ reindexed: [...reindexed].sort((left, right) => left - right),
5085
5300
  removed: [...removed].sort((left, right) => left - right),
5086
5301
  volumes_added: volumeChanges.added,
5087
5302
  volumes_updated: volumeChanges.updated,
5088
5303
  volumes_removed: volumeChanges.removed
5089
5304
  };
5090
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
+ }
5091
5420
  // src/engine/state-delta.ts
5092
5421
  const SECTIONS = [
5093
5422
  "characters",
@@ -5111,6 +5440,28 @@ const setOwn = (record, key, value) => {
5111
5440
  writable: true
5112
5441
  });
5113
5442
  };
5443
+ /** Validate resource balances without accepting numeric strings or fractions. */
5444
+ const validateResources = (state, who) => {
5445
+ if (state.resources === void 0) return;
5446
+ if (!isRecord(state.resources)) fail$1(`${who}.current_state.resources must be an object`);
5447
+ for (const [name, value] of Object.entries(state.resources)) {
5448
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) fail$1(`${who}.current_state.resources.${name} must be an integer`);
5449
+ if (value < 0) throw new EngineInputError(ERROR_CODES.RESOURCE_NEGATIVE_BALANCE, `${who}'s ${name} balance is ${value}`, "state a non-negative integer balance; spend the resource before the balance reaches zero");
5450
+ }
5451
+ };
5452
+ /** Current state is a patch; resources are a nested map, not a whole replacement. */
5453
+ const mergeCurrentState = (base, patch) => {
5454
+ const merged = {
5455
+ ...base,
5456
+ ...patch
5457
+ };
5458
+ if (patch.resources !== void 0 && !isRecord(patch.resources)) fail$1("current_state.resources must be an object");
5459
+ if (patch.resources !== void 0) merged.resources = {
5460
+ ...isRecord(base.resources) ? base.resources : {},
5461
+ ...patch.resources
5462
+ };
5463
+ return merged;
5464
+ };
5114
5465
  function fail$1(detail) {
5115
5466
  throw new EngineInputError(ERROR_CODES.DELTA_UNVERIFIED, `state_delta is incomplete: ${detail}`, "declare every entity the chapter touched as created, updated, or no_change with a reason");
5116
5467
  }
@@ -5197,6 +5548,12 @@ function applyStateDelta(input) {
5197
5548
  "advanced",
5198
5549
  "resolved"
5199
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
+ }
5200
5557
  const characters = structuredClone(input.characters);
5201
5558
  const world = structuredClone(input.world);
5202
5559
  const locks = structuredClone(input.locks);
@@ -5204,6 +5561,20 @@ function applyStateDelta(input) {
5204
5561
  const timeline = structuredClone(input.timeline);
5205
5562
  const relationships = structuredClone(input.relationships);
5206
5563
  const assigned = Object.create(null);
5564
+ const warnings = [];
5565
+ const normalizedWorld = Object.create(null);
5566
+ const warnReclass = (name, from, to, reason) => {
5567
+ normalizedWorld[name] = {
5568
+ from,
5569
+ to,
5570
+ reason
5571
+ };
5572
+ const code = reason === "existing-created-as-updated" ? "WORLD_ELEMENT_RECLASSIFIED_CREATED_TO_UPDATED" : reason === "missing-updated-as-created" ? "WORLD_ELEMENT_RECLASSIFIED_UPDATED_TO_CREATED" : "WORLD_ELEMENT_RECLASSIFIED_NO_CHANGE_TO_CREATED";
5573
+ warnings.push({
5574
+ code,
5575
+ message: `world.${name}: ${from} normalized to ${to} against state/world.yaml (${reason})`
5576
+ });
5577
+ };
5207
5578
  const appeared = [...new Set(summary.characters_appeared)];
5208
5579
  const byName = new Map(characters.characters.map((character) => [character.name, character]));
5209
5580
  const narrator = input.narrator ?? "";
@@ -5224,7 +5595,7 @@ function applyStateDelta(input) {
5224
5595
  "profile",
5225
5596
  "current_state",
5226
5597
  "growth"
5227
- ]) 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\":\"...\"}"}`);
5228
5599
  if (declared.growth !== void 0) {
5229
5600
  const growth = declared.growth;
5230
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`);
@@ -5235,12 +5606,14 @@ function applyStateDelta(input) {
5235
5606
  if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
5236
5607
  const rawHint = input.roleHints?.[name];
5237
5608
  const hinted = canonicalRole(rawHint) ?? (rawHint === void 0 && (name === narratorName || name === narrator) ? "protagonist" : "extra");
5609
+ const currentState = declared.current_state ?? {};
5610
+ validateResources(currentState, `characters.${name}`);
5238
5611
  const created = {
5239
5612
  name,
5240
5613
  role: normalizeRole(declared.role, hinted),
5241
5614
  first_appearance: chapter,
5242
5615
  profile: declared.profile ?? {},
5243
- current_state: declared.current_state ?? {},
5616
+ current_state: currentState,
5244
5617
  growth: [],
5245
5618
  updated_at: chapter
5246
5619
  };
@@ -5261,10 +5634,11 @@ function applyStateDelta(input) {
5261
5634
  ...existing.profile,
5262
5635
  ...declared.profile
5263
5636
  };
5264
- if (declared.current_state !== void 0) existing.current_state = {
5265
- ...existing.current_state,
5266
- ...declared.current_state
5267
- };
5637
+ if (declared.current_state !== void 0) {
5638
+ const merged = mergeCurrentState(existing.current_state, declared.current_state);
5639
+ validateResources(merged, `characters.${name}`);
5640
+ existing.current_state = merged;
5641
+ }
5268
5642
  if (declared.growth !== void 0) existing.growth.push({
5269
5643
  ...declared.growth,
5270
5644
  chapter
@@ -5288,6 +5662,7 @@ function applyStateDelta(input) {
5288
5662
  ...state,
5289
5663
  resources
5290
5664
  };
5665
+ validateResources(character.current_state, `characters.${change.character}`);
5291
5666
  }
5292
5667
  const findWorld = (name) => {
5293
5668
  const location = world.locations.find((candidate) => candidate.name === name);
@@ -5315,16 +5690,30 @@ function applyStateDelta(input) {
5315
5690
  if (summary.new_elements.artifacts.includes(name)) return "artifact";
5316
5691
  return null;
5317
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
+ };
5318
5704
  for (const [kind, names] of [
5319
5705
  ["location", summary.new_elements.locations],
5320
5706
  ["ability", summary.new_elements.abilities],
5321
5707
  ["artifact", summary.new_elements.artifacts]
5322
5708
  ]) for (const name of names) {
5323
- 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;`);
5324
5710
  if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
5325
5711
  const declared = delta.world[name];
5326
- if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
5327
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}`);
5713
+ if (findWorld(name) !== null) warnings.push({
5714
+ code: "WORLD_ELEMENT_ALREADY_IN_LEDGER",
5715
+ message: `summary.new_elements lists "${name}", but state/world.yaml already has it; ledger existence wins`
5716
+ });
5328
5717
  }
5329
5718
  for (const [name, raw] of Object.entries(delta.world)) {
5330
5719
  const declared = entry(raw, `world.${name}`);
@@ -5333,27 +5722,40 @@ function applyStateDelta(input) {
5333
5722
  if (declared.layout !== void 0 && typeof declared.layout !== "string") fail$1(`world.${name}.layout must be a string`);
5334
5723
  if (declared.capacity !== void 0 && (!Number.isInteger(declared.capacity) || declared.capacity < 0)) fail$1(`world.${name}.capacity must be a non-negative integer`);
5335
5724
  if (declared.detail !== void 0 && !isRecord(declared.detail)) fail$1(`world.${name}.detail must be an object`);
5336
- if (declared.detail !== void 0) {
5337
- if (declared.action !== "created") {
5338
- for (const reserved of [
5339
- "name",
5340
- "first_appearance",
5341
- "description"
5342
- ]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
5343
- }
5344
- }
5345
5725
  const expected = expectedKind(name);
5346
- if (expected !== null) {
5347
- if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
5348
- if (declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
5349
- }
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}`);
5350
5727
  const existing = findWorld(name);
5351
- if (declared.action === "created") {
5728
+ let entityAnchors = anchorsForKind(declared.kind ?? expected, name);
5729
+ let action = declared.action;
5730
+ if (action === "created" && existing !== null) {
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`);
5732
+ if (expected !== null && expected !== existing.kind) fail$1(`world.${name} is listed as ${expected} but the ledger has ${existing.kind}`);
5733
+ action = "updated";
5734
+ warnReclass(name, "created", "updated", "existing-created-as-updated");
5735
+ } else if (action === "updated" && existing === null) {
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;`);
5739
+ action = "created";
5740
+ warnReclass(name, "updated", "created", "missing-updated-as-created");
5741
+ } else if (action === "no_change" && existing === null && expected !== null) {
5742
+ entityAnchors = requireText(name, expected, `world entity "${name}" is new but does not appear in the manuscript;`);
5743
+ action = "created";
5744
+ warnReclass(name, "no_change", "created", "missing-no-change-as-created");
5745
+ }
5746
+ if (declared.detail !== void 0 && action !== "created") {
5747
+ for (const reserved of [
5748
+ "name",
5749
+ "first_appearance",
5750
+ "description"
5751
+ ]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
5752
+ }
5753
+ if (action === "created") {
5352
5754
  if (name.trim() === "") fail$1("a created world entity has an empty name");
5353
5755
  if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
5354
- if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
5355
5756
  const kind = declared.kind ?? expected;
5356
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;`);
5357
5759
  if (![
5358
5760
  "location",
5359
5761
  "ability",
@@ -5361,7 +5763,7 @@ function applyStateDelta(input) {
5361
5763
  "faction",
5362
5764
  "rule"
5363
5765
  ].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
5364
- if (expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
5766
+ if (declared.action === "created" && expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
5365
5767
  if (kind === "location") world.locations.push({
5366
5768
  name,
5367
5769
  first_appearance: chapter,
@@ -5369,23 +5771,27 @@ function applyStateDelta(input) {
5369
5771
  description: declared.description ?? "",
5370
5772
  layout: declared.layout ?? "",
5371
5773
  capacity: declared.capacity ?? 0,
5774
+ ...entityAnchors.length === 0 ? {} : { anchors: entityAnchors },
5372
5775
  changes: []
5373
5776
  });
5374
5777
  else world[kind === "ability" ? "abilities" : kind === "artifact" ? "artifacts" : kind === "faction" ? "factions" : "rules"].push({
5375
5778
  ...declared.detail ?? {},
5376
5779
  name,
5377
5780
  first_appearance: chapter,
5378
- description: declared.description ?? ""
5781
+ description: declared.description ?? "",
5782
+ ...entityAnchors.length === 0 ? {} : { anchors: entityAnchors }
5379
5783
  });
5380
5784
  continue;
5381
5785
  }
5382
5786
  if (existing === null) fail$1(`world entity "${name}" does not exist; created must be used first`);
5383
- if (declared.action === "updated") {
5787
+ if (declared.kind !== void 0 && existing.kind !== declared.kind) fail$1(`world.${name} is declared as ${declared.kind} but the ledger has ${existing.kind}; kind cannot change`);
5788
+ if (action === "updated") {
5384
5789
  if (existing.kind === "location") {
5385
5790
  const location = existing.value;
5386
5791
  if (declared.description !== void 0) location.description = declared.description;
5387
5792
  if (declared.layout !== void 0) location.layout = declared.layout;
5388
5793
  if (declared.capacity !== void 0) location.capacity = declared.capacity;
5794
+ if (entityAnchors.length > 0) location.anchors = mergeEntityAnchors(location.anchors, entityAnchors);
5389
5795
  location.changes.push({
5390
5796
  chapter,
5391
5797
  change: declared.reason ?? declared.description ?? "updated",
@@ -5394,6 +5800,7 @@ function applyStateDelta(input) {
5394
5800
  } else if (isRecord(existing.value)) {
5395
5801
  if (declared.description !== void 0 && "description" in existing.value) existing.value.description = declared.description;
5396
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);
5397
5804
  }
5398
5805
  }
5399
5806
  }
@@ -5523,6 +5930,7 @@ function applyStateDelta(input) {
5523
5930
  const declared = entry(delta.timeline[key], `timeline.${key}`);
5524
5931
  if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
5525
5932
  if (declared.characters !== void 0 && (!Array.isArray(declared.characters) || declared.characters.some((character) => typeof character !== "string"))) fail$1(`timeline.${key}.characters must be a string array`);
5933
+ for (const character of declared.characters ?? []) if (byName.get(character) === void 0) fail$1(`timeline.${key} names unknown character "${character}"`);
5526
5934
  const allocated = allocateIds("TE", timeline.next_seq, 1);
5527
5935
  const id = allocated.ids[0];
5528
5936
  if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
@@ -5589,7 +5997,9 @@ function applyStateDelta(input) {
5589
5997
  foreshadows,
5590
5998
  timeline,
5591
5999
  relationships,
5592
- assigned
6000
+ assigned,
6001
+ warnings,
6002
+ normalized_world: normalizedWorld
5593
6003
  };
5594
6004
  }
5595
6005
  // src/engine/insight.ts
@@ -5600,6 +6010,30 @@ const median = (values) => {
5600
6010
  const middle = Math.floor(sorted.length / 2);
5601
6011
  return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
5602
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
+ };
5603
6037
  /** Build the whole-book report from one committed snapshot. No IO. */
5604
6038
  function buildBookReport(input) {
5605
6039
  const records = [...input.chapters].sort((left, right) => left.num - right.num);
@@ -5715,6 +6149,7 @@ function buildBookReport(input) {
5715
6149
  };
5716
6150
  });
5717
6151
  if (records.some((record) => record.rounds === void 0 || record.archived_at === void 0)) warnings.push("部分旧章缺少归档 effort 元数据;报告已按事务日志回填");
6152
+ const persistedAnchors = worldAnchors(input.world);
5718
6153
  return {
5719
6154
  progress: {
5720
6155
  archived: records.length,
@@ -5742,7 +6177,10 @@ function buildBookReport(input) {
5742
6177
  volumes: volumeCounts,
5743
6178
  drift
5744
6179
  },
5745
- worldbook: worldbookCounts(input.worldbook),
6180
+ worldbook: {
6181
+ ...worldbookCounts(input.worldbook),
6182
+ ...persistedAnchors.length === 0 ? {} : { anchors: persistedAnchors }
6183
+ },
5746
6184
  quality: {
5747
6185
  average: Math.round(qualityAverage * 10) / 10,
5748
6186
  median: Math.round(median(scores) * 10) / 10,
@@ -6144,6 +6582,173 @@ function searchSnapshot(input) {
6144
6582
  results
6145
6583
  };
6146
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
+ }
6147
6752
  // src/engine/history.ts
6148
6753
  const PATTERN = /^ch(?<chapter>\d{3,})\.r(?<revision>\d+)\.(?<suffix>review\.yaml|check\.yaml|chapter\.yaml|md)$/;
6149
6754
  const kindOf = (directory, suffix) => {
@@ -6925,6 +7530,44 @@ const SEMANTIC_CHECK_IDS = [
6925
7530
  "L2-VOICE",
6926
7531
  "L2-POV"
6927
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
+ };
7541
+ /**
7542
+ * A caller supplied evidence that cannot be evaluated as a content judgement.
7543
+ * It must never be persisted as a failed check or consume a content round.
7544
+ */
7545
+ var CheckSubmissionError = class extends EngineInputError {
7546
+ items;
7547
+ passed;
7548
+ failed;
7549
+ conflicts;
7550
+ blocking;
7551
+ rounds_consumed = 0;
7552
+ failure_class = "submission";
7553
+ constructor(items, blocking) {
7554
+ const passed = items.filter((item) => item.state === "pass").length;
7555
+ const failed = items.filter((item) => item.state !== "pass" && item.state !== "fail").length;
7556
+ const first = items.find((item) => item.state !== "pass" && item.state !== "fail");
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");
7560
+ this.items = items;
7561
+ this.passed = passed;
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
+ }));
7569
+ }
7570
+ };
6928
7571
  /** Extract the quoted part of an evidence string, tolerating "第 N 段:" labels. */
6929
7572
  function evidenceQuote(value) {
6930
7573
  let candidate = value.trim();
@@ -6934,151 +7577,312 @@ function evidenceQuote(value) {
6934
7577
  candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
6935
7578
  return candidate;
6936
7579
  }
6937
- /** Sentence spans, including the terminating punctuation, for evidence de-duplication. */
6938
- function sentenceSpans(text) {
6939
- const spans = [];
6940
- let start = 0;
6941
- for (let index = 0; index < text.length; index += 1) if ("。!?!?".includes(text[index])) {
6942
- spans.push({
6943
- start,
6944
- end: index + 1
6945
- });
6946
- start = index + 1;
7580
+ function nearestParagraph(text, quote) {
7581
+ const paragraphs = splitParagraphs(text);
7582
+ let best = null;
7583
+ for (const [index, paragraph] of paragraphs.entries()) {
7584
+ const score = bigramSimilarity(paragraph, quote);
7585
+ if (best === null || score > best.score) best = {
7586
+ index,
7587
+ paragraph,
7588
+ score
7589
+ };
6947
7590
  }
6948
- if (start < text.length) spans.push({
6949
- start,
6950
- end: text.length
6951
- });
6952
- return spans;
7591
+ if (best === null || best.score < .5) return null;
7592
+ return {
7593
+ paragraph: best.index + 1,
7594
+ quote: Array.from(best.paragraph).slice(0, 120).join(""),
7595
+ score: Math.round(best.score * 1e3) / 1e3
7596
+ };
6953
7597
  }
7598
+ const submissionState = (state) => state !== "pass" && state !== "fail";
7599
+ const codeForState = (state) => {
7600
+ switch (state) {
7601
+ case "not_found": return "SEMANTIC_EVIDENCE_NOT_FOUND";
7602
+ case "too_short": return "SEMANTIC_EVIDENCE_TOO_SHORT";
7603
+ case "duplicate": return "SEMANTIC_EVIDENCE_DUPLICATE";
7604
+ case "overlap": return "SEMANTIC_EVIDENCE_OVERLAP";
7605
+ case "same_sentence": return "SEMANTIC_EVIDENCE_SAME_SENTENCE";
7606
+ case "missing": return "SEMANTIC_MISSING";
7607
+ case "boundary": return "SEMANTIC_EVIDENCE_BOUNDARY";
7608
+ default: return "SEMANTIC_ITEM_INVALID";
7609
+ }
7610
+ };
7611
+ const detailFor = (result) => {
7612
+ if (result.detail !== void 0) return result.detail;
7613
+ switch (result.state) {
7614
+ case "not_found": return `${result.id} cites a quote that does not appear verbatim in the manuscript`;
7615
+ case "too_short": return `${result.id} quotes fewer than 8 characters`;
7616
+ case "duplicate": return `${result.id} repeats ${result.conflict_with ?? "another item"}'s quote`;
7617
+ case "overlap": return `${result.id} overlaps ${result.conflict_with ?? "another item"}'s accepted evidence interval`;
7618
+ case "same_sentence": return `${result.id} reuses a sentence already cited by ${result.conflict_with ?? "another item"}`;
7619
+ case "boundary": return `${result.id} cannot be mapped onto a sentence boundary`;
7620
+ case "missing": return `${result.id} is required by the P6 task book`;
7621
+ default: return `${result.id} is invalid`;
7622
+ }
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
+ };
6954
7646
  /**
6955
- * Validate the model-supplied L2 semantic items. Evidence must be a real
6956
- * substring of the manuscript; a failed verdict is itself a blocking finding.
6957
- * The model cannot turn the check green by asserting it.
7647
+ * Evaluate all five L2 semantic items.
7648
+ *
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.
6958
7654
  */
6959
- function evaluateSemantic(text, items) {
6960
- const blocking = [];
6961
- const seen = /* @__PURE__ */ new Set();
6962
- const seenQuotes = /* @__PURE__ */ new Set();
6963
- const spans = [];
6964
- const sentences = sentenceSpans(text);
6965
- const usedSentences = /* @__PURE__ */ new Set();
6966
- for (const [index, item] of items.entries()) {
7655
+ function evaluateSemantic(text, input) {
7656
+ const items = [];
7657
+ const accepted = [];
7658
+ const sentences = splitSentenceSpans(text);
7659
+ const usedSentences = /* @__PURE__ */ new Map();
7660
+ const seenIds = /* @__PURE__ */ new Set();
7661
+ const acceptedQuotes = /* @__PURE__ */ new Map();
7662
+ const rawItems = Array.isArray(input) ? input : [];
7663
+ if (!Array.isArray(input)) items.push({
7664
+ id: "semantic",
7665
+ state: "invalid",
7666
+ detail: "semantic must be an array of five items"
7667
+ });
7668
+ for (const [index, raw] of rawItems.entries()) {
6967
7669
  const where = `semantic[${index}]`;
6968
- if (typeof item !== "object" || item === null) {
6969
- blocking.push({
6970
- code: "SEMANTIC_ITEM_INVALID",
6971
- scope: "check",
7670
+ if (typeof raw !== "object" || raw === null) {
7671
+ items.push({
7672
+ id: where,
7673
+ state: "invalid",
6972
7674
  detail: `${where} is not an object`
6973
7675
  });
6974
7676
  continue;
6975
7677
  }
7678
+ const item = raw;
6976
7679
  if (typeof item.id !== "string" || item.id.trim() === "") {
6977
- blocking.push({
6978
- code: "SEMANTIC_ITEM_INVALID",
6979
- scope: "check",
7680
+ items.push({
7681
+ id: where,
7682
+ state: "invalid",
6980
7683
  detail: `${where} has no id`
6981
7684
  });
6982
7685
  continue;
6983
7686
  }
6984
- if (seen.has(item.id)) {
6985
- blocking.push({
6986
- code: "SEMANTIC_ITEM_INVALID",
6987
- scope: "check",
6988
- detail: `${item.id} is declared twice`
7687
+ const id = item.id.trim();
7688
+ if (seenIds.has(id)) {
7689
+ items.push({
7690
+ id,
7691
+ state: "invalid",
7692
+ detail: `${id} is declared twice`
6989
7693
  });
6990
7694
  continue;
6991
7695
  }
6992
- seen.add(item.id);
7696
+ seenIds.add(id);
6993
7697
  if (item.verdict !== "pass" && item.verdict !== "fail") {
6994
- blocking.push({
6995
- code: "SEMANTIC_ITEM_INVALID",
6996
- scope: "check",
6997
- detail: `${item.id} has verdict ${String(item.verdict)}; expected pass|fail`
7698
+ items.push({
7699
+ id,
7700
+ state: "invalid",
7701
+ detail: `${id} has verdict ${String(item.verdict)}; expected pass|fail`
6998
7702
  });
6999
7703
  continue;
7000
7704
  }
7001
7705
  if (typeof item.evidence !== "string" || item.evidence.trim() === "") {
7002
- blocking.push({
7003
- code: "SEMANTIC_EVIDENCE_INVALID",
7004
- scope: "check",
7005
- detail: `${item.id} has no evidence quote`
7706
+ items.push({
7707
+ id,
7708
+ state: "invalid",
7709
+ detail: `${id} has no evidence quote`
7006
7710
  });
7007
7711
  continue;
7008
7712
  }
7009
7713
  const quote = evidenceQuote(item.evidence);
7010
7714
  if (Array.from(quote).length < 8) {
7011
- blocking.push({
7012
- code: "SEMANTIC_EVIDENCE_INVALID",
7013
- scope: "check",
7014
- detail: `${item.id} quotes fewer than 8 characters`
7715
+ items.push({
7716
+ id,
7717
+ state: "too_short",
7718
+ quote,
7719
+ detail: `${id} quotes fewer than 8 characters`
7015
7720
  });
7016
7721
  continue;
7017
7722
  }
7018
- if (!text.includes(quote)) {
7019
- blocking.push({
7020
- code: "SEMANTIC_EVIDENCE_INVALID",
7021
- scope: "check",
7022
- detail: `${item.id} cites "${quote}", which does not appear in the manuscript`
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`
7023
7728
  });
7024
7729
  continue;
7025
7730
  }
7026
- if (seenQuotes.has(quote)) {
7027
- blocking.push({
7028
- code: "SEMANTIC_EVIDENCE_INVALID",
7029
- scope: "check",
7030
- detail: `${item.id} repeats another semantic item's quote; each check needs distinct evidence`
7731
+ const duplicate = acceptedQuotes.get(quote);
7732
+ if (duplicate !== void 0) {
7733
+ const conflict = {
7734
+ kind: "duplicate",
7735
+ other_id: duplicate
7736
+ };
7737
+ items.push({
7738
+ id,
7739
+ state: "duplicate",
7740
+ quote,
7741
+ conflict_with: duplicate,
7742
+ conflicts: [conflict],
7743
+ detail: `${id} repeats ${duplicate}'s quote exactly`
7031
7744
  });
7032
7745
  continue;
7033
7746
  }
7034
- let start = text.indexOf(quote);
7035
- let sentence = -1;
7036
- while (start !== -1) {
7747
+ const occurrences = [];
7748
+ let found = text.indexOf(quote);
7749
+ while (found !== -1) {
7750
+ occurrences.push(found);
7751
+ found = text.indexOf(quote, found + 1);
7752
+ }
7753
+ if (occurrences.length === 0) {
7754
+ const nearest = nearestParagraph(text, quote);
7755
+ items.push({
7756
+ id,
7757
+ state: "not_found",
7758
+ quote,
7759
+ ...nearest === null ? {} : { nearest },
7760
+ detail: `${id} cites "${Array.from(quote).slice(0, 40).join("")}", which does not appear verbatim in the manuscript`
7761
+ });
7762
+ continue;
7763
+ }
7764
+ let acceptedSpan = null;
7765
+ const conflicts = [];
7766
+ for (const start of occurrences) {
7037
7767
  const end = start + quote.length;
7038
- const overlaps = spans.some((span) => start < span.end && span.start < end);
7039
- const sentenceIndex = sentences.findIndex((candidate) => start >= candidate.start && end <= candidate.end);
7040
- if (!overlaps && sentenceIndex !== -1 && !usedSentences.has(sentenceIndex)) {
7041
- sentence = sentenceIndex;
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
+ }
7786
+ }
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
+ }
7802
+ }
7803
+ if (!rejected) {
7804
+ acceptedSpan = {
7805
+ start,
7806
+ end,
7807
+ id,
7808
+ quote,
7809
+ sentenceIds
7810
+ };
7042
7811
  break;
7043
7812
  }
7044
- start = text.indexOf(quote, start + 1);
7045
7813
  }
7046
- if (start === -1 || sentence === -1) {
7047
- blocking.push({
7048
- code: "SEMANTIC_EVIDENCE_INVALID",
7049
- scope: "check",
7050
- detail: `${item.id} cites overlapping text or the same sentence as another check; each check needs a distinct passage`
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({
7826
+ id,
7827
+ state,
7828
+ quote,
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}`
7051
7832
  });
7052
7833
  continue;
7053
7834
  }
7054
- spans.push({
7055
- start,
7056
- end: start + quote.length
7057
- });
7058
- usedSentences.add(sentence);
7059
- seenQuotes.add(quote);
7060
- if (item.verdict === "fail") blocking.push({
7061
- code: "SEMANTIC_CHECK_FAILED",
7062
- scope: "check",
7063
- detail: `${item.id}: ${typeof item.note === "string" && item.note.trim() !== "" ? item.note : "semantic check failed"}`
7835
+ accepted.push(acceptedSpan);
7836
+ for (const sentenceId of acceptedSpan.sentenceIds) usedSentences.set(sentenceId, id);
7837
+ acceptedQuotes.set(quote, id);
7838
+ items.push({
7839
+ id,
7840
+ state: item.verdict === "fail" ? "fail" : "pass",
7841
+ quote,
7842
+ ...item.verdict === "fail" ? { detail: `${id}: ${typeof item.note === "string" && item.note.trim() !== "" ? item.note : "semantic check failed"}` } : {}
7064
7843
  });
7065
7844
  }
7066
- const required = new Set(SEMANTIC_CHECK_IDS);
7067
- for (const id of SEMANTIC_CHECK_IDS) if (!seen.has(id)) blocking.push({
7068
- code: "SEMANTIC_MISSING",
7069
- scope: "check",
7845
+ for (const id of SEMANTIC_CHECK_IDS) if (!seenIds.has(id)) items.push({
7846
+ id,
7847
+ state: "missing",
7070
7848
  detail: `${id} is required by the P6 task book`
7071
7849
  });
7072
- for (const id of seen) if (!required.has(id)) blocking.push({
7073
- code: "SEMANTIC_ITEM_INVALID",
7850
+ const conflicts = items.flatMap((item) => item.conflicts ?? []);
7851
+ const findings = items.filter((item) => item.state === "fail").map((item) => ({
7852
+ code: "SEMANTIC_CHECK_FAILED",
7074
7853
  scope: "check",
7075
- detail: `${id} is not one of the required P6 semantic checks`
7076
- });
7854
+ detail: item.detail ?? `${item.id}: semantic check failed`
7855
+ }));
7856
+ const submissionItems = items.filter((item) => submissionState(item.state));
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
+ ];
7077
7870
  return {
7871
+ items,
7872
+ conflicts,
7873
+ findings,
7078
7874
  blocking,
7875
+ submission_failed: submissionItems.length > 0,
7876
+ content_failed: findings.length > 0,
7079
7877
  failed: blocking.length > 0
7080
7878
  };
7081
7879
  }
7880
+ /** Throw a typed submission error when shape/evidence errors exist. */
7881
+ function validateSemanticSubmission(text, input) {
7882
+ const evaluation = evaluateSemantic(text, input);
7883
+ if (evaluation.submission_failed) throw new CheckSubmissionError(evaluation.items, evaluation.blocking);
7884
+ return evaluation;
7885
+ }
7082
7886
  // src/engine/gates.ts
7083
7887
  const block = (code, detail, hint, scope) => ({
7084
7888
  code,
@@ -7088,6 +7892,7 @@ const block = (code, detail, hint, scope) => ({
7088
7892
  });
7089
7893
  /** PG-01: the model must have received the current brief before submitting. */
7090
7894
  const briefRequired = (input) => {
7895
+ if (input.summaryOnlyRevision) return [];
7091
7896
  if (input.briefStage === null) return [block("PG-01", "this operation needs a brief, but no brief stage is mapped")];
7092
7897
  const record = input.pipeline.briefs[input.briefStage];
7093
7898
  if (record === void 0) return [block("PG-01", `brief ${input.briefStage} has not been issued`, `call novel_brief with stage ${input.briefStage} first`)];
@@ -7180,11 +7985,42 @@ const summarySchema = (input) => {
7180
7985
  }
7181
7986
  const newElements = summary.new_elements;
7182
7987
  if (typeof newElements !== "object" || newElements === null) problems.push("new_elements must be an object");
7183
- else for (const key of [
7184
- "locations",
7185
- "abilities",
7186
- "artifacts"
7187
- ]) if (!textArray(newElements[key])) problems.push(`new_elements.${key} must be a string array`);
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
+ }
8014
+ }
8015
+ }
8016
+ }
8017
+ if (textArray(summary.characters_appeared)) {
8018
+ const narrator = input.pipeline.draft?.narrator ?? "";
8019
+ for (const name of summary.characters_appeared) {
8020
+ const alias = narrator.includes(name) && (narrator.includes("第一人称") || narrator.includes("「我」")) && input.text.includes("我");
8021
+ if (!input.text.includes(name) && !alias) problems.push(`characters_appeared lists "${name}", but the manuscript never mentions it`);
8022
+ }
8023
+ }
7188
8024
  const resources = summary.resource_changes;
7189
8025
  if (!Array.isArray(resources) || !resources.every((item) => typeof item === "object" && item !== null && isText(item.character) && isText(item.resource) && Number.isInteger(item.delta) && item.delta !== 0 && isText(item.reason))) problems.push("resource_changes must contain character/resource/non-zero delta/reason");
7190
8026
  if (!isText(summary.opening)) problems.push("opening must be text");
@@ -7297,12 +8133,18 @@ const issuesCleared = (input) => {
7297
8133
  }
7298
8134
  return problems.length === 0 ? [] : [block("PG-08", problems.join("; "), "mark every blocker fixed and quote the revised text as evidence")];
7299
8135
  };
7300
- /** Paragraph index (1-based) that contains the quote, or -1. */
8136
+ /** Paragraph index (1-based) nearest the reference when the quote repeats, or -1. */
7301
8137
  function actualParagraph(text, reference) {
7302
8138
  if (typeof reference !== "object" || reference === null || typeof reference.quote !== "string") return -1;
7303
8139
  const quote = reference.quote.trim();
7304
8140
  if (Array.from(quote).length < LIMITS.minQuoteChars) return -1;
7305
- 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;
7306
8148
  }
7307
8149
  /** Verify one reading-proof reference against the manuscript. */
7308
8150
  function verifyReference(text, reference) {
@@ -7311,9 +8153,13 @@ function verifyReference(text, reference) {
7311
8153
  if (!Number.isInteger(reference.paragraph) || reference.paragraph < 1) return "reading proof paragraph must be a positive integer";
7312
8154
  const quote = reference.quote.trim();
7313
8155
  if (Array.from(quote).length < LIMITS.minQuoteChars) return `quote "${reference.quote}" is shorter than ${LIMITS.minQuoteChars} characters`;
7314
- const actual = splitParagraphs(text).findIndex((paragraph) => paragraph.includes(quote));
7315
- if (actual === -1) return `quote "${quote}" does not appear in the text`;
7316
- 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}`;
7317
8163
  return null;
7318
8164
  }
7319
8165
  /** PG-09: three verifiable quotes from three distinct paragraphs. */
@@ -7383,6 +8229,7 @@ const consistency = (input, options) => {
7383
8229
  };
7384
8230
  /** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
7385
8231
  const rounds = (input) => {
8232
+ if (input.summaryOnly === true) return [];
7386
8233
  if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
7387
8234
  const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
7388
8235
  if (!roundsExhausted(input.rounds, counter)) return [];
@@ -7448,7 +8295,13 @@ function applicableGates(input) {
7448
8295
  "PG-06",
7449
8296
  "PG-13"
7450
8297
  ];
7451
- case "submitRevision": return [
8298
+ case "submitRevision": return input.summaryOnly === true ? [
8299
+ ...always,
8300
+ "PG-04",
8301
+ "PG-03",
8302
+ "PG-05",
8303
+ "PG-06"
8304
+ ] : [
7452
8305
  ...always,
7453
8306
  "PG-04",
7454
8307
  "PG-03",
@@ -7559,12 +8412,21 @@ function projectChapter(record) {
7559
8412
  score: record.score,
7560
8413
  stage: record.status === "reviewed" ? "done" : "P3",
7561
8414
  status: record.status === "reviewed" ? "passed" : "draft",
7562
- 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
7563
8425
  };
7564
8426
  }
7565
8427
  function projectSummary(snapshot) {
7566
8428
  return {
7567
- schemaVersion: 5,
8429
+ schemaVersion: 6,
7568
8430
  panelId: PANEL_ID,
7569
8431
  root: snapshot.root,
7570
8432
  title: snapshot.title,
@@ -7586,6 +8448,8 @@ function projectSummary(snapshot) {
7586
8448
  ...snapshot.book === void 0 ? {} : { book: snapshot.book },
7587
8449
  ...snapshot.quality === void 0 ? {} : { quality: snapshot.quality },
7588
8450
  ...snapshot.health === void 0 ? {} : { health: snapshot.health },
8451
+ ...snapshot.bookData === void 0 ? {} : { bookData: snapshot.bookData },
8452
+ ...snapshot.pending === void 0 ? {} : { pending: snapshot.pending },
7589
8453
  ...snapshot.activity === void 0 ? {} : { activity: snapshot.activity },
7590
8454
  ...snapshot.advisories === void 0 ? {} : { advisories: snapshot.advisories },
7591
8455
  ...snapshot.acknowledgedAdvisories === void 0 ? {} : { acknowledgedAdvisories: snapshot.acknowledgedAdvisories },
@@ -7611,14 +8475,14 @@ function projectChapters(snapshot) {
7611
8475
  details: draft.details
7612
8476
  });
7613
8477
  return {
7614
- schemaVersion: 5,
8478
+ schemaVersion: 6,
7615
8479
  panelId: PANEL_ID,
7616
8480
  rows
7617
8481
  };
7618
8482
  }
7619
8483
  function projectGates(snapshot) {
7620
8484
  return {
7621
- schemaVersion: 5,
8485
+ schemaVersion: 6,
7622
8486
  panelId: PANEL_ID,
7623
8487
  stage: snapshot.stage,
7624
8488
  blocking: snapshot.blocking,
@@ -8016,8 +8880,12 @@ var EngineService = class {
8016
8880
  stores = /* @__PURE__ */ new Map();
8017
8881
  mutex = new KeyedMutex();
8018
8882
  replay = /* @__PURE__ */ new Map();
8883
+ /** Bumped when an export artifact disappeared; makes the replay key miss. */
8884
+ exportArtifactEpoch = /* @__PURE__ */ new Map();
8019
8885
  /** Status, panel and gates ask for the same stage context repeatedly. */
8020
8886
  briefContextCache = /* @__PURE__ */ new WeakMap();
8887
+ /** The 15s panel poll reuses the heavy book projection until revision/damage changes. */
8888
+ panelCache = /* @__PURE__ */ new Map();
8021
8889
  briefs;
8022
8890
  now;
8023
8891
  metadataMutex = new KeyedMutex();
@@ -8043,6 +8911,7 @@ var EngineService = class {
8043
8911
  this.disposed = true;
8044
8912
  this.stores.clear();
8045
8913
  this.replay.clear();
8914
+ this.exportArtifactEpoch.clear();
8046
8915
  this.compiled = null;
8047
8916
  }
8048
8917
  get rulepack() {
@@ -8467,6 +9336,7 @@ var EngineService = class {
8467
9336
  const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
8468
9337
  const removedVolumes = snapshot.outline.volumes.filter((volume) => !volumes.some((next) => next.title === volume.title)).map((volume) => volume.title);
8469
9338
  if (removedVolumes.length > 0 && input.confirm_remove !== true) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel_blueprint would remove volumes: ${removedVolumes.join(", ")}`, "pass confirm_remove:true to replace the volume list, or omit volumes to keep them");
9339
+ assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, volumes, "novel_blueprint");
8470
9340
  let chapters = snapshot.outline.chapters.map((card) => ({
8471
9341
  ...card,
8472
9342
  volume: volumeForChapter(volumes, card.num, card.volume)
@@ -8481,6 +9351,7 @@ var EngineService = class {
8481
9351
  chapters
8482
9352
  }, record, now, snapshot.project.targets.words_per_chapter).chapters;
8483
9353
  }
9354
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_blueprint");
8484
9355
  const outline = {
8485
9356
  ...snapshot.outline,
8486
9357
  updated_at: now,
@@ -8564,6 +9435,7 @@ var EngineService = class {
8564
9435
  volumes: merged.volumes.length,
8565
9436
  added: merged.added,
8566
9437
  updated: merged.updated,
9438
+ reindexed: merged.reindexed,
8567
9439
  removed: merged.removed,
8568
9440
  volumes_added: merged.volumes_added,
8569
9441
  volumes_updated: merged.volumes_updated,
@@ -8584,6 +9456,7 @@ var EngineService = class {
8584
9456
  ]);
8585
9457
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
8586
9458
  const current = await this.load(input.root);
9459
+ this.assertPublicBookHasNoStrictExplicit(input.book, "novel_outline");
8587
9460
  this.assertProjectReadable(current, "novel_outline");
8588
9461
  this.assertPlanningDocumentReadable(current, "outline", "novel_outline");
8589
9462
  this.assertArchiveIndexReadable(current, "novel_outline");
@@ -8596,6 +9469,7 @@ var EngineService = class {
8596
9469
  volumes: current.outline.volumes.length,
8597
9470
  added: [],
8598
9471
  updated: [],
9472
+ reindexed: [],
8599
9473
  removed: [],
8600
9474
  volumes_added: [],
8601
9475
  volumes_updated: [],
@@ -8612,10 +9486,12 @@ var EngineService = class {
8612
9486
  ...plan.data,
8613
9487
  added: [],
8614
9488
  updated: [],
9489
+ reindexed: [],
8615
9490
  removed: [],
8616
9491
  dry_run: true,
8617
9492
  would_add: plan.data.added,
8618
9493
  would_update: plan.data.updated,
9494
+ would_reindex: plan.data.reindexed,
8619
9495
  would_remove: plan.data.removed
8620
9496
  },
8621
9497
  revision: current.pipeline.revision,
@@ -8673,6 +9549,7 @@ var EngineService = class {
8673
9549
  ]);
8674
9550
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline_import argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
8675
9551
  const parsed = parseOutlineInput(input.content, input.format);
9552
+ this.assertPublicBookHasNoStrictExplicit(parsed.book, "novel_outline_import");
8676
9553
  const snapshot = await this.load(input.root);
8677
9554
  this.assertProjectReadable(snapshot, "novel_outline_import");
8678
9555
  this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline_import");
@@ -8698,6 +9575,7 @@ var EngineService = class {
8698
9575
  defaultTargetWords: snapshot.project.targets.words_per_chapter,
8699
9576
  now
8700
9577
  });
9578
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_outline_import");
8701
9579
  const candidate = {
8702
9580
  schema_version: 1,
8703
9581
  updated_at: now,
@@ -8705,6 +9583,9 @@ var EngineService = class {
8705
9583
  volumes,
8706
9584
  chapters
8707
9585
  };
9586
+ assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, volumes, "novel_outline_import");
9587
+ this.assertActiveCardPreserved(snapshot, chapters, "novel_outline_import");
9588
+ this.assertOutlineWritable(snapshot, candidate, "novel_outline_import");
8708
9589
  const preview = diffLines(yaml(snapshot.outline), yaml(candidate)).hunks.filter((hunk) => hunk.kind !== "same").slice(0, 80).map((hunk) => `${hunk.kind === "add" ? "+" : "-"} ${hunk.line}`).join("\n");
8709
9590
  const summary = {
8710
9591
  format: parsed.format,
@@ -8748,6 +9629,7 @@ var EngineService = class {
8748
9629
  defaultTargetWords: fresh.project.targets.words_per_chapter,
8749
9630
  now
8750
9631
  });
9632
+ assertArchivedCardVolumes(fresh.chapters.chapters, fresh.outline.chapters, fresh.outline.volumes, nextVolumes, "novel_outline_import");
8751
9633
  const outline = {
8752
9634
  schema_version: 1,
8753
9635
  updated_at: now,
@@ -8755,7 +9637,7 @@ var EngineService = class {
8755
9637
  volumes: nextVolumes,
8756
9638
  chapters: nextChapters
8757
9639
  };
8758
- this.assertActiveCardPreserved(fresh, nextBook.strict, nextChapters, "novel_outline_import");
9640
+ this.assertActiveCardPreserved(fresh, nextChapters, "novel_outline_import");
8759
9641
  this.assertOutlineWritable(fresh, outline, "novel_outline_import");
8760
9642
  const appliedPreview = diffLines(yaml(fresh.outline), yaml(outline)).hunks.filter((hunk) => hunk.kind !== "same").slice(0, 80).map((hunk) => `${hunk.kind === "add" ? "+" : "-"} ${hunk.line}`).join("\n");
8761
9643
  const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import_outline", this.now(), shortDigest(input.content)) });
@@ -9016,14 +9898,51 @@ var EngineService = class {
9016
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");
9017
9899
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
9018
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;
9019
9903
  return this.mutate({
9020
9904
  root: input.root,
9021
9905
  op: "restoreBackup",
9022
9906
  chapter: input.chapter,
9023
9907
  reason: `restore backup ${input.txid}`,
9908
+ ...pipelineOnly ? {
9909
+ allowPipelineRecovery: true,
9910
+ repairPaths: [pipelinePath]
9911
+ } : {},
9024
9912
  run: async (snapshot, context) => {
9025
9913
  const manifest = await readBackupManifest(context.store, input.txid);
9026
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
+ }
9027
9946
  if (input.paths?.length === 1 && input.paths[0] === "outline/outline.yaml") {
9028
9947
  this.assertArchiveIndexReadable(snapshot, "novel_restore");
9029
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]");
@@ -9039,6 +9958,8 @@ var EngineService = class {
9039
9958
  } catch (error) {
9040
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");
9041
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");
9042
9963
  const restoredCards = new Map(document.chapters.map((card) => [card.num, card]));
9043
9964
  const archivedNums = new Set(snapshot.chapters.chapters.map((record) => record.num));
9044
9965
  for (const record of snapshot.chapters.chapters) {
@@ -9182,6 +10103,13 @@ var EngineService = class {
9182
10103
  async journalBlocking(snapshot) {
9183
10104
  const store = this.storeFor(snapshot.root);
9184
10105
  if (await this.lockIsLive(store)) return [];
10106
+ const pending = await inspectPending(store);
10107
+ if (pending.state === "partial") return [{
10108
+ code: ERROR_CODES.PENDING_PARTIAL,
10109
+ scope: "journal",
10110
+ detail: `pending transaction ${pending.txid ?? "unknown"} partially changed the project: ${pending.detail ?? "mixed before/after hashes"}`,
10111
+ hint: `do not restore the whole project; inspect ${pending.stagingDir ?? "staging"}/before/ or state/journal.jsonl first`
10112
+ }];
9185
10113
  if (await store.exists("novel.yaml")) {
9186
10114
  const journalPresent = await store.exists(joinRel(STATE_DIR, STATE_FILES.journal));
9187
10115
  const journal = journalPresent ? await readJournal(store) : null;
@@ -9279,6 +10207,11 @@ var EngineService = class {
9279
10207
  async statusFor(snapshot, chapter) {
9280
10208
  const metrics = await this.metricsFor(snapshot, chapter);
9281
10209
  const journal = await this.journalBlocking(snapshot);
10210
+ const pending = await inspectPending(this.storeFor(snapshot.root));
10211
+ const pendingWarnings = pending.state === "complete" || pending.state === "untouched" ? [{
10212
+ code: pending.state === "complete" ? "PENDING_RECOVERABLE" : "PENDING_ABORTABLE",
10213
+ message: `${pending.detail ?? "pending transaction detected"} (${pending.txid ?? "unknown"})`
10214
+ }] : [];
9282
10215
  const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
9283
10216
  tool: "novel_audit",
9284
10217
  args: {},
@@ -9298,6 +10231,7 @@ var EngineService = class {
9298
10231
  stage: snapshot.pipeline.stage,
9299
10232
  rounds: { ...snapshot.pipeline.rounds },
9300
10233
  blocking: [...this.effectiveBlocking(snapshot), ...journal],
10234
+ ...pendingWarnings.length === 0 ? {} : { warnings: pendingWarnings },
9301
10235
  state_errors: this.stateErrors(snapshot),
9302
10236
  selection: {
9303
10237
  source: snapshot.selection.source,
@@ -9824,7 +10758,7 @@ var EngineService = class {
9824
10758
  severity: "error",
9825
10759
  title: "状态文档不可读",
9826
10760
  detail: `${error.code}: ${error.message}`,
9827
- 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`
9828
10762
  });
9829
10763
  add({
9830
10764
  id: "outline",
@@ -9833,6 +10767,21 @@ var EngineService = class {
9833
10767
  detail: snapshot.outlineError ?? (snapshot.outlinePresent ? `${snapshot.outline.chapters.length} 张章卡 / ${snapshot.outline.volumes.length} 卷 / strict=${String(snapshot.outline.book.strict)}` : "outline/outline.yaml is missing"),
9834
10768
  ...snapshot.outlineError !== null ? { hint: "repair outline/outline.yaml with novel_amend" } : snapshot.outlinePresent ? {} : { hint: "create a card with novel_outline before relying on PG-13" }
9835
10769
  });
10770
+ if (snapshot.handoff !== null && snapshot.outlineError === null && snapshot.outline.volumes.length > 0) {
10771
+ const chapter = snapshot.pipeline.chapter;
10772
+ const expectedIndex = snapshot.outline.volumes.findIndex((volume) => chapter >= volume.start_chapter && chapter <= volume.end_chapter);
10773
+ if (expectedIndex >= 0) {
10774
+ const expected = snapshot.outline.volumes[expectedIndex];
10775
+ const position = projectHandoff(snapshot.handoff, chapter);
10776
+ if (position.volume !== expectedIndex + 1 || position.volume_name !== expected.title) add({
10777
+ id: "handoff-outline",
10778
+ severity: "warning",
10779
+ title: "交接卷与大纲不一致",
10780
+ detail: `handoff 指向第 ${position.volume} 卷「${position.volume_name}」,当前章 ${chapter} 的大纲卷是第 ${expectedIndex + 1} 卷「${expected.title}」`,
10781
+ hint: "用 novel_amend 修正 state/handoff.yaml,或重新归档以推进交接;归档前请确认卷/幕是作者想要的"
10782
+ });
10783
+ }
10784
+ }
9836
10785
  add({
9837
10786
  id: "worldbook",
9838
10787
  severity: snapshot.worldbookError !== null ? "error" : snapshot.worldbookPresent ? "ok" : "info",
@@ -9852,6 +10801,14 @@ var EngineService = class {
9852
10801
  const journal = await readJournal(store);
9853
10802
  const journalCommitted = hasCommittedJournal(journal.records);
9854
10803
  const health = await reconcileJournal(store);
10804
+ const pending = await inspectPending(store);
10805
+ if (pending.state !== "none") add({
10806
+ id: "pending-transaction",
10807
+ severity: pending.state === "partial" ? "error" : "warning",
10808
+ title: pending.state === "partial" ? "事务只完成了一部分" : pending.state === "complete" ? "事务可安全提升" : "事务可安全回滚",
10809
+ detail: `${pending.txid ?? "unknown"}: ${pending.detail ?? pending.state}`,
10810
+ hint: pending.state === "partial" ? `先检查 ${pending.stagingDir ?? "staging"}/before/;不要从整库备份恢复` : "下一次写操作会自动完成恢复;只读命令不会改盘"
10811
+ });
9855
10812
  add({
9856
10813
  id: "journal",
9857
10814
  severity: !journalPresent || !journalCommitted || journal.malformed > 0 || health.mismatches.length > 0 ? "error" : "ok",
@@ -10079,6 +11036,7 @@ var EngineService = class {
10079
11036
  worldbook: snapshot.worldbook,
10080
11037
  worldbookPresent: snapshot.worldbookPresent,
10081
11038
  worldbookError: snapshot.worldbookError,
11039
+ world: snapshot.world,
10082
11040
  chapters: snapshot.chapters.chapters,
10083
11041
  characters: snapshot.characters,
10084
11042
  timeline: snapshot.timeline,
@@ -10747,7 +11705,11 @@ var EngineService = class {
10747
11705
  async submitRevision(input) {
10748
11706
  const hasContent = input.content !== void 0;
10749
11707
  const hasPatch = input.patch !== void 0;
10750
- if (hasContent === hasPatch) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires exactly one of content or patch", "send the full revised text, or a patch with expected_hash and unique edits");
11708
+ const summaryOnly = !hasContent && !hasPatch && input.summary !== void 0;
11709
+ if (!hasContent && !hasPatch && !summaryOnly) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires content, patch, or summary", "send revised text, a patch, or a full summary for a summary-only repair");
11710
+ if (hasContent && hasPatch) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise accepts only one of content or patch", "send the full revised text, or a patch with expected_hash and unique edits");
11711
+ if (summaryOnly && (input.issues !== void 0 || input.proof !== void 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary-only repair cannot change review issues or reading proof", "send only chapter and summary; use content/patch for a prose revision that needs a new proof");
11712
+ if (!summaryOnly && input.proof === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires proof for a content or patch revision", "provide opening/middle/ending quotes from the revised text");
10751
11713
  if (hasPatch) {
10752
11714
  const patch = input.patch;
10753
11715
  if (patch === null || typeof patch !== "object" || !Array.isArray(patch.edits) || patch.edits.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "patch.edits must be a non-empty array");
@@ -10768,7 +11730,13 @@ var EngineService = class {
10768
11730
  }))}`,
10769
11731
  run: async (snapshot, context) => {
10770
11732
  this.assertChapter(snapshot, input.chapter);
10771
- const revisedContent = input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
11733
+ if (summaryOnly && ![
11734
+ "P5",
11735
+ "P6",
11736
+ "ready_to_archive"
11737
+ ].includes(snapshot.pipeline.stage)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `summary-only repair is not available at ${snapshot.pipeline.stage}`, "use novel_revise with content/patch for an early-stage rewrite, or wait until P5/P6");
11738
+ const revisedContent = summaryOnly ? snapshot.text ?? "" : input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
11739
+ if (summaryOnly && snapshot.text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary-only repair requires an existing draft", "write the draft first, then repair its summary");
10772
11740
  if (typeof revisedContent !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must be a string", "send the full revised text as a string, or a patch object");
10773
11741
  if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
10774
11742
  let patchedReview = null;
@@ -10831,8 +11799,10 @@ var EngineService = class {
10831
11799
  text: revisedContent,
10832
11800
  summary: input.summary,
10833
11801
  summaryProvided: input.summary !== void 0,
11802
+ summaryOnly,
11803
+ summaryOnlyRevision: summaryOnly,
10834
11804
  review: patchedReview,
10835
- proof: input.proof
11805
+ proof: input.proof ?? snapshot.review?.proof ?? null
10836
11806
  });
10837
11807
  const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
10838
11808
  if (input.summary === void 0 && inheritedSummary !== void 0) {
@@ -10851,24 +11821,27 @@ var EngineService = class {
10851
11821
  content_hash: sha256(snapshot.text ?? ""),
10852
11822
  updated_at: this.now().toISOString()
10853
11823
  };
10854
- const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
11824
+ const nextStage$1 = summaryOnly ? snapshot.pipeline.stage === "P5" ? "P6" : snapshot.pipeline.stage : nextStage(snapshot.pipeline.stage, "submitRevision", 0);
10855
11825
  const nextPipeline = pipelineWith(snapshot, context.revision, {
10856
11826
  stage: nextStage$1,
10857
11827
  draft: {
10858
11828
  ...baseDraft,
10859
11829
  summary: effectiveSummary,
10860
- content_hash: sha256(revisedContent),
11830
+ content_hash: summaryOnly ? baseDraft.content_hash : sha256(revisedContent),
10861
11831
  updated_at: this.now().toISOString()
10862
11832
  },
10863
- rounds: {
11833
+ rounds: summaryOnly ? { ...snapshot.pipeline.rounds } : {
10864
11834
  ...snapshot.pipeline.rounds,
10865
11835
  revise: snapshot.pipeline.rounds.revise + 1
10866
11836
  },
10867
- blocking: [],
11837
+ blocking: summaryOnly ? snapshot.pipeline.blocking.filter((item) => item.code !== "PG-05") : [],
10868
11838
  ...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
10869
- last_action: action("novel_revise", this.now(), sha256(revisedContent))
11839
+ last_action: action(summaryOnly ? "novel_revise_summary" : "novel_revise", this.now(), sha256(revisedContent))
10870
11840
  });
10871
- const files = [
11841
+ const files = summaryOnly ? [{
11842
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
11843
+ text: yaml(nextPipeline)
11844
+ }] : [
10872
11845
  ...snapshot.text === null ? [] : [{
10873
11846
  path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
10874
11847
  text: snapshot.text
@@ -10886,17 +11859,19 @@ var EngineService = class {
10886
11859
  text: yaml(nextPipeline)
10887
11860
  }
10888
11861
  ];
10889
- if (reviewChanged && patchedReview !== null) files.push({
11862
+ if (!summaryOnly && reviewChanged && patchedReview !== null) files.push({
10890
11863
  path: reviewFile(input.chapter),
10891
11864
  text: yaml(patchedReview)
10892
11865
  });
10893
11866
  return {
10894
11867
  files,
10895
- backup: true,
11868
+ backup: !summaryOnly,
10896
11869
  data: {
10897
11870
  chapter: input.chapter,
10898
11871
  stage: nextStage$1,
10899
- metrics: machine.metrics
11872
+ metrics: machine.metrics,
11873
+ summary_only: summaryOnly,
11874
+ rounds_consumed: summaryOnly ? 0 : 1
10900
11875
  },
10901
11876
  warnings: machineWarnings(machine)
10902
11877
  };
@@ -10917,7 +11892,9 @@ var EngineService = class {
10917
11892
  run: async (snapshot, context) => {
10918
11893
  this.assertChapter(snapshot, input.chapter);
10919
11894
  const text = snapshot.text ?? "";
10920
- const semantic = Array.isArray(input.semantic) ? input.semantic : [];
11895
+ if (input.acknowledge !== void 0 && !Array.isArray(input.acknowledge)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "acknowledge must be an array of advisory codes", "omit acknowledge or pass an array of advisory codes");
11896
+ const semanticEvaluation = validateSemanticSubmission(text, input.semantic);
11897
+ const semantic = input.semantic;
10921
11898
  await this.assertGates(snapshot, "runCheck", {
10922
11899
  briefStage: "P6",
10923
11900
  text,
@@ -10938,8 +11915,8 @@ var EngineService = class {
10938
11915
  if (unknownAck.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot acknowledge non-advisory finding(s): ${unknownAck.join(", ")}`, `advisory codes in this run: ${advisoryCodes.join(", ") || "none"}; blockers and unknown codes cannot be acknowledged`);
10939
11916
  const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
10940
11917
  const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
10941
- const semanticResult = evaluateSemantic(text, semantic);
10942
- const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
11918
+ const semanticResult = semanticEvaluation;
11919
+ const verdict = machineBlocking.length === 0 && !semanticResult.content_failed ? "pass" : "fail";
10943
11920
  const check = {
10944
11921
  schema_version: 1,
10945
11922
  chapter: input.chapter,
@@ -10950,8 +11927,15 @@ var EngineService = class {
10950
11927
  advisories: machineAdvisories,
10951
11928
  metrics: scopedMachine.metrics
10952
11929
  },
10953
- semantic: { items: semantic },
11930
+ semantic: {
11931
+ items: semantic,
11932
+ ...semanticResult.findings.length === 0 ? {} : { findings: semanticResult.findings }
11933
+ },
10954
11934
  ...acknowledged.length === 0 ? {} : { acknowledged },
11935
+ ...verdict === "fail" ? {
11936
+ failure_class: "content",
11937
+ counted: true
11938
+ } : {},
10955
11939
  verdict
10956
11940
  };
10957
11941
  try {
@@ -10965,12 +11949,12 @@ var EngineService = class {
10965
11949
  code: violation.code,
10966
11950
  scope: "check",
10967
11951
  detail: violation.detail
10968
- })), ...semanticResult.blocking] : [];
11952
+ })), ...semanticResult.findings] : [];
10969
11953
  const nextPipeline = pipelineWith(snapshot, context.revision, {
10970
11954
  stage: nextStage$2,
10971
11955
  rounds: {
10972
11956
  ...snapshot.pipeline.rounds,
10973
- check: snapshot.pipeline.rounds.check + (verdict === "fail" || fullScope ? 1 : 0)
11957
+ check: snapshot.pipeline.rounds.check + (verdict === "fail" ? 1 : 0)
10974
11958
  },
10975
11959
  blocking: blockingItems,
10976
11960
  last_action: action("novel_check", this.now(), sha256(text))
@@ -10999,7 +11983,13 @@ var EngineService = class {
10999
11983
  advisory_codes: advisoryCodes,
11000
11984
  acknowledged,
11001
11985
  metrics: scopedMachine.metrics,
11002
- stage: nextStage$2
11986
+ stage: nextStage$2,
11987
+ semantic: {
11988
+ items: semanticResult.items,
11989
+ findings: semanticResult.findings
11990
+ },
11991
+ rounds: { check: nextPipeline.rounds.check },
11992
+ counted: verdict === "fail"
11003
11993
  },
11004
11994
  warnings: [
11005
11995
  ...machineWarnings({
@@ -11047,7 +12037,9 @@ var EngineService = class {
11047
12037
  chapter: input.chapter,
11048
12038
  assigned_ids: already.assigned_ids ?? {},
11049
12039
  metrics: countWordMetrics(archivedText),
11050
- score: already.score
12040
+ score: already.score,
12041
+ delta_warnings: [],
12042
+ normalized_world: {}
11051
12043
  },
11052
12044
  warnings: [],
11053
12045
  replay: true
@@ -11240,8 +12232,11 @@ var EngineService = class {
11240
12232
  chapter,
11241
12233
  assigned_ids: ledger.assigned,
11242
12234
  metrics,
11243
- score
12235
+ score,
12236
+ delta_warnings: ledger.warnings,
12237
+ normalized_world: ledger.normalized_world
11244
12238
  },
12239
+ warnings: ledger.warnings,
11245
12240
  event: {
11246
12241
  name: "novel/archived",
11247
12242
  payload: {
@@ -11524,6 +12519,15 @@ var EngineService = class {
11524
12519
  code: "JOURNAL_HASH_MISMATCH",
11525
12520
  message: `${health.mismatches.length} file(s) changed after the last commit; first: ${health.mismatches[0].path}`
11526
12521
  });
12522
+ const pending = await inspectPending(store);
12523
+ if (pending.state === "partial") warnings.push({
12524
+ code: ERROR_CODES.PENDING_PARTIAL,
12525
+ message: `pending transaction ${pending.txid ?? "unknown"} is partial: ${pending.detail ?? "mixed hashes"}; do not restore the whole project`
12526
+ });
12527
+ else if (pending.state === "complete" || pending.state === "untouched") warnings.push({
12528
+ code: pending.state === "complete" ? "PENDING_RECOVERABLE" : "PENDING_ABORTABLE",
12529
+ message: `${pending.txid ?? "unknown"}: ${pending.detail ?? pending.state}`
12530
+ });
11527
12531
  const requested = input.limit ?? 50;
11528
12532
  const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
11529
12533
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
@@ -11566,10 +12570,22 @@ var EngineService = class {
11566
12570
  const current = await this.load(input.root);
11567
12571
  this.assertExportIndexReadable(current, input.kind);
11568
12572
  }
12573
+ const artifactSnapshot = await this.load(input.root);
12574
+ const artifactStore = this.storeFor(artifactSnapshot.root);
12575
+ const suffix = input.chapter === void 0 ? "" : `-ch${String(input.chapter).padStart(3, "0")}`;
12576
+ const expectedArtifacts = input.kind === "outline" ? [joinRel(EXPORT_DIR, "outline.md")] : input.kind === "worldbook" ? [joinRel(EXPORT_DIR, "worldbook.md")] : input.kind === "proposal" ? [joinRel(EXPORT_DIR, "proposal.md")] : [
12577
+ joinRel(EXPORT_DIR, `book${suffix}.${input.kind}`),
12578
+ joinRel(EXPORT_DIR, "cover.svg"),
12579
+ joinRel(EXPORT_DIR, "blurb.txt")
12580
+ ];
12581
+ const artifactsPresent = (await Promise.all(expectedArtifacts.map((path) => artifactStore.exists(path)))).every(Boolean);
12582
+ const artifactKey = `${artifactSnapshot.root}|${input.kind}|${input.chapter ?? "all"}`;
12583
+ if (!artifactsPresent) this.exportArtifactEpoch.set(artifactKey, (this.exportArtifactEpoch.get(artifactKey) ?? 0) + 1);
12584
+ const artifactEpoch = this.exportArtifactEpoch.get(artifactKey) ?? 0;
11569
12585
  return this.mutate({
11570
12586
  root: input.root,
11571
12587
  op: "export",
11572
- key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
12588
+ key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${artifactEpoch}:${sha256(stableJson({
11573
12589
  chapter: snapshot.pipeline.chapter,
11574
12590
  text: sha256(snapshot.text ?? ""),
11575
12591
  plan_title: stripTitlePrefix(snapshot.plan?.title ?? ""),
@@ -11791,14 +12807,14 @@ var EngineService = class {
11791
12807
  root: active,
11792
12808
  source: "active"
11793
12809
  };
11794
- else if (registry.projects.length === 1) selection = {
11795
- root: registry.projects[0].root,
11796
- source: "single"
11797
- };
11798
12810
  else if (rows.has(configured) && !registry.projects.some((entry) => entry.root === configured)) selection = {
11799
12811
  root: configured,
11800
12812
  source: "config"
11801
12813
  };
12814
+ else if (registry.projects.length === 1) selection = {
12815
+ root: registry.projects[0].root,
12816
+ source: "single"
12817
+ };
11802
12818
  else selection = {
11803
12819
  root: null,
11804
12820
  source: "none"
@@ -12297,15 +13313,23 @@ var EngineService = class {
12297
13313
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is unreadable; ${operation} would overwrite the unreadable planning document`, `read ${path} with novel_state and repair it with novel_amend before ${operation}`);
12298
13314
  }
12299
13315
  /**
13316
+ * `strict_explicit` is an internal provenance bit, not an author choice.
13317
+ * Exposing it lets a caller forge "the author chose strict:false" without
13318
+ * going through the strict option. Stored documents may still contain it.
13319
+ */
13320
+ assertPublicBookHasNoStrictExplicit(book, operation) {
13321
+ if (typeof book !== "object" || book === null || Array.isArray(book)) return;
13322
+ if (Object.hasOwn(book, "strict_explicit")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot set book.strict_explicit`, "use the public strict boolean; strict_explicit is an internal provenance marker");
13323
+ }
13324
+ /**
12300
13325
  * An import is a full replacement, unlike novel_outline's keyed upsert. It
12301
13326
  * must not silently lose the active card while strict mode still requires it.
12302
13327
  */
12303
- assertActiveCardPreserved(snapshot, strict, chapters, operation) {
12304
- if (!strict) return;
13328
+ assertActiveCardPreserved(snapshot, chapters, operation) {
12305
13329
  const chapter = snapshot.pipeline.chapter;
12306
13330
  if (!snapshot.outline.chapters.some((card) => card.num === chapter)) return;
12307
13331
  if (chapters.some((card) => card.num === chapter)) return;
12308
- throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card while strict mode is on`, "include the active card in the imported outline; import replaces the whole plan and will not turn strict mode off for you");
13332
+ throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card`, "include the active card in the imported outline; import replaces the whole plan and cannot remove an in-flight card");
12309
13333
  }
12310
13334
  /**
12311
13335
  * The outline is a derived progress view and the PG-13 input. Every write
@@ -12384,6 +13408,11 @@ var EngineService = class {
12384
13408
  const target = snapshot.project.targets.total_chapters;
12385
13409
  const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
12386
13410
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
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;
12387
13416
  const journal = await readJournal(this.storeFor(snapshot.root));
12388
13417
  const journalBlocking = await this.journalBlocking(snapshot);
12389
13418
  const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
@@ -12391,7 +13420,6 @@ var EngineService = class {
12391
13420
  const scores = archived.slice(-10).map((chapter) => chapter.score);
12392
13421
  const blocking = [...this.effectiveBlocking(snapshot), ...journalBlocking];
12393
13422
  const healthBlocking = blocking.length;
12394
- const stateErrorCount = this.stateErrors(snapshot).length;
12395
13423
  const book = {
12396
13424
  premise: snapshot.outline.book.premise,
12397
13425
  logline: snapshot.outline.book.logline,
@@ -12435,11 +13463,16 @@ var EngineService = class {
12435
13463
  const store = this.storeFor(snapshot.root);
12436
13464
  const reviewNames = new Set(await store.list(REVIEW_DIR));
12437
13465
  const sampleChapters = /* @__PURE__ */ new Set();
13466
+ const currentReviews = [];
12438
13467
  for (const record of archived) {
12439
13468
  const name = reviewFile(record.num).slice(`${REVIEW_DIR}/`.length);
12440
13469
  if (!reviewNames.has(name)) continue;
12441
13470
  try {
12442
- 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
+ }
12443
13476
  } catch {}
12444
13477
  }
12445
13478
  const samples = sampleChapters.size;
@@ -12470,15 +13503,66 @@ var EngineService = class {
12470
13503
  chapter: row.chapter,
12471
13504
  status: row.status
12472
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
+ });
12473
13557
  const draft = archivedCurrent || snapshot.pipeline.stage === "done" ? null : {
12474
13558
  num: snapshot.pipeline.chapter,
12475
13559
  title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
12476
13560
  wordCount: draftWords,
12477
13561
  score: snapshot.review?.total ?? 0,
12478
13562
  status: blocking.length > 0 ? "blocked" : "draft",
12479
- details: (snapshot.plan?.summary?.events ?? []).join(";").slice(0, 240)
13563
+ details: (snapshot.pipeline.draft?.summary?.events ?? []).join(";").slice(0, 240)
12480
13564
  };
12481
- return {
13565
+ const result = {
12482
13566
  root: snapshot.root,
12483
13567
  title: snapshot.project.title,
12484
13568
  genre: snapshot.project.genre,
@@ -12509,17 +13593,33 @@ var EngineService = class {
12509
13593
  averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
12510
13594
  phases: phaseStrip(snapshot.pipeline.stage, finished),
12511
13595
  book,
13596
+ bookData,
12512
13597
  quality,
12513
13598
  health: {
12514
- ok: healthBlocking === 0 && stateErrorCount === 0,
13599
+ ok: healthBlocking === 0 && stateErrorCount === 0 && pending.state === "none",
12515
13600
  blocking: healthBlocking,
12516
- stateErrors: stateErrorCount
13601
+ stateErrors: stateErrorCount,
13602
+ pending: pending.state !== "none"
12517
13603
  },
12518
13604
  activity,
12519
13605
  ...advisories.length === 0 ? {} : { advisories },
12520
13606
  ...acknowledgedAdvisories === 0 ? {} : { acknowledgedAdvisories },
12521
- ...legacy.length === 0 ? {} : { legacy }
13607
+ ...legacy.length === 0 ? {} : { legacy },
13608
+ ...pending.state === "none" ? {} : { pending: {
13609
+ state: pending.state,
13610
+ txid: pending.txid ?? "",
13611
+ detail: pending.detail ?? ""
13612
+ } }
12522
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;
12523
13623
  }
12524
13624
  /**
12525
13625
  * The archive stage can be left by a direct revision (the 0.2.3 workflow) or
@@ -12814,7 +13914,7 @@ var EngineService = class {
12814
13914
  code: "PG-13",
12815
13915
  scope: "outline",
12816
13916
  detail: `chapter ${snapshot.pipeline.chapter} has no outline card while strict mode is on`,
12817
- 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"
12818
13918
  };
12819
13919
  }
12820
13920
  const plan = snapshot.plan;
@@ -12866,6 +13966,11 @@ var EngineService = class {
12866
13966
  code: "PROJECT_UNREADABLE",
12867
13967
  message: snapshot.projectError
12868
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
+ });
12869
13974
  const chapter = snapshot.pipeline.chapter;
12870
13975
  if (snapshot.worldPlanError !== null) errors.push({
12871
13976
  path: planWorldFile(chapter),
@@ -12901,6 +14006,11 @@ var EngineService = class {
12901
14006
  }
12902
14007
  /** The earliest rebuild point for the current state damage. */
12903
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
+ };
12904
14014
  const chapter = snapshot.pipeline.chapter;
12905
14015
  const errors = this.stateErrors(snapshot);
12906
14016
  if (snapshot.projectError !== null) return {
@@ -13196,6 +14306,13 @@ var EngineService = class {
13196
14306
  rounds: snapshot.pipeline.rounds,
13197
14307
  min_score: this.config.quality.minScore,
13198
14308
  rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
14309
+ documents: sha256(stableJson({
14310
+ project: snapshot.project,
14311
+ outline: snapshot.outline,
14312
+ worldbook: snapshot.worldbook,
14313
+ plan: snapshot.plan,
14314
+ worldPlan: snapshot.worldPlan
14315
+ })),
13199
14316
  context
13200
14317
  }));
13201
14318
  }
@@ -13241,6 +14358,8 @@ var EngineService = class {
13241
14358
  sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
13242
14359
  summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
13243
14360
  summaryProvided: extras.summaryProvided,
14361
+ summaryOnly: extras.summaryOnly,
14362
+ summaryOnlyRevision: extras.summaryOnlyRevision ?? (operation === "runCheck" && snapshot.pipeline.stage === "P6" && snapshot.pipeline.last_action?.op === "novel_revise_summary"),
13244
14363
  review: extras.review ?? snapshot.review,
13245
14364
  check: extras.check ?? snapshot.check,
13246
14365
  semantic: extras.semantic ?? [],
@@ -13367,8 +14486,22 @@ var EngineService = class {
13367
14486
  actualTitle: project.title
13368
14487
  });
13369
14488
  }
13370
- const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
13371
- 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
+ }
13372
14505
  let briefs = pipeline.briefs;
13373
14506
  const briefsRaw = await store.readText(BRIEF_RECEIPTS_FILE);
13374
14507
  if (briefsRaw !== null) {
@@ -13507,10 +14640,12 @@ var EngineService = class {
13507
14640
  if (planRaw === null && planError === null && cycleRank >= STAGE_RANK.P3) planError = `${planChapterFile(chapter)} is missing`;
13508
14641
  if (review === null && reviewError === null && cycleRank >= STAGE_RANK.P5) reviewError = `${reviewFile(chapter)} is missing`;
13509
14642
  if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
13510
- const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
13511
- if (latest === null || latest.revision !== pipeline.revision) {
13512
- if (retries > 0) return this.loadAt(selection, retries - 1, allowLock);
13513
- 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
+ }
13514
14649
  }
13515
14650
  if (!allowLock && await this.lockIsLive(store)) {
13516
14651
  if (retries > 0) return this.loadAt(selection, retries - 1, false);
@@ -13521,6 +14656,7 @@ var EngineService = class {
13521
14656
  selection,
13522
14657
  project,
13523
14658
  projectError,
14659
+ pipelineError,
13524
14660
  pipeline,
13525
14661
  briefs,
13526
14662
  chapters,
@@ -13641,7 +14777,14 @@ var EngineService = class {
13641
14777
  STATE_FILES.handoff
13642
14778
  ].map((file) => joinRel(STATE_DIR, file));
13643
14779
  stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
13644
- const stateCandidates = journalWasPresent ? stateFiles : stateFiles.filter((path) => path !== joinRel(STATE_DIR, STATE_FILES.journal));
14780
+ const pending = await inspectPending(store);
14781
+ if (pending.state === "partial") throw new EngineFaultError(ERROR_CODES.PENDING_PARTIAL, `pending transaction ${pending.txid ?? "unknown"} partially changed this directory`, `inspect ${pending.stagingDir ?? ".staging"}/ before initializing over it`);
14782
+ if (pending.state === "complete" || pending.state === "untouched") {
14783
+ await assertJournalConsistent(store);
14784
+ if (await store.exists("novel.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, "a project already exists at this root after pending recovery", "use a different directory, or call novel_status to inspect the existing project");
14785
+ }
14786
+ const journalPath = joinRel(STATE_DIR, STATE_FILES.journal);
14787
+ const stateCandidates = journalWasPresent && (await readJournal(store)).records.some((record) => record.status === "committed" && record.op !== "lock_reclaimed") ? stateFiles : stateFiles.filter((path) => path !== journalPath);
13645
14788
  if ((await Promise.all(stateCandidates.map((file) => store.exists(file)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0 || await store.exists("outline/outline.yaml") || await store.exists("outline/worldbook.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, "state documents exist but novel.yaml is missing; refusing to overwrite the project", "restore novel.yaml, or initialize a new empty directory");
13646
14789
  await assertJournalConsistent(store);
13647
14790
  const created = await options.run(this.emptySnapshot(root, selection), {
@@ -13668,6 +14811,7 @@ var EngineService = class {
13668
14811
  }
13669
14812
  await assertJournalConsistent(store, options.repairPaths ?? []);
13670
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");
13671
14815
  if (selection.source === "session" && selection.created_at !== void 0 && selection.created_at !== "" && snapshot.projectError !== null) throw identityMismatchError({
13672
14816
  selection,
13673
14817
  actualTitle: snapshot.project.title
@@ -13716,7 +14860,8 @@ var EngineService = class {
13716
14860
  },
13717
14861
  ...error instanceof EngineError ? { code: error.code } : {},
13718
14862
  ...error instanceof EngineError && error.hint !== void 0 ? { hint: error.hint } : {},
13719
- ...error instanceof EngineBlockedError ? { blocking: error.blocking } : {}
14863
+ ...error instanceof EngineBlockedError ? { blocking: error.blocking } : {},
14864
+ ...error instanceof CheckSubmissionError ? { blocking: error.blocking.slice(0, 20) } : {}
13720
14865
  }).catch(() => void 0);
13721
14866
  throw error;
13722
14867
  }
@@ -13783,6 +14928,7 @@ var EngineService = class {
13783
14928
  selection,
13784
14929
  project: { ...ENGINE_PROJECT_INITIAL },
13785
14930
  projectError: null,
14931
+ pipelineError: null,
13786
14932
  pipeline: emptyPipeline({
13787
14933
  revision: 1,
13788
14934
  chapter: 1,
@@ -14156,7 +15302,9 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
14156
15302
  if (kind !== "relationships") return problems;
14157
15303
  }
14158
15304
  if (kind === "characters") {
14159
- const names = new Set((Array.isArray(nextDocument.characters) ? nextDocument.characters : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
15305
+ const rows = Array.isArray(nextDocument.characters) ? nextDocument.characters : [];
15306
+ const names = new Set(rows.map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
15307
+ if (names.size !== rows.length) problems.push("characters must have unique names");
14160
15308
  if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
14161
15309
  for (const name of Object.keys(snapshot.locks.characters)) if (!names.has(name)) problems.push(`lock for removed character "${name}" still exists`);
14162
15310
  }
@@ -14166,6 +15314,21 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
14166
15314
  return problems;
14167
15315
  }
14168
15316
  if (kind === "world") {
15317
+ const allNames = [];
15318
+ for (const section of [
15319
+ "locations",
15320
+ "abilities",
15321
+ "artifacts",
15322
+ "factions",
15323
+ "rules"
15324
+ ]) {
15325
+ const rows = Array.isArray(nextDocument[section]) ? nextDocument[section] : [];
15326
+ for (const raw of rows) {
15327
+ const name = recordOf(raw)?.name;
15328
+ if (typeof name === "string") allNames.push(name);
15329
+ }
15330
+ }
15331
+ if (new Set(allNames).size !== allNames.length) problems.push("world entities must have unique names");
14169
15332
  const locations = new Set((Array.isArray(nextDocument.locations) ? nextDocument.locations : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
14170
15333
  if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
14171
15334
  for (const name of Object.keys(snapshot.locks.locations)) if (!locations.has(name)) problems.push(`lock for removed location "${name}" still exists`);
@@ -14255,6 +15418,19 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
14255
15418
  const row = recordOf(raw);
14256
15419
  if (row === null || !Number.isInteger(row.start_chapter) || !Number.isInteger(row.end_chapter) || row.end_chapter < row.start_chapter) problems.push(`volumes[${index}] must have start_chapter <= end_chapter`);
14257
15420
  }
15421
+ if (snapshot.outlineError === null) try {
15422
+ const nextVolumes = normalizeVolumes(volumes, {
15423
+ totalChapters: snapshot.project.targets.total_chapters,
15424
+ rejectDuplicateTitles: false
15425
+ });
15426
+ assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, nextVolumes, "novel_amend");
15427
+ assertArchivedCardsInVolumes(snapshot.chapters.chapters, [...byNum.values()].map((row) => ({
15428
+ num: row.num,
15429
+ volume: row.volume ?? null
15430
+ })), nextVolumes, "novel_amend");
15431
+ } catch (error) {
15432
+ problems.push(error instanceof Error ? error.message : String(error));
15433
+ }
14258
15434
  for (const record of snapshot.chapters.chapters) {
14259
15435
  const card = byNum.get(record.num);
14260
15436
  if (card === void 0) {
@@ -14516,7 +15692,8 @@ const asJson = (value, field) => {
14516
15692
  const fail = (error) => {
14517
15693
  if (error instanceof EngineError) {
14518
15694
  const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
14519
- throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}`);
15695
+ const details = "items" in error && Array.isArray(error.items) ? `\nitems: ${JSON.stringify(error.items)}` : "";
15696
+ throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}${details}`);
14520
15697
  }
14521
15698
  throw error;
14522
15699
  };
@@ -14905,15 +16082,17 @@ function createNovelTools(engine, options = {}) {
14905
16082
  type: "json",
14906
16083
  description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
14907
16084
  },
14908
- summary: { type: "json" },
16085
+ summary: {
16086
+ type: "json",
16087
+ description: "Full ten-field summary. Omit content+patch with a summary to repair only pipeline.draft.summary; this does not consume a revise round."
16088
+ },
14909
16089
  issues: {
14910
16090
  type: "json",
14911
16091
  description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
14912
16092
  },
14913
16093
  proof: {
14914
16094
  type: "json",
14915
- required: true,
14916
- description: arraySemanticsDescription("replace", "Three quotes from the revised text; PG-09 verifies them.")
16095
+ description: arraySemanticsDescription("replace", "Three quotes from the revised text; required unless this is a summary-only repair. PG-09 verifies them.")
14917
16096
  },
14918
16097
  project_root: root
14919
16098
  },
@@ -15447,7 +16626,7 @@ function createNovelTools(engine, options = {}) {
15447
16626
  }),
15448
16627
  defineTool({
15449
16628
  name: "novel_restore",
15450
- 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.",
15451
16630
  parameters: {
15452
16631
  txid: {
15453
16632
  type: "string",
@@ -15457,7 +16636,7 @@ function createNovelTools(engine, options = {}) {
15457
16636
  paths: {
15458
16637
  type: "array",
15459
16638
  items: { type: "string" },
15460
- 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.")
15461
16640
  },
15462
16641
  project_root: root
15463
16642
  },
@@ -15615,7 +16794,12 @@ const bad = (text) => ({
15615
16794
  kind: "error",
15616
16795
  text
15617
16796
  });
15618
- const failure = (error) => error instanceof EngineError ? bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`) : bad(String(error));
16797
+ const failure = (error) => {
16798
+ if (!(error instanceof EngineError)) return bad(String(error));
16799
+ const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
16800
+ const items = "items" in error && Array.isArray(error.items) ? `\nitems: ${JSON.stringify(error.items)}` : "";
16801
+ return bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}${items}`);
16802
+ };
15619
16803
  const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
15620
16804
  const statusText = async (engine) => {
15621
16805
  const { data } = await engine.getStatus({});
@@ -15947,7 +17131,7 @@ function createSkill() {
15947
17131
  async function panelPayload(engine, root) {
15948
17132
  return {
15949
17133
  ...await engine.panelBundle(root === void 0 ? {} : { root }),
15950
- schemaVersion: 5
17134
+ schemaVersion: 6
15951
17135
  };
15952
17136
  }
15953
17137
  const queryRoot = (url) => {
@@ -15997,7 +17181,7 @@ function panelHandler(engine, guard = denyAll) {
15997
17181
  response.setHeader("content-type", "application/json; charset=utf-8");
15998
17182
  response.setHeader("cache-control", "no-store");
15999
17183
  response.end(JSON.stringify({
16000
- schemaVersion: 5,
17184
+ schemaVersion: 6,
16001
17185
  projects: [],
16002
17186
  selection: {
16003
17187
  root: null,
@@ -16012,7 +17196,7 @@ function panelHandler(engine, guard = denyAll) {
16012
17196
  response.setHeader("content-type", "application/json; charset=utf-8");
16013
17197
  response.setHeader("cache-control", "no-store");
16014
17198
  response.end(JSON.stringify({
16015
- schemaVersion: 5,
17199
+ schemaVersion: 6,
16016
17200
  projects: [],
16017
17201
  selection: {
16018
17202
  root: null,
@@ -16205,6 +17389,119 @@ function compileRulepack(raw) {
16205
17389
  "exclusions",
16206
17390
  "exemptions"
16207
17391
  ], "root");
17392
+ const section = (name, keys) => {
17393
+ const value = root[name];
17394
+ if (value === void 0) return null;
17395
+ const objectValue = object(value, name);
17396
+ knownKeys(objectValue, keys, name);
17397
+ return objectValue;
17398
+ };
17399
+ const nested = (value, key, keys) => {
17400
+ if (value === null || value[key] === void 0) return null;
17401
+ const objectValue = object(value[key], key);
17402
+ knownKeys(objectValue, keys, key);
17403
+ return objectValue;
17404
+ };
17405
+ nested(section("format", [
17406
+ "paragraph_max_words",
17407
+ "forbid_markdown",
17408
+ "forbid_semicolon",
17409
+ "forbid_english_ellipsis",
17410
+ "title",
17411
+ "system_labels",
17412
+ "scene_separators",
17413
+ "markdown_markers"
17414
+ ]), "title", [
17415
+ "pattern",
17416
+ "min_chars",
17417
+ "max_chars"
17418
+ ]);
17419
+ const punctuation = section("punctuation", [
17420
+ "dash",
17421
+ "period_density",
17422
+ "ellipsis_run",
17423
+ "uniform_sentence",
17424
+ "consecutive_le",
17425
+ "dialogue_dominant_ratio"
17426
+ ]);
17427
+ const dash = nested(punctuation, "dash", [
17428
+ "base",
17429
+ "scale_words",
17430
+ "token"
17431
+ ]);
17432
+ if (dash !== null && dash.base !== void 0) {
17433
+ const base = object(dash.base, "punctuation.dash.base");
17434
+ knownKeys(base, SCENE_TYPES, "punctuation.dash.base");
17435
+ }
17436
+ nested(punctuation, "period_density", ["min", "max"]);
17437
+ nested(punctuation, "ellipsis_run", ["max", "token"]);
17438
+ nested(punctuation, "uniform_sentence", ["min_sentences", "ratio"]);
17439
+ nested(punctuation, "consecutive_le", ["max", "char"]);
17440
+ const semantics = section("semantics", [
17441
+ "adjective_density",
17442
+ "pleasure_density",
17443
+ "sensory_density",
17444
+ "water_words"
17445
+ ]);
17446
+ nested(semantics, "adjective_density", ["max", "adjectives"]);
17447
+ nested(semantics, "pleasure_density", [
17448
+ "min",
17449
+ "keywords",
17450
+ "window"
17451
+ ]);
17452
+ nested(semantics, "sensory_density", [
17453
+ "min",
17454
+ "senses",
17455
+ "window"
17456
+ ]);
17457
+ const waterWords = nested(semantics, "water_words", [
17458
+ "window",
17459
+ "threshold",
17460
+ "scene_thresholds",
17461
+ "terms"
17462
+ ]);
17463
+ if (waterWords !== null && waterWords.scene_thresholds !== void 0) knownKeys(object(waterWords.scene_thresholds, "semantics.water_words.scene_thresholds"), SCENE_TYPES, "semantics.water_words.scene_thresholds");
17464
+ const lexicon = section("lexicon", [
17465
+ "zero_tolerance",
17466
+ "soft",
17467
+ "contextual",
17468
+ "meta_narration",
17469
+ "reader_intrusion",
17470
+ "god_view",
17471
+ "conclusion_first"
17472
+ ]);
17473
+ nested(lexicon, "conclusion_first", ["verbs", "causals"]);
17474
+ for (const key of ["reader_intrusion", "god_view"]) {
17475
+ if (lexicon === null || lexicon[key] === void 0) continue;
17476
+ for (const [index, raw] of array(lexicon[key], `lexicon.${key}`).entries()) knownKeys(object(raw, `lexicon.${key}[${index}]`), ["pattern"], `lexicon.${key}[${index}]`);
17477
+ }
17478
+ const platform = object(root.platform, "platform");
17479
+ knownKeys(platform, PLATFORMS, "platform");
17480
+ for (const name of PLATFORMS) {
17481
+ const profile = object(platform[name], `platform.${name}`);
17482
+ knownKeys(profile, ["words_per_chapter", "dialogue_ratio"], `platform.${name}`);
17483
+ }
17484
+ const genres = object(root.genres, "genres");
17485
+ knownKeys(genres, GENRES, "genres");
17486
+ for (const name of GENRES) knownKeys(object(genres[name], `genres.${name}`), ["note"], `genres.${name}`);
17487
+ section("crosschapter", [
17488
+ "duplicate_similarity",
17489
+ "duplicate_advisory",
17490
+ "duplicate_min_chars",
17491
+ "foreshadow_min_dormancy",
17492
+ "foreshadow_dormant_after",
17493
+ "check_time",
17494
+ "lock_similarity"
17495
+ ]);
17496
+ const quality = section("quality", ["dimensions"]);
17497
+ if (quality?.dimensions !== void 0) for (const [index, rawDimension] of array(quality.dimensions, "quality.dimensions").entries()) {
17498
+ const dimension = object(rawDimension, `quality.dimensions[${index}]`);
17499
+ knownKeys(dimension, ["id", "max"], `quality.dimensions[${index}]`);
17500
+ }
17501
+ if (root.exclusions !== void 0) for (const [index, rawEntry] of array(root.exclusions, "exclusions").entries()) {
17502
+ const entry = object(rawEntry, `exclusions[${index}]`);
17503
+ knownKeys(entry, ["pair", "family"], `exclusions[${index}]`);
17504
+ }
16208
17505
  const pack = normalize(raw);
16209
17506
  const rawPlatform = object(root.platform, "platform");
16210
17507
  for (const platform of PLATFORMS) if (rawPlatform[platform] === void 0) invalid(`platform.${platform} is missing`);