dsh-novel-write-engine 0.2.0 → 0.2.1
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 +19 -0
- package/README.md +61 -43
- package/briefs/P6.md +1 -0
- package/briefs/P8.md +2 -0
- package/lib/client.js +30 -6
- package/lib/index.d.ts +181 -5
- package/lib/index.js +1186 -168
- package/lib/types/client/dto.d.ts +6 -0
- package/lib/types/client/viewmodel.d.ts +6 -1
- package/lib/types/constants.d.ts +12 -2
- package/lib/types/types.d.ts +122 -2
- package/package.json +1 -1
- package/presets/novel-write-engine.patch.yml +1 -1
- package/skills/entry.md +5 -4
package/lib/index.js
CHANGED
|
@@ -23,6 +23,9 @@ const REVIEW_DIR = "reviews";
|
|
|
23
23
|
const CHECK_DIR = "checks";
|
|
24
24
|
const EXPORT_DIR = "exports";
|
|
25
25
|
const BACKUP_DIR = "backups";
|
|
26
|
+
const HISTORY_DIR = "history";
|
|
27
|
+
/** Brief receipts are append-only evidence, separate from content transactions. */
|
|
28
|
+
const BRIEF_RECEIPTS_FILE = "state/briefs.yaml";
|
|
26
29
|
const LOCK_FILE = ".engine.lock";
|
|
27
30
|
const STAGING_DIR = ".staging";
|
|
28
31
|
const REGISTRY_FILE = "projects.yaml";
|
|
@@ -47,6 +50,21 @@ const planWorldFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3,
|
|
|
47
50
|
const planChapterFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3, "0")}.chapter.yaml`;
|
|
48
51
|
const reviewFile = (chapter) => `${REVIEW_DIR}/ch${String(chapter).padStart(3, "0")}.review.yaml`;
|
|
49
52
|
const checkFile = (chapter) => `${CHECK_DIR}/ch${String(chapter).padStart(3, "0")}.check.yaml`;
|
|
53
|
+
/** Immutable-ish prior versions; the primary paths always hold the current one. */
|
|
54
|
+
const historyChapter = (chapter) => `ch${String(chapter).padStart(3, "0")}`;
|
|
55
|
+
const historyRevision = (revision) => `r${String(Math.max(0, revision)).padStart(4, "0")}`;
|
|
56
|
+
const reviewHistoryFile = (chapter, revision) => `${HISTORY_DIR}/reviews/${historyChapter(chapter)}.${historyRevision(revision)}.review.yaml`;
|
|
57
|
+
const checkHistoryFile = (chapter, revision) => `${HISTORY_DIR}/checks/${historyChapter(chapter)}.${historyRevision(revision)}.check.yaml`;
|
|
58
|
+
const chapterHistoryFile = (chapter, revision) => `${HISTORY_DIR}/chapters/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
|
|
59
|
+
const deprecatedHistoryFile = (chapter, revision) => `${HISTORY_DIR}/deprecated/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
|
|
60
|
+
const planHistoryFile = (chapter, revision) => `${HISTORY_DIR}/plans/${historyChapter(chapter)}.${historyRevision(revision)}.chapter.yaml`;
|
|
61
|
+
const HISTORY_KINDS = [
|
|
62
|
+
"reviews",
|
|
63
|
+
"checks",
|
|
64
|
+
"chapters",
|
|
65
|
+
"deprecated",
|
|
66
|
+
"plans"
|
|
67
|
+
];
|
|
50
68
|
/** Limits that are structural, not taste. */
|
|
51
69
|
const LIMITS = {
|
|
52
70
|
/** P4 <-> P5/P6 retries before a forced rollback. */
|
|
@@ -166,7 +184,8 @@ const OPERATIONS = [
|
|
|
166
184
|
"panelSummary",
|
|
167
185
|
"panelChapters",
|
|
168
186
|
"panelGates",
|
|
169
|
-
"stats"
|
|
187
|
+
"stats",
|
|
188
|
+
"restoreBackup"
|
|
170
189
|
];
|
|
171
190
|
/** Error families map one-to-one to the three repair actions a caller can take. */
|
|
172
191
|
const ERROR_CODES = {
|
|
@@ -676,8 +695,10 @@ const STAGE_NEXT = {
|
|
|
676
695
|
const CJK = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g;
|
|
677
696
|
const ENGLISH_WORD = /[A-Za-z]+/g;
|
|
678
697
|
const NUMBER = /(?<![A-Za-z0-9])[0-9]+(?![A-Za-z0-9])/g;
|
|
679
|
-
const CHINESE_PUNCT = /[\u3000-\u303f\uff00-\uffef]/g;
|
|
680
|
-
const ENGLISH_PUNCT = /[^\p{L}\p{N}_\s\u3000-\u303f\uff00-\uffef]/gu;
|
|
698
|
+
const CHINESE_PUNCT = /[\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/g;
|
|
699
|
+
const ENGLISH_PUNCT = /[^\p{L}\p{N}_\s\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/gu;
|
|
700
|
+
/** A line participates in dialogue if it contains any of the four common quote pairs. */
|
|
701
|
+
const DIALOGUE_MARKS = /["“”「」『』]/;
|
|
681
702
|
const matchCount = (text, pattern) => text.match(pattern)?.length ?? 0;
|
|
682
703
|
/**
|
|
683
704
|
* The one word-count rule of the engine.
|
|
@@ -694,15 +715,17 @@ function countWordMetrics(text) {
|
|
|
694
715
|
const numbers = matchCount(text, NUMBER);
|
|
695
716
|
const chinesePunct = matchCount(text, CHINESE_PUNCT);
|
|
696
717
|
const englishPunct = matchCount(text, ENGLISH_PUNCT);
|
|
697
|
-
const dialogueLines = nonEmptyLines.filter((line) =>
|
|
718
|
+
const dialogueLines = nonEmptyLines.filter((line) => DIALOGUE_MARKS.test(line)).length;
|
|
698
719
|
const paragraphs = splitParagraphs(text);
|
|
720
|
+
const punctuation = chinesePunct + englishPunct;
|
|
699
721
|
return {
|
|
700
|
-
total_word_count: cjkChars + englishWords + numbers +
|
|
722
|
+
total_word_count: cjkChars + englishWords + numbers + punctuation,
|
|
701
723
|
cjk_chars: cjkChars,
|
|
702
724
|
english_words: englishWords,
|
|
703
725
|
numbers,
|
|
704
726
|
chinese_punct: chinesePunct,
|
|
705
727
|
english_punct: englishPunct,
|
|
728
|
+
punctuation_chars: punctuation,
|
|
706
729
|
non_ws_chars: matchCount(text, /\S/g),
|
|
707
730
|
total_lines: lines.length,
|
|
708
731
|
non_empty_lines: nonEmptyLines.length,
|
|
@@ -768,7 +791,10 @@ const SEMANTIC_RULES = {
|
|
|
768
791
|
ADJECTIVE_DENSITY: "ADJECTIVE_DENSITY",
|
|
769
792
|
PLEASURE_DENSITY: "PLEASURE_DENSITY",
|
|
770
793
|
SENSORY_DENSITY: "SENSORY_DENSITY",
|
|
771
|
-
WATER_WORDS: "WATER_WORDS"
|
|
794
|
+
WATER_WORDS: "WATER_WORDS",
|
|
795
|
+
DIALOGUE_RATIO: "DIALOGUE_RATIO",
|
|
796
|
+
NUMERIC_CONTRADICTION: "NUMERIC_CONTRADICTION",
|
|
797
|
+
NUMERIC_FACT_MISMATCH: "NUMERIC_FACT_MISMATCH"
|
|
772
798
|
};
|
|
773
799
|
/** Cross-chapter families. */
|
|
774
800
|
const CROSS_RULES = {
|
|
@@ -1040,6 +1066,142 @@ const slidingWindows = (text, size) => {
|
|
|
1040
1066
|
if (windows[windows.length - 1] !== tail) windows.push(tail);
|
|
1041
1067
|
return windows;
|
|
1042
1068
|
};
|
|
1069
|
+
const dialogueRatio = (ctx) => {
|
|
1070
|
+
const target = ctx.targets?.dialogue_ratio;
|
|
1071
|
+
if (target === void 0) return [];
|
|
1072
|
+
const ratio = countWordMetrics(ctx.text).dialogue_ratio;
|
|
1073
|
+
if (ratio >= target[0] && ratio <= target[1]) return [];
|
|
1074
|
+
return [v(SEMANTIC_RULES.DIALOGUE_RATIO, "advisory", 1, `dialogue ratio ${Math.round(ratio * 1e3) / 10}% is outside target ${Math.round(target[0] * 1e3) / 10}%..${Math.round(target[1] * 1e3) / 10}% (quoted lines / non-empty lines)`)];
|
|
1075
|
+
};
|
|
1076
|
+
const CN_DIGITS = {
|
|
1077
|
+
零: 0,
|
|
1078
|
+
一: 1,
|
|
1079
|
+
二: 2,
|
|
1080
|
+
两: 2,
|
|
1081
|
+
三: 3,
|
|
1082
|
+
四: 4,
|
|
1083
|
+
五: 5,
|
|
1084
|
+
六: 6,
|
|
1085
|
+
七: 7,
|
|
1086
|
+
八: 8,
|
|
1087
|
+
九: 9
|
|
1088
|
+
};
|
|
1089
|
+
const CN_UNITS = {
|
|
1090
|
+
十: 10,
|
|
1091
|
+
百: 100,
|
|
1092
|
+
千: 1e3,
|
|
1093
|
+
万: 1e4
|
|
1094
|
+
};
|
|
1095
|
+
/** Parse a small Chinese/Arabic number; null means the token was not a number. */
|
|
1096
|
+
function parseSmallNumber(raw) {
|
|
1097
|
+
if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
|
|
1098
|
+
let total = 0;
|
|
1099
|
+
let section = 0;
|
|
1100
|
+
let digit = null;
|
|
1101
|
+
for (const char of raw) {
|
|
1102
|
+
if (char in CN_DIGITS) {
|
|
1103
|
+
digit = CN_DIGITS[char];
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const unit = CN_UNITS[char];
|
|
1107
|
+
if (unit === void 0) return null;
|
|
1108
|
+
if (unit === 1e4) {
|
|
1109
|
+
section = (section + (digit ?? 0)) * unit;
|
|
1110
|
+
total += section;
|
|
1111
|
+
section = 0;
|
|
1112
|
+
digit = null;
|
|
1113
|
+
} else {
|
|
1114
|
+
section += (digit ?? 1) * unit;
|
|
1115
|
+
digit = null;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return total + section + (digit ?? 0);
|
|
1119
|
+
}
|
|
1120
|
+
const NUMBER_TOKEN = "[零一二两三四五六七八九十百千万]+|\\d+(?:\\.\\d+)?";
|
|
1121
|
+
const DISTANCE_RE = new RegExp(`(${NUMBER_TOKEN})\\s*(海里|公里|千米|米|里)`, "g");
|
|
1122
|
+
const DURATION_RE = new RegExp(`(${NUMBER_TOKEN})\\s*个?\\s*(小时|钟头|分钟)`, "g");
|
|
1123
|
+
const SPEED_RE = new RegExp(`(?:时速|每小时|速度(?:是|为)?)\\s*(${NUMBER_TOKEN})\\s*(海里|公里|千米|米)`, "g");
|
|
1124
|
+
const METRES = {
|
|
1125
|
+
米: 1,
|
|
1126
|
+
里: 500,
|
|
1127
|
+
公里: 1e3,
|
|
1128
|
+
千米: 1e3,
|
|
1129
|
+
海里: 1852
|
|
1130
|
+
};
|
|
1131
|
+
const HOURS = {
|
|
1132
|
+
分钟: 1 / 60,
|
|
1133
|
+
小时: 1,
|
|
1134
|
+
钟头: 1
|
|
1135
|
+
};
|
|
1136
|
+
const collect = (text, pattern, units) => {
|
|
1137
|
+
const result = [];
|
|
1138
|
+
for (const match of text.matchAll(pattern)) {
|
|
1139
|
+
const value = parseSmallNumber(match[1] ?? "");
|
|
1140
|
+
if (value === null || units[match[2] ?? ""] === void 0) continue;
|
|
1141
|
+
result.push({
|
|
1142
|
+
value,
|
|
1143
|
+
unit: match[2],
|
|
1144
|
+
index: match.index ?? 0,
|
|
1145
|
+
raw: match[0]
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
return result;
|
|
1149
|
+
};
|
|
1150
|
+
const numericContradiction = (ctx) => {
|
|
1151
|
+
const explicit = collect(ctx.text, SPEED_RE, METRES).map((quantity) => ({
|
|
1152
|
+
at: quantity.index,
|
|
1153
|
+
kmh: quantity.value * METRES[quantity.unit] / 1e3,
|
|
1154
|
+
raw: quantity.raw
|
|
1155
|
+
}));
|
|
1156
|
+
const distances = collect(ctx.text, DISTANCE_RE, METRES);
|
|
1157
|
+
const durations = collect(ctx.text, DURATION_RE, HOURS);
|
|
1158
|
+
const speeds = [];
|
|
1159
|
+
if (distances.length > 0 && durations.length > 0) for (const distance of distances) {
|
|
1160
|
+
const nearest = durations.map((duration) => ({
|
|
1161
|
+
duration,
|
|
1162
|
+
distance: Math.abs(duration.index - distance.index)
|
|
1163
|
+
})).sort((left, right) => left.distance - right.distance)[0];
|
|
1164
|
+
if (nearest === void 0 || nearest.distance > 60) continue;
|
|
1165
|
+
const metres = distance.value * METRES[distance.unit];
|
|
1166
|
+
const hours = nearest.duration.value * HOURS[nearest.duration.unit];
|
|
1167
|
+
if (hours <= 0) continue;
|
|
1168
|
+
speeds.push({
|
|
1169
|
+
at: Math.min(distance.index, nearest.duration.index),
|
|
1170
|
+
kmh: metres / 1e3 / hours,
|
|
1171
|
+
raw: `${distance.raw} / ${nearest.duration.raw}`
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
const anchors = explicit.length > 0 ? explicit : speeds;
|
|
1175
|
+
if (anchors.length < 2) return [];
|
|
1176
|
+
const positive = anchors.filter((anchor) => anchor.kmh > 0);
|
|
1177
|
+
if (positive.length < 2) return [];
|
|
1178
|
+
const min = Math.min(...positive.map((anchor) => anchor.kmh));
|
|
1179
|
+
const max = Math.max(...positive.map((anchor) => anchor.kmh));
|
|
1180
|
+
if (max / Math.max(1e-4, min) < 1.5) return [];
|
|
1181
|
+
const firstNeedle = positive[0].raw.split(" / ")[0];
|
|
1182
|
+
const paragraphIndex = splitParagraphs(ctx.text).findIndex((paragraph) => paragraph.includes(firstNeedle));
|
|
1183
|
+
return [v(SEMANTIC_RULES.NUMERIC_CONTRADICTION, "advisory", Math.max(1, paragraphIndex + 1), `numeric statements imply different speeds (${min.toFixed(1)}–${max.toFixed(1)} km/h): ${positive.slice(0, 3).map((anchor) => anchor.raw).join("; ")}`)];
|
|
1184
|
+
};
|
|
1185
|
+
const escapePattern = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1186
|
+
const numericFactMismatch = (ctx) => {
|
|
1187
|
+
if (ctx.facts === void 0 || ctx.facts.length === 0) return [];
|
|
1188
|
+
const results = [];
|
|
1189
|
+
const paragraphs = splitParagraphs(ctx.text);
|
|
1190
|
+
for (const fact of ctx.facts) {
|
|
1191
|
+
if (fact.alias.trim() === "" || !Number.isFinite(fact.value)) continue;
|
|
1192
|
+
const tolerance = fact.tolerance ?? 0;
|
|
1193
|
+
const pattern = new RegExp(`${escapePattern(fact.alias)}\\s*(?:是|为|约|有|:|:)?\\s*(${NUMBER_TOKEN})\\s*${escapePattern(fact.unit)}`, "g");
|
|
1194
|
+
for (const match of ctx.text.matchAll(pattern)) {
|
|
1195
|
+
const value = parseSmallNumber(match[1] ?? "");
|
|
1196
|
+
if (value === null) continue;
|
|
1197
|
+
if (Math.abs(value - fact.value) <= tolerance) continue;
|
|
1198
|
+
const paragraph = paragraphs.findIndex((candidate) => candidate.includes(match[0]));
|
|
1199
|
+
results.push(v(SEMANTIC_RULES.NUMERIC_FACT_MISMATCH, "advisory", Math.max(1, paragraph + 1), `"${fact.alias}" declares ${fact.value}${fact.unit}, text shows ${value}${fact.unit}`));
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return results;
|
|
1204
|
+
};
|
|
1043
1205
|
const pleasureDensity = (ctx) => {
|
|
1044
1206
|
const { min, keywords, window } = ctx.rulepack.semantics.pleasure_density;
|
|
1045
1207
|
if (keywords.length === 0) return [];
|
|
@@ -1238,6 +1400,9 @@ const BUILTIN_RULES = {
|
|
|
1238
1400
|
[SEMANTIC_RULES.PLEASURE_DENSITY]: pleasureDensity,
|
|
1239
1401
|
[SEMANTIC_RULES.SENSORY_DENSITY]: sensoryDensity,
|
|
1240
1402
|
[SEMANTIC_RULES.WATER_WORDS]: waterWords,
|
|
1403
|
+
[SEMANTIC_RULES.DIALOGUE_RATIO]: dialogueRatio,
|
|
1404
|
+
[SEMANTIC_RULES.NUMERIC_CONTRADICTION]: numericContradiction,
|
|
1405
|
+
[SEMANTIC_RULES.NUMERIC_FACT_MISMATCH]: numericFactMismatch,
|
|
1241
1406
|
[CROSS_RULES.DUPLICATE_PARAGRAPH]: duplicateParagraph,
|
|
1242
1407
|
[CROSS_RULES.FORESHADOW_DORMANT]: foreshadowDormant,
|
|
1243
1408
|
[CROSS_RULES.FS_REVISITED_TOO_SOON]: revisitedTooSoon,
|
|
@@ -1455,6 +1620,7 @@ const SCHEMAS = {
|
|
|
1455
1620
|
rounds: req("object"),
|
|
1456
1621
|
briefs: req("object"),
|
|
1457
1622
|
blocking: req("array"),
|
|
1623
|
+
draft: f("object"),
|
|
1458
1624
|
review_digest: f("string"),
|
|
1459
1625
|
last_action: f("any")
|
|
1460
1626
|
},
|
|
@@ -1510,6 +1676,7 @@ const SCHEMAS = {
|
|
|
1510
1676
|
content_hash: req("string"),
|
|
1511
1677
|
machine: req("object"),
|
|
1512
1678
|
semantic: req("object"),
|
|
1679
|
+
acknowledged: f("array"),
|
|
1513
1680
|
verdict: req("string", { enum: ["pass", "fail"] })
|
|
1514
1681
|
},
|
|
1515
1682
|
worldPlan: {
|
|
@@ -1546,6 +1713,7 @@ const SCHEMAS = {
|
|
|
1546
1713
|
characters: req("array"),
|
|
1547
1714
|
glossary: req("array"),
|
|
1548
1715
|
backstory: req("array"),
|
|
1716
|
+
facts: f("array"),
|
|
1549
1717
|
themes: req("array")
|
|
1550
1718
|
}
|
|
1551
1719
|
};
|
|
@@ -1625,6 +1793,26 @@ const validateNested = (kind, record) => {
|
|
|
1625
1793
|
if (targets.total_chapters !== null) integerAt(kind, targets.total_chapters, "targets.total_chapters");
|
|
1626
1794
|
return;
|
|
1627
1795
|
}
|
|
1796
|
+
case "pipeline": {
|
|
1797
|
+
const rounds = objectAt(kind, record.rounds, "rounds");
|
|
1798
|
+
for (const key of [
|
|
1799
|
+
"review",
|
|
1800
|
+
"revise",
|
|
1801
|
+
"check"
|
|
1802
|
+
]) integerAt(kind, rounds[key], `rounds.${key}`);
|
|
1803
|
+
if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, "rounds.rollbacks");
|
|
1804
|
+
if (record.draft !== void 0) {
|
|
1805
|
+
const draft = objectAt(kind, record.draft, "draft");
|
|
1806
|
+
integerAt(kind, draft.chapter, "draft.chapter");
|
|
1807
|
+
stringAt(kind, draft.title, "draft.title");
|
|
1808
|
+
stringAt(kind, draft.scene_type, "draft.scene_type");
|
|
1809
|
+
stringAt(kind, draft.narrator, "draft.narrator");
|
|
1810
|
+
stringAt(kind, draft.content_hash, "draft.content_hash");
|
|
1811
|
+
stringAt(kind, draft.updated_at, "draft.updated_at");
|
|
1812
|
+
validateSummary(kind, draft.summary, "draft.summary");
|
|
1813
|
+
}
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1628
1816
|
case "chapters":
|
|
1629
1817
|
validateRecords(kind, record.chapters, "chapters", (row, path) => {
|
|
1630
1818
|
integerAt(kind, row.num, `${path}.num`);
|
|
@@ -1641,6 +1829,17 @@ const validateNested = (kind, record) => {
|
|
|
1641
1829
|
validateSummary(kind, row.summary, `${path}.summary`);
|
|
1642
1830
|
if (row.assigned_ids !== void 0) objectAt(kind, row.assigned_ids, `${path}.assigned_ids`);
|
|
1643
1831
|
optionalString(kind, row.archive_digest, `${path}.archive_digest`);
|
|
1832
|
+
optionalString(kind, row.final_check, `${path}.final_check`);
|
|
1833
|
+
optionalString(kind, row.archived_at, `${path}.archived_at`);
|
|
1834
|
+
if (row.rounds !== void 0) {
|
|
1835
|
+
const rounds = objectAt(kind, row.rounds, `${path}.rounds`);
|
|
1836
|
+
for (const key of [
|
|
1837
|
+
"review",
|
|
1838
|
+
"revise",
|
|
1839
|
+
"check"
|
|
1840
|
+
]) integerAt(kind, rounds[key], `${path}.rounds.${key}`);
|
|
1841
|
+
if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, `${path}.rounds.rollbacks`);
|
|
1842
|
+
}
|
|
1644
1843
|
});
|
|
1645
1844
|
return;
|
|
1646
1845
|
case "characters":
|
|
@@ -1699,6 +1898,7 @@ const validateNested = (kind, record) => {
|
|
|
1699
1898
|
integerAt(kind, row.last_touched, `${path}.last_touched`);
|
|
1700
1899
|
stringAt(kind, row.status, `${path}.status`);
|
|
1701
1900
|
stringAt(kind, row.description, `${path}.description`);
|
|
1901
|
+
optionalString(kind, row.anchor, `${path}.anchor`);
|
|
1702
1902
|
listAt(kind, row.advance_log, `${path}.advance_log`);
|
|
1703
1903
|
});
|
|
1704
1904
|
return;
|
|
@@ -2153,17 +2353,29 @@ async function readJournal(store) {
|
|
|
2153
2353
|
status: raw.status
|
|
2154
2354
|
};
|
|
2155
2355
|
if (typeof raw.reason === "string") record.reason = raw.reason;
|
|
2356
|
+
if (typeof raw.code === "string") record.code = raw.code;
|
|
2357
|
+
if (typeof raw.hint === "string") record.hint = raw.hint;
|
|
2358
|
+
if (typeof raw.stage === "string") record.stage = raw.stage;
|
|
2359
|
+
if (raw.attempt === true) record.attempt = true;
|
|
2360
|
+
if (Array.isArray(raw.blocking)) record.blocking = raw.blocking.map((item) => typeof item === "object" && item !== null ? item : null).filter((item) => item !== null);
|
|
2156
2361
|
if (typeof raw.hashes === "object" && raw.hashes !== null && !Array.isArray(raw.hashes)) record.hashes = Object.fromEntries(Object.entries(raw.hashes).filter((entry) => typeof entry[1] === "string"));
|
|
2157
2362
|
if (typeof raw.before === "object" && raw.before !== null && !Array.isArray(raw.before)) record.before = Object.fromEntries(Object.entries(raw.before).filter((entry) => entry[1] === null || typeof entry[1] === "string"));
|
|
2158
2363
|
records.push(record);
|
|
2159
2364
|
if (record.status !== "pending") rows.push({
|
|
2365
|
+
seq: rows.length + 1,
|
|
2160
2366
|
at: record.at,
|
|
2161
2367
|
op: record.op,
|
|
2162
2368
|
chapter: record.chapter,
|
|
2163
2369
|
revision: record.revision,
|
|
2164
2370
|
txid: record.txid,
|
|
2165
2371
|
status: record.status,
|
|
2166
|
-
files: record.files
|
|
2372
|
+
files: record.files,
|
|
2373
|
+
...record.code === void 0 ? {} : { code: record.code },
|
|
2374
|
+
...record.reason === void 0 ? {} : { reason: record.reason },
|
|
2375
|
+
...record.hint === void 0 ? {} : { hint: record.hint },
|
|
2376
|
+
...record.stage === void 0 ? {} : { stage: record.stage },
|
|
2377
|
+
...record.attempt === true ? { attempt: true } : {},
|
|
2378
|
+
...record.blocking === void 0 ? {} : { blocking: record.blocking }
|
|
2167
2379
|
});
|
|
2168
2380
|
} catch {
|
|
2169
2381
|
malformed += 1;
|
|
@@ -2187,7 +2399,7 @@ function assertJournalHealthy(malformed) {
|
|
|
2187
2399
|
async function reconcileJournal(store) {
|
|
2188
2400
|
const { records, malformed } = await readJournal(store);
|
|
2189
2401
|
const expected = /* @__PURE__ */ new Map();
|
|
2190
|
-
const tracked = (path) => (path === "novel.yaml" || path.startsWith("state/") || path === "outline/outline.yaml" || path === "outline/worldbook.yaml") && path !== "state/archive-digests.json";
|
|
2402
|
+
const tracked = (path) => (path === "novel.yaml" || path.startsWith("state/") || path === "outline/outline.yaml" || path === "outline/worldbook.yaml") && path !== "state/archive-digests.json" && path !== "state/briefs.yaml";
|
|
2191
2403
|
for (const record of records) {
|
|
2192
2404
|
if (record.status !== "committed" || record.hashes === void 0) continue;
|
|
2193
2405
|
for (const [path, hash] of Object.entries(record.hashes)) {
|
|
@@ -2254,6 +2466,7 @@ async function recoverPending(store) {
|
|
|
2254
2466
|
await appendJournal(store, {
|
|
2255
2467
|
...last,
|
|
2256
2468
|
status: "aborted",
|
|
2469
|
+
code: ERROR_CODES.TXN_ABORTED,
|
|
2257
2470
|
reason: "recovered: the process stopped before the transaction renamed a target"
|
|
2258
2471
|
});
|
|
2259
2472
|
await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
|
|
@@ -2480,6 +2693,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
2480
2693
|
// src/store/backup.ts
|
|
2481
2694
|
/** Marks a directory this engine owns; user files under backups/ are never pruned. */
|
|
2482
2695
|
const BACKUP_MARKER = ".engine-backup";
|
|
2696
|
+
const BACKUP_MANIFEST = "manifest.json";
|
|
2483
2697
|
/** Backup transaction directories, newest first by filesystem mtime. */
|
|
2484
2698
|
async function listBackups(store) {
|
|
2485
2699
|
const entries = [];
|
|
@@ -2487,13 +2701,51 @@ async function listBackups(store) {
|
|
|
2487
2701
|
const info = await store.stat(joinRel(BACKUP_DIR, name));
|
|
2488
2702
|
if (info === null || !info.isDirectory) continue;
|
|
2489
2703
|
if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
|
|
2704
|
+
const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
|
|
2705
|
+
let manifest = null;
|
|
2706
|
+
if (raw !== null) try {
|
|
2707
|
+
manifest = JSON.parse(raw);
|
|
2708
|
+
} catch {
|
|
2709
|
+
manifest = null;
|
|
2710
|
+
}
|
|
2711
|
+
const fileNames = Array.isArray(manifest?.files) ? manifest.files.filter((file) => typeof file === "string") : [];
|
|
2490
2712
|
entries.push({
|
|
2491
2713
|
name,
|
|
2492
|
-
mtimeMs: info.mtimeMs
|
|
2714
|
+
mtimeMs: info.mtimeMs,
|
|
2715
|
+
txid: typeof manifest?.txid === "string" ? manifest.txid : name,
|
|
2716
|
+
op: typeof manifest?.op === "string" ? manifest.op : null,
|
|
2717
|
+
chapter: typeof manifest?.chapter === "number" ? manifest.chapter : null,
|
|
2718
|
+
revision: typeof manifest?.revision === "number" ? manifest.revision : null,
|
|
2719
|
+
at: typeof manifest?.at === "string" ? manifest.at : null,
|
|
2720
|
+
reason: typeof manifest?.reason === "string" ? manifest.reason : null,
|
|
2721
|
+
files: fileNames
|
|
2493
2722
|
});
|
|
2494
2723
|
}
|
|
2495
2724
|
return entries.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
2496
2725
|
}
|
|
2726
|
+
/** Read one manifest, refusing a directory this engine did not create. */
|
|
2727
|
+
async function readBackupManifest(store, name) {
|
|
2728
|
+
if (!/^[0-9a-f-]{36}$/i.test(name)) return null;
|
|
2729
|
+
if (!await store.exists(joinRel("backups", name, ".engine-backup"))) return null;
|
|
2730
|
+
const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
|
|
2731
|
+
if (raw === null) return null;
|
|
2732
|
+
try {
|
|
2733
|
+
const value = JSON.parse(raw);
|
|
2734
|
+
if (value.schema_version !== 1 || typeof value.txid !== "string" || typeof value.op !== "string" || typeof value.chapter !== "number" || typeof value.revision !== "number" || typeof value.at !== "string" || !Array.isArray(value.files)) return null;
|
|
2735
|
+
return {
|
|
2736
|
+
schema_version: 1,
|
|
2737
|
+
txid: value.txid,
|
|
2738
|
+
op: value.op,
|
|
2739
|
+
chapter: value.chapter,
|
|
2740
|
+
revision: value.revision,
|
|
2741
|
+
at: value.at,
|
|
2742
|
+
...typeof value.reason === "string" ? { reason: value.reason } : {},
|
|
2743
|
+
files: value.files.filter((file) => typeof file === "string")
|
|
2744
|
+
};
|
|
2745
|
+
} catch {
|
|
2746
|
+
return null;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2497
2749
|
/**
|
|
2498
2750
|
* Keep the newest `keep` backup directories. Only directories directly under
|
|
2499
2751
|
* `backups/` are considered, and only directories this engine created are
|
|
@@ -2585,6 +2837,16 @@ async function runTransaction(store, input) {
|
|
|
2585
2837
|
await store.writeBytes(joinRel(stagingBackup, file.path), existing);
|
|
2586
2838
|
}
|
|
2587
2839
|
await store.writeText(joinRel(stagingBackup, BACKUP_MARKER), "1");
|
|
2840
|
+
await store.writeText(joinRel(stagingBackup, BACKUP_MANIFEST), `${JSON.stringify({
|
|
2841
|
+
schema_version: 1,
|
|
2842
|
+
txid: input.txid,
|
|
2843
|
+
op: input.op,
|
|
2844
|
+
chapter: input.chapter,
|
|
2845
|
+
revision: input.revision,
|
|
2846
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2847
|
+
...input.reason === void 0 ? {} : { reason: input.reason },
|
|
2848
|
+
files: input.files.filter((file) => before.get(file.path) !== null && before.get(file.path) !== void 0).map((file) => file.path)
|
|
2849
|
+
}, null, 2)}\n`);
|
|
2588
2850
|
const targetBackup = store.path(backupDir(input.txid));
|
|
2589
2851
|
await promises.mkdir(dirname(targetBackup), { recursive: true });
|
|
2590
2852
|
await promises.rename(store.path(stagingBackup), targetBackup);
|
|
@@ -2652,6 +2914,7 @@ async function runTransaction(store, input) {
|
|
|
2652
2914
|
revision: input.revision,
|
|
2653
2915
|
files: input.files.map((file) => file.path),
|
|
2654
2916
|
status: "aborted",
|
|
2917
|
+
code: error instanceof EngineError ? error.code : ERROR_CODES.TXN_ABORTED,
|
|
2655
2918
|
reason: error instanceof Error ? error.message : String(error)
|
|
2656
2919
|
}, { repairTrailing: true }).catch(() => void 0);
|
|
2657
2920
|
const suffix = rollbackProblem === null ? "" : `; compensation was incomplete: ${rollbackProblem}; do not retry until the files are restored`;
|
|
@@ -3124,6 +3387,7 @@ function emptyWorldbook(now) {
|
|
|
3124
3387
|
characters: [],
|
|
3125
3388
|
glossary: [],
|
|
3126
3389
|
backstory: [],
|
|
3390
|
+
facts: [],
|
|
3127
3391
|
themes: []
|
|
3128
3392
|
};
|
|
3129
3393
|
}
|
|
@@ -3132,15 +3396,28 @@ function outlineCard(document, chapter) {
|
|
|
3132
3396
|
return document.chapters.find((card) => card.num === chapter) ?? null;
|
|
3133
3397
|
}
|
|
3134
3398
|
/** A compact progress view used by status, the panel and the report. */
|
|
3135
|
-
function outlineSummary(document, archivedCount) {
|
|
3399
|
+
function outlineSummary(document, archivedCount, target = null, archivedNumbers) {
|
|
3136
3400
|
const planned = document.chapters.filter((card) => card.status === "planned").length;
|
|
3137
3401
|
const archived = document.chapters.filter((card) => card.status === "archived").length;
|
|
3402
|
+
const total = document.chapters.length;
|
|
3403
|
+
const missingCards = target === null ? 0 : Math.max(0, target - total);
|
|
3404
|
+
const plannedCoverage = target === null || target === 0 ? null : Math.min(1, total / target);
|
|
3405
|
+
const archivedCoverage = target === null || target === 0 ? null : Math.min(1, archived / target);
|
|
3406
|
+
const archivedSet = archivedNumbers === void 0 ? null : new Set(archivedNumbers);
|
|
3407
|
+
const matchedCards = archivedSet === null ? archived : document.chapters.filter((card) => card.status === "archived" && archivedSet.has(card.num)).length;
|
|
3408
|
+
const archivedDenominator = archivedSet === null ? archivedCount : archivedSet.size;
|
|
3409
|
+
const cardCoverage = archivedDenominator === 0 ? 1 : Math.min(1, matchedCards / archivedDenominator);
|
|
3138
3410
|
return {
|
|
3139
|
-
present: bookPlanPresent(document.book) || document.volumes.length > 0 ||
|
|
3140
|
-
|
|
3411
|
+
present: bookPlanPresent(document.book) || document.volumes.length > 0 || total > 0,
|
|
3412
|
+
target,
|
|
3413
|
+
total,
|
|
3141
3414
|
planned,
|
|
3142
3415
|
archived,
|
|
3143
|
-
|
|
3416
|
+
missingCards,
|
|
3417
|
+
plannedCoverage,
|
|
3418
|
+
archivedCoverage,
|
|
3419
|
+
coverage: plannedCoverage ?? (archivedDenominator === 0 ? 0 : cardCoverage),
|
|
3420
|
+
cardCoverage,
|
|
3144
3421
|
volumes: document.volumes.length
|
|
3145
3422
|
};
|
|
3146
3423
|
}
|
|
@@ -3469,6 +3746,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
3469
3746
|
characters: [...existing.characters],
|
|
3470
3747
|
glossary: [...existing.glossary],
|
|
3471
3748
|
backstory: [...existing.backstory],
|
|
3749
|
+
facts: [...existing.facts ?? []],
|
|
3472
3750
|
themes: [...existing.themes]
|
|
3473
3751
|
};
|
|
3474
3752
|
if (patch.premise !== void 0) result.premise = optionalText(patch.premise, "premise", LIMITS.maxWorldbookTextChars);
|
|
@@ -3534,6 +3812,26 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
3534
3812
|
time: text$1(raw.time, `backstory[${index}].time`, 120),
|
|
3535
3813
|
event: text$1(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
|
|
3536
3814
|
}));
|
|
3815
|
+
if (patch.facts !== void 0) {
|
|
3816
|
+
result.facts = records(patch.facts, "facts").map((raw, index) => {
|
|
3817
|
+
const value = raw.value;
|
|
3818
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].value must be a finite number`);
|
|
3819
|
+
const tolerance = raw.tolerance ?? 0;
|
|
3820
|
+
if (typeof tolerance !== "number" || !Number.isFinite(tolerance) || tolerance < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].tolerance must be a non-negative number`);
|
|
3821
|
+
return {
|
|
3822
|
+
alias: text$1(raw.alias, `facts[${index}].alias`, 120),
|
|
3823
|
+
value,
|
|
3824
|
+
unit: text$1(raw.unit, `facts[${index}].unit`, 40),
|
|
3825
|
+
tolerance,
|
|
3826
|
+
...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
|
|
3827
|
+
};
|
|
3828
|
+
});
|
|
3829
|
+
const seenFacts = /* @__PURE__ */ new Set();
|
|
3830
|
+
for (const fact of result.facts) {
|
|
3831
|
+
if (seenFacts.has(fact.alias)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts contains duplicate "${fact.alias}"`);
|
|
3832
|
+
seenFacts.add(fact.alias);
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3537
3835
|
return result;
|
|
3538
3836
|
}
|
|
3539
3837
|
/** Validate a worldbook loaded from disk. */
|
|
@@ -3552,6 +3850,7 @@ function validateWorldbookDocument(value, now) {
|
|
|
3552
3850
|
"characters",
|
|
3553
3851
|
"glossary",
|
|
3554
3852
|
"backstory",
|
|
3853
|
+
"facts",
|
|
3555
3854
|
"themes"
|
|
3556
3855
|
]);
|
|
3557
3856
|
for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml has unknown key "${key}"`);
|
|
@@ -3672,14 +3971,16 @@ function outlineDrift(document, records) {
|
|
|
3672
3971
|
}
|
|
3673
3972
|
/** Counts for status/panel/report; no IO. */
|
|
3674
3973
|
function worldbookCounts(document) {
|
|
3974
|
+
const facts = document.facts?.length ?? 0;
|
|
3675
3975
|
return {
|
|
3676
|
-
present: document.premise !== "" || document.setting !== "" || document.themes.length > 0 || document.characters.length > 0 || document.locations.length > 0 || document.factions.length > 0 || document.rules.length > 0 || document.glossary.length > 0 || document.backstory.length > 0,
|
|
3976
|
+
present: document.premise !== "" || document.setting !== "" || document.themes.length > 0 || document.characters.length > 0 || document.locations.length > 0 || document.factions.length > 0 || document.rules.length > 0 || document.glossary.length > 0 || document.backstory.length > 0 || facts > 0,
|
|
3677
3977
|
characters: document.characters.length,
|
|
3678
3978
|
locations: document.locations.length,
|
|
3679
3979
|
factions: document.factions.length,
|
|
3680
3980
|
rules: document.rules.length,
|
|
3681
3981
|
glossary: document.glossary.length,
|
|
3682
|
-
backstory: document.backstory.length
|
|
3982
|
+
backstory: document.backstory.length,
|
|
3983
|
+
facts
|
|
3683
3984
|
};
|
|
3684
3985
|
}
|
|
3685
3986
|
const pick = (values, mentioned, key, limit) => {
|
|
@@ -3728,6 +4029,13 @@ function worldbookBrief(document, mentioned, names) {
|
|
|
3728
4029
|
backstory: document.backstory.slice(0, 3).map((entry) => ({
|
|
3729
4030
|
time: entry.time,
|
|
3730
4031
|
event: clip(entry.event, 160)
|
|
4032
|
+
})),
|
|
4033
|
+
facts: (document.facts ?? []).slice(0, 20).map((fact) => ({
|
|
4034
|
+
alias: clip(fact.alias, 120),
|
|
4035
|
+
value: fact.value,
|
|
4036
|
+
unit: clip(fact.unit, 40),
|
|
4037
|
+
tolerance: fact.tolerance ?? 0,
|
|
4038
|
+
...fact.note === void 0 ? {} : { note: clip(fact.note, 160) }
|
|
3731
4039
|
}))
|
|
3732
4040
|
};
|
|
3733
4041
|
}
|
|
@@ -4095,14 +4403,18 @@ function buildBookReport(input) {
|
|
|
4095
4403
|
role: character.role,
|
|
4096
4404
|
appearances: appearances.get(character.name) ?? 0,
|
|
4097
4405
|
last_seen: Math.max(character.updated_at, lastAppearance.get(character.name) ?? 0)
|
|
4098
|
-
})).sort((left, right) => right.appearances - left.appearances || right.last_seen - left.last_seen || left.name
|
|
4406
|
+
})).sort((left, right) => right.appearances - left.appearances || right.last_seen - left.last_seen || compareText(left.name, right.name)).slice(0, 10);
|
|
4099
4407
|
const latestChapter = records.length === 0 ? 0 : records[records.length - 1].num;
|
|
4100
4408
|
const open = input.foreshadows.items.filter((item) => item.status !== "resolved");
|
|
4101
4409
|
const dormant = open.filter((item) => item.status === "dormant");
|
|
4102
4410
|
const overdue = open.filter((item) => latestChapter - item.last_touched >= input.foreshadowDormantAfter);
|
|
4103
4411
|
const relationshipTypes = {};
|
|
4104
4412
|
for (const relationship of input.relationships.items) relationshipTypes[relationship.type] = (relationshipTypes[relationship.type] ?? 0) + 1;
|
|
4105
|
-
const outline = outlineSummary(input.outline, records.length);
|
|
4413
|
+
const outline = outlineSummary(input.outline, records.length, input.targetChapters, records.map((record) => record.num));
|
|
4414
|
+
const averageWords = records.length === 0 ? 0 : Math.round(words / records.length);
|
|
4415
|
+
const remainingChapters = input.targetChapters === null ? null : Math.max(0, input.targetChapters - records.length);
|
|
4416
|
+
const minimumRemaining = remainingChapters === null ? null : remainingChapters * input.project.targets.words_per_chapter[0];
|
|
4417
|
+
const averageRemaining = remainingChapters === null ? null : remainingChapters * (records.length === 0 ? input.project.targets.words_per_chapter[0] : averageWords);
|
|
4106
4418
|
const drift = outlineDrift(input.outline, records);
|
|
4107
4419
|
const volumeCounts = input.outline.volumes.map((volume) => {
|
|
4108
4420
|
const cards = input.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
|
|
@@ -4128,16 +4440,25 @@ function buildBookReport(input) {
|
|
|
4128
4440
|
archived: records.length,
|
|
4129
4441
|
target: input.targetChapters,
|
|
4130
4442
|
words,
|
|
4131
|
-
average_words:
|
|
4132
|
-
percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters *
|
|
4133
|
-
remaining_words:
|
|
4443
|
+
average_words: averageWords,
|
|
4444
|
+
percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters * 1e3) / 10),
|
|
4445
|
+
remaining_words: averageRemaining,
|
|
4446
|
+
remaining_words_min: minimumRemaining,
|
|
4447
|
+
remaining_words_average: averageRemaining,
|
|
4448
|
+
remaining_words_basis: input.targetChapters === null ? "none" : records.length === 0 ? "minimum_target" : "actual_average"
|
|
4134
4449
|
},
|
|
4135
4450
|
outline: {
|
|
4136
4451
|
present: outline.present,
|
|
4137
4452
|
strict: input.outline.book.strict,
|
|
4453
|
+
target: outline.target,
|
|
4454
|
+
total: outline.total,
|
|
4138
4455
|
planned: outline.planned,
|
|
4139
4456
|
archived: outline.archived,
|
|
4457
|
+
missing_cards: outline.missingCards,
|
|
4458
|
+
planned_coverage: outline.plannedCoverage,
|
|
4459
|
+
archived_coverage: outline.archivedCoverage,
|
|
4140
4460
|
coverage: outline.coverage,
|
|
4461
|
+
card_coverage: outline.cardCoverage,
|
|
4141
4462
|
volumes: volumeCounts,
|
|
4142
4463
|
drift
|
|
4143
4464
|
},
|
|
@@ -4153,7 +4474,22 @@ function buildBookReport(input) {
|
|
|
4153
4474
|
id,
|
|
4154
4475
|
average: Math.round(value.total / value.samples * 10) / 10,
|
|
4155
4476
|
samples: value.samples
|
|
4156
|
-
})).sort((left, right) => left.id
|
|
4477
|
+
})).sort((left, right) => compareText(left.id, right.id))
|
|
4478
|
+
},
|
|
4479
|
+
effort: {
|
|
4480
|
+
reviews: records.reduce((total, record) => total + (record.rounds?.review ?? 0), 0),
|
|
4481
|
+
revisions: records.reduce((total, record) => total + (record.rounds?.revise ?? 0), 0),
|
|
4482
|
+
checks: records.reduce((total, record) => total + (record.rounds?.check ?? 0), 0),
|
|
4483
|
+
rollbacks: records.reduce((total, record) => total + (record.rounds?.rollbacks ?? 0), 0),
|
|
4484
|
+
chapters: records.map((record) => ({
|
|
4485
|
+
num: record.num,
|
|
4486
|
+
reviews: record.rounds?.review ?? 0,
|
|
4487
|
+
revisions: record.rounds?.revise ?? 0,
|
|
4488
|
+
checks: record.rounds?.check ?? 0,
|
|
4489
|
+
rollbacks: record.rounds?.rollbacks ?? 0,
|
|
4490
|
+
final_check: record.final_check ?? null,
|
|
4491
|
+
archived_at: record.archived_at ?? null
|
|
4492
|
+
}))
|
|
4157
4493
|
},
|
|
4158
4494
|
pacing: {
|
|
4159
4495
|
scene_types: sceneTypes,
|
|
@@ -4171,7 +4507,7 @@ function buildBookReport(input) {
|
|
|
4171
4507
|
dormant: dormant.length,
|
|
4172
4508
|
overdue: overdue.length,
|
|
4173
4509
|
resolved: input.foreshadows.items.length - open.length,
|
|
4174
|
-
oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || left.id
|
|
4510
|
+
oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || compareText(left.id, right.id)).slice(0, 5).map((item) => ({
|
|
4175
4511
|
id: item.id,
|
|
4176
4512
|
title: item.title,
|
|
4177
4513
|
last_touched: item.last_touched,
|
|
@@ -4455,6 +4791,10 @@ function searchSnapshot(input) {
|
|
|
4455
4791
|
...input.worldbook.backstory.map((entry) => ({
|
|
4456
4792
|
title: entry.time,
|
|
4457
4793
|
source: `${entry.time}\n${entry.event}`
|
|
4794
|
+
})),
|
|
4795
|
+
...(input.worldbook.facts ?? []).map((fact) => ({
|
|
4796
|
+
title: fact.alias,
|
|
4797
|
+
source: `${fact.alias}\n${fact.value} ${fact.unit}${fact.tolerance === void 0 ? "" : ` ±${fact.tolerance}`}\n${fact.note ?? ""}`
|
|
4458
4798
|
}))
|
|
4459
4799
|
];
|
|
4460
4800
|
for (const entry of entries) {
|
|
@@ -4529,6 +4869,58 @@ function searchSnapshot(input) {
|
|
|
4529
4869
|
results
|
|
4530
4870
|
};
|
|
4531
4871
|
}
|
|
4872
|
+
// src/engine/history.ts
|
|
4873
|
+
const PATTERN = /^ch(?<chapter>\d{3,})\.r(?<revision>\d+)\.(?<suffix>review\.yaml|check\.yaml|chapter\.yaml|md)$/;
|
|
4874
|
+
const kindOf = (directory, suffix) => {
|
|
4875
|
+
if (directory === "reviews") return suffix === "review.yaml";
|
|
4876
|
+
if (directory === "checks") return suffix === "check.yaml";
|
|
4877
|
+
if (directory === "plans") return suffix === "chapter.yaml";
|
|
4878
|
+
return suffix === "md";
|
|
4879
|
+
};
|
|
4880
|
+
/**
|
|
4881
|
+
* List prior versions, newest revision first. Files beyond the visible cap are
|
|
4882
|
+
* still on disk: history is evidence, not a cache, and this function never
|
|
4883
|
+
* deletes it.
|
|
4884
|
+
*/
|
|
4885
|
+
async function listHistory(store, chapter) {
|
|
4886
|
+
const entries = [];
|
|
4887
|
+
for (const kind of HISTORY_KINDS) {
|
|
4888
|
+
const names = await store.list(joinRel(HISTORY_DIR, kind));
|
|
4889
|
+
for (const name of names) {
|
|
4890
|
+
const match = PATTERN.exec(name);
|
|
4891
|
+
if (match === null) continue;
|
|
4892
|
+
const suffix = match.groups.suffix;
|
|
4893
|
+
if (!kindOf(kind, suffix)) continue;
|
|
4894
|
+
const num = Number(match.groups.chapter);
|
|
4895
|
+
const revision = Number(match.groups.revision);
|
|
4896
|
+
if (chapter !== void 0 && num !== chapter) continue;
|
|
4897
|
+
const path = joinRel(HISTORY_DIR, kind, name);
|
|
4898
|
+
const text = await store.readText(path);
|
|
4899
|
+
entries.push({
|
|
4900
|
+
kind,
|
|
4901
|
+
chapter: num,
|
|
4902
|
+
revision,
|
|
4903
|
+
path,
|
|
4904
|
+
chars: text?.length ?? 0
|
|
4905
|
+
});
|
|
4906
|
+
}
|
|
4907
|
+
}
|
|
4908
|
+
return entries.sort((left, right) => left.chapter - right.chapter || right.revision - left.revision || compareText(left.kind, right.kind));
|
|
4909
|
+
}
|
|
4910
|
+
/** Read every prior version of one kind for a chapter, oldest first. */
|
|
4911
|
+
async function historyTexts(store, kind, chapter, entries) {
|
|
4912
|
+
const entriesForChapter = (entries ?? await listHistory(store, chapter)).filter((entry) => entry.kind === kind && entry.chapter === chapter);
|
|
4913
|
+
const result = [];
|
|
4914
|
+
for (const entry of entriesForChapter) {
|
|
4915
|
+
const text = await store.readText(entry.path);
|
|
4916
|
+
if (text !== null) result.push({
|
|
4917
|
+
path: entry.path,
|
|
4918
|
+
revision: entry.revision,
|
|
4919
|
+
text
|
|
4920
|
+
});
|
|
4921
|
+
}
|
|
4922
|
+
return result.sort((left, right) => left.revision - right.revision);
|
|
4923
|
+
}
|
|
4532
4924
|
// src/engine/brief.ts
|
|
4533
4925
|
const TITLES = {
|
|
4534
4926
|
P1: "P1 世界观约束",
|
|
@@ -5285,8 +5677,12 @@ const requireReason = (value, where) => {
|
|
|
5285
5677
|
if (value.action !== "no_change") return;
|
|
5286
5678
|
if (typeof value.reason !== "string" || value.reason.trim() === "") fail$1(`${where} is no_change but has no reason`);
|
|
5287
5679
|
};
|
|
5680
|
+
/** A one-character anchor is indistinguishable from a substring accident. */
|
|
5681
|
+
const ANCHOR_MIN_CHARS = 8;
|
|
5682
|
+
const anchorIsStrong = (value) => typeof value === "string" && Array.from(value.trim()).length >= ANCHOR_MIN_CHARS;
|
|
5288
5683
|
const requireForeshadowFields = (value, where) => {
|
|
5289
5684
|
if (value.title !== void 0 && typeof value.title !== "string") fail$1(`${where}.title must be a string`);
|
|
5685
|
+
if (value.anchor !== void 0 && !anchorIsStrong(value.anchor)) fail$1(`${where}.anchor must be a verbatim quote of at least ${ANCHOR_MIN_CHARS} characters`);
|
|
5290
5686
|
if (value.description !== void 0 && typeof value.description !== "string") fail$1(`${where}.description must be a string`);
|
|
5291
5687
|
if (value.note !== void 0 && typeof value.note !== "string") fail$1(`${where}.note must be a string`);
|
|
5292
5688
|
if (value.new_info !== void 0 && typeof value.new_info !== "boolean") fail$1(`${where}.new_info must be a boolean`);
|
|
@@ -5297,6 +5693,32 @@ const appearanceOf = (text, name, aliases = []) => {
|
|
|
5297
5693
|
return Array.from(source).slice(0, 120).join("");
|
|
5298
5694
|
};
|
|
5299
5695
|
const named = (value) => isRecord(value) && typeof value.name === "string";
|
|
5696
|
+
const ROLE_ALIASES = {
|
|
5697
|
+
主角: "protagonist",
|
|
5698
|
+
男主角: "protagonist",
|
|
5699
|
+
女主: "protagonist",
|
|
5700
|
+
女主角: "protagonist",
|
|
5701
|
+
男主: "protagonist",
|
|
5702
|
+
主人公: "protagonist",
|
|
5703
|
+
protagonist: "protagonist",
|
|
5704
|
+
配角: "supporting",
|
|
5705
|
+
重要配角: "supporting",
|
|
5706
|
+
男二: "supporting",
|
|
5707
|
+
女二: "supporting",
|
|
5708
|
+
supporting: "supporting",
|
|
5709
|
+
反派: "antagonist",
|
|
5710
|
+
antagonist: "antagonist",
|
|
5711
|
+
龙套: "extra",
|
|
5712
|
+
路人: "extra",
|
|
5713
|
+
extra: "extra"
|
|
5714
|
+
};
|
|
5715
|
+
/** Keep one canonical role vocabulary instead of letting free text reach reports. */
|
|
5716
|
+
const normalizeRole = (value, fallback) => {
|
|
5717
|
+
const canonicalFallback = ROLE_ALIASES[fallback] ?? "extra";
|
|
5718
|
+
const trimmed = value?.trim() ?? "";
|
|
5719
|
+
if (trimmed === "") return canonicalFallback;
|
|
5720
|
+
return ROLE_ALIASES[trimmed] ?? canonicalFallback;
|
|
5721
|
+
};
|
|
5300
5722
|
/**
|
|
5301
5723
|
* Apply the archive reconciliation sheet. Every ledger entity the summary
|
|
5302
5724
|
* mentions must be declared; unknown keys are refused instead of being
|
|
@@ -5358,9 +5780,10 @@ function applyStateDelta(input) {
|
|
|
5358
5780
|
if (declared.action === "created") {
|
|
5359
5781
|
if (existing !== void 0) fail$1(`character "${name}" already exists; use updated or no_change`);
|
|
5360
5782
|
if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
|
|
5783
|
+
const hinted = normalizeRole(input.roleHints?.[name], name === narratorName || name === narrator ? "protagonist" : "extra");
|
|
5361
5784
|
const created = {
|
|
5362
5785
|
name,
|
|
5363
|
-
role: declared.role
|
|
5786
|
+
role: normalizeRole(declared.role, hinted),
|
|
5364
5787
|
first_appearance: chapter,
|
|
5365
5788
|
profile: declared.profile ?? {},
|
|
5366
5789
|
current_state: declared.current_state ?? {},
|
|
@@ -5373,7 +5796,7 @@ function applyStateDelta(input) {
|
|
|
5373
5796
|
}
|
|
5374
5797
|
if (existing === void 0) fail$1(`character "${name}" does not exist; created must be used first`);
|
|
5375
5798
|
if (declared.action === "updated") {
|
|
5376
|
-
if (declared.role !== void 0) existing.role = declared.role;
|
|
5799
|
+
if (declared.role !== void 0) existing.role = normalizeRole(declared.role, existing.role);
|
|
5377
5800
|
if (declared.profile !== void 0) existing.profile = {
|
|
5378
5801
|
...existing.profile,
|
|
5379
5802
|
...declared.profile
|
|
@@ -5542,11 +5965,15 @@ function applyStateDelta(input) {
|
|
|
5542
5965
|
requireForeshadowFields(declared, `foreshadows.${plant.key}`);
|
|
5543
5966
|
if (declared.action !== "created") fail$1(`foreshadows.${plant.key} is planted; its action must be created`);
|
|
5544
5967
|
if (typeof plant.title !== "string" || plant.title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty summary title`);
|
|
5545
|
-
if (!
|
|
5968
|
+
if (plant.anchor !== void 0 && !anchorIsStrong(plant.anchor)) fail$1(`foreshadow "${plant.key}" anchor must be a verbatim quote of at least ${ANCHOR_MIN_CHARS} characters`);
|
|
5546
5969
|
if (declared.title !== void 0 && typeof declared.title !== "string") fail$1(`foreshadows.${plant.key}.title must be a string`);
|
|
5547
5970
|
const title = declared.title ?? plant.title;
|
|
5548
5971
|
if (title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty title`);
|
|
5549
|
-
|
|
5972
|
+
const anchor = declared.anchor ?? plant.anchor;
|
|
5973
|
+
if (anchor === void 0) {
|
|
5974
|
+
if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" has no anchor and does not appear verbatim in the manuscript`);
|
|
5975
|
+
if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" has no anchor and does not appear verbatim in the manuscript; add a verbatim anchor or keep the title as text`);
|
|
5976
|
+
} else if (!input.text.includes(anchor)) fail$1(`planted foreshadow "${title}" anchor "${anchor}" does not appear verbatim in the manuscript`);
|
|
5550
5977
|
const allocated = allocateIds("FS", foreshadows.next_seq, 1);
|
|
5551
5978
|
const id = allocated.ids[0];
|
|
5552
5979
|
if (byId.has(id)) fail$1(`foreshadows.next_seq is behind the ledger; ${id} already exists`);
|
|
@@ -5559,6 +5986,7 @@ function applyStateDelta(input) {
|
|
|
5559
5986
|
status: "planted",
|
|
5560
5987
|
resolved_at: null,
|
|
5561
5988
|
description: declared.description ?? plant.description,
|
|
5989
|
+
...anchor === void 0 ? {} : { anchor },
|
|
5562
5990
|
advance_log: []
|
|
5563
5991
|
};
|
|
5564
5992
|
foreshadows.items.push(item);
|
|
@@ -5884,7 +6312,7 @@ const wordCount = (input) => {
|
|
|
5884
6312
|
if (min <= 0) return [];
|
|
5885
6313
|
const metrics = countWordMetrics(input.text);
|
|
5886
6314
|
if (metrics.total_word_count >= min) return [];
|
|
5887
|
-
return [block("PG-03", `text has ${metrics.total_word_count} words, minimum is ${min}`, `write at least ${min - metrics.total_word_count} more words, then call novel_word_count to verify`)];
|
|
6315
|
+
return [block("PG-03", `text has ${metrics.total_word_count} counted words (${metrics.cjk_chars} 汉字 + ${metrics.punctuation_chars} 标点 + ${metrics.english_words} 英文词 + ${metrics.numbers} 数字), minimum is ${min}`, `the engine counts punctuation as words; write at least ${min - metrics.total_word_count} more counted words, then call novel_word_count to verify the same metric`)];
|
|
5888
6316
|
};
|
|
5889
6317
|
/** PG-04: format blockers must be zero before length is judged. */
|
|
5890
6318
|
const format = (input, options) => {
|
|
@@ -6332,7 +6760,7 @@ function projectChapter(record) {
|
|
|
6332
6760
|
}
|
|
6333
6761
|
function projectSummary(snapshot) {
|
|
6334
6762
|
return {
|
|
6335
|
-
schemaVersion:
|
|
6763
|
+
schemaVersion: 3,
|
|
6336
6764
|
panelId: PANEL_ID,
|
|
6337
6765
|
title: snapshot.title,
|
|
6338
6766
|
genre: snapshot.genre,
|
|
@@ -6375,14 +6803,14 @@ function projectChapters(snapshot) {
|
|
|
6375
6803
|
details: draft.details
|
|
6376
6804
|
});
|
|
6377
6805
|
return {
|
|
6378
|
-
schemaVersion:
|
|
6806
|
+
schemaVersion: 3,
|
|
6379
6807
|
panelId: PANEL_ID,
|
|
6380
6808
|
rows
|
|
6381
6809
|
};
|
|
6382
6810
|
}
|
|
6383
6811
|
function projectGates(snapshot) {
|
|
6384
6812
|
return {
|
|
6385
|
-
schemaVersion:
|
|
6813
|
+
schemaVersion: 3,
|
|
6386
6814
|
panelId: PANEL_ID,
|
|
6387
6815
|
stage: snapshot.stage,
|
|
6388
6816
|
blocking: snapshot.blocking,
|
|
@@ -6675,6 +7103,13 @@ var EngineService = class {
|
|
|
6675
7103
|
path: PROJECT_FILE,
|
|
6676
7104
|
text: yaml(project)
|
|
6677
7105
|
},
|
|
7106
|
+
{
|
|
7107
|
+
path: BRIEF_RECEIPTS_FILE,
|
|
7108
|
+
text: yaml({
|
|
7109
|
+
schema_version: 1,
|
|
7110
|
+
receipts: []
|
|
7111
|
+
})
|
|
7112
|
+
},
|
|
6678
7113
|
{
|
|
6679
7114
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
6680
7115
|
text: yaml(pipeline)
|
|
@@ -6698,7 +7133,14 @@ var EngineService = class {
|
|
|
6698
7133
|
title: project.title,
|
|
6699
7134
|
chapter: 1,
|
|
6700
7135
|
stage: "idle"
|
|
6701
|
-
}
|
|
7136
|
+
},
|
|
7137
|
+
warnings: [...project.author.trim() === "" ? [{
|
|
7138
|
+
code: "PROJECT_AUTHOR_EMPTY",
|
|
7139
|
+
message: "作者为空;proposal 与导出会缺作者,可用 novel_amend 补 novel.yaml.author"
|
|
7140
|
+
}] : [], ...project.description.trim() === "" ? [{
|
|
7141
|
+
code: "PROJECT_DESCRIPTION_EMPTY",
|
|
7142
|
+
message: "简介为空;proposal 与导出会缺简介,可用 novel_amend 补 novel.yaml.description"
|
|
7143
|
+
}] : []]
|
|
6702
7144
|
};
|
|
6703
7145
|
},
|
|
6704
7146
|
emptyAllowed: true
|
|
@@ -7023,6 +7465,7 @@ var EngineService = class {
|
|
|
7023
7465
|
"characters",
|
|
7024
7466
|
"glossary",
|
|
7025
7467
|
"backstory",
|
|
7468
|
+
"facts",
|
|
7026
7469
|
"themes"
|
|
7027
7470
|
]);
|
|
7028
7471
|
const keys = Object.entries(input).filter(([key, value]) => key !== "root" && value !== void 0).map(([key]) => key);
|
|
@@ -7050,9 +7493,10 @@ var EngineService = class {
|
|
|
7050
7493
|
locations: worldbook.locations.length,
|
|
7051
7494
|
characters: worldbook.characters.length,
|
|
7052
7495
|
glossary: worldbook.glossary.length,
|
|
7053
|
-
backstory: worldbook.backstory.length
|
|
7496
|
+
backstory: worldbook.backstory.length,
|
|
7497
|
+
facts: (worldbook.facts ?? []).length
|
|
7054
7498
|
};
|
|
7055
|
-
const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory;
|
|
7499
|
+
const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts;
|
|
7056
7500
|
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_worldbook", this.now(), shortDigest(stableJson(input))) });
|
|
7057
7501
|
return {
|
|
7058
7502
|
files: [{
|
|
@@ -7092,6 +7536,7 @@ var EngineService = class {
|
|
|
7092
7536
|
return this.mutate({
|
|
7093
7537
|
root: input.root,
|
|
7094
7538
|
op: "reopenChapter",
|
|
7539
|
+
chapter: input.chapter,
|
|
7095
7540
|
reason,
|
|
7096
7541
|
key: () => `reopen:${input.chapter}:${shortDigest(reason)}`,
|
|
7097
7542
|
run: async (snapshot, context) => {
|
|
@@ -7116,7 +7561,7 @@ var EngineService = class {
|
|
|
7116
7561
|
if (changedAfterArchive.length > 0) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `refusing to reopen: ${changedAfterArchive.join(", ")} changed after chapter ${input.chapter} was archived`, "move the amended files aside or restore the archived versions first; reopening would overwrite them silently");
|
|
7117
7562
|
const files = [];
|
|
7118
7563
|
for (const path of archive.files) {
|
|
7119
|
-
if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml") continue;
|
|
7564
|
+
if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml" || path === planChapterFile(input.chapter)) continue;
|
|
7120
7565
|
const hasBeforeEntry = beforeHashes !== void 0 && Object.hasOwn(beforeHashes, path);
|
|
7121
7566
|
const expectedBefore = hasBeforeEntry ? beforeHashes[path] : void 0;
|
|
7122
7567
|
if (hasBeforeEntry && expectedBefore === null) {
|
|
@@ -7180,6 +7625,15 @@ var EngineService = class {
|
|
|
7180
7625
|
briefs: {},
|
|
7181
7626
|
blocking: [],
|
|
7182
7627
|
review_digest: void 0,
|
|
7628
|
+
draft: {
|
|
7629
|
+
chapter: input.chapter,
|
|
7630
|
+
title: stripChapterPrefix(plan.title),
|
|
7631
|
+
scene_type: plan.scene_type,
|
|
7632
|
+
narrator: plan.narrator,
|
|
7633
|
+
summary: record.summary,
|
|
7634
|
+
content_hash: record.content_hash,
|
|
7635
|
+
updated_at: reopenedAt
|
|
7636
|
+
},
|
|
7183
7637
|
last_action: action("novel_reopen", this.now(), shortDigest(reason))
|
|
7184
7638
|
});
|
|
7185
7639
|
delete nextPipeline.review_digest;
|
|
@@ -7213,6 +7667,126 @@ var EngineService = class {
|
|
|
7213
7667
|
});
|
|
7214
7668
|
}
|
|
7215
7669
|
/**
|
|
7670
|
+
* Restore one active chapter from a backup transaction. Archived chapters
|
|
7671
|
+
* are refused: their manuscript hash is bound by the index, so restoring
|
|
7672
|
+
* behind the index would corrupt the project. Reopen first, then restore.
|
|
7673
|
+
*/
|
|
7674
|
+
async restoreBackup(input) {
|
|
7675
|
+
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");
|
|
7676
|
+
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
7677
|
+
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");
|
|
7678
|
+
return this.mutate({
|
|
7679
|
+
root: input.root,
|
|
7680
|
+
op: "restoreBackup",
|
|
7681
|
+
chapter: input.chapter,
|
|
7682
|
+
reason: `restore backup ${input.txid}`,
|
|
7683
|
+
run: async (snapshot, context) => {
|
|
7684
|
+
const manifest = await readBackupManifest(context.store, input.txid);
|
|
7685
|
+
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");
|
|
7686
|
+
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
7687
|
+
if (snapshot.chapters.chapters.some((record) => record.num === chapter)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is archived; restoring it behind the index would break its content hash`, "call novel_reopen for the chapter, then restore the backup");
|
|
7688
|
+
if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not the active chapter ${snapshot.pipeline.chapter}; restore only the active chapter`, "switch the active chapter first, or restore files from the active chapter");
|
|
7689
|
+
if (manifest.chapter !== chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} belongs to chapter ${manifest.chapter}, not chapter ${chapter}`, "choose a backup whose manifest chapter matches the active chapter");
|
|
7690
|
+
const fileFor = (path) => `backups/${input.txid}/${path}`;
|
|
7691
|
+
const allowed = [
|
|
7692
|
+
chapterFile(chapter),
|
|
7693
|
+
planChapterFile(chapter),
|
|
7694
|
+
planWorldFile(chapter),
|
|
7695
|
+
reviewFile(chapter),
|
|
7696
|
+
checkFile(chapter)
|
|
7697
|
+
];
|
|
7698
|
+
const requested = input.paths ?? allowed.filter((path) => manifest.files.includes(path));
|
|
7699
|
+
for (const path of requested) if (!allowed.includes(path)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `restore path ${path} is not a chapter file for chapter ${chapter}`, `allowed: ${allowed.join(", ")}`);
|
|
7700
|
+
if (requested.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains no restorable file for chapter ${chapter}`, "choose a backup whose files include the chapter you are restoring");
|
|
7701
|
+
const files = [];
|
|
7702
|
+
let restoredReview = null;
|
|
7703
|
+
let restoredCheck = null;
|
|
7704
|
+
for (const path of requested) {
|
|
7705
|
+
if (!manifest.files.includes(path)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} manifest does not record ${path}`, "restore only files that the transaction captured as pre-images");
|
|
7706
|
+
const text = await context.store.readText(fileFor(path));
|
|
7707
|
+
if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${path}`, "the file was created by that transaction, so it has no pre-image to restore");
|
|
7708
|
+
const kind = path === planChapterFile(chapter) ? "chapterPlan" : path === planWorldFile(chapter) ? "worldPlan" : path === reviewFile(chapter) ? "review" : path === checkFile(chapter) ? "check" : null;
|
|
7709
|
+
if (kind !== null) try {
|
|
7710
|
+
const parsed = parse(text);
|
|
7711
|
+
const row = recordOf(parsed);
|
|
7712
|
+
if (row === null) throw new Error("document is not a mapping");
|
|
7713
|
+
if (kind === "chapterPlan") {
|
|
7714
|
+
validateStoredChapterPlan(row, path);
|
|
7715
|
+
if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
|
|
7716
|
+
} else if (kind === "worldPlan") {
|
|
7717
|
+
validateWorldPlanShape(narrow("worldPlan", parsed), path);
|
|
7718
|
+
if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
|
|
7719
|
+
} else {
|
|
7720
|
+
const restored = narrow(kind, parsed);
|
|
7721
|
+
if (restored.chapter !== chapter) throw new Error(`document belongs to chapter ${String(restored.chapter)}, not ${chapter}`);
|
|
7722
|
+
if (kind === "review") restoredReview = restored;
|
|
7723
|
+
if (kind === "check") restoredCheck = restored;
|
|
7724
|
+
}
|
|
7725
|
+
} catch (error) {
|
|
7726
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid ${kind} document: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
|
|
7727
|
+
}
|
|
7728
|
+
files.push({
|
|
7729
|
+
path,
|
|
7730
|
+
text
|
|
7731
|
+
});
|
|
7732
|
+
}
|
|
7733
|
+
const clearDraft = requested.includes(chapterFile(chapter)) || requested.includes(planChapterFile(chapter));
|
|
7734
|
+
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
|
|
7735
|
+
if (clearDraft) delete nextPipeline.draft;
|
|
7736
|
+
if (restoredReview !== null) nextPipeline.review_digest = sha256(stableJson(restoredReview));
|
|
7737
|
+
if (restoredReview !== null || restoredCheck !== null) {
|
|
7738
|
+
const effectiveReview = restoredReview ?? snapshot.review;
|
|
7739
|
+
const effectiveCheck = restoredCheck ?? snapshot.check;
|
|
7740
|
+
const reviewBlocking = effectiveReview === null ? [] : [...effectiveReview.issues.filter((issue) => issue.severity === "blocker" && issue.status !== "fixed").map((issue) => ({
|
|
7741
|
+
code: `ISSUE-${issue.id}`,
|
|
7742
|
+
scope: issue.dimension,
|
|
7743
|
+
detail: `${issue.problem} → ${issue.fix}`,
|
|
7744
|
+
hint: issue.location
|
|
7745
|
+
})), ...effectiveReview.total < this.config.quality.minScore ? [{
|
|
7746
|
+
code: "SCORE-BELOW-MIN",
|
|
7747
|
+
scope: "quality",
|
|
7748
|
+
detail: `score ${effectiveReview.total} is below the minimum ${this.config.quality.minScore}`,
|
|
7749
|
+
hint: "revise the chapter and submit a new P4 review"
|
|
7750
|
+
}] : []];
|
|
7751
|
+
const checkMatches = effectiveCheck !== null && effectiveCheck.content_hash === sha256(snapshot.text ?? "");
|
|
7752
|
+
const checkBlocking = effectiveCheck === null || !checkMatches || effectiveCheck.verdict === "pass" ? [] : [...effectiveCheck.machine.violations.map((violation) => ({
|
|
7753
|
+
code: violation.code,
|
|
7754
|
+
scope: "check",
|
|
7755
|
+
detail: violation.detail
|
|
7756
|
+
})), ...evaluateSemantic(snapshot.text ?? "", effectiveCheck.semantic.items).blocking];
|
|
7757
|
+
nextPipeline.blocking = [...reviewBlocking, ...checkBlocking];
|
|
7758
|
+
}
|
|
7759
|
+
files.push({
|
|
7760
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
7761
|
+
text: yaml(nextPipeline)
|
|
7762
|
+
});
|
|
7763
|
+
return {
|
|
7764
|
+
files,
|
|
7765
|
+
backup: true,
|
|
7766
|
+
data: {
|
|
7767
|
+
txid: input.txid,
|
|
7768
|
+
chapter,
|
|
7769
|
+
restored: requested
|
|
7770
|
+
},
|
|
7771
|
+
warnings: clearDraft ? [{
|
|
7772
|
+
code: "RESTORE_DRAFT_CLEARED",
|
|
7773
|
+
message: "the restored manuscript/plan invalidated the live draft summary; call novel_rollback to P3, then submit novel_write_draft again"
|
|
7774
|
+
}] : [],
|
|
7775
|
+
event: {
|
|
7776
|
+
name: "novel/restored",
|
|
7777
|
+
payload: {
|
|
7778
|
+
schemaVersion: 1,
|
|
7779
|
+
projectRoot: snapshot.root,
|
|
7780
|
+
txid: input.txid,
|
|
7781
|
+
chapter,
|
|
7782
|
+
paths: requested
|
|
7783
|
+
}
|
|
7784
|
+
}
|
|
7785
|
+
};
|
|
7786
|
+
}
|
|
7787
|
+
});
|
|
7788
|
+
}
|
|
7789
|
+
/**
|
|
7216
7790
|
* Read-only paths must surface a committed-journal mismatch. The write path
|
|
7217
7791
|
* already fails closed; without this, status recommends the next write and
|
|
7218
7792
|
* the model only learns about the mismatch when that write aborts.
|
|
@@ -7256,6 +7830,21 @@ var EngineService = class {
|
|
|
7256
7830
|
}];
|
|
7257
7831
|
}
|
|
7258
7832
|
}
|
|
7833
|
+
/**
|
|
7834
|
+
* Count every committed attempt for one chapter, not just the loop since the
|
|
7835
|
+
* last rollback. `pipeline.rounds` is deliberately reset by rollback to keep
|
|
7836
|
+
* the retry budget fair; it is not the chapter's lifetime effort.
|
|
7837
|
+
*/
|
|
7838
|
+
async lifetimeRounds(snapshot, chapter) {
|
|
7839
|
+
const { rows } = await readJournal(this.storeFor(snapshot.root));
|
|
7840
|
+
const committed = (op) => rows.filter((row) => row.status === "committed" && row.chapter === chapter && row.op === op).length;
|
|
7841
|
+
return {
|
|
7842
|
+
review: Math.max(snapshot.pipeline.rounds.review, committed("submitReview")),
|
|
7843
|
+
revise: Math.max(snapshot.pipeline.rounds.revise, committed("submitRevision")),
|
|
7844
|
+
check: Math.max(snapshot.pipeline.rounds.check, committed("runCheck")),
|
|
7845
|
+
rollbacks: committed("rollback")
|
|
7846
|
+
};
|
|
7847
|
+
}
|
|
7259
7848
|
async getStatus(input = {}) {
|
|
7260
7849
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
7261
7850
|
const snapshot = await this.load(input.root);
|
|
@@ -7316,6 +7905,7 @@ var EngineService = class {
|
|
|
7316
7905
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
7317
7906
|
const snapshot = await this.load(input.root);
|
|
7318
7907
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
7908
|
+
if (input.revision !== void 0 && input.kind !== "history") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision is only meaningful for kind:\"history\"", "request history with chapter+revision, or omit revision for current documents");
|
|
7319
7909
|
const store = this.storeFor(snapshot.root);
|
|
7320
7910
|
const ledgerPath = LEDGER_PATHS[input.kind];
|
|
7321
7911
|
if (ledgerPath !== void 0) {
|
|
@@ -7333,7 +7923,10 @@ var EngineService = class {
|
|
|
7333
7923
|
}
|
|
7334
7924
|
switch (input.kind) {
|
|
7335
7925
|
case "pipeline": return {
|
|
7336
|
-
data:
|
|
7926
|
+
data: {
|
|
7927
|
+
...snapshot.pipeline,
|
|
7928
|
+
briefs: snapshot.briefs
|
|
7929
|
+
},
|
|
7337
7930
|
revision: snapshot.pipeline.revision,
|
|
7338
7931
|
warnings: []
|
|
7339
7932
|
};
|
|
@@ -7428,6 +8021,66 @@ var EngineService = class {
|
|
|
7428
8021
|
revision: snapshot.pipeline.revision,
|
|
7429
8022
|
warnings: []
|
|
7430
8023
|
};
|
|
8024
|
+
case "briefs": {
|
|
8025
|
+
const receipts = await this.readBriefReceipts(store);
|
|
8026
|
+
if (receipts.receipts.length > 0) return {
|
|
8027
|
+
data: receipts,
|
|
8028
|
+
revision: snapshot.pipeline.revision,
|
|
8029
|
+
warnings: []
|
|
8030
|
+
};
|
|
8031
|
+
return {
|
|
8032
|
+
data: {
|
|
8033
|
+
schema_version: 1,
|
|
8034
|
+
receipts: Object.entries(snapshot.briefs).map(([stage, receipt]) => ({
|
|
8035
|
+
chapter: snapshot.pipeline.chapter,
|
|
8036
|
+
stage,
|
|
8037
|
+
digest: receipt.digest,
|
|
8038
|
+
issued_at: receipt.issued_at
|
|
8039
|
+
}))
|
|
8040
|
+
},
|
|
8041
|
+
revision: snapshot.pipeline.revision,
|
|
8042
|
+
warnings: []
|
|
8043
|
+
};
|
|
8044
|
+
}
|
|
8045
|
+
case "history": {
|
|
8046
|
+
const entries = await listHistory(store, input.chapter);
|
|
8047
|
+
if (input.revision !== void 0) {
|
|
8048
|
+
if (!Number.isInteger(input.revision) || input.revision < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "history revision must be a positive integer");
|
|
8049
|
+
const matching = entries.filter((entry) => entry.chapter === chapter && entry.revision === input.revision);
|
|
8050
|
+
const contents = [];
|
|
8051
|
+
for (const entry of matching) contents.push({
|
|
8052
|
+
...entry,
|
|
8053
|
+
text: await store.readText(entry.path)
|
|
8054
|
+
});
|
|
8055
|
+
return {
|
|
8056
|
+
data: {
|
|
8057
|
+
schema_version: 1,
|
|
8058
|
+
chapter,
|
|
8059
|
+
revision: input.revision,
|
|
8060
|
+
files: contents
|
|
8061
|
+
},
|
|
8062
|
+
revision: snapshot.pipeline.revision,
|
|
8063
|
+
warnings: []
|
|
8064
|
+
};
|
|
8065
|
+
}
|
|
8066
|
+
return {
|
|
8067
|
+
data: {
|
|
8068
|
+
schema_version: 1,
|
|
8069
|
+
entries,
|
|
8070
|
+
total: entries.length
|
|
8071
|
+
},
|
|
8072
|
+
revision: snapshot.pipeline.revision,
|
|
8073
|
+
warnings: []
|
|
8074
|
+
};
|
|
8075
|
+
}
|
|
8076
|
+
case "backups": return {
|
|
8077
|
+
data: {
|
|
8078
|
+
schema_version: 1,
|
|
8079
|
+
backups: await listBackups(store)
|
|
8080
|
+
},
|
|
8081
|
+
revision: snapshot.pipeline.revision,
|
|
8082
|
+
warnings: []
|
|
8083
|
+
};
|
|
7431
8084
|
case "review":
|
|
7432
8085
|
if (chapter === snapshot.pipeline.chapter && snapshot.reviewError !== null) return {
|
|
7433
8086
|
data: {
|
|
@@ -7774,13 +8427,14 @@ var EngineService = class {
|
|
|
7774
8427
|
const archivedCards = new Set(snapshot.outline.chapters.filter((card) => card.status === "archived").map((card) => card.num));
|
|
7775
8428
|
const missingCards = archivedRecords.filter((record) => !archivedCards.has(record.num));
|
|
7776
8429
|
const extraCards = [...archivedCards].filter((num) => !archivedRecords.some((record) => record.num === num));
|
|
7777
|
-
const
|
|
8430
|
+
const outlineProgress = outlineSummary(snapshot.outline, archivedRecords.length, snapshot.project.targets.total_chapters, archivedRecords.map((record) => record.num));
|
|
8431
|
+
const percentText = (value) => value === null ? "—" : `${Math.round(value * 1e3) / 10}%`;
|
|
7778
8432
|
add({
|
|
7779
8433
|
id: "outline-coverage",
|
|
7780
|
-
severity: missingCards.length === 0 && extraCards.length === 0 ? "ok" : "warning",
|
|
8434
|
+
severity: missingCards.length === 0 && extraCards.length === 0 && (outlineProgress.target === null || outlineProgress.missingCards === 0) ? "ok" : "warning",
|
|
7781
8435
|
title: "大纲覆盖率",
|
|
7782
|
-
detail: archivedRecords.length
|
|
7783
|
-
...missingCards.length === 0 && extraCards.length === 0 ? {} : { hint:
|
|
8436
|
+
detail: `规划覆盖 ${percentText(outlineProgress.plannedCoverage)}(${outlineProgress.total}/${outlineProgress.target ?? "不限"} 张卡)· 归档覆盖 ${percentText(outlineProgress.archivedCoverage)}(${archivedRecords.length}/${outlineProgress.target ?? "不限"} 章)· ${outlineProgress.missingCards} 章缺卡 · ${missingCards.length} 章有正文无卡 · ${extraCards.length} 张卡无正文`,
|
|
8437
|
+
...missingCards.length === 0 && extraCards.length === 0 && (outlineProgress.target === null || outlineProgress.missingCards === 0) ? {} : { hint: `缺卡章节数 ${outlineProgress.missingCards}; archived chapters without a card: ${missingCards.map((record) => record.num).join(", ") || "none"}; cards without a manuscript: ${extraCards.join(", ") || "none"}` }
|
|
7784
8438
|
});
|
|
7785
8439
|
const stageMissing = [];
|
|
7786
8440
|
switch (snapshot.pipeline.stage) {
|
|
@@ -7815,11 +8469,20 @@ var EngineService = class {
|
|
|
7815
8469
|
detail: stageMissing.length === 0 ? `${snapshot.pipeline.stage} 阶段所需产物齐全` : `缺少:${stageMissing.join("、")}`,
|
|
7816
8470
|
...stageMissing.length === 0 ? {} : { hint: "use novel_brief/novel_status to rebuild the missing stage product" }
|
|
7817
8471
|
});
|
|
8472
|
+
const restoreCandidates = /* @__PURE__ */ new Set([
|
|
8473
|
+
chapterFile(snapshot.pipeline.chapter),
|
|
8474
|
+
planChapterFile(snapshot.pipeline.chapter),
|
|
8475
|
+
planWorldFile(snapshot.pipeline.chapter),
|
|
8476
|
+
reviewFile(snapshot.pipeline.chapter),
|
|
8477
|
+
checkFile(snapshot.pipeline.chapter)
|
|
8478
|
+
]);
|
|
8479
|
+
const activeRestorable = backups.find((entry) => entry.op !== null && entry.chapter === snapshot.pipeline.chapter && entry.files.some((path) => restoreCandidates.has(path)));
|
|
7818
8480
|
add({
|
|
7819
8481
|
id: "backups",
|
|
7820
8482
|
severity: "info",
|
|
7821
8483
|
title: "备份",
|
|
7822
|
-
detail: `${backups.length}
|
|
8484
|
+
detail: backups.length === 0 ? "0 个备份快照" : `${backups.length} 个备份快照;最新:${backups[0].op ?? "旧版备份"}${backups[0].chapter === null ? "" : ` · 第 ${backups[0].chapter} 章`} · ${backups[0].files.length} 个文件`,
|
|
8485
|
+
...backups.length === 0 ? {} : activeRestorable !== void 0 ? { hint: `用 novel_state {kind:"backups"} 列出;用 novel_restore {txid:"${activeRestorable.name}"} 恢复活动章` } : backups[0].op === null || backups[0].files.length === 0 ? { hint: "最新备份没有 manifest 或没有可恢复的 pre-image,只能列出;它不是 novel_restore 的候选" } : { hint: "最新备份不属于活动章或没有本章可恢复的 pre-image;用 novel_state {kind:\"backups\"} 列出,归档章先 novel_reopen" }
|
|
7823
8486
|
});
|
|
7824
8487
|
const locked = await store.exists(LOCK_FILE);
|
|
7825
8488
|
add({
|
|
@@ -7875,13 +8538,25 @@ var EngineService = class {
|
|
|
7875
8538
|
const store = this.storeFor(snapshot.root);
|
|
7876
8539
|
const reviews = [];
|
|
7877
8540
|
const reviewIssues = [];
|
|
8541
|
+
const historicalEntries = await listHistory(store);
|
|
7878
8542
|
for (const record of snapshot.chapters.chapters) try {
|
|
7879
8543
|
const review = await store.readDoc(reviewFile(record.num), "review");
|
|
7880
8544
|
if (review === null) reviewIssues.push(`chapter ${record.num}: review is missing`);
|
|
7881
8545
|
else reviews.push(review);
|
|
8546
|
+
for (const historical of await historyTexts(store, "reviews", record.num, historicalEntries)) try {
|
|
8547
|
+
const parsed = narrow("review", parse(historical.text));
|
|
8548
|
+
reviews.push(parsed);
|
|
8549
|
+
} catch (error) {
|
|
8550
|
+
reviewIssues.push(`chapter ${record.num} ${historical.path}: ${errorMessage(error)}`);
|
|
8551
|
+
}
|
|
7882
8552
|
} catch (error) {
|
|
7883
8553
|
reviewIssues.push(`chapter ${record.num}: ${errorMessage(error)}`);
|
|
7884
8554
|
}
|
|
8555
|
+
const uniqueReviews = /* @__PURE__ */ new Map();
|
|
8556
|
+
for (const review of reviews) {
|
|
8557
|
+
const key = stableJson(review);
|
|
8558
|
+
if (!uniqueReviews.has(key)) uniqueReviews.set(key, review);
|
|
8559
|
+
}
|
|
7885
8560
|
const journal = await readJournal(store);
|
|
7886
8561
|
const report = buildBookReport({
|
|
7887
8562
|
project: snapshot.project,
|
|
@@ -7896,7 +8571,7 @@ var EngineService = class {
|
|
|
7896
8571
|
timeline: snapshot.timeline,
|
|
7897
8572
|
foreshadows: snapshot.foreshadows,
|
|
7898
8573
|
relationships: snapshot.relationships,
|
|
7899
|
-
reviews,
|
|
8574
|
+
reviews: [...uniqueReviews.values()],
|
|
7900
8575
|
activity: journal.rows.slice(-8),
|
|
7901
8576
|
targetChapters: snapshot.project.targets.total_chapters,
|
|
7902
8577
|
minScore: this.config.quality.minScore,
|
|
@@ -8007,6 +8682,8 @@ var EngineService = class {
|
|
|
8007
8682
|
return this.mutate({
|
|
8008
8683
|
root: input.root,
|
|
8009
8684
|
op: "getBrief",
|
|
8685
|
+
chapter: input.chapter,
|
|
8686
|
+
advanceRevision: false,
|
|
8010
8687
|
run: async (snapshot, context) => {
|
|
8011
8688
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
8012
8689
|
if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `brief ${input.stage} belongs to active chapter ${snapshot.pipeline.chapter}, not ${chapter}`, `omit chapter or pass ${snapshot.pipeline.chapter}`);
|
|
@@ -8019,6 +8696,20 @@ var EngineService = class {
|
|
|
8019
8696
|
chapter,
|
|
8020
8697
|
digest,
|
|
8021
8698
|
min_words: snapshot.project.targets.words_per_chapter[0],
|
|
8699
|
+
max_words: snapshot.project.targets.words_per_chapter[1],
|
|
8700
|
+
word_count_policy: {
|
|
8701
|
+
metric: "total_word_count",
|
|
8702
|
+
includes_punctuation: true,
|
|
8703
|
+
formula: "cjk_chars + english_words + numbers + punctuation_chars",
|
|
8704
|
+
note: "引擎与商业平台一致把标点计入字数;brief/status/check 的 min_words 都是这个口径"
|
|
8705
|
+
},
|
|
8706
|
+
dialogue_ratio_target: snapshot.project.targets.dialogue_ratio,
|
|
8707
|
+
dialogue_ratio_metric: "含引号行数 / 非空行数",
|
|
8708
|
+
delta_rules: {
|
|
8709
|
+
title_is_a_label: true,
|
|
8710
|
+
anchor_required_for_verification: true,
|
|
8711
|
+
rule: "伏笔 title 可以写成可读短语;要逐字校验请在 anchor 里放正文原句(至少 8 个字符)。缺 anchor 时 title 必须逐字出现在正文。"
|
|
8712
|
+
},
|
|
8022
8713
|
min_score: this.config.quality.minScore,
|
|
8023
8714
|
scene_types: [
|
|
8024
8715
|
"战斗/动作章",
|
|
@@ -8051,26 +8742,28 @@ var EngineService = class {
|
|
|
8051
8742
|
fields,
|
|
8052
8743
|
context: contextPack
|
|
8053
8744
|
};
|
|
8054
|
-
const existing = snapshot.
|
|
8745
|
+
const existing = snapshot.briefs[input.stage];
|
|
8055
8746
|
if (existing !== void 0 && existing.digest === digest) return {
|
|
8056
8747
|
files: [],
|
|
8057
8748
|
data,
|
|
8058
8749
|
replay: true
|
|
8059
8750
|
};
|
|
8060
|
-
const
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
},
|
|
8068
|
-
last_action: action("novel_brief", this.now(), digest)
|
|
8751
|
+
const issuedAt = this.now().toISOString();
|
|
8752
|
+
const receiptDocument = await this.readBriefReceipts(context.store);
|
|
8753
|
+
receiptDocument.receipts.push({
|
|
8754
|
+
chapter,
|
|
8755
|
+
stage: input.stage,
|
|
8756
|
+
digest,
|
|
8757
|
+
issued_at: issuedAt
|
|
8069
8758
|
});
|
|
8759
|
+
const receipts = receiptDocument.receipts;
|
|
8070
8760
|
return {
|
|
8071
8761
|
files: [{
|
|
8072
|
-
path:
|
|
8073
|
-
text: yaml(
|
|
8762
|
+
path: BRIEF_RECEIPTS_FILE,
|
|
8763
|
+
text: yaml({
|
|
8764
|
+
schema_version: 1,
|
|
8765
|
+
receipts
|
|
8766
|
+
})
|
|
8074
8767
|
}],
|
|
8075
8768
|
backup: false,
|
|
8076
8769
|
data
|
|
@@ -8139,6 +8832,7 @@ var EngineService = class {
|
|
|
8139
8832
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8140
8833
|
text: yaml(nextPipeline)
|
|
8141
8834
|
}],
|
|
8835
|
+
backup: snapshot.worldPlan !== null,
|
|
8142
8836
|
data: {
|
|
8143
8837
|
chapter,
|
|
8144
8838
|
stage: "P2",
|
|
@@ -8193,13 +8887,21 @@ var EngineService = class {
|
|
|
8193
8887
|
last_action: action("novel_plan_chapter", this.now(), shortDigest(stableJson(plan)))
|
|
8194
8888
|
});
|
|
8195
8889
|
return {
|
|
8196
|
-
files: [
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8890
|
+
files: [
|
|
8891
|
+
...snapshot.plan === null ? [] : [{
|
|
8892
|
+
path: planHistoryFile(chapter, snapshot.pipeline.revision),
|
|
8893
|
+
text: yaml(snapshot.plan)
|
|
8894
|
+
}],
|
|
8895
|
+
{
|
|
8896
|
+
path,
|
|
8897
|
+
text: yaml(plan)
|
|
8898
|
+
},
|
|
8899
|
+
{
|
|
8900
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8901
|
+
text: yaml(nextPipeline)
|
|
8902
|
+
}
|
|
8903
|
+
],
|
|
8904
|
+
backup: snapshot.plan !== null,
|
|
8203
8905
|
data: {
|
|
8204
8906
|
chapter,
|
|
8205
8907
|
stage: "P3",
|
|
@@ -8217,6 +8919,7 @@ var EngineService = class {
|
|
|
8217
8919
|
return this.mutate({
|
|
8218
8920
|
root: input.root,
|
|
8219
8921
|
op: "submitDraft",
|
|
8922
|
+
chapter: input.chapter,
|
|
8220
8923
|
key: () => `draft:${input.chapter}:${sha256(stableJson({
|
|
8221
8924
|
content: input.content,
|
|
8222
8925
|
chapter_title: input.chapter_title,
|
|
@@ -8242,31 +8945,32 @@ var EngineService = class {
|
|
|
8242
8945
|
summary: input.summary
|
|
8243
8946
|
});
|
|
8244
8947
|
const chapter = input.chapter;
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
...plan,
|
|
8948
|
+
if (snapshot.plan === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no usable plan`, "rollback to P2 and submit novel_plan_chapter; the draft was not written");
|
|
8949
|
+
const draft = {
|
|
8950
|
+
chapter,
|
|
8249
8951
|
title: input.chapter_title,
|
|
8250
8952
|
scene_type: input.scene_type,
|
|
8251
8953
|
narrator: input.narrator,
|
|
8252
|
-
summary: input.summary
|
|
8954
|
+
summary: input.summary,
|
|
8955
|
+
content_hash: sha256(input.content),
|
|
8956
|
+
updated_at: this.now().toISOString()
|
|
8253
8957
|
};
|
|
8254
|
-
const planPath = planChapterFile(chapter);
|
|
8255
8958
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
8256
8959
|
stage: "P4",
|
|
8960
|
+
draft,
|
|
8257
8961
|
blocking: [],
|
|
8258
8962
|
last_action: action("novel_write_draft", this.now(), sha256(input.content))
|
|
8259
8963
|
});
|
|
8260
8964
|
return {
|
|
8261
8965
|
files: [
|
|
8966
|
+
...snapshot.text === null ? [] : [{
|
|
8967
|
+
path: chapterHistoryFile(chapter, snapshot.pipeline.revision),
|
|
8968
|
+
text: snapshot.text
|
|
8969
|
+
}],
|
|
8262
8970
|
{
|
|
8263
8971
|
path: chapterFile(chapter),
|
|
8264
8972
|
text: input.content
|
|
8265
8973
|
},
|
|
8266
|
-
{
|
|
8267
|
-
path: planPath,
|
|
8268
|
-
text: yaml(mergedPlan)
|
|
8269
|
-
},
|
|
8270
8974
|
{
|
|
8271
8975
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8272
8976
|
text: yaml(nextPipeline)
|
|
@@ -8287,6 +8991,7 @@ var EngineService = class {
|
|
|
8287
8991
|
return this.mutate({
|
|
8288
8992
|
root: input.root,
|
|
8289
8993
|
op: "submitReview",
|
|
8994
|
+
chapter: input.chapter,
|
|
8290
8995
|
key: (snapshot) => `review:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
|
|
8291
8996
|
dimensions: input.dimensions,
|
|
8292
8997
|
issues: input.issues,
|
|
@@ -8368,13 +9073,21 @@ var EngineService = class {
|
|
|
8368
9073
|
last_action: action("novel_review", this.now(), reviewDigest)
|
|
8369
9074
|
});
|
|
8370
9075
|
return {
|
|
8371
|
-
files: [
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
9076
|
+
files: [
|
|
9077
|
+
...snapshot.review === null ? [] : [{
|
|
9078
|
+
path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
9079
|
+
text: yaml(snapshot.review)
|
|
9080
|
+
}],
|
|
9081
|
+
{
|
|
9082
|
+
path: reviewFile(input.chapter),
|
|
9083
|
+
text: yaml(review)
|
|
9084
|
+
},
|
|
9085
|
+
{
|
|
9086
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9087
|
+
text: yaml(nextPipeline)
|
|
9088
|
+
}
|
|
9089
|
+
],
|
|
9090
|
+
backup: snapshot.review !== null,
|
|
8378
9091
|
data: {
|
|
8379
9092
|
chapter: input.chapter,
|
|
8380
9093
|
round: review.round,
|
|
@@ -8391,16 +9104,31 @@ var EngineService = class {
|
|
|
8391
9104
|
});
|
|
8392
9105
|
}
|
|
8393
9106
|
async submitRevision(input) {
|
|
9107
|
+
const hasContent = typeof input.content === "string" && input.content.trim() !== "";
|
|
9108
|
+
const hasPatch = input.patch !== void 0;
|
|
9109
|
+
if (hasContent === hasPatch) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires exactly one of content or patch", "send the full revised text, or a patch with expected_hash and unique edits");
|
|
9110
|
+
if (hasPatch) {
|
|
9111
|
+
const patch = input.patch;
|
|
9112
|
+
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");
|
|
9113
|
+
if (typeof patch.expected_hash !== "string" || patch.expected_hash.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "patch.expected_hash must be the sha256 of the current manuscript", "read novel_status or the current manuscript, then hash the exact text");
|
|
9114
|
+
for (const [index, edit] of patch.edits.entries()) {
|
|
9115
|
+
if (typeof edit !== "object" || edit === null || typeof edit.old_text !== "string" || edit.old_text === "" || typeof edit.new_text !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] needs non-empty old_text and a string new_text`);
|
|
9116
|
+
if (edit.occurrence !== void 0 && (!Number.isInteger(edit.occurrence) || edit.occurrence < 1)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}].occurrence must be a positive integer`);
|
|
9117
|
+
}
|
|
9118
|
+
}
|
|
8394
9119
|
return this.mutate({
|
|
8395
9120
|
root: input.root,
|
|
8396
9121
|
op: "submitRevision",
|
|
8397
|
-
|
|
9122
|
+
chapter: input.chapter,
|
|
9123
|
+
key: () => `revision:${input.chapter}:${sha256(input.content ?? stableJson(input.patch))}:${sha256(stableJson({
|
|
8398
9124
|
summary: input.summary,
|
|
8399
9125
|
issues: input.issues,
|
|
8400
9126
|
proof: input.proof
|
|
8401
9127
|
}))}`,
|
|
8402
9128
|
run: async (snapshot, context) => {
|
|
8403
9129
|
this.assertChapter(snapshot, input.chapter);
|
|
9130
|
+
const revisedContent = input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
|
|
9131
|
+
if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
|
|
8404
9132
|
let patchedReview = null;
|
|
8405
9133
|
if (snapshot.review !== null) {
|
|
8406
9134
|
const original = snapshot.review.issues;
|
|
@@ -8450,6 +9178,7 @@ var EngineService = class {
|
|
|
8450
9178
|
issues
|
|
8451
9179
|
};
|
|
8452
9180
|
}
|
|
9181
|
+
const reviewChanged = patchedReview !== null && (snapshot.review === null || stableJson(patchedReview) !== stableJson(snapshot.review));
|
|
8453
9182
|
if (patchedReview !== null) try {
|
|
8454
9183
|
narrow("review", patchedReview);
|
|
8455
9184
|
} catch (error) {
|
|
@@ -8457,46 +9186,65 @@ var EngineService = class {
|
|
|
8457
9186
|
}
|
|
8458
9187
|
await this.assertGates(snapshot, "submitRevision", {
|
|
8459
9188
|
briefStage: briefStageFor("submitRevision", snapshot.pipeline.stage),
|
|
8460
|
-
text:
|
|
9189
|
+
text: revisedContent,
|
|
8461
9190
|
summary: input.summary,
|
|
8462
9191
|
summaryProvided: input.summary !== void 0,
|
|
8463
9192
|
review: patchedReview,
|
|
8464
9193
|
proof: input.proof
|
|
8465
9194
|
});
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
9195
|
+
const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
|
|
9196
|
+
if (input.summary === void 0 && inheritedSummary !== void 0) {
|
|
9197
|
+
const opening = inheritedSummary.opening;
|
|
9198
|
+
if (!openingInFirstTwoParagraphs(revisedContent, opening)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
|
|
8469
9199
|
}
|
|
8470
|
-
const
|
|
9200
|
+
const effectiveSummary = input.summary ?? inheritedSummary;
|
|
9201
|
+
if (effectiveSummary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the revised draft has no summary", "resubmit novel_revise with the ten-field summary");
|
|
9202
|
+
const machine = this.machine(snapshot, revisedContent, { summary: effectiveSummary });
|
|
9203
|
+
const baseDraft = snapshot.pipeline.draft ?? {
|
|
9204
|
+
chapter: input.chapter,
|
|
9205
|
+
title: snapshot.plan?.title ?? `第${input.chapter}章`,
|
|
9206
|
+
scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
|
|
9207
|
+
narrator: snapshot.plan?.narrator ?? "",
|
|
9208
|
+
summary: effectiveSummary,
|
|
9209
|
+
content_hash: sha256(snapshot.text ?? ""),
|
|
9210
|
+
updated_at: this.now().toISOString()
|
|
9211
|
+
};
|
|
8471
9212
|
const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
|
|
8472
9213
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
8473
9214
|
stage: nextStage$1,
|
|
9215
|
+
draft: {
|
|
9216
|
+
...baseDraft,
|
|
9217
|
+
summary: effectiveSummary,
|
|
9218
|
+
content_hash: sha256(revisedContent),
|
|
9219
|
+
updated_at: this.now().toISOString()
|
|
9220
|
+
},
|
|
8474
9221
|
rounds: {
|
|
8475
9222
|
...snapshot.pipeline.rounds,
|
|
8476
9223
|
revise: snapshot.pipeline.rounds.revise + 1
|
|
8477
9224
|
},
|
|
8478
9225
|
blocking: [],
|
|
8479
|
-
...patchedReview === null ? {} : { review_digest: sha256(stableJson(patchedReview)) },
|
|
8480
|
-
last_action: action("novel_revise", this.now(), sha256(
|
|
8481
|
-
});
|
|
8482
|
-
const planPath = planChapterFile(input.chapter);
|
|
8483
|
-
const planText = await context.store.readText(planPath);
|
|
8484
|
-
const plan = planText === null ? null : parsePlanYaml(planText, planPath);
|
|
8485
|
-
const files = [{
|
|
8486
|
-
path: chapterFile(input.chapter),
|
|
8487
|
-
text: input.content
|
|
8488
|
-
}, {
|
|
8489
|
-
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8490
|
-
text: yaml(nextPipeline)
|
|
8491
|
-
}];
|
|
8492
|
-
if (plan !== null && input.summary !== void 0) files.push({
|
|
8493
|
-
path: planPath,
|
|
8494
|
-
text: yaml({
|
|
8495
|
-
...plan,
|
|
8496
|
-
summary: input.summary
|
|
8497
|
-
})
|
|
9226
|
+
...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
|
|
9227
|
+
last_action: action("novel_revise", this.now(), sha256(revisedContent))
|
|
8498
9228
|
});
|
|
8499
|
-
|
|
9229
|
+
const files = [
|
|
9230
|
+
...snapshot.text === null ? [] : [{
|
|
9231
|
+
path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
9232
|
+
text: snapshot.text
|
|
9233
|
+
}],
|
|
9234
|
+
...snapshot.review === null || !reviewChanged ? [] : [{
|
|
9235
|
+
path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
9236
|
+
text: yaml(snapshot.review)
|
|
9237
|
+
}],
|
|
9238
|
+
{
|
|
9239
|
+
path: chapterFile(input.chapter),
|
|
9240
|
+
text: revisedContent
|
|
9241
|
+
},
|
|
9242
|
+
{
|
|
9243
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9244
|
+
text: yaml(nextPipeline)
|
|
9245
|
+
}
|
|
9246
|
+
];
|
|
9247
|
+
if (reviewChanged && patchedReview !== null) files.push({
|
|
8500
9248
|
path: reviewFile(input.chapter),
|
|
8501
9249
|
text: yaml(patchedReview)
|
|
8502
9250
|
});
|
|
@@ -8518,9 +9266,11 @@ var EngineService = class {
|
|
|
8518
9266
|
return this.mutate({
|
|
8519
9267
|
root: input.root,
|
|
8520
9268
|
op: "runCheck",
|
|
9269
|
+
chapter: input.chapter,
|
|
8521
9270
|
key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
|
|
8522
9271
|
semantic: input.semantic ?? [],
|
|
8523
|
-
scope
|
|
9272
|
+
scope,
|
|
9273
|
+
acknowledge: input.acknowledge ?? []
|
|
8524
9274
|
}))}`,
|
|
8525
9275
|
run: async (snapshot, context) => {
|
|
8526
9276
|
this.assertChapter(snapshot, input.chapter);
|
|
@@ -8539,6 +9289,13 @@ var EngineService = class {
|
|
|
8539
9289
|
violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
|
|
8540
9290
|
};
|
|
8541
9291
|
const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
|
|
9292
|
+
const machineAdvisories = scopedMachine.violations.filter((violation) => violation.severity === "advisory");
|
|
9293
|
+
const advisoryCodes = [...new Set(machineAdvisories.map((violation) => violation.code))];
|
|
9294
|
+
const requestedAck = Array.isArray(input.acknowledge) ? input.acknowledge : [];
|
|
9295
|
+
const unknownAck = requestedAck.filter((code) => !advisoryCodes.includes(code));
|
|
9296
|
+
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`);
|
|
9297
|
+
const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
|
|
9298
|
+
const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
|
|
8542
9299
|
const semanticResult = evaluateSemantic(text, semantic);
|
|
8543
9300
|
const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
|
|
8544
9301
|
const check = {
|
|
@@ -8547,10 +9304,12 @@ var EngineService = class {
|
|
|
8547
9304
|
scope,
|
|
8548
9305
|
content_hash: sha256(text),
|
|
8549
9306
|
machine: {
|
|
8550
|
-
violations:
|
|
9307
|
+
violations: machineBlocking,
|
|
9308
|
+
advisories: machineAdvisories,
|
|
8551
9309
|
metrics: scopedMachine.metrics
|
|
8552
9310
|
},
|
|
8553
9311
|
semantic: { items: semantic },
|
|
9312
|
+
...acknowledged.length === 0 ? {} : { acknowledged },
|
|
8554
9313
|
verdict
|
|
8555
9314
|
};
|
|
8556
9315
|
try {
|
|
@@ -8575,24 +9334,42 @@ var EngineService = class {
|
|
|
8575
9334
|
last_action: action("novel_check", this.now(), sha256(text))
|
|
8576
9335
|
});
|
|
8577
9336
|
return {
|
|
8578
|
-
files: [
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
9337
|
+
files: [
|
|
9338
|
+
...snapshot.check === null ? [] : [{
|
|
9339
|
+
path: checkHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
9340
|
+
text: yaml(snapshot.check)
|
|
9341
|
+
}],
|
|
9342
|
+
{
|
|
9343
|
+
path: checkFile(input.chapter),
|
|
9344
|
+
text: yaml(check)
|
|
9345
|
+
},
|
|
9346
|
+
{
|
|
9347
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9348
|
+
text: yaml(nextPipeline)
|
|
9349
|
+
}
|
|
9350
|
+
],
|
|
9351
|
+
backup: snapshot.check !== null,
|
|
8585
9352
|
data: {
|
|
8586
9353
|
chapter: input.chapter,
|
|
8587
9354
|
verdict,
|
|
8588
|
-
violations:
|
|
9355
|
+
violations: machineBlocking.length,
|
|
9356
|
+
advisories: machineAdvisories.filter((violation) => !acknowledged.includes(violation.code)).length,
|
|
9357
|
+
advisory_codes: advisoryCodes,
|
|
9358
|
+
acknowledged,
|
|
8589
9359
|
metrics: scopedMachine.metrics,
|
|
8590
9360
|
stage: nextStage$2
|
|
8591
9361
|
},
|
|
8592
|
-
warnings: [
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
9362
|
+
warnings: [
|
|
9363
|
+
...machineWarnings(scopedMachine, acknowledged),
|
|
9364
|
+
...acknowledged.length === 0 ? [] : [{
|
|
9365
|
+
code: "CHECK_ADVISORY_ACKNOWLEDGED",
|
|
9366
|
+
message: `已记录知悉:${acknowledged.join(", ")};advisory 不阻塞归档`
|
|
9367
|
+
}],
|
|
9368
|
+
...verdict === "pass" && !fullScope ? [{
|
|
9369
|
+
code: "CHECK_SCOPE_PARTIAL",
|
|
9370
|
+
message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
|
|
9371
|
+
}] : []
|
|
9372
|
+
]
|
|
8596
9373
|
};
|
|
8597
9374
|
}
|
|
8598
9375
|
});
|
|
@@ -8607,6 +9384,7 @@ var EngineService = class {
|
|
|
8607
9384
|
return this.mutate({
|
|
8608
9385
|
root: input.root,
|
|
8609
9386
|
op: "archiveChapter",
|
|
9387
|
+
chapter: input.chapter,
|
|
8610
9388
|
key: (snapshot) => {
|
|
8611
9389
|
const archived = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
|
|
8612
9390
|
return `archive:${input.chapter}:${archived?.content_hash ?? sha256(snapshot.text ?? "")}:${archiveDigest}`;
|
|
@@ -8633,7 +9411,8 @@ var EngineService = class {
|
|
|
8633
9411
|
this.assertChapter(snapshot, input.chapter);
|
|
8634
9412
|
this.assertChapterWithinTarget(snapshot, input.chapter, "novel_archive");
|
|
8635
9413
|
const text = snapshot.text ?? "";
|
|
8636
|
-
const
|
|
9414
|
+
const draft = snapshot.pipeline.draft;
|
|
9415
|
+
const summary = draft?.summary ?? snapshot.plan?.summary;
|
|
8637
9416
|
if (summary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the draft has no summary; resubmit the draft with a summary", "call novel_write_draft with the full summary block");
|
|
8638
9417
|
if (snapshot.review !== null && snapshot.pipeline.review_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review has no review_digest; it predates digest-bound reviews and cannot be archived blindly", "re-run novel_review for this chapter, then re-run the archive");
|
|
8639
9418
|
if (snapshot.review !== null && snapshot.pipeline.review_digest !== void 0 && snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review changed after submission; its digest no longer matches", "re-run novel_review instead of editing reviews/ directly");
|
|
@@ -8644,6 +9423,10 @@ var EngineService = class {
|
|
|
8644
9423
|
review: snapshot.review,
|
|
8645
9424
|
proof: snapshot.review?.proof ?? null
|
|
8646
9425
|
});
|
|
9426
|
+
if (draft !== void 0) {
|
|
9427
|
+
if (draft.chapter !== input.chapter) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `pipeline draft belongs to chapter ${draft.chapter}, but chapter ${input.chapter} is being archived`, "re-open the intended chapter or resubmit the draft before archiving");
|
|
9428
|
+
if (draft.content_hash !== sha256(text)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the manuscript changed after its draft summary was written; the summary and title cannot be sealed against this text", "restore the manuscript to the draft version, or call novel_write_draft / novel_revise again before archiving");
|
|
9429
|
+
}
|
|
8647
9430
|
if (text === "") throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${input.chapter} has no draft`);
|
|
8648
9431
|
const archiveBlockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
|
|
8649
9432
|
if (archiveBlockers.length > 0) throw new EngineBlockedError(ERROR_CODES.GATE_BLOCKED, `archiveChapter blocked by ${archiveBlockers.length} machine finding(s), first: ${archiveBlockers[0].code}`, archiveBlockers.map((violation) => ({
|
|
@@ -8659,7 +9442,7 @@ var EngineService = class {
|
|
|
8659
9442
|
const metrics = countWordMetrics(text);
|
|
8660
9443
|
const score = snapshot.review?.total ?? 0;
|
|
8661
9444
|
const chapter = input.chapter;
|
|
8662
|
-
const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
|
|
9445
|
+
const storedTitle = stripTitlePrefix(snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? "");
|
|
8663
9446
|
const record = {
|
|
8664
9447
|
num: chapter,
|
|
8665
9448
|
title: storedTitle === "" ? `第${chapter}章` : storedTitle,
|
|
@@ -8667,13 +9450,16 @@ var EngineService = class {
|
|
|
8667
9450
|
word_count: metrics.total_word_count,
|
|
8668
9451
|
dialogue_lines: metrics.dialogue_lines,
|
|
8669
9452
|
dialogue_ratio: metrics.dialogue_ratio,
|
|
8670
|
-
scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
|
|
8671
|
-
narrator: snapshot.plan?.narrator ?? "",
|
|
9453
|
+
scene_type: snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? "日常过渡章",
|
|
9454
|
+
narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? "",
|
|
8672
9455
|
score,
|
|
8673
9456
|
status: "reviewed",
|
|
8674
9457
|
content_hash: sha256(text),
|
|
8675
9458
|
summary
|
|
8676
9459
|
};
|
|
9460
|
+
const roleHints = {};
|
|
9461
|
+
for (const character of snapshot.worldbook.characters) if (character.role !== "") roleHints[character.name] = character.role;
|
|
9462
|
+
for (const character of snapshot.worldPlan?.characters ?? []) if (character.role !== "") roleHints[character.name] = character.role;
|
|
8677
9463
|
const ledger = applyStateDelta({
|
|
8678
9464
|
chapter,
|
|
8679
9465
|
text,
|
|
@@ -8685,11 +9471,15 @@ var EngineService = class {
|
|
|
8685
9471
|
foreshadows: snapshot.foreshadows,
|
|
8686
9472
|
timeline: snapshot.timeline,
|
|
8687
9473
|
relationships: snapshot.relationships,
|
|
8688
|
-
narrator: snapshot.plan?.narrator ?? null
|
|
9474
|
+
narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? null,
|
|
9475
|
+
roleHints
|
|
8689
9476
|
});
|
|
8690
9477
|
record.assigned_ids = ledger.assigned;
|
|
8691
9478
|
record.archive_digest = archiveDigest;
|
|
8692
9479
|
const archivedAt = this.now().toISOString();
|
|
9480
|
+
record.rounds = await this.lifetimeRounds(snapshot, chapter);
|
|
9481
|
+
record.final_check = snapshot.check?.verdict ?? "fail";
|
|
9482
|
+
record.archived_at = archivedAt;
|
|
8693
9483
|
const outline = markOutlineArchived(snapshot.outline, record, archivedAt, snapshot.project.targets.words_per_chapter);
|
|
8694
9484
|
this.assertOutlineWritable(snapshot, outline, "novel_archive");
|
|
8695
9485
|
const foreshadows = ledger.foreshadows;
|
|
@@ -8730,6 +9520,7 @@ var EngineService = class {
|
|
|
8730
9520
|
blocking: [],
|
|
8731
9521
|
last_action: action("novel_archive", this.now(), sha256(text))
|
|
8732
9522
|
});
|
|
9523
|
+
delete nextPipeline.draft;
|
|
8733
9524
|
assertArchiveDocuments([
|
|
8734
9525
|
["chapters", chapters],
|
|
8735
9526
|
["characters", ledger.characters],
|
|
@@ -8742,12 +9533,18 @@ var EngineService = class {
|
|
|
8742
9533
|
]);
|
|
8743
9534
|
const archiveDigests = await this.readArchiveDigests(context.store, snapshot);
|
|
8744
9535
|
archiveDigests[String(chapter)] = archiveDigest;
|
|
9536
|
+
const planText = await context.store.readText(planChapterFile(chapter));
|
|
9537
|
+
if (planText === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no stored plan; archiving would not be able to prove it later`, "restore the plan from backups/ before archiving");
|
|
8745
9538
|
return {
|
|
8746
9539
|
files: [
|
|
8747
9540
|
{
|
|
8748
9541
|
path: chapterFile(chapter),
|
|
8749
9542
|
text
|
|
8750
9543
|
},
|
|
9544
|
+
{
|
|
9545
|
+
path: planChapterFile(chapter),
|
|
9546
|
+
text: planText
|
|
9547
|
+
},
|
|
8751
9548
|
{
|
|
8752
9549
|
path: joinRel(STATE_DIR, STATE_FILES.chapters),
|
|
8753
9550
|
text: yaml(chapters)
|
|
@@ -8833,7 +9630,6 @@ var EngineService = class {
|
|
|
8833
9630
|
op: "amend",
|
|
8834
9631
|
reason: input.reason,
|
|
8835
9632
|
...repairPaths.length === 0 ? {} : { repairPaths },
|
|
8836
|
-
key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
|
|
8837
9633
|
run: async (snapshot, context) => {
|
|
8838
9634
|
if (input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason", "state the intent so the journal explains the change");
|
|
8839
9635
|
if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
|
|
@@ -8966,8 +9762,9 @@ var EngineService = class {
|
|
|
8966
9762
|
return this.mutate({
|
|
8967
9763
|
root: input.root,
|
|
8968
9764
|
op: "rollback",
|
|
9765
|
+
chapter: input.chapter,
|
|
8969
9766
|
reason: input.reason,
|
|
8970
|
-
key: () => `rollback:${input.chapter}:${input.toStage}`,
|
|
9767
|
+
key: (snapshot) => `rollback:${input.chapter}:${input.toStage}:${sha256(snapshot.text ?? "")}`,
|
|
8971
9768
|
run: async (snapshot, context) => {
|
|
8972
9769
|
this.assertChapter(snapshot, input.chapter);
|
|
8973
9770
|
if (!Object.prototype.hasOwnProperty.call(ROUND_TRANSITIONS, input.toStage)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown rollback target "${String(input.toStage)}"`, `allowed targets: ${Object.keys(ROUND_TRANSITIONS).join(", ")}`);
|
|
@@ -8985,15 +9782,31 @@ var EngineService = class {
|
|
|
8985
9782
|
blocking: [],
|
|
8986
9783
|
last_action: action("novel_rollback", this.now(), shortDigest(input.reason ?? ""))
|
|
8987
9784
|
});
|
|
9785
|
+
delete nextPipeline.draft;
|
|
9786
|
+
if (stage !== "idle" && stage !== "P2" && snapshot.pipeline.draft !== void 0) nextPipeline.draft = snapshot.pipeline.draft;
|
|
8988
9787
|
const files = [{
|
|
8989
9788
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8990
9789
|
text: yaml(nextPipeline)
|
|
8991
9790
|
}];
|
|
8992
9791
|
const current = await context.store.readText(chapterFile(input.chapter));
|
|
8993
|
-
if (current !== null)
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
9792
|
+
if (current !== null) {
|
|
9793
|
+
files.push({
|
|
9794
|
+
path: chapterFile(input.chapter),
|
|
9795
|
+
text: current
|
|
9796
|
+
});
|
|
9797
|
+
files.push({
|
|
9798
|
+
path: deprecatedChapterFile(input.chapter),
|
|
9799
|
+
text: current
|
|
9800
|
+
});
|
|
9801
|
+
files.push({
|
|
9802
|
+
path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
9803
|
+
text: current
|
|
9804
|
+
});
|
|
9805
|
+
files.push({
|
|
9806
|
+
path: deprecatedHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
9807
|
+
text: current
|
|
9808
|
+
});
|
|
9809
|
+
}
|
|
8997
9810
|
return {
|
|
8998
9811
|
files,
|
|
8999
9812
|
backup: true,
|
|
@@ -9074,8 +9887,21 @@ var EngineService = class {
|
|
|
9074
9887
|
});
|
|
9075
9888
|
const requested = input.limit ?? 50;
|
|
9076
9889
|
const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
|
|
9890
|
+
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
9891
|
+
if (input.cursor !== void 0 && (!Number.isInteger(input.cursor) || input.cursor < 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit cursor must be a non-negative integer");
|
|
9892
|
+
if (input.since !== void 0 && Number.isNaN(Date.parse(input.since))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit since must be an ISO timestamp");
|
|
9893
|
+
if (input.until !== void 0 && Number.isNaN(Date.parse(input.until))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit until must be an ISO timestamp");
|
|
9894
|
+
const filtered = rows.filter((row) => {
|
|
9895
|
+
if (input.chapter !== void 0 && row.chapter !== input.chapter) return false;
|
|
9896
|
+
if (input.op !== void 0 && row.op !== input.op) return false;
|
|
9897
|
+
if (input.status !== void 0 && row.status !== input.status) return false;
|
|
9898
|
+
if (input.since !== void 0 && Date.parse(row.at) < Date.parse(input.since)) return false;
|
|
9899
|
+
if (input.until !== void 0 && Date.parse(row.at) > Date.parse(input.until)) return false;
|
|
9900
|
+
if (input.cursor !== void 0 && (row.seq ?? 0) <= input.cursor) return false;
|
|
9901
|
+
return true;
|
|
9902
|
+
});
|
|
9077
9903
|
return {
|
|
9078
|
-
data: limit === 0 ? [] :
|
|
9904
|
+
data: limit === 0 ? [] : input.cursor === void 0 ? filtered.slice(-limit) : filtered.slice(0, limit),
|
|
9079
9905
|
revision: snapshot.pipeline.revision,
|
|
9080
9906
|
warnings
|
|
9081
9907
|
};
|
|
@@ -9275,7 +10101,7 @@ var EngineService = class {
|
|
|
9275
10101
|
const snapshot = await this.load(input.root);
|
|
9276
10102
|
const totalWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
|
|
9277
10103
|
const scores = snapshot.chapters.chapters.map((chapter) => chapter.score);
|
|
9278
|
-
const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
|
|
10104
|
+
const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
|
|
9279
10105
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
9280
10106
|
return {
|
|
9281
10107
|
totalWords,
|
|
@@ -9286,9 +10112,12 @@ var EngineService = class {
|
|
|
9286
10112
|
lastScore: scores.length === 0 ? 0 : scores[scores.length - 1],
|
|
9287
10113
|
openForeshadows: snapshot.foreshadows.items.filter((item) => item.status !== "resolved").length,
|
|
9288
10114
|
plannedChapters: outline.planned,
|
|
9289
|
-
|
|
10115
|
+
totalCards: outline.total,
|
|
10116
|
+
outlineCoverage: outline.plannedCoverage,
|
|
10117
|
+
archivedCoverage: outline.archivedCoverage,
|
|
10118
|
+
missingCards: outline.missingCards,
|
|
9290
10119
|
blueprintReady: bookPlanPresent(snapshot.outline.book),
|
|
9291
|
-
worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory,
|
|
10120
|
+
worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts,
|
|
9292
10121
|
strictOutline: snapshot.outline.book.strict,
|
|
9293
10122
|
targetChapters: snapshot.project.targets.total_chapters
|
|
9294
10123
|
};
|
|
@@ -9448,7 +10277,7 @@ var EngineService = class {
|
|
|
9448
10277
|
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `state/${STATE_FILES.chapters} is unreadable; ${operation} cannot preserve archived outline cards`, `repair state/${STATE_FILES.chapters} with novel_amend before ${operation}`);
|
|
9449
10278
|
}
|
|
9450
10279
|
bookStatus(snapshot) {
|
|
9451
|
-
const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
|
|
10280
|
+
const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
|
|
9452
10281
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
9453
10282
|
return {
|
|
9454
10283
|
premise: snapshot.outline.book.premise,
|
|
@@ -9456,10 +10285,15 @@ var EngineService = class {
|
|
|
9456
10285
|
strict: snapshot.outline.book.strict,
|
|
9457
10286
|
outline: {
|
|
9458
10287
|
present: snapshot.outlinePresent && summary.present,
|
|
10288
|
+
target: summary.target,
|
|
9459
10289
|
total: summary.total,
|
|
9460
10290
|
planned: summary.planned,
|
|
9461
10291
|
archived: summary.archived,
|
|
10292
|
+
missing_cards: summary.missingCards,
|
|
10293
|
+
planned_coverage: summary.plannedCoverage,
|
|
10294
|
+
archived_coverage: summary.archivedCoverage,
|
|
9462
10295
|
coverage: summary.coverage,
|
|
10296
|
+
card_coverage: summary.cardCoverage,
|
|
9463
10297
|
volumes: summary.volumes,
|
|
9464
10298
|
current: outlineCard(snapshot.outline, snapshot.pipeline.chapter)
|
|
9465
10299
|
},
|
|
@@ -9470,7 +10304,8 @@ var EngineService = class {
|
|
|
9470
10304
|
factions: counts.factions,
|
|
9471
10305
|
rules: counts.rules,
|
|
9472
10306
|
glossary: counts.glossary,
|
|
9473
|
-
backstory: counts.backstory
|
|
10307
|
+
backstory: counts.backstory,
|
|
10308
|
+
facts: counts.facts
|
|
9474
10309
|
}
|
|
9475
10310
|
};
|
|
9476
10311
|
}
|
|
@@ -9483,7 +10318,7 @@ var EngineService = class {
|
|
|
9483
10318
|
const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
|
|
9484
10319
|
const journal = await readJournal(this.storeFor(snapshot.root));
|
|
9485
10320
|
const journalBlocking = await this.journalBlocking(snapshot);
|
|
9486
|
-
const outline = outlineSummary(snapshot.outline, archived.length);
|
|
10321
|
+
const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
|
|
9487
10322
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
9488
10323
|
const scores = archived.slice(-10).map((chapter) => chapter.score);
|
|
9489
10324
|
const blocking = [...this.effectiveBlocking(snapshot), ...journalBlocking];
|
|
@@ -9496,10 +10331,15 @@ var EngineService = class {
|
|
|
9496
10331
|
target,
|
|
9497
10332
|
outline: {
|
|
9498
10333
|
present: snapshot.outlinePresent && outline.present,
|
|
10334
|
+
target: outline.target,
|
|
9499
10335
|
total: outline.total,
|
|
9500
10336
|
planned: outline.planned,
|
|
9501
10337
|
archived: outline.archived,
|
|
10338
|
+
missing_cards: outline.missingCards,
|
|
10339
|
+
planned_coverage: outline.plannedCoverage,
|
|
10340
|
+
archived_coverage: outline.archivedCoverage,
|
|
9502
10341
|
coverage: outline.coverage,
|
|
10342
|
+
card_coverage: outline.cardCoverage,
|
|
9503
10343
|
volumes: snapshot.outline.volumes.map((volume) => {
|
|
9504
10344
|
const cards = snapshot.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
|
|
9505
10345
|
return {
|
|
@@ -9518,7 +10358,8 @@ var EngineService = class {
|
|
|
9518
10358
|
factions: counts.factions,
|
|
9519
10359
|
rules: counts.rules,
|
|
9520
10360
|
glossary: counts.glossary,
|
|
9521
|
-
backstory: counts.backstory
|
|
10361
|
+
backstory: counts.backstory,
|
|
10362
|
+
facts: counts.facts
|
|
9522
10363
|
}
|
|
9523
10364
|
};
|
|
9524
10365
|
const qualityScores = scores.slice(-5);
|
|
@@ -9609,6 +10450,16 @@ var EngineService = class {
|
|
|
9609
10450
|
to_stage: target
|
|
9610
10451
|
};
|
|
9611
10452
|
}
|
|
10453
|
+
const draft = snapshot.pipeline.draft;
|
|
10454
|
+
if (draft !== void 0 && (draft.chapter !== snapshot.pipeline.chapter || draft.content_hash !== sha256(text))) return {
|
|
10455
|
+
item: {
|
|
10456
|
+
code: "DRAFT_STALE",
|
|
10457
|
+
scope: "archive",
|
|
10458
|
+
detail: "the manuscript or active chapter no longer matches its draft summary",
|
|
10459
|
+
hint: "call novel_rollback to P3 and submit novel_write_draft again"
|
|
10460
|
+
},
|
|
10461
|
+
to_stage: "P3"
|
|
10462
|
+
};
|
|
9612
10463
|
const summary = snapshot.plan?.summary ?? null;
|
|
9613
10464
|
const blockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
|
|
9614
10465
|
if (blockers.length > 0) return {
|
|
@@ -10030,8 +10881,31 @@ var EngineService = class {
|
|
|
10030
10881
|
}));
|
|
10031
10882
|
}
|
|
10032
10883
|
/** Whether the stored brief still signs the current snapshot. */
|
|
10884
|
+
async readBriefReceipts(store) {
|
|
10885
|
+
const raw = await store.readText(BRIEF_RECEIPTS_FILE);
|
|
10886
|
+
if (raw === null) return {
|
|
10887
|
+
schema_version: 1,
|
|
10888
|
+
receipts: []
|
|
10889
|
+
};
|
|
10890
|
+
try {
|
|
10891
|
+
const parsed = parse(raw);
|
|
10892
|
+
if (parsed === null || !Array.isArray(parsed.receipts)) return {
|
|
10893
|
+
schema_version: 1,
|
|
10894
|
+
receipts: []
|
|
10895
|
+
};
|
|
10896
|
+
return {
|
|
10897
|
+
schema_version: 1,
|
|
10898
|
+
receipts: parsed.receipts.filter((receipt) => typeof receipt === "object" && receipt !== null && Number.isInteger(receipt.chapter) && typeof receipt.stage === "string" && typeof receipt.digest === "string" && typeof receipt.issued_at === "string")
|
|
10899
|
+
};
|
|
10900
|
+
} catch {
|
|
10901
|
+
return {
|
|
10902
|
+
schema_version: 1,
|
|
10903
|
+
receipts: []
|
|
10904
|
+
};
|
|
10905
|
+
}
|
|
10906
|
+
}
|
|
10033
10907
|
briefIsCurrent(snapshot, stage) {
|
|
10034
|
-
const record = snapshot.
|
|
10908
|
+
const record = snapshot.briefs[stage];
|
|
10035
10909
|
if (record === void 0) return false;
|
|
10036
10910
|
const context = this.briefContextFor(stage, snapshot, snapshot.pipeline.chapter);
|
|
10037
10911
|
return record.digest === this.briefDigest(stage, snapshot, context);
|
|
@@ -10117,6 +10991,8 @@ var EngineService = class {
|
|
|
10117
10991
|
genre: snapshot.project.genre,
|
|
10118
10992
|
sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
|
|
10119
10993
|
rulepack: this.rulepack,
|
|
10994
|
+
targets: snapshot.project.targets,
|
|
10995
|
+
facts: snapshot.worldbook.facts ?? [],
|
|
10120
10996
|
summary: extra.summary ?? snapshot.plan?.summary ?? null,
|
|
10121
10997
|
evidence: {
|
|
10122
10998
|
characters: snapshot.characters.characters.length > 0 ? snapshot.characters : null,
|
|
@@ -10222,13 +11098,16 @@ var EngineService = class {
|
|
|
10222
11098
|
text,
|
|
10223
11099
|
chapter: snapshot.pipeline.chapter,
|
|
10224
11100
|
project: snapshot.project,
|
|
10225
|
-
pipeline:
|
|
11101
|
+
pipeline: {
|
|
11102
|
+
...snapshot.pipeline,
|
|
11103
|
+
briefs: snapshot.briefs
|
|
11104
|
+
},
|
|
10226
11105
|
chapters: snapshot.chapters,
|
|
10227
11106
|
briefDigest: extras.briefDigest ?? (extras.briefStage === void 0 || extras.briefStage === null ? null : this.briefDigest(extras.briefStage, snapshot, this.briefContextFor(extras.briefStage, snapshot, snapshot.pipeline.chapter))),
|
|
10228
11107
|
briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
|
|
10229
|
-
draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
|
|
10230
|
-
sceneType: extras.sceneType ?? snapshot.plan?.scene_type ?? null,
|
|
10231
|
-
summary: extras.summary ?? snapshot.plan?.summary ?? null,
|
|
11108
|
+
draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
|
|
11109
|
+
sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
|
|
11110
|
+
summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
|
|
10232
11111
|
summaryProvided: extras.summaryProvided,
|
|
10233
11112
|
review: extras.review ?? snapshot.review,
|
|
10234
11113
|
check: extras.check ?? snapshot.check,
|
|
@@ -10295,7 +11174,7 @@ var EngineService = class {
|
|
|
10295
11174
|
STATE_FILES.relationships,
|
|
10296
11175
|
STATE_FILES.handoff
|
|
10297
11176
|
].map((file) => joinRel(STATE_DIR, file));
|
|
10298
|
-
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
|
|
11177
|
+
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
|
|
10299
11178
|
return (await Promise.all(stateFiles.map((path) => store.exists(path)))).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");
|
|
10300
11179
|
}
|
|
10301
11180
|
/** A live writer lock makes a read-only snapshot potentially torn. */
|
|
@@ -10334,6 +11213,19 @@ var EngineService = class {
|
|
|
10334
11213
|
}
|
|
10335
11214
|
const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
10336
11215
|
if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
|
|
11216
|
+
let briefs = pipeline.briefs;
|
|
11217
|
+
const briefsRaw = await store.readText(BRIEF_RECEIPTS_FILE);
|
|
11218
|
+
if (briefsRaw !== null) try {
|
|
11219
|
+
const parsed = parse(briefsRaw);
|
|
11220
|
+
if (parsed !== null && Array.isArray(parsed.receipts)) {
|
|
11221
|
+
const next = {};
|
|
11222
|
+
for (const receipt of parsed.receipts) if (typeof receipt === "object" && receipt !== null && Number.isInteger(receipt.chapter) && typeof receipt.stage === "string" && typeof receipt.digest === "string" && typeof receipt.issued_at === "string") next[receipt.stage] = {
|
|
11223
|
+
digest: receipt.digest,
|
|
11224
|
+
issued_at: receipt.issued_at
|
|
11225
|
+
};
|
|
11226
|
+
briefs = next;
|
|
11227
|
+
}
|
|
11228
|
+
} catch {}
|
|
10337
11229
|
const ledgerErrors = [];
|
|
10338
11230
|
const ledger = async (path, kind, code, fallback) => {
|
|
10339
11231
|
const read = await this.recoverableDoc(store, path, kind);
|
|
@@ -10422,6 +11314,10 @@ var EngineService = class {
|
|
|
10422
11314
|
const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
|
|
10423
11315
|
validateStoredChapterPlan(parsed, planChapterFile(chapter));
|
|
10424
11316
|
plan = parsed;
|
|
11317
|
+
if (pipeline.draft !== void 0 && pipeline.draft.chapter === chapter) plan = {
|
|
11318
|
+
...plan,
|
|
11319
|
+
summary: pipeline.draft.summary
|
|
11320
|
+
};
|
|
10425
11321
|
} catch (error) {
|
|
10426
11322
|
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
10427
11323
|
planError = errorMessage(error);
|
|
@@ -10464,6 +11360,7 @@ var EngineService = class {
|
|
|
10464
11360
|
project,
|
|
10465
11361
|
projectError,
|
|
10466
11362
|
pipeline,
|
|
11363
|
+
briefs,
|
|
10467
11364
|
chapters,
|
|
10468
11365
|
characters,
|
|
10469
11366
|
world,
|
|
@@ -10565,7 +11462,7 @@ var EngineService = class {
|
|
|
10565
11462
|
STATE_FILES.relationships,
|
|
10566
11463
|
STATE_FILES.handoff
|
|
10567
11464
|
].map((file) => joinRel(STATE_DIR, file));
|
|
10568
|
-
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
|
|
11465
|
+
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
|
|
10569
11466
|
const stateCandidates = journalWasPresent ? stateFiles : stateFiles.filter((path) => path !== joinRel(STATE_DIR, STATE_FILES.journal));
|
|
10570
11467
|
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");
|
|
10571
11468
|
await assertJournalConsistent(store);
|
|
@@ -10603,12 +11500,32 @@ var EngineService = class {
|
|
|
10603
11500
|
replayed: true
|
|
10604
11501
|
};
|
|
10605
11502
|
}
|
|
10606
|
-
const revision = snapshot.pipeline.revision + 1;
|
|
11503
|
+
const revision = options.advanceRevision === false ? snapshot.pipeline.revision : snapshot.pipeline.revision + 1;
|
|
10607
11504
|
abortIfNeeded();
|
|
10608
|
-
|
|
10609
|
-
|
|
10610
|
-
|
|
10611
|
-
|
|
11505
|
+
let outcome;
|
|
11506
|
+
try {
|
|
11507
|
+
outcome = await options.run(snapshot, {
|
|
11508
|
+
store,
|
|
11509
|
+
revision
|
|
11510
|
+
});
|
|
11511
|
+
} catch (error) {
|
|
11512
|
+
await appendJournal(store, {
|
|
11513
|
+
txid: randomUUID(),
|
|
11514
|
+
at: this.now().toISOString(),
|
|
11515
|
+
op: options.op,
|
|
11516
|
+
chapter: options.chapter ?? snapshot.pipeline.chapter,
|
|
11517
|
+
revision: snapshot.pipeline.revision,
|
|
11518
|
+
files: [],
|
|
11519
|
+
status: "aborted",
|
|
11520
|
+
reason: errorMessage(error),
|
|
11521
|
+
attempt: true,
|
|
11522
|
+
stage: snapshot.pipeline.stage,
|
|
11523
|
+
...error instanceof EngineError ? { code: error.code } : {},
|
|
11524
|
+
...error instanceof EngineError && error.hint !== void 0 ? { hint: error.hint } : {},
|
|
11525
|
+
...error instanceof EngineBlockedError ? { blocking: error.blocking } : {}
|
|
11526
|
+
}).catch(() => void 0);
|
|
11527
|
+
throw error;
|
|
11528
|
+
}
|
|
10612
11529
|
if (outcome.replay === true) return {
|
|
10613
11530
|
data: outcome.data,
|
|
10614
11531
|
revision: snapshot.pipeline.revision,
|
|
@@ -10619,7 +11536,7 @@ var EngineService = class {
|
|
|
10619
11536
|
await runTransaction(store, {
|
|
10620
11537
|
txid: randomUUID(),
|
|
10621
11538
|
op: options.op,
|
|
10622
|
-
chapter: snapshot.pipeline.chapter,
|
|
11539
|
+
chapter: options.chapter ?? snapshot.pipeline.chapter,
|
|
10623
11540
|
revision,
|
|
10624
11541
|
files: outcome.files,
|
|
10625
11542
|
backup: outcome.backup ?? false,
|
|
@@ -10661,6 +11578,7 @@ var EngineService = class {
|
|
|
10661
11578
|
chapter: 1,
|
|
10662
11579
|
stage: "idle"
|
|
10663
11580
|
}),
|
|
11581
|
+
briefs: {},
|
|
10664
11582
|
chapters: emptyChapters(),
|
|
10665
11583
|
characters: emptyCharacters(),
|
|
10666
11584
|
world: emptyWorld(),
|
|
@@ -10728,6 +11646,38 @@ const STATUS_BRIEF = {
|
|
|
10728
11646
|
done: "P1"
|
|
10729
11647
|
};
|
|
10730
11648
|
const yaml = (value) => stringify(value, { lineWidth: 0 });
|
|
11649
|
+
/** Apply a patch only to the exact manuscript the caller hashed. */
|
|
11650
|
+
function applyRevisionPatch(text, patch) {
|
|
11651
|
+
const actual = sha256(text);
|
|
11652
|
+
if (actual !== patch.expected_hash) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch expected_hash ${patch.expected_hash} does not match the current manuscript ${actual}`, "read the current text and compute its sha256 again; nothing was written");
|
|
11653
|
+
const planned = [];
|
|
11654
|
+
for (const [index, edit] of patch.edits.entries()) {
|
|
11655
|
+
const positions = [];
|
|
11656
|
+
let cursor = 0;
|
|
11657
|
+
while (cursor <= text.length) {
|
|
11658
|
+
const found = text.indexOf(edit.old_text, cursor);
|
|
11659
|
+
if (found === -1) break;
|
|
11660
|
+
positions.push(found);
|
|
11661
|
+
cursor = found + 1;
|
|
11662
|
+
}
|
|
11663
|
+
const expectedOccurrence = edit.occurrence ?? 1;
|
|
11664
|
+
if (edit.occurrence === void 0 && positions.length !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] matches ${positions.length} place(s); a unique old_text is required`, "include more surrounding text, or set occurrence explicitly");
|
|
11665
|
+
if (positions.length < expectedOccurrence) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}].occurrence ${expectedOccurrence} exceeds ${positions.length} match(es)`);
|
|
11666
|
+
const start = positions[expectedOccurrence - 1];
|
|
11667
|
+
const current = {
|
|
11668
|
+
index,
|
|
11669
|
+
start,
|
|
11670
|
+
end: start + edit.old_text.length,
|
|
11671
|
+
newText: edit.new_text
|
|
11672
|
+
};
|
|
11673
|
+
if (planned.some((other) => current.start < other.end && other.start < current.end)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] overlaps another edit; overlapping edits cannot be applied atomically`, "merge overlapping edits into one old_text/new_text pair; nothing was written");
|
|
11674
|
+
planned.push(current);
|
|
11675
|
+
}
|
|
11676
|
+
planned.sort((left, right) => right.start - left.start);
|
|
11677
|
+
let next = text;
|
|
11678
|
+
for (const edit of planned) next = `${next.slice(0, edit.start)}${edit.newText}${next.slice(edit.end)}`;
|
|
11679
|
+
return next;
|
|
11680
|
+
}
|
|
10731
11681
|
const emptyChapters = () => ({
|
|
10732
11682
|
schema_version: 1,
|
|
10733
11683
|
chapters: []
|
|
@@ -10752,6 +11702,7 @@ const pipelineWith = (snapshot, revision, patch) => ({
|
|
|
10752
11702
|
rounds: snapshot.pipeline.rounds,
|
|
10753
11703
|
briefs: snapshot.pipeline.briefs,
|
|
10754
11704
|
blocking: snapshot.pipeline.blocking,
|
|
11705
|
+
...snapshot.pipeline.draft === void 0 ? {} : { draft: snapshot.pipeline.draft },
|
|
10755
11706
|
review_digest: snapshot.pipeline.review_digest,
|
|
10756
11707
|
last_action: snapshot.pipeline.last_action,
|
|
10757
11708
|
...patch
|
|
@@ -10775,7 +11726,7 @@ const action = (op, at, digest) => ({
|
|
|
10775
11726
|
at: at.toISOString(),
|
|
10776
11727
|
digest
|
|
10777
11728
|
});
|
|
10778
|
-
const machineWarnings = (machine) => machine.violations.filter((violation) => violation.severity === "advisory").slice(0, 5).map((violation) => ({
|
|
11729
|
+
const machineWarnings = (machine, acknowledged = []) => machine.violations.filter((violation) => violation.severity === "advisory" && !acknowledged.includes(violation.code)).slice(0, 5).map((violation) => ({
|
|
10779
11730
|
code: violation.code,
|
|
10780
11731
|
message: `${violation.detail} (paragraph ${violation.location.paragraph})`
|
|
10781
11732
|
}));
|
|
@@ -10940,7 +11891,10 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
10940
11891
|
"narrator",
|
|
10941
11892
|
"score",
|
|
10942
11893
|
"status",
|
|
10943
|
-
"summary"
|
|
11894
|
+
"summary",
|
|
11895
|
+
"rounds",
|
|
11896
|
+
"final_check",
|
|
11897
|
+
"archived_at"
|
|
10944
11898
|
]) if (stableJson(before[field]) !== stableJson(after[field])) problems.push(`archived chapter ${String(before.num)} cannot change its ${field}`);
|
|
10945
11899
|
}
|
|
10946
11900
|
return problems;
|
|
@@ -11362,6 +12316,7 @@ const makeGuarded = (engine) => (body) => async (args, exec) => {
|
|
|
11362
12316
|
return fail(error);
|
|
11363
12317
|
}
|
|
11364
12318
|
};
|
|
12319
|
+
const percent = (value) => `${Math.round(value * 1e3) / 10}%`;
|
|
11365
12320
|
const text = (value) => [{
|
|
11366
12321
|
type: "text",
|
|
11367
12322
|
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
@@ -11408,7 +12363,7 @@ const root = {
|
|
|
11408
12363
|
description: "Project root. When omitted, resolves to the last active project, then the registry active root, then the configured projectRoot; pass it explicitly when several projects exist."
|
|
11409
12364
|
};
|
|
11410
12365
|
/**
|
|
11411
|
-
* The twenty-
|
|
12366
|
+
* The twenty-eight tools are thin adapters: narrow parameters, call one service
|
|
11412
12367
|
* operation, render the canonical value. They never read a file.
|
|
11413
12368
|
*/
|
|
11414
12369
|
function createNovelTools(engine) {
|
|
@@ -11492,7 +12447,8 @@ function createNovelTools(engine) {
|
|
|
11492
12447
|
...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
|
|
11493
12448
|
value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
|
|
11494
12449
|
...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
|
|
11495
|
-
...value.
|
|
12450
|
+
...value.metrics === null || value.metrics === void 0 ? [] : [`字数:${value.metrics.total_word_count}(汉字 ${value.metrics.cjk_chars} + 标点 ${value.metrics.punctuation_chars} + 英文词/数字,含标点)`],
|
|
12451
|
+
...value.book === void 0 ? [] : [`全书:大纲 ${value.book.outline.total} 张卡(规划覆盖 ${value.book.outline.planned_coverage === null || value.book.outline.planned_coverage === void 0 ? "—" : percent(value.book.outline.planned_coverage)} · 归档覆盖 ${value.book.outline.archived_coverage === null || value.book.outline.archived_coverage === void 0 ? "—" : percent(value.book.outline.archived_coverage)}${(value.book.outline.missing_cards ?? 0) > 0 ? ` · 缺卡 ${value.book.outline.missing_cards}` : ""})· 世界观 ${value.book.worldbook.characters} 角色 / ${value.book.worldbook.locations} 地点 / ${value.book.worldbook.factions} 势力 / ${value.book.worldbook.rules} 规则${value.book.strict ? " · 严格大纲" : ""}`, `当前章卡:${value.book.outline.current === null ? "(无)" : `第${value.book.outline.current.num}章 ${value.book.outline.current.title} · ${value.book.outline.current.scene_type} · ${value.book.outline.current.target_words[0]}-${value.book.outline.current.target_words[1]} 字`}`],
|
|
11496
12452
|
`下一步:${value.next.tool} — ${value.next.reason}`
|
|
11497
12453
|
].join("\n"));
|
|
11498
12454
|
}
|
|
@@ -11701,7 +12657,11 @@ function createNovelTools(engine) {
|
|
|
11701
12657
|
},
|
|
11702
12658
|
content: {
|
|
11703
12659
|
type: "string",
|
|
11704
|
-
|
|
12660
|
+
description: "Full revised text; mutually exclusive with patch."
|
|
12661
|
+
},
|
|
12662
|
+
patch: {
|
|
12663
|
+
type: "json",
|
|
12664
|
+
description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
|
|
11705
12665
|
},
|
|
11706
12666
|
summary: { type: "json" },
|
|
11707
12667
|
issues: {
|
|
@@ -11718,7 +12678,8 @@ function createNovelTools(engine) {
|
|
|
11718
12678
|
output: jsonOutput,
|
|
11719
12679
|
execute: guarded(async (args) => engine.submitRevision({
|
|
11720
12680
|
chapter: args.chapter,
|
|
11721
|
-
content: args.content,
|
|
12681
|
+
...args.content === void 0 ? {} : { content: args.content },
|
|
12682
|
+
...args.patch === void 0 ? {} : { patch: asJson(args.patch, "patch") },
|
|
11722
12683
|
summary: asJson(args.summary, "summary"),
|
|
11723
12684
|
issues: asJson(args.issues, "issues"),
|
|
11724
12685
|
proof: asJson(args.proof, "proof"),
|
|
@@ -11739,6 +12700,11 @@ function createNovelTools(engine) {
|
|
|
11739
12700
|
required: true,
|
|
11740
12701
|
description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
|
|
11741
12702
|
},
|
|
12703
|
+
acknowledge: {
|
|
12704
|
+
type: "array",
|
|
12705
|
+
items: { type: "string" },
|
|
12706
|
+
description: "Advisory codes read and accepted; blockers cannot be acknowledged."
|
|
12707
|
+
},
|
|
11742
12708
|
project_root: root
|
|
11743
12709
|
},
|
|
11744
12710
|
output: jsonOutput,
|
|
@@ -11746,12 +12712,13 @@ function createNovelTools(engine) {
|
|
|
11746
12712
|
chapter: args.chapter,
|
|
11747
12713
|
scope: args.scope,
|
|
11748
12714
|
semantic: asJson(args.semantic, "semantic"),
|
|
12715
|
+
...args.acknowledge === void 0 ? {} : { acknowledge: args.acknowledge },
|
|
11749
12716
|
root: args.project_root
|
|
11750
12717
|
}))
|
|
11751
12718
|
}),
|
|
11752
12719
|
defineTool({
|
|
11753
12720
|
name: "novel_archive",
|
|
11754
|
-
description: "Archive the active chapter: all gates run, then one transaction writes the index,
|
|
12721
|
+
description: "Archive the active chapter: all gates run, then one transaction writes the index, eight ledgers and handoff.",
|
|
11755
12722
|
parameters: {
|
|
11756
12723
|
chapter: {
|
|
11757
12724
|
type: "integer",
|
|
@@ -11799,7 +12766,7 @@ function createNovelTools(engine) {
|
|
|
11799
12766
|
}),
|
|
11800
12767
|
defineTool({
|
|
11801
12768
|
name: "novel_state",
|
|
11802
|
-
description: "Read a state document (ledgers, plans, review, check, outline, worldbook) without touching the manuscript.",
|
|
12769
|
+
description: "Read a state document (ledgers, plans, review, check, outline, worldbook), list brief receipts, revision history or backup manifests without touching the manuscript.",
|
|
11803
12770
|
parameters: {
|
|
11804
12771
|
kind: {
|
|
11805
12772
|
type: "string",
|
|
@@ -11820,10 +12787,14 @@ function createNovelTools(engine) {
|
|
|
11820
12787
|
"planWorld",
|
|
11821
12788
|
"planChapter",
|
|
11822
12789
|
"outline",
|
|
11823
|
-
"worldbook"
|
|
12790
|
+
"worldbook",
|
|
12791
|
+
"history",
|
|
12792
|
+
"backups",
|
|
12793
|
+
"briefs"
|
|
11824
12794
|
]
|
|
11825
12795
|
},
|
|
11826
12796
|
chapter: { type: "integer" },
|
|
12797
|
+
revision: { type: "integer" },
|
|
11827
12798
|
include_summary: {
|
|
11828
12799
|
type: "boolean",
|
|
11829
12800
|
description: "For kind=chapters: include the full ten-field summary (default compact)."
|
|
@@ -11839,6 +12810,7 @@ function createNovelTools(engine) {
|
|
|
11839
12810
|
execute: guarded(async (args) => engine.queryState({
|
|
11840
12811
|
kind: args.kind,
|
|
11841
12812
|
chapter: args.chapter,
|
|
12813
|
+
...args.revision === void 0 ? {} : { revision: args.revision },
|
|
11842
12814
|
...args.include_summary === void 0 ? {} : { include_summary: args.include_summary },
|
|
11843
12815
|
...args.chapter_range === void 0 ? {} : { chapter_range: args.chapter_range },
|
|
11844
12816
|
root: args.project_root
|
|
@@ -11959,14 +12931,29 @@ function createNovelTools(engine) {
|
|
|
11959
12931
|
}),
|
|
11960
12932
|
defineTool({
|
|
11961
12933
|
name: "novel_audit",
|
|
11962
|
-
description: "Read the append-only journal
|
|
12934
|
+
description: "Read the append-only journal, including refused gate attempts, with chapter/op/status/time filters and a revision cursor.",
|
|
11963
12935
|
parameters: {
|
|
11964
12936
|
limit: { type: "integer" },
|
|
12937
|
+
cursor: { type: "integer" },
|
|
12938
|
+
chapter: { type: "integer" },
|
|
12939
|
+
op: { type: "string" },
|
|
12940
|
+
status: {
|
|
12941
|
+
type: "string",
|
|
12942
|
+
enum: ["committed", "aborted"]
|
|
12943
|
+
},
|
|
12944
|
+
since: { type: "string" },
|
|
12945
|
+
until: { type: "string" },
|
|
11965
12946
|
project_root: root
|
|
11966
12947
|
},
|
|
11967
12948
|
output: jsonOutput,
|
|
11968
12949
|
execute: guarded(async (args) => engine.auditHistory({
|
|
11969
12950
|
limit: args.limit,
|
|
12951
|
+
cursor: args.cursor,
|
|
12952
|
+
chapter: args.chapter,
|
|
12953
|
+
op: args.op,
|
|
12954
|
+
status: args.status,
|
|
12955
|
+
since: args.since,
|
|
12956
|
+
until: args.until,
|
|
11970
12957
|
root: args.project_root
|
|
11971
12958
|
}))
|
|
11972
12959
|
}),
|
|
@@ -12083,6 +13070,10 @@ function createNovelTools(engine) {
|
|
|
12083
13070
|
characters: { type: "json" },
|
|
12084
13071
|
glossary: { type: "json" },
|
|
12085
13072
|
backstory: { type: "json" },
|
|
13073
|
+
facts: {
|
|
13074
|
+
type: "json",
|
|
13075
|
+
description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
|
|
13076
|
+
},
|
|
12086
13077
|
themes: stringArray,
|
|
12087
13078
|
project_root: root
|
|
12088
13079
|
},
|
|
@@ -12096,6 +13087,7 @@ function createNovelTools(engine) {
|
|
|
12096
13087
|
characters: asJson(args.characters, "characters"),
|
|
12097
13088
|
glossary: asJson(args.glossary, "glossary"),
|
|
12098
13089
|
backstory: asJson(args.backstory, "backstory"),
|
|
13090
|
+
...args.facts === void 0 ? {} : { facts: asJson(args.facts, "facts") },
|
|
12099
13091
|
themes: args.themes,
|
|
12100
13092
|
root: args.project_root
|
|
12101
13093
|
}))
|
|
@@ -12171,6 +13163,30 @@ function createNovelTools(engine) {
|
|
|
12171
13163
|
reason: args.reason,
|
|
12172
13164
|
root: args.project_root
|
|
12173
13165
|
}))
|
|
13166
|
+
}),
|
|
13167
|
+
defineTool({
|
|
13168
|
+
name: "novel_restore",
|
|
13169
|
+
description: "Restore an active chapter file from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
|
|
13170
|
+
parameters: {
|
|
13171
|
+
txid: {
|
|
13172
|
+
type: "string",
|
|
13173
|
+
required: true
|
|
13174
|
+
},
|
|
13175
|
+
chapter: { type: "integer" },
|
|
13176
|
+
paths: {
|
|
13177
|
+
type: "array",
|
|
13178
|
+
items: { type: "string" },
|
|
13179
|
+
description: "Project-relative chapter files; omitted restores every restorable chapter file that the backup manifest records."
|
|
13180
|
+
},
|
|
13181
|
+
project_root: root
|
|
13182
|
+
},
|
|
13183
|
+
output: jsonOutput,
|
|
13184
|
+
execute: guarded(async (args) => engine.restoreBackup({
|
|
13185
|
+
txid: args.txid,
|
|
13186
|
+
chapter: args.chapter,
|
|
13187
|
+
...args.paths === void 0 ? {} : { paths: args.paths },
|
|
13188
|
+
root: args.project_root
|
|
13189
|
+
}))
|
|
12174
13190
|
})
|
|
12175
13191
|
];
|
|
12176
13192
|
}
|
|
@@ -12214,7 +13230,7 @@ const rootArg = (args) => {
|
|
|
12214
13230
|
return args.root;
|
|
12215
13231
|
};
|
|
12216
13232
|
/**
|
|
12217
|
-
* The
|
|
13233
|
+
* The eighteen tools hidden when `tools.mode=core`, plus the explicit
|
|
12218
13234
|
* registry/settings-pack operations that are command-only by design (§6.8).
|
|
12219
13235
|
* They stay reachable through `/novel admin <operation> <json>`; the caller
|
|
12220
13236
|
* supplies the same arguments the tool schema would have accepted.
|
|
@@ -12242,6 +13258,7 @@ const OPERATIONS$1 = {
|
|
|
12242
13258
|
novel_report: (engine, args) => engine.report(args),
|
|
12243
13259
|
novel_guide: (engine, args) => engine.guide(args),
|
|
12244
13260
|
novel_reopen: (engine, args) => engine.reopenChapter(args),
|
|
13261
|
+
novel_restore: (engine, args) => engine.restoreBackup(args),
|
|
12245
13262
|
novel_registry_list: (engine) => engine.listProjects(),
|
|
12246
13263
|
novel_registry_register: (engine, args) => engine.registerProject({ root: rootArg(args) }),
|
|
12247
13264
|
novel_registry_activate: (engine, args) => engine.setActiveProject({ root: rootArg(args) }),
|
|
@@ -12305,16 +13322,17 @@ const bad = (text) => ({
|
|
|
12305
13322
|
text
|
|
12306
13323
|
});
|
|
12307
13324
|
const failure = (error) => error instanceof EngineError ? bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`) : bad(String(error));
|
|
13325
|
+
const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
|
|
12308
13326
|
const statusText = async (engine) => {
|
|
12309
13327
|
const { data } = await engine.getStatus({});
|
|
12310
13328
|
return [
|
|
12311
13329
|
`《${data.project.title}》 ${data.project.genre} · ${data.project.platform}`,
|
|
12312
13330
|
`第 ${data.chapter} 章 · 阶段 ${data.stage}`,
|
|
12313
13331
|
`轮次 评审 ${data.rounds.review} / 修订 ${data.rounds.revise} / 校验 ${data.rounds.check}`,
|
|
12314
|
-
data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}
|
|
13332
|
+
data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}(汉字 ${data.metrics.cjk_chars} + 标点 ${data.metrics.punctuation_chars},含标点)`,
|
|
12315
13333
|
data.blocking.length === 0 ? "门控 全部通过" : `门控 ${data.blocking.length} 条阻塞:`,
|
|
12316
13334
|
...data.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
|
|
12317
|
-
...data.book === void 0 ? [] : [`全书:大纲 ${data.book.outline.total}
|
|
13335
|
+
...data.book === void 0 ? [] : [`全书:大纲 ${data.book.outline.total} 张卡(规划覆盖 ${formatPercent(data.book.outline.planned_coverage)} · 归档覆盖 ${formatPercent(data.book.outline.archived_coverage)}${data.book.outline.missing_cards > 0 ? ` · 缺卡 ${data.book.outline.missing_cards}` : ""})· 世界观 ${data.book.worldbook.characters} 角色 / ${data.book.worldbook.locations} 地点 / ${data.book.worldbook.factions} 势力 / ${data.book.worldbook.rules} 规则${data.book.strict ? " · 严格大纲" : ""}`, ...data.book.outline.current === null ? ["当前章卡:(无)"] : [`当前章卡:第${data.book.outline.current.num}章 ${data.book.outline.current.title} · ${data.book.outline.current.scene_type} · ${data.book.outline.current.target_words[0]}-${data.book.outline.current.target_words[1]} 字`]],
|
|
12318
13336
|
`下一步 ${data.next.tool}:${data.next.reason}`
|
|
12319
13337
|
].join("\n");
|
|
12320
13338
|
};
|
|
@@ -12351,7 +13369,7 @@ function createCommands(engine) {
|
|
|
12351
13369
|
}
|
|
12352
13370
|
case "stats": {
|
|
12353
13371
|
const stats = await engine.stats({});
|
|
12354
|
-
return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.
|
|
13372
|
+
return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
|
|
12355
13373
|
}
|
|
12356
13374
|
case "outline": {
|
|
12357
13375
|
const status = await engine.getStatus({});
|
|
@@ -12365,7 +13383,7 @@ function createCommands(engine) {
|
|
|
12365
13383
|
if (book === void 0) return ok("这本书还没有书级规划数据。");
|
|
12366
13384
|
return ok([
|
|
12367
13385
|
`蓝图:${book.premise === "" ? "(空)" : book.premise}`,
|
|
12368
|
-
`大纲:${book.outline.total} 张卡 ·
|
|
13386
|
+
`大纲:${book.outline.total} 张卡 · 规划覆盖 ${formatPercent(book.outline.planned_coverage)} · 归档覆盖 ${formatPercent(book.outline.archived_coverage)}${book.outline.missing_cards > 0 ? ` · 缺卡 ${book.outline.missing_cards}` : ""} · 严格模式 ${book.strict ? "开" : "关"}`,
|
|
12369
13387
|
`当前章卡:${book.outline.current === null ? "(无)" : `第${book.outline.current.num}章 ${book.outline.current.title} / ${book.outline.current.scene_type}`}`,
|
|
12370
13388
|
`世界观:${book.worldbook.characters} 角色 · ${book.worldbook.locations} 地点 · ${book.worldbook.factions} 势力 · ${book.worldbook.rules} 规则`
|
|
12371
13389
|
].join("\n"));
|
|
@@ -12454,7 +13472,7 @@ function createCommands(engine) {
|
|
|
12454
13472
|
if (invocation.signal.aborted) return bad("已取消");
|
|
12455
13473
|
try {
|
|
12456
13474
|
const stats = await engine.stats({});
|
|
12457
|
-
return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.
|
|
13475
|
+
return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
|
|
12458
13476
|
} catch (error) {
|
|
12459
13477
|
return failure(error);
|
|
12460
13478
|
}
|
|
@@ -12520,7 +13538,7 @@ function createSkill() {
|
|
|
12520
13538
|
/** Build the payload from one synchronized snapshot of the project. */
|
|
12521
13539
|
async function panelPayload(engine) {
|
|
12522
13540
|
return {
|
|
12523
|
-
schemaVersion:
|
|
13541
|
+
schemaVersion: 3,
|
|
12524
13542
|
...await engine.panelBundle()
|
|
12525
13543
|
};
|
|
12526
13544
|
}
|
|
@@ -12562,7 +13580,7 @@ function panelHandler(engine, guard = denyAll) {
|
|
|
12562
13580
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
12563
13581
|
response.setHeader("cache-control", "no-store");
|
|
12564
13582
|
response.end(JSON.stringify({
|
|
12565
|
-
schemaVersion:
|
|
13583
|
+
schemaVersion: 3,
|
|
12566
13584
|
error: "还没有项目:调用 novel_init 创建。",
|
|
12567
13585
|
empty: true
|
|
12568
13586
|
}));
|
|
@@ -12572,7 +13590,7 @@ function panelHandler(engine, guard = denyAll) {
|
|
|
12572
13590
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
12573
13591
|
response.setHeader("cache-control", "no-store");
|
|
12574
13592
|
response.end(JSON.stringify({
|
|
12575
|
-
schemaVersion:
|
|
13593
|
+
schemaVersion: 3,
|
|
12576
13594
|
error: error instanceof Error ? error.message : String(error)
|
|
12577
13595
|
}));
|
|
12578
13596
|
}
|