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/CHANGELOG.md +51 -0
- package/README.md +25 -18
- package/briefs/P3.md +2 -0
- package/briefs/P5.md +2 -0
- package/briefs/P6.md +3 -2
- package/briefs/P8.md +5 -4
- package/lib/client.js +148 -16
- package/lib/index.d.ts +244 -4
- package/lib/index.js +1608 -311
- package/lib/types/client/dto.d.ts +19 -0
- package/lib/types/client/viewmodel.d.ts +30 -1
- package/lib/types/constants.d.ts +1 -1
- package/lib/types/panel-contract.d.ts +125 -0
- package/lib/types/types.d.ts +29 -1
- package/package.json +1 -1
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 =
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
2230
|
-
if (row.status !== "planned" && row.status !== "archived") invalid$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
2331
|
-
if (!Object.hasOwn(SCHEMAS, kind)) invalid$
|
|
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$
|
|
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$
|
|
2474
|
+
if (field.required === true) invalid$2(kind, `missing required key "${key}"`);
|
|
2341
2475
|
continue;
|
|
2342
2476
|
}
|
|
2343
|
-
if (!matchesType(fieldValue, field)) invalid$
|
|
2344
|
-
if (field.enum !== void 0 && !field.enum.includes(fieldValue)) invalid$
|
|
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
|
-
*
|
|
2761
|
-
*
|
|
2762
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 ??
|
|
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 ??
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
5007
|
-
const
|
|
5008
|
-
const
|
|
5009
|
-
const
|
|
5010
|
-
|
|
5011
|
-
const
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
5284
|
+
reindexed.add(card.num);
|
|
5073
5285
|
}
|
|
5074
5286
|
} else {
|
|
5075
5287
|
byNum.set(card.num, remapped);
|
|
5076
|
-
if (
|
|
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
|
|
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\":{\"港币\"
|
|
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:
|
|
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)
|
|
5265
|
-
|
|
5266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
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 (
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
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
|
-
*
|
|
6956
|
-
*
|
|
6957
|
-
*
|
|
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,
|
|
6960
|
-
const
|
|
6961
|
-
const
|
|
6962
|
-
const
|
|
6963
|
-
const
|
|
6964
|
-
const
|
|
6965
|
-
const
|
|
6966
|
-
|
|
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
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
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
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
7680
|
+
items.push({
|
|
7681
|
+
id: where,
|
|
7682
|
+
state: "invalid",
|
|
6980
7683
|
detail: `${where} has no id`
|
|
6981
7684
|
});
|
|
6982
7685
|
continue;
|
|
6983
7686
|
}
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
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
|
-
|
|
7696
|
+
seenIds.add(id);
|
|
6993
7697
|
if (item.verdict !== "pass" && item.verdict !== "fail") {
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
detail: `${
|
|
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
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
detail: `${
|
|
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
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
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 (!
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
detail: `${
|
|
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
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
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
|
-
|
|
7035
|
-
let
|
|
7036
|
-
while (
|
|
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
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
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 (
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
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
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
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
|
|
7067
|
-
|
|
7068
|
-
|
|
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
|
-
|
|
7073
|
-
|
|
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}
|
|
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
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
7315
|
-
|
|
7316
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
10942
|
-
const verdict = machineBlocking.length === 0 && !semanticResult.
|
|
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: {
|
|
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.
|
|
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"
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
13563
|
+
details: (snapshot.pipeline.draft?.summary?.events ?? []).join(";").slice(0, 240)
|
|
12480
13564
|
};
|
|
12481
|
-
|
|
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
|
|
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
|
|
13371
|
-
|
|
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
|
-
|
|
13511
|
-
|
|
13512
|
-
if (
|
|
13513
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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) =>
|
|
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:
|
|
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:
|
|
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:
|
|
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`);
|