dsh-novel-write-engine 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -1
- package/README.md +145 -75
- package/briefs/P6.md +1 -0
- package/briefs/P8.md +2 -0
- package/lib/client.js +304 -31
- package/lib/index.d.ts +471 -24
- package/lib/index.js +2730 -314
- package/lib/types/client/dto.d.ts +35 -3
- package/lib/types/client/panel.d.ts +1 -1
- package/lib/types/client/viewmodel.d.ts +36 -2
- package/lib/types/constants.d.ts +15 -2
- package/lib/types/types.d.ts +140 -3
- package/package.json +1 -1
- package/presets/novel-write-engine.patch.yml +1 -1
- package/skills/entry.md +12 -4
package/lib/index.js
CHANGED
|
@@ -9,9 +9,14 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { deflateRawSync } from "node:zlib";
|
|
10
10
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
11
|
// src/constants.ts
|
|
12
|
+
/** Panel and session identifiers. */
|
|
13
|
+
const PLUGIN_NAME = "dsh-novel-write-engine";
|
|
12
14
|
const PANEL_ID = "novel-workshop";
|
|
13
15
|
const PANEL_ROUTE = "/api/novel-write-engine/panel";
|
|
14
16
|
const SKILL_NAME = "novel-write-engine";
|
|
17
|
+
const REMOTE_VERSION = 4;
|
|
18
|
+
/** The panel is a recent-history view; the wire payload and UI use one cap. */
|
|
19
|
+
const PANEL_CHAPTER_LIMIT = 30;
|
|
15
20
|
/** Project-relative locations. Every write goes through these constants. */
|
|
16
21
|
const PROJECT_FILE = "novel.yaml";
|
|
17
22
|
const OUTLINE_FILE = "outline/outline.yaml";
|
|
@@ -23,9 +28,16 @@ const REVIEW_DIR = "reviews";
|
|
|
23
28
|
const CHECK_DIR = "checks";
|
|
24
29
|
const EXPORT_DIR = "exports";
|
|
25
30
|
const BACKUP_DIR = "backups";
|
|
31
|
+
const HISTORY_DIR = "history";
|
|
32
|
+
/** Brief receipts are append-only evidence, separate from content transactions. */
|
|
33
|
+
const BRIEF_RECEIPTS_FILE = "state/briefs.yaml";
|
|
34
|
+
const OUTLINE_DIR = "outline";
|
|
26
35
|
const LOCK_FILE = ".engine.lock";
|
|
27
36
|
const STAGING_DIR = ".staging";
|
|
28
37
|
const REGISTRY_FILE = "projects.yaml";
|
|
38
|
+
const SESSION_BINDINGS_FILE = "session-bindings.yaml";
|
|
39
|
+
const METADATA_LOCK_FILE = ".metadata.lock";
|
|
40
|
+
const ARCHIVE_DIGESTS_FILE = "state/archive-digests.json";
|
|
29
41
|
/** Files owned by the engine under `state/`. */
|
|
30
42
|
const STATE_FILES = {
|
|
31
43
|
pipeline: "pipeline.yaml",
|
|
@@ -47,6 +59,21 @@ const planWorldFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3,
|
|
|
47
59
|
const planChapterFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3, "0")}.chapter.yaml`;
|
|
48
60
|
const reviewFile = (chapter) => `${REVIEW_DIR}/ch${String(chapter).padStart(3, "0")}.review.yaml`;
|
|
49
61
|
const checkFile = (chapter) => `${CHECK_DIR}/ch${String(chapter).padStart(3, "0")}.check.yaml`;
|
|
62
|
+
/** Immutable-ish prior versions; the primary paths always hold the current one. */
|
|
63
|
+
const historyChapter = (chapter) => `ch${String(chapter).padStart(3, "0")}`;
|
|
64
|
+
const historyRevision = (revision) => `r${String(Math.max(0, revision)).padStart(4, "0")}`;
|
|
65
|
+
const reviewHistoryFile = (chapter, revision) => `${HISTORY_DIR}/reviews/${historyChapter(chapter)}.${historyRevision(revision)}.review.yaml`;
|
|
66
|
+
const checkHistoryFile = (chapter, revision) => `${HISTORY_DIR}/checks/${historyChapter(chapter)}.${historyRevision(revision)}.check.yaml`;
|
|
67
|
+
const chapterHistoryFile = (chapter, revision) => `${HISTORY_DIR}/chapters/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
|
|
68
|
+
const deprecatedHistoryFile = (chapter, revision) => `${HISTORY_DIR}/deprecated/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
|
|
69
|
+
const planHistoryFile = (chapter, revision) => `${HISTORY_DIR}/plans/${historyChapter(chapter)}.${historyRevision(revision)}.chapter.yaml`;
|
|
70
|
+
const HISTORY_KINDS = [
|
|
71
|
+
"reviews",
|
|
72
|
+
"checks",
|
|
73
|
+
"chapters",
|
|
74
|
+
"deprecated",
|
|
75
|
+
"plans"
|
|
76
|
+
];
|
|
50
77
|
/** Limits that are structural, not taste. */
|
|
51
78
|
const LIMITS = {
|
|
52
79
|
/** P4 <-> P5/P6 retries before a forced rollback. */
|
|
@@ -72,6 +99,11 @@ const LIMITS = {
|
|
|
72
99
|
maxWorldbookTextChars: 2e3,
|
|
73
100
|
maxSearchQueryChars: 200
|
|
74
101
|
};
|
|
102
|
+
/** Re-exported enums make adapter code readable without importing types twice. */
|
|
103
|
+
const SCENE_TYPE_DEFAULT = "日常过渡章";
|
|
104
|
+
const DEFAULT_GENRE = "都市现代";
|
|
105
|
+
const DEFAULT_PLATFORM = "fanqie";
|
|
106
|
+
const INITIAL_STAGE = "idle";
|
|
75
107
|
/** The order used to render the nine-phase progress strip. */
|
|
76
108
|
const PHASE_STRIP = [
|
|
77
109
|
"P1",
|
|
@@ -84,6 +116,37 @@ const PHASE_STRIP = [
|
|
|
84
116
|
"P8",
|
|
85
117
|
"P9"
|
|
86
118
|
];
|
|
119
|
+
/** Tool names, kept here so the arch test can assert one namespace. */
|
|
120
|
+
const TOOL_NAMES = [
|
|
121
|
+
"novel_init",
|
|
122
|
+
"novel_status",
|
|
123
|
+
"novel_brief",
|
|
124
|
+
"novel_plan_world",
|
|
125
|
+
"novel_plan_chapter",
|
|
126
|
+
"novel_write_draft",
|
|
127
|
+
"novel_review",
|
|
128
|
+
"novel_revise",
|
|
129
|
+
"novel_check",
|
|
130
|
+
"novel_archive",
|
|
131
|
+
"novel_word_count",
|
|
132
|
+
"novel_state",
|
|
133
|
+
"novel_amend",
|
|
134
|
+
"novel_rollback",
|
|
135
|
+
"novel_diff",
|
|
136
|
+
"novel_export",
|
|
137
|
+
"novel_audit",
|
|
138
|
+
"novel_pipeline",
|
|
139
|
+
"novel_blueprint",
|
|
140
|
+
"novel_outline",
|
|
141
|
+
"novel_outline_import",
|
|
142
|
+
"novel_worldbook",
|
|
143
|
+
"novel_search",
|
|
144
|
+
"novel_doctor",
|
|
145
|
+
"novel_report",
|
|
146
|
+
"novel_guide",
|
|
147
|
+
"novel_reopen",
|
|
148
|
+
"novel_restore"
|
|
149
|
+
];
|
|
87
150
|
/** The ten high-frequency tools kept when `tools.mode = core`. */
|
|
88
151
|
const CORE_TOOLS = [
|
|
89
152
|
"novel_init",
|
|
@@ -134,7 +197,7 @@ const SCENE_TYPES = [
|
|
|
134
197
|
"情感爆发章",
|
|
135
198
|
"纯叙事章"
|
|
136
199
|
];
|
|
137
|
-
/** All thirty-
|
|
200
|
+
/** All thirty-two service operations (22 inherited + 9 book-level + restoreBackup). */
|
|
138
201
|
const OPERATIONS = [
|
|
139
202
|
"initProject",
|
|
140
203
|
"archiveChapter",
|
|
@@ -166,11 +229,15 @@ const OPERATIONS = [
|
|
|
166
229
|
"panelSummary",
|
|
167
230
|
"panelChapters",
|
|
168
231
|
"panelGates",
|
|
169
|
-
"stats"
|
|
232
|
+
"stats",
|
|
233
|
+
"restoreBackup"
|
|
170
234
|
];
|
|
171
235
|
/** Error families map one-to-one to the three repair actions a caller can take. */
|
|
172
236
|
const ERROR_CODES = {
|
|
173
237
|
PROJECT_NOT_FOUND: "ENGINE_PROJECT_NOT_FOUND",
|
|
238
|
+
PROJECT_AMBIGUOUS: "ENGINE_PROJECT_AMBIGUOUS",
|
|
239
|
+
PROJECT_MISSING: "ENGINE_PROJECT_MISSING",
|
|
240
|
+
PROJECT_IDENTITY_MISMATCH: "ENGINE_PROJECT_IDENTITY_MISMATCH",
|
|
174
241
|
PROJECT_EXISTS: "ENGINE_PROJECT_EXISTS",
|
|
175
242
|
STAGE_ORDER: "ENGINE_STAGE_ORDER",
|
|
176
243
|
GATE_BLOCKED: "ENGINE_GATE_BLOCKED",
|
|
@@ -676,8 +743,10 @@ const STAGE_NEXT = {
|
|
|
676
743
|
const CJK = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g;
|
|
677
744
|
const ENGLISH_WORD = /[A-Za-z]+/g;
|
|
678
745
|
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;
|
|
746
|
+
const CHINESE_PUNCT = /[\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/g;
|
|
747
|
+
const ENGLISH_PUNCT = /[^\p{L}\p{N}_\s\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/gu;
|
|
748
|
+
/** A line participates in dialogue if it contains any of the four common quote pairs. */
|
|
749
|
+
const DIALOGUE_MARKS = /["“”「」『』]/;
|
|
681
750
|
const matchCount = (text, pattern) => text.match(pattern)?.length ?? 0;
|
|
682
751
|
/**
|
|
683
752
|
* The one word-count rule of the engine.
|
|
@@ -694,15 +763,17 @@ function countWordMetrics(text) {
|
|
|
694
763
|
const numbers = matchCount(text, NUMBER);
|
|
695
764
|
const chinesePunct = matchCount(text, CHINESE_PUNCT);
|
|
696
765
|
const englishPunct = matchCount(text, ENGLISH_PUNCT);
|
|
697
|
-
const dialogueLines = nonEmptyLines.filter((line) =>
|
|
766
|
+
const dialogueLines = nonEmptyLines.filter((line) => DIALOGUE_MARKS.test(line)).length;
|
|
698
767
|
const paragraphs = splitParagraphs(text);
|
|
768
|
+
const punctuation = chinesePunct + englishPunct;
|
|
699
769
|
return {
|
|
700
|
-
total_word_count: cjkChars + englishWords + numbers +
|
|
770
|
+
total_word_count: cjkChars + englishWords + numbers + punctuation,
|
|
701
771
|
cjk_chars: cjkChars,
|
|
702
772
|
english_words: englishWords,
|
|
703
773
|
numbers,
|
|
704
774
|
chinese_punct: chinesePunct,
|
|
705
775
|
english_punct: englishPunct,
|
|
776
|
+
punctuation_chars: punctuation,
|
|
706
777
|
non_ws_chars: matchCount(text, /\S/g),
|
|
707
778
|
total_lines: lines.length,
|
|
708
779
|
non_empty_lines: nonEmptyLines.length,
|
|
@@ -768,7 +839,10 @@ const SEMANTIC_RULES = {
|
|
|
768
839
|
ADJECTIVE_DENSITY: "ADJECTIVE_DENSITY",
|
|
769
840
|
PLEASURE_DENSITY: "PLEASURE_DENSITY",
|
|
770
841
|
SENSORY_DENSITY: "SENSORY_DENSITY",
|
|
771
|
-
WATER_WORDS: "WATER_WORDS"
|
|
842
|
+
WATER_WORDS: "WATER_WORDS",
|
|
843
|
+
DIALOGUE_RATIO: "DIALOGUE_RATIO",
|
|
844
|
+
NUMERIC_CONTRADICTION: "NUMERIC_CONTRADICTION",
|
|
845
|
+
NUMERIC_FACT_MISMATCH: "NUMERIC_FACT_MISMATCH"
|
|
772
846
|
};
|
|
773
847
|
/** Cross-chapter families. */
|
|
774
848
|
const CROSS_RULES = {
|
|
@@ -1040,6 +1114,142 @@ const slidingWindows = (text, size) => {
|
|
|
1040
1114
|
if (windows[windows.length - 1] !== tail) windows.push(tail);
|
|
1041
1115
|
return windows;
|
|
1042
1116
|
};
|
|
1117
|
+
const dialogueRatio = (ctx) => {
|
|
1118
|
+
const target = ctx.targets?.dialogue_ratio;
|
|
1119
|
+
if (target === void 0) return [];
|
|
1120
|
+
const ratio = countWordMetrics(ctx.text).dialogue_ratio;
|
|
1121
|
+
if (ratio >= target[0] && ratio <= target[1]) return [];
|
|
1122
|
+
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)`)];
|
|
1123
|
+
};
|
|
1124
|
+
const CN_DIGITS = {
|
|
1125
|
+
零: 0,
|
|
1126
|
+
一: 1,
|
|
1127
|
+
二: 2,
|
|
1128
|
+
两: 2,
|
|
1129
|
+
三: 3,
|
|
1130
|
+
四: 4,
|
|
1131
|
+
五: 5,
|
|
1132
|
+
六: 6,
|
|
1133
|
+
七: 7,
|
|
1134
|
+
八: 8,
|
|
1135
|
+
九: 9
|
|
1136
|
+
};
|
|
1137
|
+
const CN_UNITS = {
|
|
1138
|
+
十: 10,
|
|
1139
|
+
百: 100,
|
|
1140
|
+
千: 1e3,
|
|
1141
|
+
万: 1e4
|
|
1142
|
+
};
|
|
1143
|
+
/** Parse a small Chinese/Arabic number; null means the token was not a number. */
|
|
1144
|
+
function parseSmallNumber(raw) {
|
|
1145
|
+
if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
|
|
1146
|
+
let total = 0;
|
|
1147
|
+
let section = 0;
|
|
1148
|
+
let digit = null;
|
|
1149
|
+
for (const char of raw) {
|
|
1150
|
+
if (char in CN_DIGITS) {
|
|
1151
|
+
digit = CN_DIGITS[char];
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const unit = CN_UNITS[char];
|
|
1155
|
+
if (unit === void 0) return null;
|
|
1156
|
+
if (unit === 1e4) {
|
|
1157
|
+
section = (section + (digit ?? 0)) * unit;
|
|
1158
|
+
total += section;
|
|
1159
|
+
section = 0;
|
|
1160
|
+
digit = null;
|
|
1161
|
+
} else {
|
|
1162
|
+
section += (digit ?? 1) * unit;
|
|
1163
|
+
digit = null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return total + section + (digit ?? 0);
|
|
1167
|
+
}
|
|
1168
|
+
const NUMBER_TOKEN = "[零一二两三四五六七八九十百千万]+|\\d+(?:\\.\\d+)?";
|
|
1169
|
+
const DISTANCE_RE = new RegExp(`(${NUMBER_TOKEN})\\s*(海里|公里|千米|米|里)`, "g");
|
|
1170
|
+
const DURATION_RE = new RegExp(`(${NUMBER_TOKEN})\\s*个?\\s*(小时|钟头|分钟)`, "g");
|
|
1171
|
+
const SPEED_RE = new RegExp(`(?:时速|每小时|速度(?:是|为)?)\\s*(${NUMBER_TOKEN})\\s*(海里|公里|千米|米)`, "g");
|
|
1172
|
+
const METRES = {
|
|
1173
|
+
米: 1,
|
|
1174
|
+
里: 500,
|
|
1175
|
+
公里: 1e3,
|
|
1176
|
+
千米: 1e3,
|
|
1177
|
+
海里: 1852
|
|
1178
|
+
};
|
|
1179
|
+
const HOURS = {
|
|
1180
|
+
分钟: 1 / 60,
|
|
1181
|
+
小时: 1,
|
|
1182
|
+
钟头: 1
|
|
1183
|
+
};
|
|
1184
|
+
const collect = (text, pattern, units) => {
|
|
1185
|
+
const result = [];
|
|
1186
|
+
for (const match of text.matchAll(pattern)) {
|
|
1187
|
+
const value = parseSmallNumber(match[1] ?? "");
|
|
1188
|
+
if (value === null || units[match[2] ?? ""] === void 0) continue;
|
|
1189
|
+
result.push({
|
|
1190
|
+
value,
|
|
1191
|
+
unit: match[2],
|
|
1192
|
+
index: match.index ?? 0,
|
|
1193
|
+
raw: match[0]
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
return result;
|
|
1197
|
+
};
|
|
1198
|
+
const numericContradiction = (ctx) => {
|
|
1199
|
+
const explicit = collect(ctx.text, SPEED_RE, METRES).map((quantity) => ({
|
|
1200
|
+
at: quantity.index,
|
|
1201
|
+
kmh: quantity.value * METRES[quantity.unit] / 1e3,
|
|
1202
|
+
raw: quantity.raw
|
|
1203
|
+
}));
|
|
1204
|
+
const distances = collect(ctx.text, DISTANCE_RE, METRES);
|
|
1205
|
+
const durations = collect(ctx.text, DURATION_RE, HOURS);
|
|
1206
|
+
const speeds = [];
|
|
1207
|
+
if (distances.length > 0 && durations.length > 0) for (const distance of distances) {
|
|
1208
|
+
const nearest = durations.map((duration) => ({
|
|
1209
|
+
duration,
|
|
1210
|
+
distance: Math.abs(duration.index - distance.index)
|
|
1211
|
+
})).sort((left, right) => left.distance - right.distance)[0];
|
|
1212
|
+
if (nearest === void 0 || nearest.distance > 60) continue;
|
|
1213
|
+
const metres = distance.value * METRES[distance.unit];
|
|
1214
|
+
const hours = nearest.duration.value * HOURS[nearest.duration.unit];
|
|
1215
|
+
if (hours <= 0) continue;
|
|
1216
|
+
speeds.push({
|
|
1217
|
+
at: Math.min(distance.index, nearest.duration.index),
|
|
1218
|
+
kmh: metres / 1e3 / hours,
|
|
1219
|
+
raw: `${distance.raw} / ${nearest.duration.raw}`
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
const anchors = explicit.length > 0 ? explicit : speeds;
|
|
1223
|
+
if (anchors.length < 2) return [];
|
|
1224
|
+
const positive = anchors.filter((anchor) => anchor.kmh > 0);
|
|
1225
|
+
if (positive.length < 2) return [];
|
|
1226
|
+
const min = Math.min(...positive.map((anchor) => anchor.kmh));
|
|
1227
|
+
const max = Math.max(...positive.map((anchor) => anchor.kmh));
|
|
1228
|
+
if (max / Math.max(1e-4, min) < 1.5) return [];
|
|
1229
|
+
const firstNeedle = positive[0].raw.split(" / ")[0];
|
|
1230
|
+
const paragraphIndex = splitParagraphs(ctx.text).findIndex((paragraph) => paragraph.includes(firstNeedle));
|
|
1231
|
+
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("; ")}`)];
|
|
1232
|
+
};
|
|
1233
|
+
const escapePattern = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1234
|
+
const numericFactMismatch = (ctx) => {
|
|
1235
|
+
if (ctx.facts === void 0 || ctx.facts.length === 0) return [];
|
|
1236
|
+
const results = [];
|
|
1237
|
+
const paragraphs = splitParagraphs(ctx.text);
|
|
1238
|
+
for (const fact of ctx.facts) {
|
|
1239
|
+
if (fact.alias.trim() === "" || !Number.isFinite(fact.value)) continue;
|
|
1240
|
+
const tolerance = fact.tolerance ?? 0;
|
|
1241
|
+
const pattern = new RegExp(`${escapePattern(fact.alias)}\\s*(?:是|为|约|有|:|:)?\\s*(${NUMBER_TOKEN})\\s*${escapePattern(fact.unit)}`, "g");
|
|
1242
|
+
for (const match of ctx.text.matchAll(pattern)) {
|
|
1243
|
+
const value = parseSmallNumber(match[1] ?? "");
|
|
1244
|
+
if (value === null) continue;
|
|
1245
|
+
if (Math.abs(value - fact.value) <= tolerance) continue;
|
|
1246
|
+
const paragraph = paragraphs.findIndex((candidate) => candidate.includes(match[0]));
|
|
1247
|
+
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}`));
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return results;
|
|
1252
|
+
};
|
|
1043
1253
|
const pleasureDensity = (ctx) => {
|
|
1044
1254
|
const { min, keywords, window } = ctx.rulepack.semantics.pleasure_density;
|
|
1045
1255
|
if (keywords.length === 0) return [];
|
|
@@ -1238,6 +1448,9 @@ const BUILTIN_RULES = {
|
|
|
1238
1448
|
[SEMANTIC_RULES.PLEASURE_DENSITY]: pleasureDensity,
|
|
1239
1449
|
[SEMANTIC_RULES.SENSORY_DENSITY]: sensoryDensity,
|
|
1240
1450
|
[SEMANTIC_RULES.WATER_WORDS]: waterWords,
|
|
1451
|
+
[SEMANTIC_RULES.DIALOGUE_RATIO]: dialogueRatio,
|
|
1452
|
+
[SEMANTIC_RULES.NUMERIC_CONTRADICTION]: numericContradiction,
|
|
1453
|
+
[SEMANTIC_RULES.NUMERIC_FACT_MISMATCH]: numericFactMismatch,
|
|
1241
1454
|
[CROSS_RULES.DUPLICATE_PARAGRAPH]: duplicateParagraph,
|
|
1242
1455
|
[CROSS_RULES.FORESHADOW_DORMANT]: foreshadowDormant,
|
|
1243
1456
|
[CROSS_RULES.FS_REVISITED_TOO_SOON]: revisitedTooSoon,
|
|
@@ -1455,6 +1668,7 @@ const SCHEMAS = {
|
|
|
1455
1668
|
rounds: req("object"),
|
|
1456
1669
|
briefs: req("object"),
|
|
1457
1670
|
blocking: req("array"),
|
|
1671
|
+
draft: f("object"),
|
|
1458
1672
|
review_digest: f("string"),
|
|
1459
1673
|
last_action: f("any")
|
|
1460
1674
|
},
|
|
@@ -1510,6 +1724,7 @@ const SCHEMAS = {
|
|
|
1510
1724
|
content_hash: req("string"),
|
|
1511
1725
|
machine: req("object"),
|
|
1512
1726
|
semantic: req("object"),
|
|
1727
|
+
acknowledged: f("array"),
|
|
1513
1728
|
verdict: req("string", { enum: ["pass", "fail"] })
|
|
1514
1729
|
},
|
|
1515
1730
|
worldPlan: {
|
|
@@ -1546,6 +1761,7 @@ const SCHEMAS = {
|
|
|
1546
1761
|
characters: req("array"),
|
|
1547
1762
|
glossary: req("array"),
|
|
1548
1763
|
backstory: req("array"),
|
|
1764
|
+
facts: f("array"),
|
|
1549
1765
|
themes: req("array")
|
|
1550
1766
|
}
|
|
1551
1767
|
};
|
|
@@ -1625,6 +1841,26 @@ const validateNested = (kind, record) => {
|
|
|
1625
1841
|
if (targets.total_chapters !== null) integerAt(kind, targets.total_chapters, "targets.total_chapters");
|
|
1626
1842
|
return;
|
|
1627
1843
|
}
|
|
1844
|
+
case "pipeline": {
|
|
1845
|
+
const rounds = objectAt(kind, record.rounds, "rounds");
|
|
1846
|
+
for (const key of [
|
|
1847
|
+
"review",
|
|
1848
|
+
"revise",
|
|
1849
|
+
"check"
|
|
1850
|
+
]) integerAt(kind, rounds[key], `rounds.${key}`);
|
|
1851
|
+
if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, "rounds.rollbacks");
|
|
1852
|
+
if (record.draft !== void 0) {
|
|
1853
|
+
const draft = objectAt(kind, record.draft, "draft");
|
|
1854
|
+
integerAt(kind, draft.chapter, "draft.chapter");
|
|
1855
|
+
stringAt(kind, draft.title, "draft.title");
|
|
1856
|
+
stringAt(kind, draft.scene_type, "draft.scene_type");
|
|
1857
|
+
stringAt(kind, draft.narrator, "draft.narrator");
|
|
1858
|
+
stringAt(kind, draft.content_hash, "draft.content_hash");
|
|
1859
|
+
stringAt(kind, draft.updated_at, "draft.updated_at");
|
|
1860
|
+
validateSummary(kind, draft.summary, "draft.summary");
|
|
1861
|
+
}
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1628
1864
|
case "chapters":
|
|
1629
1865
|
validateRecords(kind, record.chapters, "chapters", (row, path) => {
|
|
1630
1866
|
integerAt(kind, row.num, `${path}.num`);
|
|
@@ -1641,6 +1877,17 @@ const validateNested = (kind, record) => {
|
|
|
1641
1877
|
validateSummary(kind, row.summary, `${path}.summary`);
|
|
1642
1878
|
if (row.assigned_ids !== void 0) objectAt(kind, row.assigned_ids, `${path}.assigned_ids`);
|
|
1643
1879
|
optionalString(kind, row.archive_digest, `${path}.archive_digest`);
|
|
1880
|
+
optionalString(kind, row.final_check, `${path}.final_check`);
|
|
1881
|
+
optionalString(kind, row.archived_at, `${path}.archived_at`);
|
|
1882
|
+
if (row.rounds !== void 0) {
|
|
1883
|
+
const rounds = objectAt(kind, row.rounds, `${path}.rounds`);
|
|
1884
|
+
for (const key of [
|
|
1885
|
+
"review",
|
|
1886
|
+
"revise",
|
|
1887
|
+
"check"
|
|
1888
|
+
]) integerAt(kind, rounds[key], `${path}.rounds.${key}`);
|
|
1889
|
+
if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, `${path}.rounds.rollbacks`);
|
|
1890
|
+
}
|
|
1644
1891
|
});
|
|
1645
1892
|
return;
|
|
1646
1893
|
case "characters":
|
|
@@ -1699,6 +1946,7 @@ const validateNested = (kind, record) => {
|
|
|
1699
1946
|
integerAt(kind, row.last_touched, `${path}.last_touched`);
|
|
1700
1947
|
stringAt(kind, row.status, `${path}.status`);
|
|
1701
1948
|
stringAt(kind, row.description, `${path}.description`);
|
|
1949
|
+
optionalString(kind, row.anchor, `${path}.anchor`);
|
|
1702
1950
|
listAt(kind, row.advance_log, `${path}.advance_log`);
|
|
1703
1951
|
});
|
|
1704
1952
|
return;
|
|
@@ -1982,6 +2230,61 @@ var FsStore = class {
|
|
|
1982
2230
|
throw error;
|
|
1983
2231
|
}
|
|
1984
2232
|
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Directory entries without following symlinks. Used by bounded workspace
|
|
2235
|
+
* discovery so a symlinked child is never mistaken for a project.
|
|
2236
|
+
*/
|
|
2237
|
+
async entries(relativeDir) {
|
|
2238
|
+
try {
|
|
2239
|
+
const absolute = relativeDir === "." ? resolve(this.root) : await this.safePath(relativeDir);
|
|
2240
|
+
return (await promises.readdir(absolute, { withFileTypes: true })).map((entry) => ({
|
|
2241
|
+
name: entry.name,
|
|
2242
|
+
directory: entry.isDirectory(),
|
|
2243
|
+
symbolicLink: entry.isSymbolicLink()
|
|
2244
|
+
})).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
if (isMissing(error)) return [];
|
|
2247
|
+
throw error;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* Metadata for a path without following the final symlink. A scandir probe
|
|
2252
|
+
* only needs to know that a candidate is a real directory; it must not read
|
|
2253
|
+
* through a link to decide that.
|
|
2254
|
+
*/
|
|
2255
|
+
async lstat(relativePath) {
|
|
2256
|
+
try {
|
|
2257
|
+
const info = await promises.lstat(relativePath === "." ? resolve(this.root) : this.path(relativePath));
|
|
2258
|
+
return {
|
|
2259
|
+
directory: info.isDirectory(),
|
|
2260
|
+
symbolicLink: info.isSymbolicLink(),
|
|
2261
|
+
file: info.isFile()
|
|
2262
|
+
};
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
if (isMissing(error)) return null;
|
|
2265
|
+
throw error;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Follow a project-root symlink when classifying the root itself. Child
|
|
2270
|
+
* discovery still uses lstat/entries, so a symlinked *subdirectory* remains
|
|
2271
|
+
* out of workspace scans. The device/inode pair lets a transaction detect a
|
|
2272
|
+
* root that was deleted and recreated under the same path.
|
|
2273
|
+
*/
|
|
2274
|
+
async statRoot() {
|
|
2275
|
+
try {
|
|
2276
|
+
const info = await promises.stat(resolve(this.root));
|
|
2277
|
+
return {
|
|
2278
|
+
directory: info.isDirectory(),
|
|
2279
|
+
dev: info.dev,
|
|
2280
|
+
ino: info.ino,
|
|
2281
|
+
birthtimeMs: info.birthtimeMs
|
|
2282
|
+
};
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
if (isMissing(error)) return null;
|
|
2285
|
+
throw error;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
1985
2288
|
async readText(relativePath) {
|
|
1986
2289
|
try {
|
|
1987
2290
|
return await promises.readFile(await this.safePath(relativePath), "utf8");
|
|
@@ -2002,10 +2305,33 @@ var FsStore = class {
|
|
|
2002
2305
|
}
|
|
2003
2306
|
return narrow(kind, (typeof parsed === "object" && parsed !== null && typeof parsed.schema_version === "number" ? parsed.schema_version : 1) < 1 ? migrateDocument(parsed, MIGRATIONS, 1) : parsed);
|
|
2004
2307
|
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Create missing directories below the store root without ever recreating the
|
|
2310
|
+
* root itself. `mkdir(..., {recursive:true})` silently recreates a deleted
|
|
2311
|
+
* project directory; walking one segment at a time makes the first missing
|
|
2312
|
+
* root fail with ENOENT and keeps deleted projects deleted.
|
|
2313
|
+
*/
|
|
2314
|
+
async ensureParent(absolutePath) {
|
|
2315
|
+
const root = resolve(this.root);
|
|
2316
|
+
const parent = dirname(absolutePath);
|
|
2317
|
+
const rel = relative(root, parent);
|
|
2318
|
+
if (rel === "" || rel === ".") return;
|
|
2319
|
+
let current = root;
|
|
2320
|
+
for (const segment of rel.split(sep)) {
|
|
2321
|
+
if (segment === "") continue;
|
|
2322
|
+
current = join(current, segment);
|
|
2323
|
+
try {
|
|
2324
|
+
await promises.mkdir(current);
|
|
2325
|
+
} catch (error) {
|
|
2326
|
+
if (error.code === "EEXIST") continue;
|
|
2327
|
+
throw error;
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2005
2331
|
/** Write text atomically: temp file, fsync, rename, directory fsync. */
|
|
2006
2332
|
async writeText(relativePath, text) {
|
|
2007
2333
|
const absolute = await this.safePath(relativePath);
|
|
2008
|
-
await
|
|
2334
|
+
await this.ensureParent(absolute);
|
|
2009
2335
|
const temp = `${absolute}.${randomUUID()}.tmp`;
|
|
2010
2336
|
try {
|
|
2011
2337
|
const handle = await promises.open(temp, "w");
|
|
@@ -2042,7 +2368,7 @@ var FsStore = class {
|
|
|
2042
2368
|
/** Raw append used only by the journal (JSONL is append-only by design). */
|
|
2043
2369
|
async appendText(relativePath, text) {
|
|
2044
2370
|
const absolute = await this.safePath(relativePath);
|
|
2045
|
-
await
|
|
2371
|
+
await this.ensureParent(absolute);
|
|
2046
2372
|
const handle = await promises.open(absolute, "a+");
|
|
2047
2373
|
try {
|
|
2048
2374
|
const info = await handle.stat();
|
|
@@ -2069,7 +2395,7 @@ var FsStore = class {
|
|
|
2069
2395
|
}
|
|
2070
2396
|
async writeBytes(relativePath, bytes) {
|
|
2071
2397
|
const absolute = await this.safePath(relativePath);
|
|
2072
|
-
await
|
|
2398
|
+
await this.ensureParent(absolute);
|
|
2073
2399
|
const temp = `${absolute}.${randomUUID()}.tmp`;
|
|
2074
2400
|
try {
|
|
2075
2401
|
const handle = await promises.open(temp, "w");
|
|
@@ -2153,17 +2479,29 @@ async function readJournal(store) {
|
|
|
2153
2479
|
status: raw.status
|
|
2154
2480
|
};
|
|
2155
2481
|
if (typeof raw.reason === "string") record.reason = raw.reason;
|
|
2482
|
+
if (typeof raw.code === "string") record.code = raw.code;
|
|
2483
|
+
if (typeof raw.hint === "string") record.hint = raw.hint;
|
|
2484
|
+
if (typeof raw.stage === "string") record.stage = raw.stage;
|
|
2485
|
+
if (raw.attempt === true) record.attempt = true;
|
|
2486
|
+
if (Array.isArray(raw.blocking)) record.blocking = raw.blocking.map((item) => typeof item === "object" && item !== null ? item : null).filter((item) => item !== null);
|
|
2156
2487
|
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
2488
|
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
2489
|
records.push(record);
|
|
2159
2490
|
if (record.status !== "pending") rows.push({
|
|
2491
|
+
seq: rows.length + 1,
|
|
2160
2492
|
at: record.at,
|
|
2161
2493
|
op: record.op,
|
|
2162
2494
|
chapter: record.chapter,
|
|
2163
2495
|
revision: record.revision,
|
|
2164
2496
|
txid: record.txid,
|
|
2165
2497
|
status: record.status,
|
|
2166
|
-
files: record.files
|
|
2498
|
+
files: record.files,
|
|
2499
|
+
...record.code === void 0 ? {} : { code: record.code },
|
|
2500
|
+
...record.reason === void 0 ? {} : { reason: record.reason },
|
|
2501
|
+
...record.hint === void 0 ? {} : { hint: record.hint },
|
|
2502
|
+
...record.stage === void 0 ? {} : { stage: record.stage },
|
|
2503
|
+
...record.attempt === true ? { attempt: true } : {},
|
|
2504
|
+
...record.blocking === void 0 ? {} : { blocking: record.blocking }
|
|
2167
2505
|
});
|
|
2168
2506
|
} catch {
|
|
2169
2507
|
malformed += 1;
|
|
@@ -2187,7 +2525,7 @@ function assertJournalHealthy(malformed) {
|
|
|
2187
2525
|
async function reconcileJournal(store) {
|
|
2188
2526
|
const { records, malformed } = await readJournal(store);
|
|
2189
2527
|
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";
|
|
2528
|
+
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
2529
|
for (const record of records) {
|
|
2192
2530
|
if (record.status !== "committed" || record.hashes === void 0) continue;
|
|
2193
2531
|
for (const [path, hash] of Object.entries(record.hashes)) {
|
|
@@ -2254,6 +2592,7 @@ async function recoverPending(store) {
|
|
|
2254
2592
|
await appendJournal(store, {
|
|
2255
2593
|
...last,
|
|
2256
2594
|
status: "aborted",
|
|
2595
|
+
code: ERROR_CODES.TXN_ABORTED,
|
|
2257
2596
|
reason: "recovered: the process stopped before the transaction renamed a target"
|
|
2258
2597
|
});
|
|
2259
2598
|
await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
|
|
@@ -2480,6 +2819,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
2480
2819
|
// src/store/backup.ts
|
|
2481
2820
|
/** Marks a directory this engine owns; user files under backups/ are never pruned. */
|
|
2482
2821
|
const BACKUP_MARKER = ".engine-backup";
|
|
2822
|
+
const BACKUP_MANIFEST = "manifest.json";
|
|
2483
2823
|
/** Backup transaction directories, newest first by filesystem mtime. */
|
|
2484
2824
|
async function listBackups(store) {
|
|
2485
2825
|
const entries = [];
|
|
@@ -2487,13 +2827,51 @@ async function listBackups(store) {
|
|
|
2487
2827
|
const info = await store.stat(joinRel(BACKUP_DIR, name));
|
|
2488
2828
|
if (info === null || !info.isDirectory) continue;
|
|
2489
2829
|
if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
|
|
2830
|
+
const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
|
|
2831
|
+
let manifest = null;
|
|
2832
|
+
if (raw !== null) try {
|
|
2833
|
+
manifest = JSON.parse(raw);
|
|
2834
|
+
} catch {
|
|
2835
|
+
manifest = null;
|
|
2836
|
+
}
|
|
2837
|
+
const fileNames = Array.isArray(manifest?.files) ? manifest.files.filter((file) => typeof file === "string") : [];
|
|
2490
2838
|
entries.push({
|
|
2491
2839
|
name,
|
|
2492
|
-
mtimeMs: info.mtimeMs
|
|
2840
|
+
mtimeMs: info.mtimeMs,
|
|
2841
|
+
txid: typeof manifest?.txid === "string" ? manifest.txid : name,
|
|
2842
|
+
op: typeof manifest?.op === "string" ? manifest.op : null,
|
|
2843
|
+
chapter: typeof manifest?.chapter === "number" ? manifest.chapter : null,
|
|
2844
|
+
revision: typeof manifest?.revision === "number" ? manifest.revision : null,
|
|
2845
|
+
at: typeof manifest?.at === "string" ? manifest.at : null,
|
|
2846
|
+
reason: typeof manifest?.reason === "string" ? manifest.reason : null,
|
|
2847
|
+
files: fileNames
|
|
2493
2848
|
});
|
|
2494
2849
|
}
|
|
2495
2850
|
return entries.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
2496
2851
|
}
|
|
2852
|
+
/** Read one manifest, refusing a directory this engine did not create. */
|
|
2853
|
+
async function readBackupManifest(store, name) {
|
|
2854
|
+
if (!/^[0-9a-f-]{36}$/i.test(name)) return null;
|
|
2855
|
+
if (!await store.exists(joinRel("backups", name, ".engine-backup"))) return null;
|
|
2856
|
+
const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
|
|
2857
|
+
if (raw === null) return null;
|
|
2858
|
+
try {
|
|
2859
|
+
const value = JSON.parse(raw);
|
|
2860
|
+
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;
|
|
2861
|
+
return {
|
|
2862
|
+
schema_version: 1,
|
|
2863
|
+
txid: value.txid,
|
|
2864
|
+
op: value.op,
|
|
2865
|
+
chapter: value.chapter,
|
|
2866
|
+
revision: value.revision,
|
|
2867
|
+
at: value.at,
|
|
2868
|
+
...typeof value.reason === "string" ? { reason: value.reason } : {},
|
|
2869
|
+
files: value.files.filter((file) => typeof file === "string")
|
|
2870
|
+
};
|
|
2871
|
+
} catch {
|
|
2872
|
+
return null;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2497
2875
|
/**
|
|
2498
2876
|
* Keep the newest `keep` backup directories. Only directories directly under
|
|
2499
2877
|
* `backups/` are considered, and only directories this engine created are
|
|
@@ -2557,8 +2935,37 @@ async function runTransaction(store, input) {
|
|
|
2557
2935
|
let journaledCommitted = false;
|
|
2558
2936
|
let backupMarked = false;
|
|
2559
2937
|
let backupComplete = false;
|
|
2938
|
+
let rootDisappeared = false;
|
|
2939
|
+
let rootIdentity = null;
|
|
2940
|
+
const rootKey = (info) => `${info.dev}:${info.ino}:${info.birthtimeMs}`;
|
|
2941
|
+
const rootUnavailable = async () => {
|
|
2942
|
+
try {
|
|
2943
|
+
const info = await store.statRoot();
|
|
2944
|
+
return info === null || !info.directory || rootIdentity !== null && rootKey(info) !== rootIdentity;
|
|
2945
|
+
} catch {
|
|
2946
|
+
return false;
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2949
|
+
const assertRootPresent = async () => {
|
|
2950
|
+
const info = await store.statRoot();
|
|
2951
|
+
if (info === null || !info.directory) {
|
|
2952
|
+
rootDisappeared = true;
|
|
2953
|
+
throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project root disappeared during the transaction", "restore the directory/mount and retry; the engine will not recreate a deleted project");
|
|
2954
|
+
}
|
|
2955
|
+
const key = rootKey(info);
|
|
2956
|
+
if (rootIdentity === null) {
|
|
2957
|
+
rootIdentity = key;
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
if (key !== rootIdentity) {
|
|
2961
|
+
rootDisappeared = true;
|
|
2962
|
+
throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project root was replaced during the transaction", "restore the original directory/mount and retry; the engine will not write into a replacement project");
|
|
2963
|
+
}
|
|
2964
|
+
};
|
|
2560
2965
|
try {
|
|
2966
|
+
await assertRootPresent();
|
|
2561
2967
|
for (const file of input.files) {
|
|
2968
|
+
await assertRootPresent();
|
|
2562
2969
|
const target = joinRel(staging, file.path);
|
|
2563
2970
|
if (file.bytes !== void 0) await store.writeBytes(target, file.bytes);
|
|
2564
2971
|
else await store.writeText(target, file.text ?? "");
|
|
@@ -2571,6 +2978,7 @@ async function runTransaction(store, input) {
|
|
|
2571
2978
|
if (sha256(stagedBytes.toString("base64")) !== expected.get(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `staged copy of ${file.path} failed verification`);
|
|
2572
2979
|
}
|
|
2573
2980
|
if (input.afterVerify !== void 0) await input.afterVerify();
|
|
2981
|
+
await assertRootPresent();
|
|
2574
2982
|
for (const file of input.files) before.set(file.path, await store.readBytes(file.path));
|
|
2575
2983
|
for (const file of input.files) {
|
|
2576
2984
|
const existing = before.get(file.path);
|
|
@@ -2585,6 +2993,16 @@ async function runTransaction(store, input) {
|
|
|
2585
2993
|
await store.writeBytes(joinRel(stagingBackup, file.path), existing);
|
|
2586
2994
|
}
|
|
2587
2995
|
await store.writeText(joinRel(stagingBackup, BACKUP_MARKER), "1");
|
|
2996
|
+
await store.writeText(joinRel(stagingBackup, BACKUP_MANIFEST), `${JSON.stringify({
|
|
2997
|
+
schema_version: 1,
|
|
2998
|
+
txid: input.txid,
|
|
2999
|
+
op: input.op,
|
|
3000
|
+
chapter: input.chapter,
|
|
3001
|
+
revision: input.revision,
|
|
3002
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3003
|
+
...input.reason === void 0 ? {} : { reason: input.reason },
|
|
3004
|
+
files: input.files.filter((file) => before.get(file.path) !== null && before.get(file.path) !== void 0).map((file) => file.path)
|
|
3005
|
+
}, null, 2)}\n`);
|
|
2588
3006
|
const targetBackup = store.path(backupDir(input.txid));
|
|
2589
3007
|
await promises.mkdir(dirname(targetBackup), { recursive: true });
|
|
2590
3008
|
await promises.rename(store.path(stagingBackup), targetBackup);
|
|
@@ -2592,6 +3010,7 @@ async function runTransaction(store, input) {
|
|
|
2592
3010
|
backupMarked = true;
|
|
2593
3011
|
backupComplete = true;
|
|
2594
3012
|
}
|
|
3013
|
+
await assertRootPresent();
|
|
2595
3014
|
const beforeHashes = Object.fromEntries(input.files.map((file) => {
|
|
2596
3015
|
const bytes = before.get(file.path);
|
|
2597
3016
|
return [file.path, bytes === null || bytes === void 0 ? null : sha256(Buffer.from(bytes).toString("base64"))];
|
|
@@ -2609,6 +3028,7 @@ async function runTransaction(store, input) {
|
|
|
2609
3028
|
...input.reason === void 0 ? {} : { reason: input.reason }
|
|
2610
3029
|
});
|
|
2611
3030
|
for (const [index, file] of input.files.entries()) {
|
|
3031
|
+
await assertRootPresent();
|
|
2612
3032
|
const from = await store.safePath(joinRel(staging, file.path));
|
|
2613
3033
|
const to = await store.safePath(file.path);
|
|
2614
3034
|
await promises.mkdir(dirname(to), { recursive: true });
|
|
@@ -2617,6 +3037,7 @@ async function runTransaction(store, input) {
|
|
|
2617
3037
|
renamed.push(file.path);
|
|
2618
3038
|
if (input.afterRename !== void 0) await input.afterRename(file.path, index);
|
|
2619
3039
|
}
|
|
3040
|
+
await assertRootPresent();
|
|
2620
3041
|
for (const directory of targetDirectories) await syncDirectory(directory);
|
|
2621
3042
|
for (const file of input.files) if (await store.hashBytes(file.path) !== expected.get(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `readback of ${file.path} does not match what was staged`, "the file on disk was modified by another process during the commit");
|
|
2622
3043
|
await appendJournal(store, {
|
|
@@ -2638,13 +3059,14 @@ async function runTransaction(store, input) {
|
|
|
2638
3059
|
force: true
|
|
2639
3060
|
}).catch(() => void 0);
|
|
2640
3061
|
} catch (error) {
|
|
2641
|
-
|
|
2642
|
-
|
|
3062
|
+
if (!rootDisappeared) rootDisappeared = await rootUnavailable();
|
|
3063
|
+
const rollbackProblem = rootDisappeared ? null : await restoreTargets(store, renamed, before);
|
|
3064
|
+
if (!rootDisappeared && rollbackProblem === null) await promises.rm(store.path(staging), {
|
|
2643
3065
|
recursive: true,
|
|
2644
3066
|
force: true
|
|
2645
3067
|
}).catch(() => void 0);
|
|
2646
|
-
if (input.backup && (!backupMarked || !backupComplete && renamed.length === 0)) await store.removeTree(backupDir(input.txid)).catch(() => void 0);
|
|
2647
|
-
if (!journaledCommitted) await appendJournal(store, {
|
|
3068
|
+
if (!rootDisappeared && input.backup && (!backupMarked || !backupComplete && renamed.length === 0)) await store.removeTree(backupDir(input.txid)).catch(() => void 0);
|
|
3069
|
+
if (!journaledCommitted && !rootDisappeared) await appendJournal(store, {
|
|
2648
3070
|
txid: input.txid,
|
|
2649
3071
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2650
3072
|
op: input.op,
|
|
@@ -2652,6 +3074,7 @@ async function runTransaction(store, input) {
|
|
|
2652
3074
|
revision: input.revision,
|
|
2653
3075
|
files: input.files.map((file) => file.path),
|
|
2654
3076
|
status: "aborted",
|
|
3077
|
+
code: error instanceof EngineError ? error.code : ERROR_CODES.TXN_ABORTED,
|
|
2655
3078
|
reason: error instanceof Error ? error.message : String(error)
|
|
2656
3079
|
}, { repairTrailing: true }).catch(() => void 0);
|
|
2657
3080
|
const suffix = rollbackProblem === null ? "" : `; compensation was incomplete: ${rollbackProblem}; do not retry until the files are restored`;
|
|
@@ -2663,7 +3086,7 @@ async function runTransaction(store, input) {
|
|
|
2663
3086
|
}
|
|
2664
3087
|
}
|
|
2665
3088
|
// src/store/registry.ts
|
|
2666
|
-
const empty = () => ({
|
|
3089
|
+
const empty$1 = () => ({
|
|
2667
3090
|
schema_version: 1,
|
|
2668
3091
|
active: null,
|
|
2669
3092
|
projects: []
|
|
@@ -2680,7 +3103,7 @@ var ProjectRegistry = class {
|
|
|
2680
3103
|
/** Read the registry; a missing file is an empty registry, a corrupt one is a fault. */
|
|
2681
3104
|
async read() {
|
|
2682
3105
|
const text = await this.store.readText(REGISTRY_FILE);
|
|
2683
|
-
if (text === null) return empty();
|
|
3106
|
+
if (text === null) return empty$1();
|
|
2684
3107
|
let raw;
|
|
2685
3108
|
try {
|
|
2686
3109
|
raw = parse(text);
|
|
@@ -2690,20 +3113,45 @@ var ProjectRegistry = class {
|
|
|
2690
3113
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
|
|
2691
3114
|
const record = raw;
|
|
2692
3115
|
if (record.schema_version !== 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} has an unsupported schema_version`);
|
|
3116
|
+
const projects = [];
|
|
3117
|
+
const indexes = /* @__PURE__ */ new Map();
|
|
3118
|
+
if (record.projects !== void 0 && !Array.isArray(record.projects)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE}.projects must be an array`, "fix or delete the registry file; projects on disk are unaffected");
|
|
3119
|
+
if (Array.isArray(record.projects)) for (const value of record.projects) {
|
|
3120
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
3121
|
+
const entry = value;
|
|
3122
|
+
if (typeof entry.root !== "string" || entry.root === "") continue;
|
|
3123
|
+
if (typeof entry.title !== "string") continue;
|
|
3124
|
+
const normalized = {
|
|
3125
|
+
root: resolve(entry.root),
|
|
3126
|
+
title: entry.title,
|
|
3127
|
+
...typeof entry.created_at === "string" && entry.created_at !== "" ? { created_at: entry.created_at } : {},
|
|
3128
|
+
updated_at: typeof entry.updated_at === "string" ? entry.updated_at : ""
|
|
3129
|
+
};
|
|
3130
|
+
const existing = indexes.get(normalized.root);
|
|
3131
|
+
if (existing === void 0) {
|
|
3132
|
+
indexes.set(normalized.root, projects.length);
|
|
3133
|
+
projects.push(normalized);
|
|
3134
|
+
} else projects[existing] = normalized;
|
|
3135
|
+
}
|
|
2693
3136
|
return {
|
|
2694
3137
|
schema_version: 1,
|
|
2695
|
-
active: typeof record.active === "string" ? record.active : null,
|
|
2696
|
-
projects
|
|
3138
|
+
active: typeof record.active === "string" && record.active.trim() !== "" ? resolve(record.active) : null,
|
|
3139
|
+
projects
|
|
2697
3140
|
};
|
|
2698
3141
|
}
|
|
2699
3142
|
/** Insert or update one project row. */
|
|
2700
3143
|
async register(entry) {
|
|
2701
3144
|
const document = await this.read();
|
|
3145
|
+
const root = resolve(entry.root);
|
|
3146
|
+
const previous = document.projects.find((candidate) => candidate.root === root);
|
|
3147
|
+
const created_at = entry.created_at ?? previous?.created_at;
|
|
2702
3148
|
const row = {
|
|
2703
|
-
|
|
3149
|
+
root,
|
|
3150
|
+
title: entry.title,
|
|
3151
|
+
...created_at === void 0 || created_at === "" ? {} : { created_at },
|
|
2704
3152
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2705
3153
|
};
|
|
2706
|
-
const rows = document.projects.filter((candidate) => candidate.root !==
|
|
3154
|
+
const rows = document.projects.filter((candidate) => candidate.root !== root);
|
|
2707
3155
|
rows.push(row);
|
|
2708
3156
|
const next = {
|
|
2709
3157
|
...document,
|
|
@@ -2716,7 +3164,7 @@ var ProjectRegistry = class {
|
|
|
2716
3164
|
async setActive(root) {
|
|
2717
3165
|
const next = {
|
|
2718
3166
|
...await this.read(),
|
|
2719
|
-
active: root
|
|
3167
|
+
active: resolve(root)
|
|
2720
3168
|
};
|
|
2721
3169
|
await this.write(next);
|
|
2722
3170
|
return next;
|
|
@@ -2724,10 +3172,11 @@ var ProjectRegistry = class {
|
|
|
2724
3172
|
/** Drop a row; the project on disk is untouched. */
|
|
2725
3173
|
async forget(root) {
|
|
2726
3174
|
const document = await this.read();
|
|
3175
|
+
const target = resolve(root);
|
|
2727
3176
|
const next = {
|
|
2728
3177
|
...document,
|
|
2729
|
-
projects: document.projects.filter((entry) => entry.root !==
|
|
2730
|
-
active: document.active ===
|
|
3178
|
+
projects: document.projects.filter((entry) => entry.root !== target),
|
|
3179
|
+
active: document.active === target ? null : document.active
|
|
2731
3180
|
};
|
|
2732
3181
|
await this.write(next);
|
|
2733
3182
|
return next;
|
|
@@ -2736,6 +3185,132 @@ var ProjectRegistry = class {
|
|
|
2736
3185
|
await this.store.writeText(REGISTRY_FILE, stringify(document, { lineWidth: 0 }));
|
|
2737
3186
|
}
|
|
2738
3187
|
};
|
|
3188
|
+
// src/store/bindings.ts
|
|
3189
|
+
const empty = () => ({
|
|
3190
|
+
schema_version: 1,
|
|
3191
|
+
bindings: []
|
|
3192
|
+
});
|
|
3193
|
+
const sources = /* @__PURE__ */ new Set([
|
|
3194
|
+
"explicit",
|
|
3195
|
+
"init",
|
|
3196
|
+
"command"
|
|
3197
|
+
]);
|
|
3198
|
+
const row = (value) => {
|
|
3199
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
3200
|
+
const record = value;
|
|
3201
|
+
if (typeof record.session_id !== "string" || record.session_id === "") return null;
|
|
3202
|
+
if (typeof record.root !== "string" || record.root === "") return null;
|
|
3203
|
+
const source = typeof record.source === "string" && sources.has(record.source) ? record.source : "explicit";
|
|
3204
|
+
return {
|
|
3205
|
+
session_id: record.session_id,
|
|
3206
|
+
root: resolve(record.root),
|
|
3207
|
+
title: typeof record.title === "string" ? record.title : "",
|
|
3208
|
+
project_created_at: typeof record.project_created_at === "string" ? record.project_created_at : "",
|
|
3209
|
+
bound_at: typeof record.bound_at === "string" ? record.bound_at : "",
|
|
3210
|
+
source
|
|
3211
|
+
};
|
|
3212
|
+
};
|
|
3213
|
+
/**
|
|
3214
|
+
* Session-scoped project choices. This file is convenience state, never project
|
|
3215
|
+
* truth: deleting it loses no book, and a corrupt file is a hard read error
|
|
3216
|
+
* rather than a silent "no bindings" that could write into another project.
|
|
3217
|
+
*/
|
|
3218
|
+
var SessionBindings = class {
|
|
3219
|
+
store;
|
|
3220
|
+
options;
|
|
3221
|
+
constructor(store, options = {}) {
|
|
3222
|
+
this.store = store;
|
|
3223
|
+
this.options = options;
|
|
3224
|
+
}
|
|
3225
|
+
async read() {
|
|
3226
|
+
const text = await this.store.readText(SESSION_BINDINGS_FILE);
|
|
3227
|
+
if (text === null) return empty();
|
|
3228
|
+
let raw;
|
|
3229
|
+
try {
|
|
3230
|
+
raw = parse(text);
|
|
3231
|
+
} catch (error) {
|
|
3232
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix or delete the session binding file; projects on disk are unaffected", { cause: error });
|
|
3233
|
+
}
|
|
3234
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE} is not an object`);
|
|
3235
|
+
const record = raw;
|
|
3236
|
+
if (record.schema_version !== 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE} has an unsupported schema_version`);
|
|
3237
|
+
if (record.bindings !== void 0 && !Array.isArray(record.bindings)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE}.bindings must be an array`, "fix or delete the session binding file; projects on disk are unaffected");
|
|
3238
|
+
const bindings = [];
|
|
3239
|
+
const indexes = /* @__PURE__ */ new Map();
|
|
3240
|
+
for (const candidate of record.bindings ?? []) {
|
|
3241
|
+
const parsed = row(candidate);
|
|
3242
|
+
if (parsed === null) continue;
|
|
3243
|
+
const existing = indexes.get(parsed.session_id);
|
|
3244
|
+
if (existing === void 0) {
|
|
3245
|
+
indexes.set(parsed.session_id, bindings.length);
|
|
3246
|
+
bindings.push(parsed);
|
|
3247
|
+
} else bindings[existing] = parsed;
|
|
3248
|
+
}
|
|
3249
|
+
return {
|
|
3250
|
+
schema_version: 1,
|
|
3251
|
+
bindings
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
async get(sessionId) {
|
|
3255
|
+
return (await this.read()).bindings.find((entry) => entry.session_id === sessionId);
|
|
3256
|
+
}
|
|
3257
|
+
async list() {
|
|
3258
|
+
return (await this.read()).bindings;
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* Insert or update one session's choice. A byte-for-byte equivalent binding
|
|
3262
|
+
* does not touch the file, so a read-only operation can call this safely.
|
|
3263
|
+
*/
|
|
3264
|
+
async bind(input) {
|
|
3265
|
+
const document = await this.read();
|
|
3266
|
+
const root = resolve(input.root);
|
|
3267
|
+
const existing = document.bindings.find((entry) => entry.session_id === input.session_id);
|
|
3268
|
+
if (existing !== void 0 && existing.root === root && existing.title === input.title && existing.project_created_at === input.project_created_at) return {
|
|
3269
|
+
binding: existing,
|
|
3270
|
+
document,
|
|
3271
|
+
changed: false
|
|
3272
|
+
};
|
|
3273
|
+
const binding = {
|
|
3274
|
+
session_id: input.session_id,
|
|
3275
|
+
root,
|
|
3276
|
+
title: input.title,
|
|
3277
|
+
project_created_at: input.project_created_at,
|
|
3278
|
+
bound_at: (this.options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
3279
|
+
source: input.source
|
|
3280
|
+
};
|
|
3281
|
+
const next = {
|
|
3282
|
+
schema_version: 1,
|
|
3283
|
+
bindings: existing === void 0 ? [...document.bindings, binding] : document.bindings.map((entry) => entry.session_id === input.session_id ? binding : entry)
|
|
3284
|
+
};
|
|
3285
|
+
await this.write(next);
|
|
3286
|
+
return {
|
|
3287
|
+
binding,
|
|
3288
|
+
document: next,
|
|
3289
|
+
changed: true
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
/** Remove one session binding; a missing row is a no-op. */
|
|
3293
|
+
async clear(sessionId) {
|
|
3294
|
+
const document = await this.read();
|
|
3295
|
+
const bindings = document.bindings.filter((entry) => entry.session_id !== sessionId);
|
|
3296
|
+
if (bindings.length === document.bindings.length) return {
|
|
3297
|
+
document,
|
|
3298
|
+
changed: false
|
|
3299
|
+
};
|
|
3300
|
+
const next = {
|
|
3301
|
+
schema_version: 1,
|
|
3302
|
+
bindings
|
|
3303
|
+
};
|
|
3304
|
+
await this.write(next);
|
|
3305
|
+
return {
|
|
3306
|
+
document: next,
|
|
3307
|
+
changed: true
|
|
3308
|
+
};
|
|
3309
|
+
}
|
|
3310
|
+
async write(document) {
|
|
3311
|
+
await this.store.writeText(SESSION_BINDINGS_FILE, stringify(document, { lineWidth: 0 }));
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
2739
3314
|
// src/assets.ts
|
|
2740
3315
|
/**
|
|
2741
3316
|
* The only reader of bundled assets. `apply()` performs no IO; the engine and
|
|
@@ -3124,6 +3699,7 @@ function emptyWorldbook(now) {
|
|
|
3124
3699
|
characters: [],
|
|
3125
3700
|
glossary: [],
|
|
3126
3701
|
backstory: [],
|
|
3702
|
+
facts: [],
|
|
3127
3703
|
themes: []
|
|
3128
3704
|
};
|
|
3129
3705
|
}
|
|
@@ -3132,15 +3708,28 @@ function outlineCard(document, chapter) {
|
|
|
3132
3708
|
return document.chapters.find((card) => card.num === chapter) ?? null;
|
|
3133
3709
|
}
|
|
3134
3710
|
/** A compact progress view used by status, the panel and the report. */
|
|
3135
|
-
function outlineSummary(document, archivedCount) {
|
|
3711
|
+
function outlineSummary(document, archivedCount, target = null, archivedNumbers) {
|
|
3136
3712
|
const planned = document.chapters.filter((card) => card.status === "planned").length;
|
|
3137
3713
|
const archived = document.chapters.filter((card) => card.status === "archived").length;
|
|
3714
|
+
const total = document.chapters.length;
|
|
3715
|
+
const missingCards = target === null ? 0 : Math.max(0, target - total);
|
|
3716
|
+
const plannedCoverage = target === null || target === 0 ? null : Math.min(1, total / target);
|
|
3717
|
+
const archivedCoverage = target === null || target === 0 ? null : Math.min(1, archived / target);
|
|
3718
|
+
const archivedSet = archivedNumbers === void 0 ? null : new Set(archivedNumbers);
|
|
3719
|
+
const matchedCards = archivedSet === null ? archived : document.chapters.filter((card) => card.status === "archived" && archivedSet.has(card.num)).length;
|
|
3720
|
+
const archivedDenominator = archivedSet === null ? archivedCount : archivedSet.size;
|
|
3721
|
+
const cardCoverage = archivedDenominator === 0 ? 1 : Math.min(1, matchedCards / archivedDenominator);
|
|
3138
3722
|
return {
|
|
3139
|
-
present: bookPlanPresent(document.book) || document.volumes.length > 0 ||
|
|
3140
|
-
|
|
3723
|
+
present: bookPlanPresent(document.book) || document.volumes.length > 0 || total > 0,
|
|
3724
|
+
target,
|
|
3725
|
+
total,
|
|
3141
3726
|
planned,
|
|
3142
3727
|
archived,
|
|
3143
|
-
|
|
3728
|
+
missingCards,
|
|
3729
|
+
plannedCoverage,
|
|
3730
|
+
archivedCoverage,
|
|
3731
|
+
coverage: plannedCoverage ?? (archivedDenominator === 0 ? 0 : cardCoverage),
|
|
3732
|
+
cardCoverage,
|
|
3144
3733
|
volumes: document.volumes.length
|
|
3145
3734
|
};
|
|
3146
3735
|
}
|
|
@@ -3469,6 +4058,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
3469
4058
|
characters: [...existing.characters],
|
|
3470
4059
|
glossary: [...existing.glossary],
|
|
3471
4060
|
backstory: [...existing.backstory],
|
|
4061
|
+
facts: [...existing.facts ?? []],
|
|
3472
4062
|
themes: [...existing.themes]
|
|
3473
4063
|
};
|
|
3474
4064
|
if (patch.premise !== void 0) result.premise = optionalText(patch.premise, "premise", LIMITS.maxWorldbookTextChars);
|
|
@@ -3534,6 +4124,26 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
3534
4124
|
time: text$1(raw.time, `backstory[${index}].time`, 120),
|
|
3535
4125
|
event: text$1(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
|
|
3536
4126
|
}));
|
|
4127
|
+
if (patch.facts !== void 0) {
|
|
4128
|
+
result.facts = records(patch.facts, "facts").map((raw, index) => {
|
|
4129
|
+
const value = raw.value;
|
|
4130
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].value must be a finite number`);
|
|
4131
|
+
const tolerance = raw.tolerance ?? 0;
|
|
4132
|
+
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`);
|
|
4133
|
+
return {
|
|
4134
|
+
alias: text$1(raw.alias, `facts[${index}].alias`, 120),
|
|
4135
|
+
value,
|
|
4136
|
+
unit: text$1(raw.unit, `facts[${index}].unit`, 40),
|
|
4137
|
+
tolerance,
|
|
4138
|
+
...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
|
|
4139
|
+
};
|
|
4140
|
+
});
|
|
4141
|
+
const seenFacts = /* @__PURE__ */ new Set();
|
|
4142
|
+
for (const fact of result.facts) {
|
|
4143
|
+
if (seenFacts.has(fact.alias)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts contains duplicate "${fact.alias}"`);
|
|
4144
|
+
seenFacts.add(fact.alias);
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
3537
4147
|
return result;
|
|
3538
4148
|
}
|
|
3539
4149
|
/** Validate a worldbook loaded from disk. */
|
|
@@ -3552,6 +4162,7 @@ function validateWorldbookDocument(value, now) {
|
|
|
3552
4162
|
"characters",
|
|
3553
4163
|
"glossary",
|
|
3554
4164
|
"backstory",
|
|
4165
|
+
"facts",
|
|
3555
4166
|
"themes"
|
|
3556
4167
|
]);
|
|
3557
4168
|
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 +4283,16 @@ function outlineDrift(document, records) {
|
|
|
3672
4283
|
}
|
|
3673
4284
|
/** Counts for status/panel/report; no IO. */
|
|
3674
4285
|
function worldbookCounts(document) {
|
|
4286
|
+
const facts = document.facts?.length ?? 0;
|
|
3675
4287
|
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,
|
|
4288
|
+
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
4289
|
characters: document.characters.length,
|
|
3678
4290
|
locations: document.locations.length,
|
|
3679
4291
|
factions: document.factions.length,
|
|
3680
4292
|
rules: document.rules.length,
|
|
3681
4293
|
glossary: document.glossary.length,
|
|
3682
|
-
backstory: document.backstory.length
|
|
4294
|
+
backstory: document.backstory.length,
|
|
4295
|
+
facts
|
|
3683
4296
|
};
|
|
3684
4297
|
}
|
|
3685
4298
|
const pick = (values, mentioned, key, limit) => {
|
|
@@ -3728,6 +4341,13 @@ function worldbookBrief(document, mentioned, names) {
|
|
|
3728
4341
|
backstory: document.backstory.slice(0, 3).map((entry) => ({
|
|
3729
4342
|
time: entry.time,
|
|
3730
4343
|
event: clip(entry.event, 160)
|
|
4344
|
+
})),
|
|
4345
|
+
facts: (document.facts ?? []).slice(0, 20).map((fact) => ({
|
|
4346
|
+
alias: clip(fact.alias, 120),
|
|
4347
|
+
value: fact.value,
|
|
4348
|
+
unit: clip(fact.unit, 40),
|
|
4349
|
+
tolerance: fact.tolerance ?? 0,
|
|
4350
|
+
...fact.note === void 0 ? {} : { note: clip(fact.note, 160) }
|
|
3731
4351
|
}))
|
|
3732
4352
|
};
|
|
3733
4353
|
}
|
|
@@ -4095,14 +4715,18 @@ function buildBookReport(input) {
|
|
|
4095
4715
|
role: character.role,
|
|
4096
4716
|
appearances: appearances.get(character.name) ?? 0,
|
|
4097
4717
|
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
|
|
4718
|
+
})).sort((left, right) => right.appearances - left.appearances || right.last_seen - left.last_seen || compareText(left.name, right.name)).slice(0, 10);
|
|
4099
4719
|
const latestChapter = records.length === 0 ? 0 : records[records.length - 1].num;
|
|
4100
4720
|
const open = input.foreshadows.items.filter((item) => item.status !== "resolved");
|
|
4101
4721
|
const dormant = open.filter((item) => item.status === "dormant");
|
|
4102
4722
|
const overdue = open.filter((item) => latestChapter - item.last_touched >= input.foreshadowDormantAfter);
|
|
4103
4723
|
const relationshipTypes = {};
|
|
4104
4724
|
for (const relationship of input.relationships.items) relationshipTypes[relationship.type] = (relationshipTypes[relationship.type] ?? 0) + 1;
|
|
4105
|
-
const outline = outlineSummary(input.outline, records.length);
|
|
4725
|
+
const outline = outlineSummary(input.outline, records.length, input.targetChapters, records.map((record) => record.num));
|
|
4726
|
+
const averageWords = records.length === 0 ? 0 : Math.round(words / records.length);
|
|
4727
|
+
const remainingChapters = input.targetChapters === null ? null : Math.max(0, input.targetChapters - records.length);
|
|
4728
|
+
const minimumRemaining = remainingChapters === null ? null : remainingChapters * input.project.targets.words_per_chapter[0];
|
|
4729
|
+
const averageRemaining = remainingChapters === null ? null : remainingChapters * (records.length === 0 ? input.project.targets.words_per_chapter[0] : averageWords);
|
|
4106
4730
|
const drift = outlineDrift(input.outline, records);
|
|
4107
4731
|
const volumeCounts = input.outline.volumes.map((volume) => {
|
|
4108
4732
|
const cards = input.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
|
|
@@ -4128,16 +4752,25 @@ function buildBookReport(input) {
|
|
|
4128
4752
|
archived: records.length,
|
|
4129
4753
|
target: input.targetChapters,
|
|
4130
4754
|
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:
|
|
4755
|
+
average_words: averageWords,
|
|
4756
|
+
percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters * 1e3) / 10),
|
|
4757
|
+
remaining_words: averageRemaining,
|
|
4758
|
+
remaining_words_min: minimumRemaining,
|
|
4759
|
+
remaining_words_average: averageRemaining,
|
|
4760
|
+
remaining_words_basis: input.targetChapters === null ? "none" : records.length === 0 ? "minimum_target" : "actual_average"
|
|
4134
4761
|
},
|
|
4135
4762
|
outline: {
|
|
4136
4763
|
present: outline.present,
|
|
4137
4764
|
strict: input.outline.book.strict,
|
|
4765
|
+
target: outline.target,
|
|
4766
|
+
total: outline.total,
|
|
4138
4767
|
planned: outline.planned,
|
|
4139
4768
|
archived: outline.archived,
|
|
4769
|
+
missing_cards: outline.missingCards,
|
|
4770
|
+
planned_coverage: outline.plannedCoverage,
|
|
4771
|
+
archived_coverage: outline.archivedCoverage,
|
|
4140
4772
|
coverage: outline.coverage,
|
|
4773
|
+
card_coverage: outline.cardCoverage,
|
|
4141
4774
|
volumes: volumeCounts,
|
|
4142
4775
|
drift
|
|
4143
4776
|
},
|
|
@@ -4153,7 +4786,22 @@ function buildBookReport(input) {
|
|
|
4153
4786
|
id,
|
|
4154
4787
|
average: Math.round(value.total / value.samples * 10) / 10,
|
|
4155
4788
|
samples: value.samples
|
|
4156
|
-
})).sort((left, right) => left.id
|
|
4789
|
+
})).sort((left, right) => compareText(left.id, right.id))
|
|
4790
|
+
},
|
|
4791
|
+
effort: {
|
|
4792
|
+
reviews: records.reduce((total, record) => total + (record.rounds?.review ?? 0), 0),
|
|
4793
|
+
revisions: records.reduce((total, record) => total + (record.rounds?.revise ?? 0), 0),
|
|
4794
|
+
checks: records.reduce((total, record) => total + (record.rounds?.check ?? 0), 0),
|
|
4795
|
+
rollbacks: records.reduce((total, record) => total + (record.rounds?.rollbacks ?? 0), 0),
|
|
4796
|
+
chapters: records.map((record) => ({
|
|
4797
|
+
num: record.num,
|
|
4798
|
+
reviews: record.rounds?.review ?? 0,
|
|
4799
|
+
revisions: record.rounds?.revise ?? 0,
|
|
4800
|
+
checks: record.rounds?.check ?? 0,
|
|
4801
|
+
rollbacks: record.rounds?.rollbacks ?? 0,
|
|
4802
|
+
final_check: record.final_check ?? null,
|
|
4803
|
+
archived_at: record.archived_at ?? null
|
|
4804
|
+
}))
|
|
4157
4805
|
},
|
|
4158
4806
|
pacing: {
|
|
4159
4807
|
scene_types: sceneTypes,
|
|
@@ -4171,7 +4819,7 @@ function buildBookReport(input) {
|
|
|
4171
4819
|
dormant: dormant.length,
|
|
4172
4820
|
overdue: overdue.length,
|
|
4173
4821
|
resolved: input.foreshadows.items.length - open.length,
|
|
4174
|
-
oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || left.id
|
|
4822
|
+
oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || compareText(left.id, right.id)).slice(0, 5).map((item) => ({
|
|
4175
4823
|
id: item.id,
|
|
4176
4824
|
title: item.title,
|
|
4177
4825
|
last_touched: item.last_touched,
|
|
@@ -4455,6 +5103,10 @@ function searchSnapshot(input) {
|
|
|
4455
5103
|
...input.worldbook.backstory.map((entry) => ({
|
|
4456
5104
|
title: entry.time,
|
|
4457
5105
|
source: `${entry.time}\n${entry.event}`
|
|
5106
|
+
})),
|
|
5107
|
+
...(input.worldbook.facts ?? []).map((fact) => ({
|
|
5108
|
+
title: fact.alias,
|
|
5109
|
+
source: `${fact.alias}\n${fact.value} ${fact.unit}${fact.tolerance === void 0 ? "" : ` ±${fact.tolerance}`}\n${fact.note ?? ""}`
|
|
4458
5110
|
}))
|
|
4459
5111
|
];
|
|
4460
5112
|
for (const entry of entries) {
|
|
@@ -4529,6 +5181,58 @@ function searchSnapshot(input) {
|
|
|
4529
5181
|
results
|
|
4530
5182
|
};
|
|
4531
5183
|
}
|
|
5184
|
+
// src/engine/history.ts
|
|
5185
|
+
const PATTERN = /^ch(?<chapter>\d{3,})\.r(?<revision>\d+)\.(?<suffix>review\.yaml|check\.yaml|chapter\.yaml|md)$/;
|
|
5186
|
+
const kindOf = (directory, suffix) => {
|
|
5187
|
+
if (directory === "reviews") return suffix === "review.yaml";
|
|
5188
|
+
if (directory === "checks") return suffix === "check.yaml";
|
|
5189
|
+
if (directory === "plans") return suffix === "chapter.yaml";
|
|
5190
|
+
return suffix === "md";
|
|
5191
|
+
};
|
|
5192
|
+
/**
|
|
5193
|
+
* List prior versions, newest revision first. Files beyond the visible cap are
|
|
5194
|
+
* still on disk: history is evidence, not a cache, and this function never
|
|
5195
|
+
* deletes it.
|
|
5196
|
+
*/
|
|
5197
|
+
async function listHistory(store, chapter) {
|
|
5198
|
+
const entries = [];
|
|
5199
|
+
for (const kind of HISTORY_KINDS) {
|
|
5200
|
+
const names = await store.list(joinRel(HISTORY_DIR, kind));
|
|
5201
|
+
for (const name of names) {
|
|
5202
|
+
const match = PATTERN.exec(name);
|
|
5203
|
+
if (match === null) continue;
|
|
5204
|
+
const suffix = match.groups.suffix;
|
|
5205
|
+
if (!kindOf(kind, suffix)) continue;
|
|
5206
|
+
const num = Number(match.groups.chapter);
|
|
5207
|
+
const revision = Number(match.groups.revision);
|
|
5208
|
+
if (chapter !== void 0 && num !== chapter) continue;
|
|
5209
|
+
const path = joinRel(HISTORY_DIR, kind, name);
|
|
5210
|
+
const text = await store.readText(path);
|
|
5211
|
+
entries.push({
|
|
5212
|
+
kind,
|
|
5213
|
+
chapter: num,
|
|
5214
|
+
revision,
|
|
5215
|
+
path,
|
|
5216
|
+
chars: text?.length ?? 0
|
|
5217
|
+
});
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
return entries.sort((left, right) => left.chapter - right.chapter || right.revision - left.revision || compareText(left.kind, right.kind));
|
|
5221
|
+
}
|
|
5222
|
+
/** Read every prior version of one kind for a chapter, oldest first. */
|
|
5223
|
+
async function historyTexts(store, kind, chapter, entries) {
|
|
5224
|
+
const entriesForChapter = (entries ?? await listHistory(store, chapter)).filter((entry) => entry.kind === kind && entry.chapter === chapter);
|
|
5225
|
+
const result = [];
|
|
5226
|
+
for (const entry of entriesForChapter) {
|
|
5227
|
+
const text = await store.readText(entry.path);
|
|
5228
|
+
if (text !== null) result.push({
|
|
5229
|
+
path: entry.path,
|
|
5230
|
+
revision: entry.revision,
|
|
5231
|
+
text
|
|
5232
|
+
});
|
|
5233
|
+
}
|
|
5234
|
+
return result.sort((left, right) => left.revision - right.revision);
|
|
5235
|
+
}
|
|
4532
5236
|
// src/engine/brief.ts
|
|
4533
5237
|
const TITLES = {
|
|
4534
5238
|
P1: "P1 世界观约束",
|
|
@@ -5285,8 +5989,12 @@ const requireReason = (value, where) => {
|
|
|
5285
5989
|
if (value.action !== "no_change") return;
|
|
5286
5990
|
if (typeof value.reason !== "string" || value.reason.trim() === "") fail$1(`${where} is no_change but has no reason`);
|
|
5287
5991
|
};
|
|
5992
|
+
/** A one-character anchor is indistinguishable from a substring accident. */
|
|
5993
|
+
const ANCHOR_MIN_CHARS = 8;
|
|
5994
|
+
const anchorIsStrong = (value) => typeof value === "string" && Array.from(value.trim()).length >= ANCHOR_MIN_CHARS;
|
|
5288
5995
|
const requireForeshadowFields = (value, where) => {
|
|
5289
5996
|
if (value.title !== void 0 && typeof value.title !== "string") fail$1(`${where}.title must be a string`);
|
|
5997
|
+
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
5998
|
if (value.description !== void 0 && typeof value.description !== "string") fail$1(`${where}.description must be a string`);
|
|
5291
5999
|
if (value.note !== void 0 && typeof value.note !== "string") fail$1(`${where}.note must be a string`);
|
|
5292
6000
|
if (value.new_info !== void 0 && typeof value.new_info !== "boolean") fail$1(`${where}.new_info must be a boolean`);
|
|
@@ -5297,6 +6005,32 @@ const appearanceOf = (text, name, aliases = []) => {
|
|
|
5297
6005
|
return Array.from(source).slice(0, 120).join("");
|
|
5298
6006
|
};
|
|
5299
6007
|
const named = (value) => isRecord(value) && typeof value.name === "string";
|
|
6008
|
+
const ROLE_ALIASES = {
|
|
6009
|
+
主角: "protagonist",
|
|
6010
|
+
男主角: "protagonist",
|
|
6011
|
+
女主: "protagonist",
|
|
6012
|
+
女主角: "protagonist",
|
|
6013
|
+
男主: "protagonist",
|
|
6014
|
+
主人公: "protagonist",
|
|
6015
|
+
protagonist: "protagonist",
|
|
6016
|
+
配角: "supporting",
|
|
6017
|
+
重要配角: "supporting",
|
|
6018
|
+
男二: "supporting",
|
|
6019
|
+
女二: "supporting",
|
|
6020
|
+
supporting: "supporting",
|
|
6021
|
+
反派: "antagonist",
|
|
6022
|
+
antagonist: "antagonist",
|
|
6023
|
+
龙套: "extra",
|
|
6024
|
+
路人: "extra",
|
|
6025
|
+
extra: "extra"
|
|
6026
|
+
};
|
|
6027
|
+
/** Keep one canonical role vocabulary instead of letting free text reach reports. */
|
|
6028
|
+
const normalizeRole = (value, fallback) => {
|
|
6029
|
+
const canonicalFallback = ROLE_ALIASES[fallback] ?? "extra";
|
|
6030
|
+
const trimmed = value?.trim() ?? "";
|
|
6031
|
+
if (trimmed === "") return canonicalFallback;
|
|
6032
|
+
return ROLE_ALIASES[trimmed] ?? canonicalFallback;
|
|
6033
|
+
};
|
|
5300
6034
|
/**
|
|
5301
6035
|
* Apply the archive reconciliation sheet. Every ledger entity the summary
|
|
5302
6036
|
* mentions must be declared; unknown keys are refused instead of being
|
|
@@ -5358,9 +6092,10 @@ function applyStateDelta(input) {
|
|
|
5358
6092
|
if (declared.action === "created") {
|
|
5359
6093
|
if (existing !== void 0) fail$1(`character "${name}" already exists; use updated or no_change`);
|
|
5360
6094
|
if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
|
|
6095
|
+
const hinted = normalizeRole(input.roleHints?.[name], name === narratorName || name === narrator ? "protagonist" : "extra");
|
|
5361
6096
|
const created = {
|
|
5362
6097
|
name,
|
|
5363
|
-
role: declared.role
|
|
6098
|
+
role: normalizeRole(declared.role, hinted),
|
|
5364
6099
|
first_appearance: chapter,
|
|
5365
6100
|
profile: declared.profile ?? {},
|
|
5366
6101
|
current_state: declared.current_state ?? {},
|
|
@@ -5373,7 +6108,7 @@ function applyStateDelta(input) {
|
|
|
5373
6108
|
}
|
|
5374
6109
|
if (existing === void 0) fail$1(`character "${name}" does not exist; created must be used first`);
|
|
5375
6110
|
if (declared.action === "updated") {
|
|
5376
|
-
if (declared.role !== void 0) existing.role = declared.role;
|
|
6111
|
+
if (declared.role !== void 0) existing.role = normalizeRole(declared.role, existing.role);
|
|
5377
6112
|
if (declared.profile !== void 0) existing.profile = {
|
|
5378
6113
|
...existing.profile,
|
|
5379
6114
|
...declared.profile
|
|
@@ -5542,11 +6277,15 @@ function applyStateDelta(input) {
|
|
|
5542
6277
|
requireForeshadowFields(declared, `foreshadows.${plant.key}`);
|
|
5543
6278
|
if (declared.action !== "created") fail$1(`foreshadows.${plant.key} is planted; its action must be created`);
|
|
5544
6279
|
if (typeof plant.title !== "string" || plant.title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty summary title`);
|
|
5545
|
-
if (!
|
|
6280
|
+
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
6281
|
if (declared.title !== void 0 && typeof declared.title !== "string") fail$1(`foreshadows.${plant.key}.title must be a string`);
|
|
5547
6282
|
const title = declared.title ?? plant.title;
|
|
5548
6283
|
if (title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty title`);
|
|
5549
|
-
|
|
6284
|
+
const anchor = declared.anchor ?? plant.anchor;
|
|
6285
|
+
if (anchor === void 0) {
|
|
6286
|
+
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`);
|
|
6287
|
+
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`);
|
|
6288
|
+
} else if (!input.text.includes(anchor)) fail$1(`planted foreshadow "${title}" anchor "${anchor}" does not appear verbatim in the manuscript`);
|
|
5550
6289
|
const allocated = allocateIds("FS", foreshadows.next_seq, 1);
|
|
5551
6290
|
const id = allocated.ids[0];
|
|
5552
6291
|
if (byId.has(id)) fail$1(`foreshadows.next_seq is behind the ledger; ${id} already exists`);
|
|
@@ -5559,6 +6298,7 @@ function applyStateDelta(input) {
|
|
|
5559
6298
|
status: "planted",
|
|
5560
6299
|
resolved_at: null,
|
|
5561
6300
|
description: declared.description ?? plant.description,
|
|
6301
|
+
...anchor === void 0 ? {} : { anchor },
|
|
5562
6302
|
advance_log: []
|
|
5563
6303
|
};
|
|
5564
6304
|
foreshadows.items.push(item);
|
|
@@ -5884,7 +6624,7 @@ const wordCount = (input) => {
|
|
|
5884
6624
|
if (min <= 0) return [];
|
|
5885
6625
|
const metrics = countWordMetrics(input.text);
|
|
5886
6626
|
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`)];
|
|
6627
|
+
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
6628
|
};
|
|
5889
6629
|
/** PG-04: format blockers must be zero before length is judged. */
|
|
5890
6630
|
const format = (input, options) => {
|
|
@@ -6289,6 +7029,17 @@ function briefStageFor(operation, stage) {
|
|
|
6289
7029
|
}
|
|
6290
7030
|
}
|
|
6291
7031
|
// src/engine/projections.ts
|
|
7032
|
+
/** One shared prompt template for `/novel resume` and the panel copy button. */
|
|
7033
|
+
function formatResumePrompt(input) {
|
|
7034
|
+
return [
|
|
7035
|
+
`请继续《${input.title}》。`,
|
|
7036
|
+
`项目根:${input.root}(第一次工具调用必须显式传 project_root=${input.root})。`,
|
|
7037
|
+
`当前第 ${input.chapter} 章,阶段 ${input.stage}。`,
|
|
7038
|
+
`下一步:${input.nextTool}。`,
|
|
7039
|
+
"先调用 novel_status 核对项目根与书名,再按 novel_pipeline 的下一步执行;",
|
|
7040
|
+
"只操作这个项目,不要读取或写入其他书。"
|
|
7041
|
+
].join("\n");
|
|
7042
|
+
}
|
|
6292
7043
|
const STAGE_TO_PHASE = {
|
|
6293
7044
|
idle: "P1",
|
|
6294
7045
|
P2: "P2",
|
|
@@ -6332,8 +7083,9 @@ function projectChapter(record) {
|
|
|
6332
7083
|
}
|
|
6333
7084
|
function projectSummary(snapshot) {
|
|
6334
7085
|
return {
|
|
6335
|
-
schemaVersion:
|
|
7086
|
+
schemaVersion: 4,
|
|
6336
7087
|
panelId: PANEL_ID,
|
|
7088
|
+
root: snapshot.root,
|
|
6337
7089
|
title: snapshot.title,
|
|
6338
7090
|
genre: snapshot.genre,
|
|
6339
7091
|
platform: snapshot.platform,
|
|
@@ -6375,40 +7127,280 @@ function projectChapters(snapshot) {
|
|
|
6375
7127
|
details: draft.details
|
|
6376
7128
|
});
|
|
6377
7129
|
return {
|
|
6378
|
-
schemaVersion:
|
|
7130
|
+
schemaVersion: 4,
|
|
6379
7131
|
panelId: PANEL_ID,
|
|
6380
7132
|
rows
|
|
6381
7133
|
};
|
|
6382
7134
|
}
|
|
6383
7135
|
function projectGates(snapshot) {
|
|
6384
7136
|
return {
|
|
6385
|
-
schemaVersion:
|
|
7137
|
+
schemaVersion: 4,
|
|
6386
7138
|
panelId: PANEL_ID,
|
|
6387
7139
|
stage: snapshot.stage,
|
|
6388
7140
|
blocking: snapshot.blocking,
|
|
6389
7141
|
explanations: snapshot.blocking.map((item) => `${item.code}${item.scope === void 0 ? "" : ` (${item.scope})`}: ${item.detail}`)
|
|
6390
7142
|
};
|
|
6391
7143
|
}
|
|
6392
|
-
// src/engine/
|
|
6393
|
-
|
|
6394
|
-
|
|
7144
|
+
// src/engine/project-selection.ts
|
|
7145
|
+
const MAX_SCAN_CANDIDATES = 50;
|
|
7146
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
7147
|
+
/** Filesystem access failures are unreadable; parse/schema faults are damage. */
|
|
7148
|
+
const unreadableFsError = (error) => {
|
|
7149
|
+
const code = error.code;
|
|
7150
|
+
return typeof code === "string" && [
|
|
7151
|
+
"EACCES",
|
|
7152
|
+
"EPERM",
|
|
7153
|
+
"EIO",
|
|
7154
|
+
"EBUSY",
|
|
7155
|
+
"ELOOP",
|
|
7156
|
+
"EMFILE",
|
|
7157
|
+
"ENFILE",
|
|
7158
|
+
"EISDIR"
|
|
7159
|
+
].includes(code);
|
|
7160
|
+
};
|
|
7161
|
+
/**
|
|
7162
|
+
* True when anything survives in the project directory besides novel.yaml.
|
|
7163
|
+
* Kept next to inspection so the panel, resolver and scanner agree on what
|
|
7164
|
+
* "a project used to be here" means.
|
|
7165
|
+
*/
|
|
7166
|
+
async function hasProjectState(store) {
|
|
7167
|
+
const stateFiles = [
|
|
7168
|
+
STATE_FILES.pipeline,
|
|
7169
|
+
STATE_FILES.chapters,
|
|
7170
|
+
STATE_FILES.characters,
|
|
7171
|
+
STATE_FILES.world,
|
|
7172
|
+
STATE_FILES.locks,
|
|
7173
|
+
STATE_FILES.timeline,
|
|
7174
|
+
STATE_FILES.foreshadows,
|
|
7175
|
+
STATE_FILES.relationships,
|
|
7176
|
+
STATE_FILES.handoff
|
|
7177
|
+
].map((file) => joinRel(STATE_DIR, file));
|
|
7178
|
+
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS_FILE, BRIEF_RECEIPTS_FILE);
|
|
7179
|
+
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");
|
|
7180
|
+
}
|
|
7181
|
+
/**
|
|
7182
|
+
* Inspect one candidate without constructing a full Snapshot. `ok` and
|
|
7183
|
+
* `damaged` are both project evidence; only `not-project`/`missing` are absent.
|
|
7184
|
+
*/
|
|
7185
|
+
async function inspectProject(root) {
|
|
7186
|
+
const absolute = resolve(root);
|
|
7187
|
+
const store = new FsStore(absolute);
|
|
7188
|
+
let directory;
|
|
7189
|
+
try {
|
|
7190
|
+
const info = await store.statRoot();
|
|
7191
|
+
if (info === null) return {
|
|
7192
|
+
root: absolute,
|
|
7193
|
+
state: "missing",
|
|
7194
|
+
title: "(项目目录不存在)"
|
|
7195
|
+
};
|
|
7196
|
+
directory = info.directory;
|
|
7197
|
+
} catch (error) {
|
|
7198
|
+
return {
|
|
7199
|
+
root: absolute,
|
|
7200
|
+
state: "unreadable",
|
|
7201
|
+
title: "(项目目录不可读)",
|
|
7202
|
+
error: errorText(error)
|
|
7203
|
+
};
|
|
7204
|
+
}
|
|
7205
|
+
if (!directory) return {
|
|
7206
|
+
root: absolute,
|
|
7207
|
+
state: "not-project",
|
|
7208
|
+
title: "(不是项目目录)"
|
|
7209
|
+
};
|
|
7210
|
+
let projectExists;
|
|
7211
|
+
try {
|
|
7212
|
+
projectExists = await store.exists(PROJECT_FILE);
|
|
7213
|
+
} catch (error) {
|
|
7214
|
+
return {
|
|
7215
|
+
root: absolute,
|
|
7216
|
+
state: "unreadable",
|
|
7217
|
+
title: "(项目文件不可读)",
|
|
7218
|
+
error: errorText(error)
|
|
7219
|
+
};
|
|
7220
|
+
}
|
|
7221
|
+
if (projectExists) try {
|
|
7222
|
+
const project = await store.readDoc(PROJECT_FILE, "project");
|
|
7223
|
+
if (project === null) return {
|
|
7224
|
+
root: absolute,
|
|
7225
|
+
state: "damaged",
|
|
7226
|
+
title: "(项目文件不可读)",
|
|
7227
|
+
error: `${PROJECT_FILE} disappeared during inspection`
|
|
7228
|
+
};
|
|
7229
|
+
return {
|
|
7230
|
+
root: absolute,
|
|
7231
|
+
state: "ok",
|
|
7232
|
+
title: typeof project.title === "string" ? project.title : "",
|
|
7233
|
+
...typeof project.created_at === "string" && project.created_at !== "" ? { created_at: project.created_at } : {}
|
|
7234
|
+
};
|
|
7235
|
+
} catch (error) {
|
|
7236
|
+
return {
|
|
7237
|
+
root: absolute,
|
|
7238
|
+
state: unreadableFsError(error) ? "unreadable" : "damaged",
|
|
7239
|
+
title: "(项目文件不可读)",
|
|
7240
|
+
error: errorText(error)
|
|
7241
|
+
};
|
|
7242
|
+
}
|
|
7243
|
+
try {
|
|
7244
|
+
if (await hasProjectState(store)) return {
|
|
7245
|
+
root: absolute,
|
|
7246
|
+
state: "damaged",
|
|
7247
|
+
title: "(项目文件缺失)",
|
|
7248
|
+
error: `${PROJECT_FILE} is missing`
|
|
7249
|
+
};
|
|
7250
|
+
} catch (error) {
|
|
7251
|
+
return {
|
|
7252
|
+
root: absolute,
|
|
7253
|
+
state: "unreadable",
|
|
7254
|
+
title: "(项目目录不可读)",
|
|
7255
|
+
error: errorText(error)
|
|
7256
|
+
};
|
|
7257
|
+
}
|
|
6395
7258
|
return {
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
stage: patch.stage,
|
|
6400
|
-
rounds: patch.rounds ?? {
|
|
6401
|
-
review: 0,
|
|
6402
|
-
revise: 0,
|
|
6403
|
-
check: 0
|
|
6404
|
-
},
|
|
6405
|
-
briefs: patch.briefs ?? {},
|
|
6406
|
-
blocking: patch.blocking ?? [],
|
|
6407
|
-
last_action: patch.last_action ?? null
|
|
7259
|
+
root: absolute,
|
|
7260
|
+
state: "not-project",
|
|
7261
|
+
title: "(不是项目)"
|
|
6408
7262
|
};
|
|
6409
7263
|
}
|
|
6410
|
-
/**
|
|
6411
|
-
|
|
7264
|
+
/**
|
|
7265
|
+
* Enumerate the finite workspace shape: cwd, cwd/novel and direct child
|
|
7266
|
+
* directories. No ancestor walk, no recursion, and no symlink directory is
|
|
7267
|
+
* followed. `truncated` is explicit rather than a silent 50-row cut.
|
|
7268
|
+
*/
|
|
7269
|
+
async function scanWorkspace(cwd, limit = MAX_SCAN_CANDIDATES) {
|
|
7270
|
+
const base = resolve(cwd);
|
|
7271
|
+
const store = new FsStore(base);
|
|
7272
|
+
const roots = [base];
|
|
7273
|
+
const add = (path) => {
|
|
7274
|
+
const absolute = resolve(path);
|
|
7275
|
+
if (!roots.includes(absolute)) roots.push(absolute);
|
|
7276
|
+
};
|
|
7277
|
+
try {
|
|
7278
|
+
const novel = await store.lstat("novel");
|
|
7279
|
+
if (novel !== null && novel.directory && !novel.symbolicLink) add(join(base, "novel"));
|
|
7280
|
+
for (const entry of await store.entries(".")) {
|
|
7281
|
+
if (!entry.directory || entry.symbolicLink) continue;
|
|
7282
|
+
add(join(base, entry.name));
|
|
7283
|
+
}
|
|
7284
|
+
} catch {}
|
|
7285
|
+
const bounded = Math.max(1, limit);
|
|
7286
|
+
const candidates = [];
|
|
7287
|
+
let truncated = false;
|
|
7288
|
+
for (const root of roots) try {
|
|
7289
|
+
const inspection = await inspectProject(root);
|
|
7290
|
+
if (inspection.state === "not-project" || inspection.state === "missing") continue;
|
|
7291
|
+
if (candidates.length >= bounded) {
|
|
7292
|
+
truncated = true;
|
|
7293
|
+
break;
|
|
7294
|
+
}
|
|
7295
|
+
candidates.push(inspection);
|
|
7296
|
+
} catch (error) {
|
|
7297
|
+
if (candidates.length >= bounded) {
|
|
7298
|
+
truncated = true;
|
|
7299
|
+
break;
|
|
7300
|
+
}
|
|
7301
|
+
candidates.push({
|
|
7302
|
+
root,
|
|
7303
|
+
state: "unreadable",
|
|
7304
|
+
title: "(项目目录不可读)",
|
|
7305
|
+
error: errorText(error)
|
|
7306
|
+
});
|
|
7307
|
+
}
|
|
7308
|
+
return {
|
|
7309
|
+
candidates,
|
|
7310
|
+
truncated
|
|
7311
|
+
};
|
|
7312
|
+
}
|
|
7313
|
+
/**
|
|
7314
|
+
* One/many/none classification with the §6.4 short-circuit order:
|
|
7315
|
+
* cwd itself, then cwd/novel, then exactly one readable direct child. Damaged
|
|
7316
|
+
* projects are not workspace discovery candidates; scan may still list them.
|
|
7317
|
+
*/
|
|
7318
|
+
async function discoverWorkspace(cwd) {
|
|
7319
|
+
const base = resolve(cwd);
|
|
7320
|
+
const direct = await inspectProject(base);
|
|
7321
|
+
if (direct.state === "ok") return {
|
|
7322
|
+
kind: "one",
|
|
7323
|
+
root: direct.root
|
|
7324
|
+
};
|
|
7325
|
+
const store = new FsStore(base);
|
|
7326
|
+
const nested = join(base, "novel");
|
|
7327
|
+
try {
|
|
7328
|
+
const info = await store.lstat("novel");
|
|
7329
|
+
if (info !== null && info.directory && !info.symbolicLink) {
|
|
7330
|
+
const candidate = await inspectProject(nested);
|
|
7331
|
+
if (candidate.state === "ok") return {
|
|
7332
|
+
kind: "one",
|
|
7333
|
+
root: candidate.root
|
|
7334
|
+
};
|
|
7335
|
+
}
|
|
7336
|
+
} catch {
|
|
7337
|
+
return { kind: "none" };
|
|
7338
|
+
}
|
|
7339
|
+
const roots = [];
|
|
7340
|
+
try {
|
|
7341
|
+
for (const entry of await store.entries(".")) {
|
|
7342
|
+
if (!entry.directory || entry.symbolicLink) continue;
|
|
7343
|
+
const candidate = await inspectProject(join(base, entry.name)).catch(() => null);
|
|
7344
|
+
if (candidate?.state !== "ok") continue;
|
|
7345
|
+
roots.push(candidate.root);
|
|
7346
|
+
if (roots.length > 50) break;
|
|
7347
|
+
}
|
|
7348
|
+
} catch {
|
|
7349
|
+
return { kind: "none" };
|
|
7350
|
+
}
|
|
7351
|
+
if (roots.length === 0) return { kind: "none" };
|
|
7352
|
+
if (roots.length === 1) return {
|
|
7353
|
+
kind: "one",
|
|
7354
|
+
root: roots[0]
|
|
7355
|
+
};
|
|
7356
|
+
return {
|
|
7357
|
+
kind: "many",
|
|
7358
|
+
roots
|
|
7359
|
+
};
|
|
7360
|
+
}
|
|
7361
|
+
function describeCandidates(candidates) {
|
|
7362
|
+
return candidates.map((candidate) => {
|
|
7363
|
+
return `- ${candidate.title === void 0 || candidate.title === "" ? "(无标题)" : candidate.title}${candidate.state === void 0 ? "" : ` [${candidate.state}]`}: ${candidate.root}`;
|
|
7364
|
+
}).join("\n");
|
|
7365
|
+
}
|
|
7366
|
+
function ambiguousError(candidates) {
|
|
7367
|
+
const list = describeCandidates(candidates);
|
|
7368
|
+
return new EngineInputError(ERROR_CODES.PROJECT_AMBIGUOUS, `multiple projects are available; choose one explicitly:\n${list}`, "pass project_root on the next tool call, or use /novel use <path|title>. The registry active project is not used as a silent default for an Agent session.");
|
|
7369
|
+
}
|
|
7370
|
+
function projectNotFoundError(root) {
|
|
7371
|
+
return new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no novel project at ${resolve(root)}`, "call novel_init to create one, pass the correct project_root, or run /novel scan to discover projects");
|
|
7372
|
+
}
|
|
7373
|
+
function missingProjectError(input) {
|
|
7374
|
+
const title = input.lastTitle === void 0 || input.lastTitle === "" ? "" : `《${input.lastTitle}》`;
|
|
7375
|
+
const moved = input.movedTo === void 0 ? "" : ` A project with the same created_at is registered at ${input.movedTo};`;
|
|
7376
|
+
return new EngineFaultError(ERROR_CODES.PROJECT_MISSING, `the project ${title} bound to this session is missing: ${input.selection.root}.${moved} projects are never switched automatically`, `restore the directory or mount, then retry; or explicitly run /novel use <new path> or pass project_root. The binding is kept.${input.movedTo === void 0 ? "" : ` Candidate moved project: ${input.movedTo}.`}`, input.cause === void 0 ? void 0 : { cause: input.cause });
|
|
7377
|
+
}
|
|
7378
|
+
function identityMismatchError(input) {
|
|
7379
|
+
const expected = input.selection.created_at ?? "(unknown)";
|
|
7380
|
+
const actual = input.actualCreatedAt ?? "(unknown)";
|
|
7381
|
+
const title = input.actualTitle === void 0 || input.actualTitle === "" ? "" : `《${input.actualTitle}》`;
|
|
7382
|
+
return new EngineInputError(ERROR_CODES.PROJECT_IDENTITY_MISMATCH, `the path ${input.selection.root} now contains a different project ${title} (created_at ${actual}; this session is bound to ${expected})`, "the binding was not switched and no write was attempted. Run /novel use <path> or pass project_root to confirm the replacement; old bindings keep failing closed.");
|
|
7383
|
+
}
|
|
7384
|
+
// src/engine/state.ts
|
|
7385
|
+
/** A pipeline document for a brand-new project. */
|
|
7386
|
+
function emptyPipeline(patch) {
|
|
7387
|
+
return {
|
|
7388
|
+
schema_version: 1,
|
|
7389
|
+
revision: patch.revision,
|
|
7390
|
+
chapter: patch.chapter,
|
|
7391
|
+
stage: patch.stage,
|
|
7392
|
+
rounds: patch.rounds ?? {
|
|
7393
|
+
review: 0,
|
|
7394
|
+
revise: 0,
|
|
7395
|
+
check: 0
|
|
7396
|
+
},
|
|
7397
|
+
briefs: patch.briefs ?? {},
|
|
7398
|
+
blocking: patch.blocking ?? [],
|
|
7399
|
+
last_action: patch.last_action ?? null
|
|
7400
|
+
};
|
|
7401
|
+
}
|
|
7402
|
+
/** Every state document a project starts with, keyed by file base name. */
|
|
7403
|
+
function emptyDocuments() {
|
|
6412
7404
|
return {
|
|
6413
7405
|
"chapters.yaml": {
|
|
6414
7406
|
schema_version: 1,
|
|
@@ -6497,7 +7489,7 @@ const STAGE_RANK = {
|
|
|
6497
7489
|
done: 7
|
|
6498
7490
|
};
|
|
6499
7491
|
/** Internal, journal-tracked archive idempotency ledger; not a user state doc. */
|
|
6500
|
-
const ARCHIVE_DIGESTS =
|
|
7492
|
+
const ARCHIVE_DIGESTS = ARCHIVE_DIGESTS_FILE;
|
|
6501
7493
|
const LEDGER_PATHS = {
|
|
6502
7494
|
chapters: joinRel(STATE_DIR, STATE_FILES.chapters),
|
|
6503
7495
|
characters: joinRel(STATE_DIR, STATE_FILES.characters),
|
|
@@ -6535,8 +7527,8 @@ var EngineService = class {
|
|
|
6535
7527
|
briefContextCache = /* @__PURE__ */ new WeakMap();
|
|
6536
7528
|
briefs;
|
|
6537
7529
|
now;
|
|
6538
|
-
|
|
6539
|
-
|
|
7530
|
+
metadataMutex = new KeyedMutex();
|
|
7531
|
+
callerContext = new AsyncLocalStorage();
|
|
6540
7532
|
constructor(config, rulepack, options = {}) {
|
|
6541
7533
|
this.config = config;
|
|
6542
7534
|
this.rulepackSource = typeof rulepack === "function" ? rulepack : () => rulepack;
|
|
@@ -6558,7 +7550,6 @@ var EngineService = class {
|
|
|
6558
7550
|
this.disposed = true;
|
|
6559
7551
|
this.stores.clear();
|
|
6560
7552
|
this.replay.clear();
|
|
6561
|
-
this.lastRoot = null;
|
|
6562
7553
|
this.compiled = null;
|
|
6563
7554
|
}
|
|
6564
7555
|
get rulepack() {
|
|
@@ -6571,23 +7562,171 @@ var EngineService = class {
|
|
|
6571
7562
|
withSignal(signal, task) {
|
|
6572
7563
|
return this.signalContext.run(signal, task);
|
|
6573
7564
|
}
|
|
7565
|
+
/** Bind the calling Agent session and cwd to every engine operation in `task`. */
|
|
7566
|
+
withCaller(caller, task) {
|
|
7567
|
+
return this.callerContext.run(caller, task);
|
|
7568
|
+
}
|
|
7569
|
+
caller() {
|
|
7570
|
+
return this.callerContext.getStore();
|
|
7571
|
+
}
|
|
6574
7572
|
/** The configured default project root. */
|
|
6575
7573
|
defaultRoot() {
|
|
6576
7574
|
return resolve(this.config.projectRoot);
|
|
6577
7575
|
}
|
|
7576
|
+
/** Resolve a caller-supplied root against the session cwd, like /novel use. */
|
|
7577
|
+
resolveProjectPath(root) {
|
|
7578
|
+
const caller = this.caller();
|
|
7579
|
+
return resolve(caller?.cwd ?? process.cwd(), root);
|
|
7580
|
+
}
|
|
7581
|
+
/** The resolved metadata directory; resolves relative config once per call. */
|
|
7582
|
+
stateRoot() {
|
|
7583
|
+
return this.config.stateDir === "" ? resolve(homedir(), ".dsh", "novel-write-engine") : resolve(this.config.stateDir);
|
|
7584
|
+
}
|
|
7585
|
+
stateStore() {
|
|
7586
|
+
return new FsStore(this.stateRoot());
|
|
7587
|
+
}
|
|
7588
|
+
bindings() {
|
|
7589
|
+
return new SessionBindings(this.stateStore(), { now: this.now });
|
|
7590
|
+
}
|
|
6578
7591
|
/**
|
|
6579
|
-
*
|
|
6580
|
-
*
|
|
6581
|
-
*
|
|
7592
|
+
* Serialize metadata read-modify-write across sessions and processes. The
|
|
7593
|
+
* project manuscript lock is a different lock; a slow write never blocks a
|
|
7594
|
+
* registry update.
|
|
6582
7595
|
*/
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
7596
|
+
withMetadataLock(task) {
|
|
7597
|
+
const directory = this.stateRoot();
|
|
7598
|
+
return this.metadataMutex.run(directory, async () => {
|
|
7599
|
+
ensureDirAt(directory);
|
|
7600
|
+
const lock = new FileLock(join(directory, METADATA_LOCK_FILE));
|
|
7601
|
+
try {
|
|
7602
|
+
await lock.acquire();
|
|
7603
|
+
} catch (error) {
|
|
7604
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.LOCK_TIMEOUT) throw new EngineFaultError(ERROR_CODES.LOCK_TIMEOUT, "timed out waiting for the project metadata lock", "another DSH instance is writing project registration or session bindings; retry after it finishes", { cause: error });
|
|
7605
|
+
throw error;
|
|
7606
|
+
}
|
|
7607
|
+
try {
|
|
7608
|
+
return await task();
|
|
7609
|
+
} finally {
|
|
7610
|
+
await lock.release();
|
|
7611
|
+
}
|
|
7612
|
+
});
|
|
7613
|
+
}
|
|
7614
|
+
async inspectConfigRoot() {
|
|
6586
7615
|
try {
|
|
6587
|
-
const
|
|
6588
|
-
|
|
6589
|
-
} catch {
|
|
6590
|
-
|
|
7616
|
+
const inspection = await inspectProject(this.defaultRoot());
|
|
7617
|
+
return inspection.state === "ok" || inspection.state === "damaged" || inspection.state === "unreadable" ? inspection : null;
|
|
7618
|
+
} catch (error) {
|
|
7619
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `cannot inspect the configured projectRoot ${this.defaultRoot()}: ${errorMessage(error)}`, "fix the configured path or permissions; no project was selected", { cause: error });
|
|
7620
|
+
}
|
|
7621
|
+
}
|
|
7622
|
+
/**
|
|
7623
|
+
* Resolve the four-layer choice: explicit, session binding, bounded workspace
|
|
7624
|
+
* discovery, then registry/config defaults. There is deliberately no
|
|
7625
|
+
* process-global "last project" layer.
|
|
7626
|
+
*/
|
|
7627
|
+
/** Doctor/guide may run before any project exists; that is not a failure. */
|
|
7628
|
+
async resolveRootForInspection(root) {
|
|
7629
|
+
let selection;
|
|
7630
|
+
try {
|
|
7631
|
+
selection = await this.resolveRoot(root);
|
|
7632
|
+
} catch (error) {
|
|
7633
|
+
if (root === void 0 && error instanceof EngineError && error.code === ERROR_CODES.PROJECT_NOT_FOUND) return {
|
|
7634
|
+
root: this.defaultRoot(),
|
|
7635
|
+
source: "config"
|
|
7636
|
+
};
|
|
7637
|
+
throw error;
|
|
7638
|
+
}
|
|
7639
|
+
if (root === void 0 && selection.source === "session") {
|
|
7640
|
+
const inspection = await inspectProject(selection.root);
|
|
7641
|
+
if (inspection.state === "missing") throw missingProjectError({
|
|
7642
|
+
selection,
|
|
7643
|
+
lastTitle: selection.title,
|
|
7644
|
+
movedTo: await this.movedProjectHint(selection)
|
|
7645
|
+
});
|
|
7646
|
+
if (inspection.state === "not-project") throw projectNotFoundError(selection.root);
|
|
7647
|
+
if (inspection.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the project bound to this session is unreadable: ${inspection.error ?? selection.root}`, "fix the directory permissions, or explicitly rebind with /novel use after the project is readable");
|
|
7648
|
+
}
|
|
7649
|
+
return selection;
|
|
7650
|
+
}
|
|
7651
|
+
async resolveRoot(root) {
|
|
7652
|
+
if (root !== void 0) return {
|
|
7653
|
+
root: this.resolveProjectPath(root),
|
|
7654
|
+
source: "explicit"
|
|
7655
|
+
};
|
|
7656
|
+
const caller = this.caller();
|
|
7657
|
+
if (caller?.sessionId !== void 0) {
|
|
7658
|
+
const binding = await this.bindings().get(caller.sessionId);
|
|
7659
|
+
if (binding !== void 0) return {
|
|
7660
|
+
root: binding.root,
|
|
7661
|
+
source: "session",
|
|
7662
|
+
title: binding.title,
|
|
7663
|
+
created_at: binding.project_created_at
|
|
7664
|
+
};
|
|
7665
|
+
if (caller.cwd !== void 0 && caller.cwd !== "") {
|
|
7666
|
+
const found = await discoverWorkspace(caller.cwd);
|
|
7667
|
+
if (found.kind === "one") return {
|
|
7668
|
+
root: found.root,
|
|
7669
|
+
source: "workspace"
|
|
7670
|
+
};
|
|
7671
|
+
if (found.kind === "many") throw ambiguousError(await Promise.all(found.roots.map(async (candidate) => {
|
|
7672
|
+
const inspection = await inspectProject(candidate);
|
|
7673
|
+
return {
|
|
7674
|
+
root: inspection.root,
|
|
7675
|
+
title: inspection.title,
|
|
7676
|
+
state: inspection.state
|
|
7677
|
+
};
|
|
7678
|
+
})));
|
|
7679
|
+
}
|
|
7680
|
+
}
|
|
7681
|
+
const document = await this.registry().read();
|
|
7682
|
+
const candidates = document.projects.map((entry) => ({
|
|
7683
|
+
root: entry.root,
|
|
7684
|
+
title: entry.title
|
|
7685
|
+
}));
|
|
7686
|
+
const ambiguous = async () => {
|
|
7687
|
+
return ambiguousError(await Promise.all(document.projects.map(async (entry) => {
|
|
7688
|
+
const inspection = await inspectProject(entry.root).catch(() => null);
|
|
7689
|
+
return {
|
|
7690
|
+
root: entry.root,
|
|
7691
|
+
title: entry.title,
|
|
7692
|
+
...inspection === null ? {} : { state: inspection.state }
|
|
7693
|
+
};
|
|
7694
|
+
})));
|
|
7695
|
+
};
|
|
7696
|
+
if (caller?.sessionId !== void 0) {
|
|
7697
|
+
if (candidates.length === 1) return {
|
|
7698
|
+
root: candidates[0].root,
|
|
7699
|
+
source: "single"
|
|
7700
|
+
};
|
|
7701
|
+
if (candidates.length > 1) throw await ambiguous();
|
|
7702
|
+
const config = await this.inspectConfigRoot();
|
|
7703
|
+
if (config !== null) {
|
|
7704
|
+
if (config.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the configured projectRoot is unreadable: ${config.error ?? this.defaultRoot()}`, "fix the configured path or permissions");
|
|
7705
|
+
return {
|
|
7706
|
+
root: config.root,
|
|
7707
|
+
source: "config"
|
|
7708
|
+
};
|
|
7709
|
+
}
|
|
7710
|
+
throw projectNotFoundError(this.defaultRoot());
|
|
7711
|
+
}
|
|
7712
|
+
if (document.active !== null) return {
|
|
7713
|
+
root: document.active,
|
|
7714
|
+
source: "active"
|
|
7715
|
+
};
|
|
7716
|
+
const config = await this.inspectConfigRoot();
|
|
7717
|
+
if (config !== null) {
|
|
7718
|
+
if (config.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the configured projectRoot is unreadable: ${config.error ?? this.defaultRoot()}`, "fix the configured path or permissions");
|
|
7719
|
+
return {
|
|
7720
|
+
root: config.root,
|
|
7721
|
+
source: "config"
|
|
7722
|
+
};
|
|
7723
|
+
}
|
|
7724
|
+
if (candidates.length === 1) return {
|
|
7725
|
+
root: candidates[0].root,
|
|
7726
|
+
source: "single"
|
|
7727
|
+
};
|
|
7728
|
+
if (candidates.length > 1) throw await ambiguous();
|
|
7729
|
+
throw projectNotFoundError(this.defaultRoot());
|
|
6591
7730
|
}
|
|
6592
7731
|
storeFor(root) {
|
|
6593
7732
|
const absolute = resolve(root ?? this.config.projectRoot);
|
|
@@ -6597,6 +7736,69 @@ var EngineService = class {
|
|
|
6597
7736
|
this.stores.set(absolute, store);
|
|
6598
7737
|
return store;
|
|
6599
7738
|
}
|
|
7739
|
+
/** Persist one explicit choice for this DSH session. */
|
|
7740
|
+
async bindSelection(root, title, projectCreatedAt, source) {
|
|
7741
|
+
const caller = this.caller();
|
|
7742
|
+
if (caller?.sessionId === void 0) return;
|
|
7743
|
+
const existing = await this.bindings().get(caller.sessionId);
|
|
7744
|
+
if (existing !== void 0 && existing.root === resolve(root) && existing.title === title && existing.project_created_at === projectCreatedAt) return;
|
|
7745
|
+
await this.withMetadataLock(() => this.bindings().bind({
|
|
7746
|
+
session_id: caller.sessionId,
|
|
7747
|
+
root,
|
|
7748
|
+
title,
|
|
7749
|
+
project_created_at: projectCreatedAt,
|
|
7750
|
+
source
|
|
7751
|
+
}));
|
|
7752
|
+
}
|
|
7753
|
+
/**
|
|
7754
|
+
* Best-effort discovery bookkeeping. A metadata failure must never turn a
|
|
7755
|
+
* successful project read/write into a failure; the registry is convenience.
|
|
7756
|
+
*/
|
|
7757
|
+
async autoRegister(snapshot) {
|
|
7758
|
+
if (snapshot.projectError !== null) return;
|
|
7759
|
+
try {
|
|
7760
|
+
const known = (await this.registry().read()).projects.find((entry) => entry.root === snapshot.root);
|
|
7761
|
+
const sameRow = known !== void 0 && known.title === snapshot.project.title && (known.created_at ?? "") === (snapshot.project.created_at ?? "");
|
|
7762
|
+
const differentIdentity = known !== void 0 && known.created_at !== void 0 && known.created_at !== "" && known.created_at !== snapshot.project.created_at;
|
|
7763
|
+
if (sameRow || differentIdentity) return;
|
|
7764
|
+
await this.withMetadataLock(async () => {
|
|
7765
|
+
const lockedRegistry = this.registry();
|
|
7766
|
+
const existing = (await lockedRegistry.read()).projects.find((entry) => entry.root === snapshot.root);
|
|
7767
|
+
if (existing !== void 0) {
|
|
7768
|
+
if (existing.created_at !== void 0 && existing.created_at !== "" && existing.created_at !== snapshot.project.created_at) return;
|
|
7769
|
+
if (existing.title === snapshot.project.title && (existing.created_at ?? "") === (snapshot.project.created_at ?? "")) return;
|
|
7770
|
+
}
|
|
7771
|
+
await lockedRegistry.register({
|
|
7772
|
+
root: snapshot.root,
|
|
7773
|
+
title: snapshot.project.title,
|
|
7774
|
+
created_at: snapshot.project.created_at
|
|
7775
|
+
});
|
|
7776
|
+
});
|
|
7777
|
+
} catch {}
|
|
7778
|
+
}
|
|
7779
|
+
/** Record the session binding/registration implied by a successful selection. */
|
|
7780
|
+
async recordSelection(selection, snapshot, options = {}) {
|
|
7781
|
+
if (this.caller()?.sessionId === void 0) return;
|
|
7782
|
+
if ((options.bind ?? (options.bindSource !== void 0 || selection.source === "explicit")) && snapshot.projectError === null) await this.bindSelection(snapshot.root, snapshot.project.title, snapshot.project.created_at, options.bindSource ?? "explicit");
|
|
7783
|
+
if (options.register ?? (selection.source === "explicit" || selection.source === "workspace")) await this.autoRegister(snapshot);
|
|
7784
|
+
}
|
|
7785
|
+
/**
|
|
7786
|
+
* Read paths may record convenience metadata, but a corrupt or read-only
|
|
7787
|
+
* state directory must not turn a successful explicit-root read into a
|
|
7788
|
+
* failure. Resolution without an explicit root still reads the binding
|
|
7789
|
+
* fail-closed; writes use recordSelection directly and report a committed
|
|
7790
|
+
* mutation with a warning if its metadata update fails.
|
|
7791
|
+
*/
|
|
7792
|
+
async recordSelectionBestEffort(selection, snapshot) {
|
|
7793
|
+
try {
|
|
7794
|
+
await this.recordSelection(selection, snapshot);
|
|
7795
|
+
} catch {}
|
|
7796
|
+
}
|
|
7797
|
+
/** Registration/activation/use need a readable project identity. */
|
|
7798
|
+
assertProjectHealthy(snapshot, operation) {
|
|
7799
|
+
if (snapshot.projectError === null) return;
|
|
7800
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${operation} requires a readable novel.yaml: ${snapshot.projectError}`, "repair the project definition with novel_status + novel_amend using an explicit project_root; the registry/binding was not changed");
|
|
7801
|
+
}
|
|
6600
7802
|
async initProject(input) {
|
|
6601
7803
|
const title = typeof input.title === "string" ? input.title.trim() : "";
|
|
6602
7804
|
if (title === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "title must not be empty");
|
|
@@ -6615,10 +7817,16 @@ var EngineService = class {
|
|
|
6615
7817
|
const [min, max] = input.dialogue_ratio;
|
|
6616
7818
|
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max > 1 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "dialogue_ratio must be [min, max] within 0..1 with min <= max");
|
|
6617
7819
|
}
|
|
6618
|
-
|
|
6619
|
-
root: input.root,
|
|
7820
|
+
const selection = {
|
|
7821
|
+
root: input.root === void 0 ? this.defaultRoot() : this.resolveProjectPath(input.root),
|
|
7822
|
+
source: input.root === void 0 ? "config" : "explicit"
|
|
7823
|
+
};
|
|
7824
|
+
const result = await this.mutate({
|
|
7825
|
+
selection,
|
|
6620
7826
|
op: "initProject",
|
|
6621
|
-
|
|
7827
|
+
bindOnSuccess: false,
|
|
7828
|
+
registerOnSuccess: false,
|
|
7829
|
+
key: () => `init:${selection.root}`,
|
|
6622
7830
|
run: async (_snapshot, context) => {
|
|
6623
7831
|
if (await context.store.exists("novel.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, `a project already exists at ${context.store.root}`, "use a different directory, or call novel_status to inspect the existing project");
|
|
6624
7832
|
const project = {
|
|
@@ -6675,6 +7883,13 @@ var EngineService = class {
|
|
|
6675
7883
|
path: PROJECT_FILE,
|
|
6676
7884
|
text: yaml(project)
|
|
6677
7885
|
},
|
|
7886
|
+
{
|
|
7887
|
+
path: BRIEF_RECEIPTS_FILE,
|
|
7888
|
+
text: yaml({
|
|
7889
|
+
schema_version: 1,
|
|
7890
|
+
receipts: []
|
|
7891
|
+
})
|
|
7892
|
+
},
|
|
6678
7893
|
{
|
|
6679
7894
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
6680
7895
|
text: yaml(pipeline)
|
|
@@ -6698,11 +7913,32 @@ var EngineService = class {
|
|
|
6698
7913
|
title: project.title,
|
|
6699
7914
|
chapter: 1,
|
|
6700
7915
|
stage: "idle"
|
|
6701
|
-
}
|
|
7916
|
+
},
|
|
7917
|
+
warnings: [...project.author.trim() === "" ? [{
|
|
7918
|
+
code: "PROJECT_AUTHOR_EMPTY",
|
|
7919
|
+
message: "作者为空;proposal 与导出会缺作者,可用 novel_amend 补 novel.yaml.author"
|
|
7920
|
+
}] : [], ...project.description.trim() === "" ? [{
|
|
7921
|
+
code: "PROJECT_DESCRIPTION_EMPTY",
|
|
7922
|
+
message: "简介为空;proposal 与导出会缺简介,可用 novel_amend 补 novel.yaml.description"
|
|
7923
|
+
}] : []]
|
|
6702
7924
|
};
|
|
6703
7925
|
},
|
|
6704
7926
|
emptyAllowed: true
|
|
6705
7927
|
});
|
|
7928
|
+
const created = await this.loadAt({
|
|
7929
|
+
root: result.data.root,
|
|
7930
|
+
source: "explicit"
|
|
7931
|
+
});
|
|
7932
|
+
await this.autoRegister(created);
|
|
7933
|
+
try {
|
|
7934
|
+
await this.bindSelection(created.root, created.project.title, created.project.created_at, "init");
|
|
7935
|
+
} catch (error) {
|
|
7936
|
+
result.warnings.push({
|
|
7937
|
+
code: "SESSION_BINDING_FAILED",
|
|
7938
|
+
message: `project was created, but the session binding could not be written: ${errorMessage(error)}`
|
|
7939
|
+
});
|
|
7940
|
+
}
|
|
7941
|
+
return result;
|
|
6706
7942
|
}
|
|
6707
7943
|
async submitBookPlan(input) {
|
|
6708
7944
|
const allowed = /* @__PURE__ */ new Set([
|
|
@@ -7023,6 +8259,7 @@ var EngineService = class {
|
|
|
7023
8259
|
"characters",
|
|
7024
8260
|
"glossary",
|
|
7025
8261
|
"backstory",
|
|
8262
|
+
"facts",
|
|
7026
8263
|
"themes"
|
|
7027
8264
|
]);
|
|
7028
8265
|
const keys = Object.entries(input).filter(([key, value]) => key !== "root" && value !== void 0).map(([key]) => key);
|
|
@@ -7050,9 +8287,10 @@ var EngineService = class {
|
|
|
7050
8287
|
locations: worldbook.locations.length,
|
|
7051
8288
|
characters: worldbook.characters.length,
|
|
7052
8289
|
glossary: worldbook.glossary.length,
|
|
7053
|
-
backstory: worldbook.backstory.length
|
|
8290
|
+
backstory: worldbook.backstory.length,
|
|
8291
|
+
facts: (worldbook.facts ?? []).length
|
|
7054
8292
|
};
|
|
7055
|
-
const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory;
|
|
8293
|
+
const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts;
|
|
7056
8294
|
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_worldbook", this.now(), shortDigest(stableJson(input))) });
|
|
7057
8295
|
return {
|
|
7058
8296
|
files: [{
|
|
@@ -7092,6 +8330,7 @@ var EngineService = class {
|
|
|
7092
8330
|
return this.mutate({
|
|
7093
8331
|
root: input.root,
|
|
7094
8332
|
op: "reopenChapter",
|
|
8333
|
+
chapter: input.chapter,
|
|
7095
8334
|
reason,
|
|
7096
8335
|
key: () => `reopen:${input.chapter}:${shortDigest(reason)}`,
|
|
7097
8336
|
run: async (snapshot, context) => {
|
|
@@ -7116,7 +8355,7 @@ var EngineService = class {
|
|
|
7116
8355
|
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
8356
|
const files = [];
|
|
7118
8357
|
for (const path of archive.files) {
|
|
7119
|
-
if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml") continue;
|
|
8358
|
+
if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml" || path === planChapterFile(input.chapter)) continue;
|
|
7120
8359
|
const hasBeforeEntry = beforeHashes !== void 0 && Object.hasOwn(beforeHashes, path);
|
|
7121
8360
|
const expectedBefore = hasBeforeEntry ? beforeHashes[path] : void 0;
|
|
7122
8361
|
if (hasBeforeEntry && expectedBefore === null) {
|
|
@@ -7180,6 +8419,15 @@ var EngineService = class {
|
|
|
7180
8419
|
briefs: {},
|
|
7181
8420
|
blocking: [],
|
|
7182
8421
|
review_digest: void 0,
|
|
8422
|
+
draft: {
|
|
8423
|
+
chapter: input.chapter,
|
|
8424
|
+
title: stripChapterPrefix(plan.title),
|
|
8425
|
+
scene_type: plan.scene_type,
|
|
8426
|
+
narrator: plan.narrator,
|
|
8427
|
+
summary: record.summary,
|
|
8428
|
+
content_hash: record.content_hash,
|
|
8429
|
+
updated_at: reopenedAt
|
|
8430
|
+
},
|
|
7183
8431
|
last_action: action("novel_reopen", this.now(), shortDigest(reason))
|
|
7184
8432
|
});
|
|
7185
8433
|
delete nextPipeline.review_digest;
|
|
@@ -7213,6 +8461,126 @@ var EngineService = class {
|
|
|
7213
8461
|
});
|
|
7214
8462
|
}
|
|
7215
8463
|
/**
|
|
8464
|
+
* Restore one active chapter from a backup transaction. Archived chapters
|
|
8465
|
+
* are refused: their manuscript hash is bound by the index, so restoring
|
|
8466
|
+
* behind the index would corrupt the project. Reopen first, then restore.
|
|
8467
|
+
*/
|
|
8468
|
+
async restoreBackup(input) {
|
|
8469
|
+
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");
|
|
8470
|
+
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
8471
|
+
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");
|
|
8472
|
+
return this.mutate({
|
|
8473
|
+
root: input.root,
|
|
8474
|
+
op: "restoreBackup",
|
|
8475
|
+
chapter: input.chapter,
|
|
8476
|
+
reason: `restore backup ${input.txid}`,
|
|
8477
|
+
run: async (snapshot, context) => {
|
|
8478
|
+
const manifest = await readBackupManifest(context.store, input.txid);
|
|
8479
|
+
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");
|
|
8480
|
+
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
8481
|
+
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");
|
|
8482
|
+
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");
|
|
8483
|
+
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");
|
|
8484
|
+
const fileFor = (path) => `backups/${input.txid}/${path}`;
|
|
8485
|
+
const allowed = [
|
|
8486
|
+
chapterFile(chapter),
|
|
8487
|
+
planChapterFile(chapter),
|
|
8488
|
+
planWorldFile(chapter),
|
|
8489
|
+
reviewFile(chapter),
|
|
8490
|
+
checkFile(chapter)
|
|
8491
|
+
];
|
|
8492
|
+
const requested = input.paths ?? allowed.filter((path) => manifest.files.includes(path));
|
|
8493
|
+
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(", ")}`);
|
|
8494
|
+
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");
|
|
8495
|
+
const files = [];
|
|
8496
|
+
let restoredReview = null;
|
|
8497
|
+
let restoredCheck = null;
|
|
8498
|
+
for (const path of requested) {
|
|
8499
|
+
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");
|
|
8500
|
+
const text = await context.store.readText(fileFor(path));
|
|
8501
|
+
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");
|
|
8502
|
+
const kind = path === planChapterFile(chapter) ? "chapterPlan" : path === planWorldFile(chapter) ? "worldPlan" : path === reviewFile(chapter) ? "review" : path === checkFile(chapter) ? "check" : null;
|
|
8503
|
+
if (kind !== null) try {
|
|
8504
|
+
const parsed = parse(text);
|
|
8505
|
+
const row = recordOf(parsed);
|
|
8506
|
+
if (row === null) throw new Error("document is not a mapping");
|
|
8507
|
+
if (kind === "chapterPlan") {
|
|
8508
|
+
validateStoredChapterPlan(row, path);
|
|
8509
|
+
if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
|
|
8510
|
+
} else if (kind === "worldPlan") {
|
|
8511
|
+
validateWorldPlanShape(narrow("worldPlan", parsed), path);
|
|
8512
|
+
if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
|
|
8513
|
+
} else {
|
|
8514
|
+
const restored = narrow(kind, parsed);
|
|
8515
|
+
if (restored.chapter !== chapter) throw new Error(`document belongs to chapter ${String(restored.chapter)}, not ${chapter}`);
|
|
8516
|
+
if (kind === "review") restoredReview = restored;
|
|
8517
|
+
if (kind === "check") restoredCheck = restored;
|
|
8518
|
+
}
|
|
8519
|
+
} catch (error) {
|
|
8520
|
+
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");
|
|
8521
|
+
}
|
|
8522
|
+
files.push({
|
|
8523
|
+
path,
|
|
8524
|
+
text
|
|
8525
|
+
});
|
|
8526
|
+
}
|
|
8527
|
+
const clearDraft = requested.includes(chapterFile(chapter)) || requested.includes(planChapterFile(chapter));
|
|
8528
|
+
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
|
|
8529
|
+
if (clearDraft) delete nextPipeline.draft;
|
|
8530
|
+
if (restoredReview !== null) nextPipeline.review_digest = sha256(stableJson(restoredReview));
|
|
8531
|
+
if (restoredReview !== null || restoredCheck !== null) {
|
|
8532
|
+
const effectiveReview = restoredReview ?? snapshot.review;
|
|
8533
|
+
const effectiveCheck = restoredCheck ?? snapshot.check;
|
|
8534
|
+
const reviewBlocking = effectiveReview === null ? [] : [...effectiveReview.issues.filter((issue) => issue.severity === "blocker" && issue.status !== "fixed").map((issue) => ({
|
|
8535
|
+
code: `ISSUE-${issue.id}`,
|
|
8536
|
+
scope: issue.dimension,
|
|
8537
|
+
detail: `${issue.problem} → ${issue.fix}`,
|
|
8538
|
+
hint: issue.location
|
|
8539
|
+
})), ...effectiveReview.total < this.config.quality.minScore ? [{
|
|
8540
|
+
code: "SCORE-BELOW-MIN",
|
|
8541
|
+
scope: "quality",
|
|
8542
|
+
detail: `score ${effectiveReview.total} is below the minimum ${this.config.quality.minScore}`,
|
|
8543
|
+
hint: "revise the chapter and submit a new P4 review"
|
|
8544
|
+
}] : []];
|
|
8545
|
+
const checkMatches = effectiveCheck !== null && effectiveCheck.content_hash === sha256(snapshot.text ?? "");
|
|
8546
|
+
const checkBlocking = effectiveCheck === null || !checkMatches || effectiveCheck.verdict === "pass" ? [] : [...effectiveCheck.machine.violations.map((violation) => ({
|
|
8547
|
+
code: violation.code,
|
|
8548
|
+
scope: "check",
|
|
8549
|
+
detail: violation.detail
|
|
8550
|
+
})), ...evaluateSemantic(snapshot.text ?? "", effectiveCheck.semantic.items).blocking];
|
|
8551
|
+
nextPipeline.blocking = [...reviewBlocking, ...checkBlocking];
|
|
8552
|
+
}
|
|
8553
|
+
files.push({
|
|
8554
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8555
|
+
text: yaml(nextPipeline)
|
|
8556
|
+
});
|
|
8557
|
+
return {
|
|
8558
|
+
files,
|
|
8559
|
+
backup: true,
|
|
8560
|
+
data: {
|
|
8561
|
+
txid: input.txid,
|
|
8562
|
+
chapter,
|
|
8563
|
+
restored: requested
|
|
8564
|
+
},
|
|
8565
|
+
warnings: clearDraft ? [{
|
|
8566
|
+
code: "RESTORE_DRAFT_CLEARED",
|
|
8567
|
+
message: "the restored manuscript/plan invalidated the live draft summary; call novel_rollback to P3, then submit novel_write_draft again"
|
|
8568
|
+
}] : [],
|
|
8569
|
+
event: {
|
|
8570
|
+
name: "novel/restored",
|
|
8571
|
+
payload: {
|
|
8572
|
+
schemaVersion: 1,
|
|
8573
|
+
projectRoot: snapshot.root,
|
|
8574
|
+
txid: input.txid,
|
|
8575
|
+
chapter,
|
|
8576
|
+
paths: requested
|
|
8577
|
+
}
|
|
8578
|
+
}
|
|
8579
|
+
};
|
|
8580
|
+
}
|
|
8581
|
+
});
|
|
8582
|
+
}
|
|
8583
|
+
/**
|
|
7216
8584
|
* Read-only paths must surface a committed-journal mismatch. The write path
|
|
7217
8585
|
* already fails closed; without this, status recommends the next write and
|
|
7218
8586
|
* the model only learns about the mismatch when that write aborts.
|
|
@@ -7256,10 +8624,32 @@ var EngineService = class {
|
|
|
7256
8624
|
}];
|
|
7257
8625
|
}
|
|
7258
8626
|
}
|
|
8627
|
+
/**
|
|
8628
|
+
* Count every committed attempt for one chapter, not just the loop since the
|
|
8629
|
+
* last rollback. `pipeline.rounds` is deliberately reset by rollback to keep
|
|
8630
|
+
* the retry budget fair; it is not the chapter's lifetime effort.
|
|
8631
|
+
*/
|
|
8632
|
+
async lifetimeRounds(snapshot, chapter) {
|
|
8633
|
+
const { rows } = await readJournal(this.storeFor(snapshot.root));
|
|
8634
|
+
const committed = (op) => rows.filter((row) => row.status === "committed" && row.chapter === chapter && row.op === op).length;
|
|
8635
|
+
return {
|
|
8636
|
+
review: Math.max(snapshot.pipeline.rounds.review, committed("submitReview")),
|
|
8637
|
+
revise: Math.max(snapshot.pipeline.rounds.revise, committed("submitRevision")),
|
|
8638
|
+
check: Math.max(snapshot.pipeline.rounds.check, committed("runCheck")),
|
|
8639
|
+
rollbacks: committed("rollback")
|
|
8640
|
+
};
|
|
8641
|
+
}
|
|
7259
8642
|
async getStatus(input = {}) {
|
|
7260
8643
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
7261
8644
|
const snapshot = await this.load(input.root);
|
|
7262
|
-
|
|
8645
|
+
return this.statusFor(snapshot, input.chapter ?? snapshot.pipeline.chapter);
|
|
8646
|
+
}
|
|
8647
|
+
/**
|
|
8648
|
+
* Build a status report from an already-loaded snapshot. This deliberately
|
|
8649
|
+
* has no selection side effect: read-only callers such as /novel resume must
|
|
8650
|
+
* not register or bind the project just by reading it.
|
|
8651
|
+
*/
|
|
8652
|
+
async statusFor(snapshot, chapter) {
|
|
7263
8653
|
const metrics = await this.metricsFor(snapshot, chapter);
|
|
7264
8654
|
const journal = await this.journalBlocking(snapshot);
|
|
7265
8655
|
const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
|
|
@@ -7281,6 +8671,10 @@ var EngineService = class {
|
|
|
7281
8671
|
rounds: { ...snapshot.pipeline.rounds },
|
|
7282
8672
|
blocking: [...this.effectiveBlocking(snapshot), ...journal],
|
|
7283
8673
|
state_errors: this.stateErrors(snapshot),
|
|
8674
|
+
selection: {
|
|
8675
|
+
source: snapshot.selection.source,
|
|
8676
|
+
root: snapshot.selection.root
|
|
8677
|
+
},
|
|
7284
8678
|
metrics,
|
|
7285
8679
|
...snapshot.handoff === null ? {} : (() => {
|
|
7286
8680
|
const position = projectHandoff(snapshot.handoff, snapshot.pipeline.chapter);
|
|
@@ -7316,6 +8710,7 @@ var EngineService = class {
|
|
|
7316
8710
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
7317
8711
|
const snapshot = await this.load(input.root);
|
|
7318
8712
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
8713
|
+
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
8714
|
const store = this.storeFor(snapshot.root);
|
|
7320
8715
|
const ledgerPath = LEDGER_PATHS[input.kind];
|
|
7321
8716
|
if (ledgerPath !== void 0) {
|
|
@@ -7333,7 +8728,10 @@ var EngineService = class {
|
|
|
7333
8728
|
}
|
|
7334
8729
|
switch (input.kind) {
|
|
7335
8730
|
case "pipeline": return {
|
|
7336
|
-
data:
|
|
8731
|
+
data: {
|
|
8732
|
+
...snapshot.pipeline,
|
|
8733
|
+
briefs: snapshot.briefs
|
|
8734
|
+
},
|
|
7337
8735
|
revision: snapshot.pipeline.revision,
|
|
7338
8736
|
warnings: []
|
|
7339
8737
|
};
|
|
@@ -7428,6 +8826,66 @@ var EngineService = class {
|
|
|
7428
8826
|
revision: snapshot.pipeline.revision,
|
|
7429
8827
|
warnings: []
|
|
7430
8828
|
};
|
|
8829
|
+
case "briefs": {
|
|
8830
|
+
const receipts = await this.readBriefReceipts(store);
|
|
8831
|
+
if (receipts.receipts.length > 0) return {
|
|
8832
|
+
data: receipts,
|
|
8833
|
+
revision: snapshot.pipeline.revision,
|
|
8834
|
+
warnings: []
|
|
8835
|
+
};
|
|
8836
|
+
return {
|
|
8837
|
+
data: {
|
|
8838
|
+
schema_version: 1,
|
|
8839
|
+
receipts: Object.entries(snapshot.briefs).map(([stage, receipt]) => ({
|
|
8840
|
+
chapter: snapshot.pipeline.chapter,
|
|
8841
|
+
stage,
|
|
8842
|
+
digest: receipt.digest,
|
|
8843
|
+
issued_at: receipt.issued_at
|
|
8844
|
+
}))
|
|
8845
|
+
},
|
|
8846
|
+
revision: snapshot.pipeline.revision,
|
|
8847
|
+
warnings: []
|
|
8848
|
+
};
|
|
8849
|
+
}
|
|
8850
|
+
case "history": {
|
|
8851
|
+
const entries = await listHistory(store, input.chapter);
|
|
8852
|
+
if (input.revision !== void 0) {
|
|
8853
|
+
if (!Number.isInteger(input.revision) || input.revision < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "history revision must be a positive integer");
|
|
8854
|
+
const matching = entries.filter((entry) => entry.chapter === chapter && entry.revision === input.revision);
|
|
8855
|
+
const contents = [];
|
|
8856
|
+
for (const entry of matching) contents.push({
|
|
8857
|
+
...entry,
|
|
8858
|
+
text: await store.readText(entry.path)
|
|
8859
|
+
});
|
|
8860
|
+
return {
|
|
8861
|
+
data: {
|
|
8862
|
+
schema_version: 1,
|
|
8863
|
+
chapter,
|
|
8864
|
+
revision: input.revision,
|
|
8865
|
+
files: contents
|
|
8866
|
+
},
|
|
8867
|
+
revision: snapshot.pipeline.revision,
|
|
8868
|
+
warnings: []
|
|
8869
|
+
};
|
|
8870
|
+
}
|
|
8871
|
+
return {
|
|
8872
|
+
data: {
|
|
8873
|
+
schema_version: 1,
|
|
8874
|
+
entries,
|
|
8875
|
+
total: entries.length
|
|
8876
|
+
},
|
|
8877
|
+
revision: snapshot.pipeline.revision,
|
|
8878
|
+
warnings: []
|
|
8879
|
+
};
|
|
8880
|
+
}
|
|
8881
|
+
case "backups": return {
|
|
8882
|
+
data: {
|
|
8883
|
+
schema_version: 1,
|
|
8884
|
+
backups: await listBackups(store)
|
|
8885
|
+
},
|
|
8886
|
+
revision: snapshot.pipeline.revision,
|
|
8887
|
+
warnings: []
|
|
8888
|
+
};
|
|
7431
8889
|
case "review":
|
|
7432
8890
|
if (chapter === snapshot.pipeline.chapter && snapshot.reviewError !== null) return {
|
|
7433
8891
|
data: {
|
|
@@ -7609,13 +9067,14 @@ var EngineService = class {
|
|
|
7609
9067
|
};
|
|
7610
9068
|
}
|
|
7611
9069
|
async doctor(input = {}) {
|
|
7612
|
-
const
|
|
9070
|
+
const selection = await this.resolveRootForInspection(input.root);
|
|
9071
|
+
const root = selection.root;
|
|
7613
9072
|
const store = this.storeFor(root);
|
|
7614
9073
|
const checks = [];
|
|
7615
9074
|
const add = (check) => {
|
|
7616
9075
|
checks.push(check);
|
|
7617
9076
|
};
|
|
7618
|
-
if (!await store.exists("novel.yaml") && !await
|
|
9077
|
+
if (!await store.exists("novel.yaml") && !await hasProjectState(store)) {
|
|
7619
9078
|
add({
|
|
7620
9079
|
id: "project",
|
|
7621
9080
|
severity: "error",
|
|
@@ -7640,7 +9099,8 @@ var EngineService = class {
|
|
|
7640
9099
|
}
|
|
7641
9100
|
let snapshot;
|
|
7642
9101
|
try {
|
|
7643
|
-
snapshot = await this.
|
|
9102
|
+
snapshot = await this.loadAt(selection);
|
|
9103
|
+
await this.recordSelectionBestEffort(selection, snapshot);
|
|
7644
9104
|
} catch (error) {
|
|
7645
9105
|
add({
|
|
7646
9106
|
id: "load",
|
|
@@ -7774,13 +9234,14 @@ var EngineService = class {
|
|
|
7774
9234
|
const archivedCards = new Set(snapshot.outline.chapters.filter((card) => card.status === "archived").map((card) => card.num));
|
|
7775
9235
|
const missingCards = archivedRecords.filter((record) => !archivedCards.has(record.num));
|
|
7776
9236
|
const extraCards = [...archivedCards].filter((num) => !archivedRecords.some((record) => record.num === num));
|
|
7777
|
-
const
|
|
9237
|
+
const outlineProgress = outlineSummary(snapshot.outline, archivedRecords.length, snapshot.project.targets.total_chapters, archivedRecords.map((record) => record.num));
|
|
9238
|
+
const percentText = (value) => value === null ? "—" : `${Math.round(value * 1e3) / 10}%`;
|
|
7778
9239
|
add({
|
|
7779
9240
|
id: "outline-coverage",
|
|
7780
|
-
severity: missingCards.length === 0 && extraCards.length === 0 ? "ok" : "warning",
|
|
9241
|
+
severity: missingCards.length === 0 && extraCards.length === 0 && (outlineProgress.target === null || outlineProgress.missingCards === 0) ? "ok" : "warning",
|
|
7781
9242
|
title: "大纲覆盖率",
|
|
7782
|
-
detail: archivedRecords.length
|
|
7783
|
-
...missingCards.length === 0 && extraCards.length === 0 ? {} : { hint:
|
|
9243
|
+
detail: `规划覆盖 ${percentText(outlineProgress.plannedCoverage)}(${outlineProgress.total}/${outlineProgress.target ?? "不限"} 张卡)· 归档覆盖 ${percentText(outlineProgress.archivedCoverage)}(${archivedRecords.length}/${outlineProgress.target ?? "不限"} 章)· ${outlineProgress.missingCards} 章缺卡 · ${missingCards.length} 章有正文无卡 · ${extraCards.length} 张卡无正文`,
|
|
9244
|
+
...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
9245
|
});
|
|
7785
9246
|
const stageMissing = [];
|
|
7786
9247
|
switch (snapshot.pipeline.stage) {
|
|
@@ -7815,11 +9276,20 @@ var EngineService = class {
|
|
|
7815
9276
|
detail: stageMissing.length === 0 ? `${snapshot.pipeline.stage} 阶段所需产物齐全` : `缺少:${stageMissing.join("、")}`,
|
|
7816
9277
|
...stageMissing.length === 0 ? {} : { hint: "use novel_brief/novel_status to rebuild the missing stage product" }
|
|
7817
9278
|
});
|
|
9279
|
+
const restoreCandidates = /* @__PURE__ */ new Set([
|
|
9280
|
+
chapterFile(snapshot.pipeline.chapter),
|
|
9281
|
+
planChapterFile(snapshot.pipeline.chapter),
|
|
9282
|
+
planWorldFile(snapshot.pipeline.chapter),
|
|
9283
|
+
reviewFile(snapshot.pipeline.chapter),
|
|
9284
|
+
checkFile(snapshot.pipeline.chapter)
|
|
9285
|
+
]);
|
|
9286
|
+
const activeRestorable = backups.find((entry) => entry.op !== null && entry.chapter === snapshot.pipeline.chapter && entry.files.some((path) => restoreCandidates.has(path)));
|
|
7818
9287
|
add({
|
|
7819
9288
|
id: "backups",
|
|
7820
9289
|
severity: "info",
|
|
7821
9290
|
title: "备份",
|
|
7822
|
-
detail: `${backups.length}
|
|
9291
|
+
detail: backups.length === 0 ? "0 个备份快照" : `${backups.length} 个备份快照;最新:${backups[0].op ?? "旧版备份"}${backups[0].chapter === null ? "" : ` · 第 ${backups[0].chapter} 章`} · ${backups[0].files.length} 个文件`,
|
|
9292
|
+
...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
9293
|
});
|
|
7824
9294
|
const locked = await store.exists(LOCK_FILE);
|
|
7825
9295
|
add({
|
|
@@ -7875,13 +9345,25 @@ var EngineService = class {
|
|
|
7875
9345
|
const store = this.storeFor(snapshot.root);
|
|
7876
9346
|
const reviews = [];
|
|
7877
9347
|
const reviewIssues = [];
|
|
9348
|
+
const historicalEntries = await listHistory(store);
|
|
7878
9349
|
for (const record of snapshot.chapters.chapters) try {
|
|
7879
9350
|
const review = await store.readDoc(reviewFile(record.num), "review");
|
|
7880
9351
|
if (review === null) reviewIssues.push(`chapter ${record.num}: review is missing`);
|
|
7881
9352
|
else reviews.push(review);
|
|
9353
|
+
for (const historical of await historyTexts(store, "reviews", record.num, historicalEntries)) try {
|
|
9354
|
+
const parsed = narrow("review", parse(historical.text));
|
|
9355
|
+
reviews.push(parsed);
|
|
9356
|
+
} catch (error) {
|
|
9357
|
+
reviewIssues.push(`chapter ${record.num} ${historical.path}: ${errorMessage(error)}`);
|
|
9358
|
+
}
|
|
7882
9359
|
} catch (error) {
|
|
7883
9360
|
reviewIssues.push(`chapter ${record.num}: ${errorMessage(error)}`);
|
|
7884
9361
|
}
|
|
9362
|
+
const uniqueReviews = /* @__PURE__ */ new Map();
|
|
9363
|
+
for (const review of reviews) {
|
|
9364
|
+
const key = stableJson(review);
|
|
9365
|
+
if (!uniqueReviews.has(key)) uniqueReviews.set(key, review);
|
|
9366
|
+
}
|
|
7885
9367
|
const journal = await readJournal(store);
|
|
7886
9368
|
const report = buildBookReport({
|
|
7887
9369
|
project: snapshot.project,
|
|
@@ -7896,7 +9378,7 @@ var EngineService = class {
|
|
|
7896
9378
|
timeline: snapshot.timeline,
|
|
7897
9379
|
foreshadows: snapshot.foreshadows,
|
|
7898
9380
|
relationships: snapshot.relationships,
|
|
7899
|
-
reviews,
|
|
9381
|
+
reviews: [...uniqueReviews.values()],
|
|
7900
9382
|
activity: journal.rows.slice(-8),
|
|
7901
9383
|
targetChapters: snapshot.project.targets.total_chapters,
|
|
7902
9384
|
minScore: this.config.quality.minScore,
|
|
@@ -7913,9 +9395,10 @@ var EngineService = class {
|
|
|
7913
9395
|
};
|
|
7914
9396
|
}
|
|
7915
9397
|
async guide(input = {}) {
|
|
7916
|
-
const
|
|
9398
|
+
const selection = await this.resolveRootForInspection(input.root);
|
|
9399
|
+
const root = selection.root;
|
|
7917
9400
|
const store = this.storeFor(root);
|
|
7918
|
-
if (!await store.exists("novel.yaml") && !await
|
|
9401
|
+
if (!await store.exists("novel.yaml") && !await hasProjectState(store)) return {
|
|
7919
9402
|
data: buildGuide({
|
|
7920
9403
|
projectPresent: false,
|
|
7921
9404
|
chapter: 1,
|
|
@@ -7971,7 +9454,8 @@ var EngineService = class {
|
|
|
7971
9454
|
revision: 1,
|
|
7972
9455
|
warnings: []
|
|
7973
9456
|
};
|
|
7974
|
-
const snapshot = await this.
|
|
9457
|
+
const snapshot = await this.loadAt(selection);
|
|
9458
|
+
await this.recordSelectionBestEffort(selection, snapshot);
|
|
7975
9459
|
const target = snapshot.project.targets.total_chapters;
|
|
7976
9460
|
const finished = snapshot.pipeline.stage === "done" && target !== null && snapshot.chapters.chapters.length >= target;
|
|
7977
9461
|
const journalBlocking = await this.journalBlocking(snapshot);
|
|
@@ -8007,6 +9491,8 @@ var EngineService = class {
|
|
|
8007
9491
|
return this.mutate({
|
|
8008
9492
|
root: input.root,
|
|
8009
9493
|
op: "getBrief",
|
|
9494
|
+
chapter: input.chapter,
|
|
9495
|
+
advanceRevision: false,
|
|
8010
9496
|
run: async (snapshot, context) => {
|
|
8011
9497
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
8012
9498
|
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 +9505,20 @@ var EngineService = class {
|
|
|
8019
9505
|
chapter,
|
|
8020
9506
|
digest,
|
|
8021
9507
|
min_words: snapshot.project.targets.words_per_chapter[0],
|
|
9508
|
+
max_words: snapshot.project.targets.words_per_chapter[1],
|
|
9509
|
+
word_count_policy: {
|
|
9510
|
+
metric: "total_word_count",
|
|
9511
|
+
includes_punctuation: true,
|
|
9512
|
+
formula: "cjk_chars + english_words + numbers + punctuation_chars",
|
|
9513
|
+
note: "引擎与商业平台一致把标点计入字数;brief/status/check 的 min_words 都是这个口径"
|
|
9514
|
+
},
|
|
9515
|
+
dialogue_ratio_target: snapshot.project.targets.dialogue_ratio,
|
|
9516
|
+
dialogue_ratio_metric: "含引号行数 / 非空行数",
|
|
9517
|
+
delta_rules: {
|
|
9518
|
+
title_is_a_label: true,
|
|
9519
|
+
anchor_required_for_verification: true,
|
|
9520
|
+
rule: "伏笔 title 可以写成可读短语;要逐字校验请在 anchor 里放正文原句(至少 8 个字符)。缺 anchor 时 title 必须逐字出现在正文。"
|
|
9521
|
+
},
|
|
8022
9522
|
min_score: this.config.quality.minScore,
|
|
8023
9523
|
scene_types: [
|
|
8024
9524
|
"战斗/动作章",
|
|
@@ -8051,26 +9551,28 @@ var EngineService = class {
|
|
|
8051
9551
|
fields,
|
|
8052
9552
|
context: contextPack
|
|
8053
9553
|
};
|
|
8054
|
-
const existing = snapshot.
|
|
9554
|
+
const existing = snapshot.briefs[input.stage];
|
|
8055
9555
|
if (existing !== void 0 && existing.digest === digest) return {
|
|
8056
9556
|
files: [],
|
|
8057
9557
|
data,
|
|
8058
9558
|
replay: true
|
|
8059
9559
|
};
|
|
8060
|
-
const
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
},
|
|
8068
|
-
last_action: action("novel_brief", this.now(), digest)
|
|
9560
|
+
const issuedAt = this.now().toISOString();
|
|
9561
|
+
const receiptDocument = await this.readBriefReceipts(context.store);
|
|
9562
|
+
receiptDocument.receipts.push({
|
|
9563
|
+
chapter,
|
|
9564
|
+
stage: input.stage,
|
|
9565
|
+
digest,
|
|
9566
|
+
issued_at: issuedAt
|
|
8069
9567
|
});
|
|
9568
|
+
const receipts = receiptDocument.receipts;
|
|
8070
9569
|
return {
|
|
8071
9570
|
files: [{
|
|
8072
|
-
path:
|
|
8073
|
-
text: yaml(
|
|
9571
|
+
path: BRIEF_RECEIPTS_FILE,
|
|
9572
|
+
text: yaml({
|
|
9573
|
+
schema_version: 1,
|
|
9574
|
+
receipts
|
|
9575
|
+
})
|
|
8074
9576
|
}],
|
|
8075
9577
|
backup: false,
|
|
8076
9578
|
data
|
|
@@ -8139,6 +9641,7 @@ var EngineService = class {
|
|
|
8139
9641
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8140
9642
|
text: yaml(nextPipeline)
|
|
8141
9643
|
}],
|
|
9644
|
+
backup: snapshot.worldPlan !== null,
|
|
8142
9645
|
data: {
|
|
8143
9646
|
chapter,
|
|
8144
9647
|
stage: "P2",
|
|
@@ -8193,13 +9696,21 @@ var EngineService = class {
|
|
|
8193
9696
|
last_action: action("novel_plan_chapter", this.now(), shortDigest(stableJson(plan)))
|
|
8194
9697
|
});
|
|
8195
9698
|
return {
|
|
8196
|
-
files: [
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
9699
|
+
files: [
|
|
9700
|
+
...snapshot.plan === null ? [] : [{
|
|
9701
|
+
path: planHistoryFile(chapter, snapshot.pipeline.revision),
|
|
9702
|
+
text: yaml(snapshot.plan)
|
|
9703
|
+
}],
|
|
9704
|
+
{
|
|
9705
|
+
path,
|
|
9706
|
+
text: yaml(plan)
|
|
9707
|
+
},
|
|
9708
|
+
{
|
|
9709
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9710
|
+
text: yaml(nextPipeline)
|
|
9711
|
+
}
|
|
9712
|
+
],
|
|
9713
|
+
backup: snapshot.plan !== null,
|
|
8203
9714
|
data: {
|
|
8204
9715
|
chapter,
|
|
8205
9716
|
stage: "P3",
|
|
@@ -8217,6 +9728,7 @@ var EngineService = class {
|
|
|
8217
9728
|
return this.mutate({
|
|
8218
9729
|
root: input.root,
|
|
8219
9730
|
op: "submitDraft",
|
|
9731
|
+
chapter: input.chapter,
|
|
8220
9732
|
key: () => `draft:${input.chapter}:${sha256(stableJson({
|
|
8221
9733
|
content: input.content,
|
|
8222
9734
|
chapter_title: input.chapter_title,
|
|
@@ -8242,31 +9754,32 @@ var EngineService = class {
|
|
|
8242
9754
|
summary: input.summary
|
|
8243
9755
|
});
|
|
8244
9756
|
const chapter = input.chapter;
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
...plan,
|
|
9757
|
+
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");
|
|
9758
|
+
const draft = {
|
|
9759
|
+
chapter,
|
|
8249
9760
|
title: input.chapter_title,
|
|
8250
9761
|
scene_type: input.scene_type,
|
|
8251
9762
|
narrator: input.narrator,
|
|
8252
|
-
summary: input.summary
|
|
9763
|
+
summary: input.summary,
|
|
9764
|
+
content_hash: sha256(input.content),
|
|
9765
|
+
updated_at: this.now().toISOString()
|
|
8253
9766
|
};
|
|
8254
|
-
const planPath = planChapterFile(chapter);
|
|
8255
9767
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
8256
9768
|
stage: "P4",
|
|
9769
|
+
draft,
|
|
8257
9770
|
blocking: [],
|
|
8258
9771
|
last_action: action("novel_write_draft", this.now(), sha256(input.content))
|
|
8259
9772
|
});
|
|
8260
9773
|
return {
|
|
8261
9774
|
files: [
|
|
9775
|
+
...snapshot.text === null ? [] : [{
|
|
9776
|
+
path: chapterHistoryFile(chapter, snapshot.pipeline.revision),
|
|
9777
|
+
text: snapshot.text
|
|
9778
|
+
}],
|
|
8262
9779
|
{
|
|
8263
9780
|
path: chapterFile(chapter),
|
|
8264
9781
|
text: input.content
|
|
8265
9782
|
},
|
|
8266
|
-
{
|
|
8267
|
-
path: planPath,
|
|
8268
|
-
text: yaml(mergedPlan)
|
|
8269
|
-
},
|
|
8270
9783
|
{
|
|
8271
9784
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8272
9785
|
text: yaml(nextPipeline)
|
|
@@ -8287,6 +9800,7 @@ var EngineService = class {
|
|
|
8287
9800
|
return this.mutate({
|
|
8288
9801
|
root: input.root,
|
|
8289
9802
|
op: "submitReview",
|
|
9803
|
+
chapter: input.chapter,
|
|
8290
9804
|
key: (snapshot) => `review:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
|
|
8291
9805
|
dimensions: input.dimensions,
|
|
8292
9806
|
issues: input.issues,
|
|
@@ -8368,13 +9882,21 @@ var EngineService = class {
|
|
|
8368
9882
|
last_action: action("novel_review", this.now(), reviewDigest)
|
|
8369
9883
|
});
|
|
8370
9884
|
return {
|
|
8371
|
-
files: [
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
9885
|
+
files: [
|
|
9886
|
+
...snapshot.review === null ? [] : [{
|
|
9887
|
+
path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
9888
|
+
text: yaml(snapshot.review)
|
|
9889
|
+
}],
|
|
9890
|
+
{
|
|
9891
|
+
path: reviewFile(input.chapter),
|
|
9892
|
+
text: yaml(review)
|
|
9893
|
+
},
|
|
9894
|
+
{
|
|
9895
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9896
|
+
text: yaml(nextPipeline)
|
|
9897
|
+
}
|
|
9898
|
+
],
|
|
9899
|
+
backup: snapshot.review !== null,
|
|
8378
9900
|
data: {
|
|
8379
9901
|
chapter: input.chapter,
|
|
8380
9902
|
round: review.round,
|
|
@@ -8391,16 +9913,31 @@ var EngineService = class {
|
|
|
8391
9913
|
});
|
|
8392
9914
|
}
|
|
8393
9915
|
async submitRevision(input) {
|
|
9916
|
+
const hasContent = typeof input.content === "string" && input.content.trim() !== "";
|
|
9917
|
+
const hasPatch = input.patch !== void 0;
|
|
9918
|
+
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");
|
|
9919
|
+
if (hasPatch) {
|
|
9920
|
+
const patch = input.patch;
|
|
9921
|
+
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");
|
|
9922
|
+
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");
|
|
9923
|
+
for (const [index, edit] of patch.edits.entries()) {
|
|
9924
|
+
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`);
|
|
9925
|
+
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`);
|
|
9926
|
+
}
|
|
9927
|
+
}
|
|
8394
9928
|
return this.mutate({
|
|
8395
9929
|
root: input.root,
|
|
8396
9930
|
op: "submitRevision",
|
|
8397
|
-
|
|
9931
|
+
chapter: input.chapter,
|
|
9932
|
+
key: () => `revision:${input.chapter}:${sha256(input.content ?? stableJson(input.patch))}:${sha256(stableJson({
|
|
8398
9933
|
summary: input.summary,
|
|
8399
9934
|
issues: input.issues,
|
|
8400
9935
|
proof: input.proof
|
|
8401
9936
|
}))}`,
|
|
8402
9937
|
run: async (snapshot, context) => {
|
|
8403
9938
|
this.assertChapter(snapshot, input.chapter);
|
|
9939
|
+
const revisedContent = input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
|
|
9940
|
+
if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
|
|
8404
9941
|
let patchedReview = null;
|
|
8405
9942
|
if (snapshot.review !== null) {
|
|
8406
9943
|
const original = snapshot.review.issues;
|
|
@@ -8450,6 +9987,7 @@ var EngineService = class {
|
|
|
8450
9987
|
issues
|
|
8451
9988
|
};
|
|
8452
9989
|
}
|
|
9990
|
+
const reviewChanged = patchedReview !== null && (snapshot.review === null || stableJson(patchedReview) !== stableJson(snapshot.review));
|
|
8453
9991
|
if (patchedReview !== null) try {
|
|
8454
9992
|
narrow("review", patchedReview);
|
|
8455
9993
|
} catch (error) {
|
|
@@ -8457,46 +9995,65 @@ var EngineService = class {
|
|
|
8457
9995
|
}
|
|
8458
9996
|
await this.assertGates(snapshot, "submitRevision", {
|
|
8459
9997
|
briefStage: briefStageFor("submitRevision", snapshot.pipeline.stage),
|
|
8460
|
-
text:
|
|
9998
|
+
text: revisedContent,
|
|
8461
9999
|
summary: input.summary,
|
|
8462
10000
|
summaryProvided: input.summary !== void 0,
|
|
8463
10001
|
review: patchedReview,
|
|
8464
10002
|
proof: input.proof
|
|
8465
10003
|
});
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
10004
|
+
const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
|
|
10005
|
+
if (input.summary === void 0 && inheritedSummary !== void 0) {
|
|
10006
|
+
const opening = inheritedSummary.opening;
|
|
10007
|
+
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
10008
|
}
|
|
8470
|
-
const
|
|
10009
|
+
const effectiveSummary = input.summary ?? inheritedSummary;
|
|
10010
|
+
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");
|
|
10011
|
+
const machine = this.machine(snapshot, revisedContent, { summary: effectiveSummary });
|
|
10012
|
+
const baseDraft = snapshot.pipeline.draft ?? {
|
|
10013
|
+
chapter: input.chapter,
|
|
10014
|
+
title: snapshot.plan?.title ?? `第${input.chapter}章`,
|
|
10015
|
+
scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
|
|
10016
|
+
narrator: snapshot.plan?.narrator ?? "",
|
|
10017
|
+
summary: effectiveSummary,
|
|
10018
|
+
content_hash: sha256(snapshot.text ?? ""),
|
|
10019
|
+
updated_at: this.now().toISOString()
|
|
10020
|
+
};
|
|
8471
10021
|
const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
|
|
8472
10022
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
8473
10023
|
stage: nextStage$1,
|
|
10024
|
+
draft: {
|
|
10025
|
+
...baseDraft,
|
|
10026
|
+
summary: effectiveSummary,
|
|
10027
|
+
content_hash: sha256(revisedContent),
|
|
10028
|
+
updated_at: this.now().toISOString()
|
|
10029
|
+
},
|
|
8474
10030
|
rounds: {
|
|
8475
10031
|
...snapshot.pipeline.rounds,
|
|
8476
10032
|
revise: snapshot.pipeline.rounds.revise + 1
|
|
8477
10033
|
},
|
|
8478
10034
|
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
|
-
})
|
|
10035
|
+
...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
|
|
10036
|
+
last_action: action("novel_revise", this.now(), sha256(revisedContent))
|
|
8498
10037
|
});
|
|
8499
|
-
|
|
10038
|
+
const files = [
|
|
10039
|
+
...snapshot.text === null ? [] : [{
|
|
10040
|
+
path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
10041
|
+
text: snapshot.text
|
|
10042
|
+
}],
|
|
10043
|
+
...snapshot.review === null || !reviewChanged ? [] : [{
|
|
10044
|
+
path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
10045
|
+
text: yaml(snapshot.review)
|
|
10046
|
+
}],
|
|
10047
|
+
{
|
|
10048
|
+
path: chapterFile(input.chapter),
|
|
10049
|
+
text: revisedContent
|
|
10050
|
+
},
|
|
10051
|
+
{
|
|
10052
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
10053
|
+
text: yaml(nextPipeline)
|
|
10054
|
+
}
|
|
10055
|
+
];
|
|
10056
|
+
if (reviewChanged && patchedReview !== null) files.push({
|
|
8500
10057
|
path: reviewFile(input.chapter),
|
|
8501
10058
|
text: yaml(patchedReview)
|
|
8502
10059
|
});
|
|
@@ -8518,9 +10075,11 @@ var EngineService = class {
|
|
|
8518
10075
|
return this.mutate({
|
|
8519
10076
|
root: input.root,
|
|
8520
10077
|
op: "runCheck",
|
|
10078
|
+
chapter: input.chapter,
|
|
8521
10079
|
key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
|
|
8522
10080
|
semantic: input.semantic ?? [],
|
|
8523
|
-
scope
|
|
10081
|
+
scope,
|
|
10082
|
+
acknowledge: input.acknowledge ?? []
|
|
8524
10083
|
}))}`,
|
|
8525
10084
|
run: async (snapshot, context) => {
|
|
8526
10085
|
this.assertChapter(snapshot, input.chapter);
|
|
@@ -8539,6 +10098,13 @@ var EngineService = class {
|
|
|
8539
10098
|
violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
|
|
8540
10099
|
};
|
|
8541
10100
|
const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
|
|
10101
|
+
const machineAdvisories = scopedMachine.violations.filter((violation) => violation.severity === "advisory");
|
|
10102
|
+
const advisoryCodes = [...new Set(machineAdvisories.map((violation) => violation.code))];
|
|
10103
|
+
const requestedAck = Array.isArray(input.acknowledge) ? input.acknowledge : [];
|
|
10104
|
+
const unknownAck = requestedAck.filter((code) => !advisoryCodes.includes(code));
|
|
10105
|
+
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`);
|
|
10106
|
+
const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
|
|
10107
|
+
const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
|
|
8542
10108
|
const semanticResult = evaluateSemantic(text, semantic);
|
|
8543
10109
|
const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
|
|
8544
10110
|
const check = {
|
|
@@ -8547,10 +10113,12 @@ var EngineService = class {
|
|
|
8547
10113
|
scope,
|
|
8548
10114
|
content_hash: sha256(text),
|
|
8549
10115
|
machine: {
|
|
8550
|
-
violations:
|
|
10116
|
+
violations: machineBlocking,
|
|
10117
|
+
advisories: machineAdvisories,
|
|
8551
10118
|
metrics: scopedMachine.metrics
|
|
8552
10119
|
},
|
|
8553
10120
|
semantic: { items: semantic },
|
|
10121
|
+
...acknowledged.length === 0 ? {} : { acknowledged },
|
|
8554
10122
|
verdict
|
|
8555
10123
|
};
|
|
8556
10124
|
try {
|
|
@@ -8575,24 +10143,42 @@ var EngineService = class {
|
|
|
8575
10143
|
last_action: action("novel_check", this.now(), sha256(text))
|
|
8576
10144
|
});
|
|
8577
10145
|
return {
|
|
8578
|
-
files: [
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
10146
|
+
files: [
|
|
10147
|
+
...snapshot.check === null ? [] : [{
|
|
10148
|
+
path: checkHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
10149
|
+
text: yaml(snapshot.check)
|
|
10150
|
+
}],
|
|
10151
|
+
{
|
|
10152
|
+
path: checkFile(input.chapter),
|
|
10153
|
+
text: yaml(check)
|
|
10154
|
+
},
|
|
10155
|
+
{
|
|
10156
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
10157
|
+
text: yaml(nextPipeline)
|
|
10158
|
+
}
|
|
10159
|
+
],
|
|
10160
|
+
backup: snapshot.check !== null,
|
|
8585
10161
|
data: {
|
|
8586
10162
|
chapter: input.chapter,
|
|
8587
10163
|
verdict,
|
|
8588
|
-
violations:
|
|
10164
|
+
violations: machineBlocking.length,
|
|
10165
|
+
advisories: machineAdvisories.filter((violation) => !acknowledged.includes(violation.code)).length,
|
|
10166
|
+
advisory_codes: advisoryCodes,
|
|
10167
|
+
acknowledged,
|
|
8589
10168
|
metrics: scopedMachine.metrics,
|
|
8590
10169
|
stage: nextStage$2
|
|
8591
10170
|
},
|
|
8592
|
-
warnings: [
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
10171
|
+
warnings: [
|
|
10172
|
+
...machineWarnings(scopedMachine, acknowledged),
|
|
10173
|
+
...acknowledged.length === 0 ? [] : [{
|
|
10174
|
+
code: "CHECK_ADVISORY_ACKNOWLEDGED",
|
|
10175
|
+
message: `已记录知悉:${acknowledged.join(", ")};advisory 不阻塞归档`
|
|
10176
|
+
}],
|
|
10177
|
+
...verdict === "pass" && !fullScope ? [{
|
|
10178
|
+
code: "CHECK_SCOPE_PARTIAL",
|
|
10179
|
+
message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
|
|
10180
|
+
}] : []
|
|
10181
|
+
]
|
|
8596
10182
|
};
|
|
8597
10183
|
}
|
|
8598
10184
|
});
|
|
@@ -8607,6 +10193,7 @@ var EngineService = class {
|
|
|
8607
10193
|
return this.mutate({
|
|
8608
10194
|
root: input.root,
|
|
8609
10195
|
op: "archiveChapter",
|
|
10196
|
+
chapter: input.chapter,
|
|
8610
10197
|
key: (snapshot) => {
|
|
8611
10198
|
const archived = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
|
|
8612
10199
|
return `archive:${input.chapter}:${archived?.content_hash ?? sha256(snapshot.text ?? "")}:${archiveDigest}`;
|
|
@@ -8633,7 +10220,8 @@ var EngineService = class {
|
|
|
8633
10220
|
this.assertChapter(snapshot, input.chapter);
|
|
8634
10221
|
this.assertChapterWithinTarget(snapshot, input.chapter, "novel_archive");
|
|
8635
10222
|
const text = snapshot.text ?? "";
|
|
8636
|
-
const
|
|
10223
|
+
const draft = snapshot.pipeline.draft;
|
|
10224
|
+
const summary = draft?.summary ?? snapshot.plan?.summary;
|
|
8637
10225
|
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
10226
|
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
10227
|
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 +10232,10 @@ var EngineService = class {
|
|
|
8644
10232
|
review: snapshot.review,
|
|
8645
10233
|
proof: snapshot.review?.proof ?? null
|
|
8646
10234
|
});
|
|
10235
|
+
if (draft !== void 0) {
|
|
10236
|
+
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");
|
|
10237
|
+
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");
|
|
10238
|
+
}
|
|
8647
10239
|
if (text === "") throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${input.chapter} has no draft`);
|
|
8648
10240
|
const archiveBlockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
|
|
8649
10241
|
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 +10251,7 @@ var EngineService = class {
|
|
|
8659
10251
|
const metrics = countWordMetrics(text);
|
|
8660
10252
|
const score = snapshot.review?.total ?? 0;
|
|
8661
10253
|
const chapter = input.chapter;
|
|
8662
|
-
const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
|
|
10254
|
+
const storedTitle = stripTitlePrefix(snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? "");
|
|
8663
10255
|
const record = {
|
|
8664
10256
|
num: chapter,
|
|
8665
10257
|
title: storedTitle === "" ? `第${chapter}章` : storedTitle,
|
|
@@ -8667,13 +10259,16 @@ var EngineService = class {
|
|
|
8667
10259
|
word_count: metrics.total_word_count,
|
|
8668
10260
|
dialogue_lines: metrics.dialogue_lines,
|
|
8669
10261
|
dialogue_ratio: metrics.dialogue_ratio,
|
|
8670
|
-
scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
|
|
8671
|
-
narrator: snapshot.plan?.narrator ?? "",
|
|
10262
|
+
scene_type: snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? "日常过渡章",
|
|
10263
|
+
narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? "",
|
|
8672
10264
|
score,
|
|
8673
10265
|
status: "reviewed",
|
|
8674
10266
|
content_hash: sha256(text),
|
|
8675
10267
|
summary
|
|
8676
10268
|
};
|
|
10269
|
+
const roleHints = {};
|
|
10270
|
+
for (const character of snapshot.worldbook.characters) if (character.role !== "") roleHints[character.name] = character.role;
|
|
10271
|
+
for (const character of snapshot.worldPlan?.characters ?? []) if (character.role !== "") roleHints[character.name] = character.role;
|
|
8677
10272
|
const ledger = applyStateDelta({
|
|
8678
10273
|
chapter,
|
|
8679
10274
|
text,
|
|
@@ -8685,11 +10280,15 @@ var EngineService = class {
|
|
|
8685
10280
|
foreshadows: snapshot.foreshadows,
|
|
8686
10281
|
timeline: snapshot.timeline,
|
|
8687
10282
|
relationships: snapshot.relationships,
|
|
8688
|
-
narrator: snapshot.plan?.narrator ?? null
|
|
10283
|
+
narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? null,
|
|
10284
|
+
roleHints
|
|
8689
10285
|
});
|
|
8690
10286
|
record.assigned_ids = ledger.assigned;
|
|
8691
10287
|
record.archive_digest = archiveDigest;
|
|
8692
10288
|
const archivedAt = this.now().toISOString();
|
|
10289
|
+
record.rounds = await this.lifetimeRounds(snapshot, chapter);
|
|
10290
|
+
record.final_check = snapshot.check?.verdict ?? "fail";
|
|
10291
|
+
record.archived_at = archivedAt;
|
|
8693
10292
|
const outline = markOutlineArchived(snapshot.outline, record, archivedAt, snapshot.project.targets.words_per_chapter);
|
|
8694
10293
|
this.assertOutlineWritable(snapshot, outline, "novel_archive");
|
|
8695
10294
|
const foreshadows = ledger.foreshadows;
|
|
@@ -8730,6 +10329,7 @@ var EngineService = class {
|
|
|
8730
10329
|
blocking: [],
|
|
8731
10330
|
last_action: action("novel_archive", this.now(), sha256(text))
|
|
8732
10331
|
});
|
|
10332
|
+
delete nextPipeline.draft;
|
|
8733
10333
|
assertArchiveDocuments([
|
|
8734
10334
|
["chapters", chapters],
|
|
8735
10335
|
["characters", ledger.characters],
|
|
@@ -8742,12 +10342,18 @@ var EngineService = class {
|
|
|
8742
10342
|
]);
|
|
8743
10343
|
const archiveDigests = await this.readArchiveDigests(context.store, snapshot);
|
|
8744
10344
|
archiveDigests[String(chapter)] = archiveDigest;
|
|
10345
|
+
const planText = await context.store.readText(planChapterFile(chapter));
|
|
10346
|
+
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
10347
|
return {
|
|
8746
10348
|
files: [
|
|
8747
10349
|
{
|
|
8748
10350
|
path: chapterFile(chapter),
|
|
8749
10351
|
text
|
|
8750
10352
|
},
|
|
10353
|
+
{
|
|
10354
|
+
path: planChapterFile(chapter),
|
|
10355
|
+
text: planText
|
|
10356
|
+
},
|
|
8751
10357
|
{
|
|
8752
10358
|
path: joinRel(STATE_DIR, STATE_FILES.chapters),
|
|
8753
10359
|
text: yaml(chapters)
|
|
@@ -8818,7 +10424,8 @@ var EngineService = class {
|
|
|
8818
10424
|
if (typeof input.path !== "string" || input.path.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend path must be a non-empty string");
|
|
8819
10425
|
if (typeof input.reason !== "string" || input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason");
|
|
8820
10426
|
if (typeof input.content !== "object" || input.content === null || Array.isArray(input.content)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend content must be an object");
|
|
8821
|
-
const
|
|
10427
|
+
const selection = await this.resolveRoot(input.root);
|
|
10428
|
+
const resolvedRoot = selection.root;
|
|
8822
10429
|
const store = this.storeFor(resolvedRoot);
|
|
8823
10430
|
const absolute = store.path(input.path);
|
|
8824
10431
|
const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
|
|
@@ -8829,11 +10436,10 @@ var EngineService = class {
|
|
|
8829
10436
|
if (await this.stateDocIsUnreadable(store, path, docKind)) repairPaths.push(path);
|
|
8830
10437
|
}
|
|
8831
10438
|
return this.mutate({
|
|
8832
|
-
|
|
10439
|
+
selection,
|
|
8833
10440
|
op: "amend",
|
|
8834
10441
|
reason: input.reason,
|
|
8835
10442
|
...repairPaths.length === 0 ? {} : { repairPaths },
|
|
8836
|
-
key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
|
|
8837
10443
|
run: async (snapshot, context) => {
|
|
8838
10444
|
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
10445
|
if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
|
|
@@ -8966,8 +10572,9 @@ var EngineService = class {
|
|
|
8966
10572
|
return this.mutate({
|
|
8967
10573
|
root: input.root,
|
|
8968
10574
|
op: "rollback",
|
|
10575
|
+
chapter: input.chapter,
|
|
8969
10576
|
reason: input.reason,
|
|
8970
|
-
key: () => `rollback:${input.chapter}:${input.toStage}`,
|
|
10577
|
+
key: (snapshot) => `rollback:${input.chapter}:${input.toStage}:${sha256(snapshot.text ?? "")}`,
|
|
8971
10578
|
run: async (snapshot, context) => {
|
|
8972
10579
|
this.assertChapter(snapshot, input.chapter);
|
|
8973
10580
|
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 +10592,31 @@ var EngineService = class {
|
|
|
8985
10592
|
blocking: [],
|
|
8986
10593
|
last_action: action("novel_rollback", this.now(), shortDigest(input.reason ?? ""))
|
|
8987
10594
|
});
|
|
10595
|
+
delete nextPipeline.draft;
|
|
10596
|
+
if (stage !== "idle" && stage !== "P2" && snapshot.pipeline.draft !== void 0) nextPipeline.draft = snapshot.pipeline.draft;
|
|
8988
10597
|
const files = [{
|
|
8989
10598
|
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8990
10599
|
text: yaml(nextPipeline)
|
|
8991
10600
|
}];
|
|
8992
10601
|
const current = await context.store.readText(chapterFile(input.chapter));
|
|
8993
|
-
if (current !== null)
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
10602
|
+
if (current !== null) {
|
|
10603
|
+
files.push({
|
|
10604
|
+
path: chapterFile(input.chapter),
|
|
10605
|
+
text: current
|
|
10606
|
+
});
|
|
10607
|
+
files.push({
|
|
10608
|
+
path: deprecatedChapterFile(input.chapter),
|
|
10609
|
+
text: current
|
|
10610
|
+
});
|
|
10611
|
+
files.push({
|
|
10612
|
+
path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
10613
|
+
text: current
|
|
10614
|
+
});
|
|
10615
|
+
files.push({
|
|
10616
|
+
path: deprecatedHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
10617
|
+
text: current
|
|
10618
|
+
});
|
|
10619
|
+
}
|
|
8997
10620
|
return {
|
|
8998
10621
|
files,
|
|
8999
10622
|
backup: true,
|
|
@@ -9010,15 +10633,8 @@ var EngineService = class {
|
|
|
9010
10633
|
if (input.before !== void 0 && typeof input.before !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "diff before must be a string");
|
|
9011
10634
|
if (input.after !== void 0 && typeof input.after !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "diff after must be a string");
|
|
9012
10635
|
if (input.before !== void 0 && input.after !== void 0) {
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
if (chapter === void 0) try {
|
|
9016
|
-
const snapshot = await this.load(input.root);
|
|
9017
|
-
chapter = snapshot.pipeline.chapter;
|
|
9018
|
-
revision = snapshot.pipeline.revision;
|
|
9019
|
-
} catch {
|
|
9020
|
-
chapter = 0;
|
|
9021
|
-
}
|
|
10636
|
+
const chapter = input.chapter ?? 0;
|
|
10637
|
+
const revision = 1;
|
|
9022
10638
|
const report = diffLines(input.before, input.after);
|
|
9023
10639
|
return {
|
|
9024
10640
|
data: {
|
|
@@ -9074,8 +10690,21 @@ var EngineService = class {
|
|
|
9074
10690
|
});
|
|
9075
10691
|
const requested = input.limit ?? 50;
|
|
9076
10692
|
const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
|
|
10693
|
+
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
10694
|
+
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");
|
|
10695
|
+
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");
|
|
10696
|
+
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");
|
|
10697
|
+
const filtered = rows.filter((row) => {
|
|
10698
|
+
if (input.chapter !== void 0 && row.chapter !== input.chapter) return false;
|
|
10699
|
+
if (input.op !== void 0 && row.op !== input.op) return false;
|
|
10700
|
+
if (input.status !== void 0 && row.status !== input.status) return false;
|
|
10701
|
+
if (input.since !== void 0 && Date.parse(row.at) < Date.parse(input.since)) return false;
|
|
10702
|
+
if (input.until !== void 0 && Date.parse(row.at) > Date.parse(input.until)) return false;
|
|
10703
|
+
if (input.cursor !== void 0 && (row.seq ?? 0) <= input.cursor) return false;
|
|
10704
|
+
return true;
|
|
10705
|
+
});
|
|
9077
10706
|
return {
|
|
9078
|
-
data: limit === 0 ? [] :
|
|
10707
|
+
data: limit === 0 ? [] : input.cursor === void 0 ? filtered.slice(-limit) : filtered.slice(0, limit),
|
|
9079
10708
|
revision: snapshot.pipeline.revision,
|
|
9080
10709
|
warnings
|
|
9081
10710
|
};
|
|
@@ -9262,20 +10891,162 @@ var EngineService = class {
|
|
|
9262
10891
|
async panelGates(input = {}) {
|
|
9263
10892
|
return projectGates(await this.panelSnapshot(input.root));
|
|
9264
10893
|
}
|
|
9265
|
-
/**
|
|
10894
|
+
/**
|
|
10895
|
+
* Build the read-only panel payload. The query root is accepted only when it
|
|
10896
|
+
* is registered, active or the configured root; arbitrary paths never reach
|
|
10897
|
+
* the filesystem from the panel route.
|
|
10898
|
+
*/
|
|
9266
10899
|
async panelBundle(input = {}) {
|
|
9267
|
-
const
|
|
10900
|
+
const registry = await this.registry().read();
|
|
10901
|
+
const active = registry.active;
|
|
10902
|
+
const rows = /* @__PURE__ */ new Map();
|
|
10903
|
+
const add = (option) => {
|
|
10904
|
+
if (!rows.has(option.root)) rows.set(option.root, option);
|
|
10905
|
+
};
|
|
10906
|
+
const optionFor = async (root, registered, fallbackTitle, expectedCreatedAt) => {
|
|
10907
|
+
const inspection = await inspectProject(root);
|
|
10908
|
+
const state = inspection.state === "ok" ? "ok" : inspection.state === "damaged" ? "damaged" : inspection.state === "unreadable" ? "unreadable" : "missing";
|
|
10909
|
+
const sameIdentity = expectedCreatedAt === void 0 || expectedCreatedAt === "" || inspection.created_at === expectedCreatedAt;
|
|
10910
|
+
const title = inspection.state === "ok" && inspection.title !== "" && sameIdentity ? inspection.title : fallbackTitle !== void 0 && fallbackTitle !== "" ? fallbackTitle : inspection.title;
|
|
10911
|
+
return {
|
|
10912
|
+
root: inspection.root,
|
|
10913
|
+
title,
|
|
10914
|
+
active: active === inspection.root,
|
|
10915
|
+
registered,
|
|
10916
|
+
state
|
|
10917
|
+
};
|
|
10918
|
+
};
|
|
10919
|
+
for (const entry of registry.projects) add(await optionFor(entry.root, true, entry.title, entry.created_at));
|
|
10920
|
+
if (active !== null && !rows.has(active)) {
|
|
10921
|
+
const known = registry.projects.find((entry) => entry.root === active);
|
|
10922
|
+
add(await optionFor(active, known !== void 0, known?.title, known?.created_at));
|
|
10923
|
+
}
|
|
10924
|
+
const configured = this.defaultRoot();
|
|
10925
|
+
if (!rows.has(configured)) {
|
|
10926
|
+
const option = await optionFor(configured, false);
|
|
10927
|
+
if (option.state !== "missing" || active === configured) add(option);
|
|
10928
|
+
}
|
|
10929
|
+
const selectedRoot = input.root === void 0 ? void 0 : resolve(input.root);
|
|
10930
|
+
if (selectedRoot !== void 0 && !rows.has(selectedRoot) && selectedRoot === configured) add(await optionFor(configured, false));
|
|
10931
|
+
const projects = [...rows.values()].sort((left, right) => compareText(left.root, right.root));
|
|
10932
|
+
let selection;
|
|
10933
|
+
if (selectedRoot !== void 0) {
|
|
10934
|
+
if (!rows.has(selectedRoot)) return {
|
|
10935
|
+
projects,
|
|
10936
|
+
selection: {
|
|
10937
|
+
root: selectedRoot,
|
|
10938
|
+
source: "query"
|
|
10939
|
+
},
|
|
10940
|
+
missing: {
|
|
10941
|
+
root: selectedRoot,
|
|
10942
|
+
title: "(未登记的项目)",
|
|
10943
|
+
code: ERROR_CODES.PROJECT_NOT_FOUND,
|
|
10944
|
+
reason: "not_registered",
|
|
10945
|
+
hint: "用 /novel scan --register 或 /novel admin novel_registry_register 登记后刷新;面板不会读取任意路径。"
|
|
10946
|
+
}
|
|
10947
|
+
};
|
|
10948
|
+
selection = {
|
|
10949
|
+
root: selectedRoot,
|
|
10950
|
+
source: "query"
|
|
10951
|
+
};
|
|
10952
|
+
} else if (active !== null) selection = {
|
|
10953
|
+
root: active,
|
|
10954
|
+
source: "active"
|
|
10955
|
+
};
|
|
10956
|
+
else if (registry.projects.length === 1) selection = {
|
|
10957
|
+
root: registry.projects[0].root,
|
|
10958
|
+
source: "single"
|
|
10959
|
+
};
|
|
10960
|
+
else if (rows.has(configured) && !registry.projects.some((entry) => entry.root === configured)) selection = {
|
|
10961
|
+
root: configured,
|
|
10962
|
+
source: "config"
|
|
10963
|
+
};
|
|
10964
|
+
else selection = {
|
|
10965
|
+
root: null,
|
|
10966
|
+
source: "none"
|
|
10967
|
+
};
|
|
10968
|
+
if (selection.root === null) return {
|
|
10969
|
+
projects,
|
|
10970
|
+
selection,
|
|
10971
|
+
...projects.length === 0 ? {
|
|
10972
|
+
empty: true,
|
|
10973
|
+
error: "还没有项目:调用 novel_init 创建。"
|
|
10974
|
+
} : {}
|
|
10975
|
+
};
|
|
10976
|
+
const option = rows.get(selection.root);
|
|
10977
|
+
const inspection = await inspectProject(selection.root);
|
|
10978
|
+
if (option.state === "missing" || option.state === "unreadable" || inspection.state === "not-project") return {
|
|
10979
|
+
projects,
|
|
10980
|
+
selection,
|
|
10981
|
+
missing: {
|
|
10982
|
+
root: selection.root,
|
|
10983
|
+
title: option.title,
|
|
10984
|
+
code: inspection.state === "not-project" ? ERROR_CODES.PROJECT_NOT_FOUND : ERROR_CODES.PROJECT_MISSING,
|
|
10985
|
+
reason: inspection.state === "not-project" ? "not-project" : inspection.state === "unreadable" ? "unreadable" : "missing",
|
|
10986
|
+
hint: inspection.state === "not-project" ? "目录存在,但没有 novel.yaml 或项目状态;请恢复项目定义,或改选其他项目。" : inspection.state === "unreadable" ? "目录不可读;请修复权限或 IO 错误后刷新,登记行和面板偏好会保留。" : "项目目录不在了;请恢复目录或挂载后刷新,登记行和面板偏好会保留。"
|
|
10987
|
+
}
|
|
10988
|
+
};
|
|
10989
|
+
let snapshot;
|
|
10990
|
+
try {
|
|
10991
|
+
snapshot = await this.panelSnapshot(selection.root);
|
|
10992
|
+
} catch (error) {
|
|
10993
|
+
if (error instanceof EngineError && (error.code === ERROR_CODES.PROJECT_MISSING || error.code === ERROR_CODES.PROJECT_NOT_FOUND)) return {
|
|
10994
|
+
projects,
|
|
10995
|
+
selection,
|
|
10996
|
+
missing: {
|
|
10997
|
+
root: selection.root,
|
|
10998
|
+
title: option.title,
|
|
10999
|
+
code: error.code === ERROR_CODES.PROJECT_MISSING ? ERROR_CODES.PROJECT_MISSING : ERROR_CODES.PROJECT_NOT_FOUND,
|
|
11000
|
+
reason: "missing",
|
|
11001
|
+
hint: error.hint ?? "项目目录不在了;请恢复目录或挂载后刷新,登记行和面板偏好会保留。"
|
|
11002
|
+
}
|
|
11003
|
+
};
|
|
11004
|
+
throw error;
|
|
11005
|
+
}
|
|
11006
|
+
const summary = projectSummary(snapshot);
|
|
11007
|
+
const resume = summary.next === void 0 ? void 0 : { text: formatResumePrompt({
|
|
11008
|
+
title: summary.title,
|
|
11009
|
+
root: summary.root,
|
|
11010
|
+
chapter: summary.chapter,
|
|
11011
|
+
stage: summary.stage,
|
|
11012
|
+
nextTool: summary.next.tool
|
|
11013
|
+
}) };
|
|
9268
11014
|
return {
|
|
9269
|
-
|
|
11015
|
+
projects,
|
|
11016
|
+
selection,
|
|
11017
|
+
summary,
|
|
9270
11018
|
chapters: projectChapters(snapshot),
|
|
9271
|
-
gates: projectGates(snapshot)
|
|
11019
|
+
gates: projectGates(snapshot),
|
|
11020
|
+
...resume === void 0 ? {} : { resume }
|
|
11021
|
+
};
|
|
11022
|
+
}
|
|
11023
|
+
/** Read-only resume text shared with the panel copy button. */
|
|
11024
|
+
async resumePrompt(input = {}) {
|
|
11025
|
+
if (this.caller()?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "resume requires an Agent session", "run /novel resume from an Agent conversation");
|
|
11026
|
+
const selection = await this.resolveRoot(input.root);
|
|
11027
|
+
const snapshot = await this.loadAt(selection);
|
|
11028
|
+
const status = await this.statusFor(snapshot, snapshot.pipeline.chapter);
|
|
11029
|
+
const nextTool = status.data.next?.tool;
|
|
11030
|
+
return {
|
|
11031
|
+
data: {
|
|
11032
|
+
text: nextTool === void 0 ? null : formatResumePrompt({
|
|
11033
|
+
title: status.data.project.title,
|
|
11034
|
+
root: status.data.project.root,
|
|
11035
|
+
chapter: status.data.chapter,
|
|
11036
|
+
stage: status.data.stage,
|
|
11037
|
+
nextTool
|
|
11038
|
+
}),
|
|
11039
|
+
selection: status.data.selection
|
|
11040
|
+
},
|
|
11041
|
+
revision: status.revision,
|
|
11042
|
+
warnings: []
|
|
9272
11043
|
};
|
|
9273
11044
|
}
|
|
9274
11045
|
async stats(input = {}) {
|
|
9275
11046
|
const snapshot = await this.load(input.root);
|
|
9276
11047
|
const totalWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
|
|
9277
11048
|
const scores = snapshot.chapters.chapters.map((chapter) => chapter.score);
|
|
9278
|
-
const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
|
|
11049
|
+
const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
|
|
9279
11050
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
9280
11051
|
return {
|
|
9281
11052
|
totalWords,
|
|
@@ -9286,51 +11057,299 @@ var EngineService = class {
|
|
|
9286
11057
|
lastScore: scores.length === 0 ? 0 : scores[scores.length - 1],
|
|
9287
11058
|
openForeshadows: snapshot.foreshadows.items.filter((item) => item.status !== "resolved").length,
|
|
9288
11059
|
plannedChapters: outline.planned,
|
|
9289
|
-
|
|
11060
|
+
totalCards: outline.total,
|
|
11061
|
+
outlineCoverage: outline.plannedCoverage,
|
|
11062
|
+
archivedCoverage: outline.archivedCoverage,
|
|
11063
|
+
missingCards: outline.missingCards,
|
|
9290
11064
|
blueprintReady: bookPlanPresent(snapshot.outline.book),
|
|
9291
|
-
worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory,
|
|
11065
|
+
worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts,
|
|
9292
11066
|
strictOutline: snapshot.outline.book.strict,
|
|
9293
11067
|
targetChapters: snapshot.project.targets.total_chapters
|
|
9294
11068
|
};
|
|
9295
11069
|
}
|
|
9296
11070
|
registry() {
|
|
9297
|
-
return new ProjectRegistry(
|
|
11071
|
+
return new ProjectRegistry(this.stateStore());
|
|
9298
11072
|
}
|
|
9299
|
-
/** List known projects
|
|
11073
|
+
/** List known projects with a cheap directory state for the panel/command. */
|
|
9300
11074
|
async listProjects() {
|
|
9301
11075
|
const document = await this.registry().read();
|
|
11076
|
+
const projects = await Promise.all(document.projects.map(async (entry) => {
|
|
11077
|
+
const inspection = await inspectProject(entry.root);
|
|
11078
|
+
const sameIdentity = entry.created_at === void 0 || entry.created_at === "" || inspection.created_at === entry.created_at;
|
|
11079
|
+
return {
|
|
11080
|
+
...entry,
|
|
11081
|
+
...inspection.state === "ok" && inspection.title !== "" && sameIdentity ? { title: inspection.title } : {},
|
|
11082
|
+
state: inspection.state
|
|
11083
|
+
};
|
|
11084
|
+
}));
|
|
9302
11085
|
return {
|
|
9303
11086
|
active: document.active,
|
|
9304
|
-
projects
|
|
11087
|
+
projects
|
|
9305
11088
|
};
|
|
9306
11089
|
}
|
|
9307
|
-
/**
|
|
11090
|
+
/**
|
|
11091
|
+
* Record an explicit project row. An unchanged duplicate is a no-op; a path
|
|
11092
|
+
* reused by another project updates the row and reports `replaced`.
|
|
11093
|
+
*/
|
|
9308
11094
|
async registerProject(input = {}) {
|
|
9309
|
-
const
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
title: snapshot.project.title
|
|
9313
|
-
});
|
|
9314
|
-
return {
|
|
9315
|
-
active: document.active,
|
|
9316
|
-
projects: document.projects
|
|
11095
|
+
const selection = input.root === void 0 ? await this.resolveRoot(void 0) : {
|
|
11096
|
+
root: this.resolveProjectPath(input.root),
|
|
11097
|
+
source: "explicit"
|
|
9317
11098
|
};
|
|
11099
|
+
const snapshot = await this.loadAt(selection);
|
|
11100
|
+
this.assertProjectHealthy(snapshot, "novel_registry_register");
|
|
11101
|
+
return this.withMetadataLock(async () => {
|
|
11102
|
+
const registry = this.registry();
|
|
11103
|
+
const document = await registry.read();
|
|
11104
|
+
const previous = document.projects.find((entry) => entry.root === snapshot.root);
|
|
11105
|
+
const replaced = previous !== void 0 && previous.created_at !== void 0 && previous.created_at !== "" && previous.created_at !== snapshot.project.created_at;
|
|
11106
|
+
if (!replaced && previous !== void 0 && previous.title === snapshot.project.title && (previous.created_at ?? "") === (snapshot.project.created_at ?? "")) return {
|
|
11107
|
+
active: document.active,
|
|
11108
|
+
projects: document.projects,
|
|
11109
|
+
replaced: false
|
|
11110
|
+
};
|
|
11111
|
+
const next = await registry.register({
|
|
11112
|
+
root: snapshot.root,
|
|
11113
|
+
title: snapshot.project.title,
|
|
11114
|
+
created_at: snapshot.project.created_at
|
|
11115
|
+
});
|
|
11116
|
+
return {
|
|
11117
|
+
active: next.active,
|
|
11118
|
+
projects: next.projects,
|
|
11119
|
+
replaced
|
|
11120
|
+
};
|
|
11121
|
+
});
|
|
9318
11122
|
}
|
|
9319
|
-
/** Choose the default project
|
|
11123
|
+
/** Choose the panel/default project. This never binds the current session. */
|
|
9320
11124
|
async setActiveProject(input) {
|
|
9321
|
-
const
|
|
9322
|
-
|
|
9323
|
-
|
|
11125
|
+
const selection = {
|
|
11126
|
+
root: this.resolveProjectPath(input.root),
|
|
11127
|
+
source: "explicit"
|
|
11128
|
+
};
|
|
11129
|
+
const snapshot = await this.loadAt(selection);
|
|
11130
|
+
this.assertProjectHealthy(snapshot, "novel_registry_activate");
|
|
11131
|
+
return this.withMetadataLock(async () => {
|
|
11132
|
+
const registry = this.registry();
|
|
11133
|
+
const previous = (await registry.read()).projects.find((entry) => entry.root === snapshot.root);
|
|
11134
|
+
const replaced = previous !== void 0 && previous.created_at !== void 0 && previous.created_at !== "" && previous.created_at !== snapshot.project.created_at;
|
|
11135
|
+
await registry.register({
|
|
11136
|
+
root: snapshot.root,
|
|
11137
|
+
title: snapshot.project.title,
|
|
11138
|
+
created_at: snapshot.project.created_at
|
|
11139
|
+
});
|
|
11140
|
+
return {
|
|
11141
|
+
active: (await registry.setActive(snapshot.root)).active,
|
|
11142
|
+
replaced
|
|
11143
|
+
};
|
|
11144
|
+
});
|
|
9324
11145
|
}
|
|
9325
|
-
/** Forget a registry row; the project directory
|
|
11146
|
+
/** Forget a registry row; the project directory and bindings are never touched. */
|
|
9326
11147
|
async forgetProject(input) {
|
|
9327
|
-
|
|
9328
|
-
|
|
11148
|
+
return this.withMetadataLock(async () => {
|
|
11149
|
+
const document = await this.registry().forget(this.resolveProjectPath(input.root));
|
|
11150
|
+
return {
|
|
11151
|
+
active: document.active,
|
|
11152
|
+
projects: document.projects
|
|
11153
|
+
};
|
|
11154
|
+
});
|
|
11155
|
+
}
|
|
11156
|
+
/**
|
|
11157
|
+
* Bounded workspace scan. Normal mode is read-only; `register` updates only
|
|
11158
|
+
* healthy, newly discovered rows, never active or session bindings.
|
|
11159
|
+
*/
|
|
11160
|
+
async scanProjects(input) {
|
|
11161
|
+
const cwd = resolve(input.cwd);
|
|
11162
|
+
const scan = await scanWorkspace(cwd);
|
|
11163
|
+
const before = await this.registry().read();
|
|
11164
|
+
const registeredRoots = new Set(before.projects.map((entry) => entry.root));
|
|
11165
|
+
const candidates = scan.candidates.map((candidate) => ({
|
|
11166
|
+
...candidate,
|
|
11167
|
+
registered: registeredRoots.has(candidate.root)
|
|
11168
|
+
}));
|
|
11169
|
+
if (input.register !== true) return {
|
|
11170
|
+
cwd,
|
|
11171
|
+
candidates,
|
|
11172
|
+
truncated: scan.truncated,
|
|
11173
|
+
added: [],
|
|
11174
|
+
already: [],
|
|
11175
|
+
skipped: [],
|
|
11176
|
+
identity_changed: []
|
|
11177
|
+
};
|
|
11178
|
+
const loaded = /* @__PURE__ */ new Map();
|
|
11179
|
+
const skipped = [];
|
|
11180
|
+
for (const candidate of candidates) {
|
|
11181
|
+
if (candidate.state !== "ok") {
|
|
11182
|
+
skipped.push({
|
|
11183
|
+
root: candidate.root,
|
|
11184
|
+
reason: candidate.state === "damaged" ? `damaged: ${candidate.error ?? "novel.yaml is missing/unreadable"}` : candidate.state === "unreadable" ? `unreadable: ${candidate.error ?? "permission or IO error"}` : `not a project (${candidate.state})`
|
|
11185
|
+
});
|
|
11186
|
+
continue;
|
|
11187
|
+
}
|
|
11188
|
+
if (registeredRoots.has(candidate.root)) continue;
|
|
11189
|
+
try {
|
|
11190
|
+
const snapshot = await this.loadAt({
|
|
11191
|
+
root: candidate.root,
|
|
11192
|
+
source: "explicit"
|
|
11193
|
+
});
|
|
11194
|
+
if (snapshot.projectError !== null) {
|
|
11195
|
+
skipped.push({
|
|
11196
|
+
root: candidate.root,
|
|
11197
|
+
reason: `damaged: ${snapshot.projectError}`
|
|
11198
|
+
});
|
|
11199
|
+
continue;
|
|
11200
|
+
}
|
|
11201
|
+
loaded.set(candidate.root, snapshot);
|
|
11202
|
+
} catch (error) {
|
|
11203
|
+
skipped.push({
|
|
11204
|
+
root: candidate.root,
|
|
11205
|
+
reason: errorMessage(error)
|
|
11206
|
+
});
|
|
11207
|
+
}
|
|
11208
|
+
}
|
|
11209
|
+
const added = [];
|
|
11210
|
+
const already = [];
|
|
11211
|
+
const identityChanged = [];
|
|
11212
|
+
await this.withMetadataLock(async () => {
|
|
11213
|
+
const registry = this.registry();
|
|
11214
|
+
const document = await registry.read();
|
|
11215
|
+
for (const candidate of candidates) {
|
|
11216
|
+
if (candidate.state !== "ok") continue;
|
|
11217
|
+
const existing = document.projects.find((entry) => entry.root === candidate.root);
|
|
11218
|
+
if (existing !== void 0) {
|
|
11219
|
+
candidate.registered = true;
|
|
11220
|
+
already.push(candidate.root);
|
|
11221
|
+
const previousCreatedAt = existing.created_at ?? "";
|
|
11222
|
+
const currentCreatedAt = candidate.created_at ?? "";
|
|
11223
|
+
if (previousCreatedAt !== "" && previousCreatedAt !== currentCreatedAt) {
|
|
11224
|
+
identityChanged.push(candidate.root);
|
|
11225
|
+
candidate.identity_changed = true;
|
|
11226
|
+
candidate.reason = "registered under a different created_at (or the replacement has none); not overwritten. Run explicit register/activate to confirm the replacement.";
|
|
11227
|
+
continue;
|
|
11228
|
+
}
|
|
11229
|
+
if (candidate.title !== "" && (existing.title !== candidate.title || previousCreatedAt !== currentCreatedAt)) try {
|
|
11230
|
+
await registry.register({
|
|
11231
|
+
root: candidate.root,
|
|
11232
|
+
title: candidate.title,
|
|
11233
|
+
created_at: candidate.created_at
|
|
11234
|
+
});
|
|
11235
|
+
} catch (error) {
|
|
11236
|
+
skipped.push({
|
|
11237
|
+
root: candidate.root,
|
|
11238
|
+
reason: `registry update failed: ${errorMessage(error)}`
|
|
11239
|
+
});
|
|
11240
|
+
}
|
|
11241
|
+
continue;
|
|
11242
|
+
}
|
|
11243
|
+
const snapshot = loaded.get(candidate.root);
|
|
11244
|
+
if (snapshot === void 0) continue;
|
|
11245
|
+
try {
|
|
11246
|
+
await registry.register({
|
|
11247
|
+
root: snapshot.root,
|
|
11248
|
+
title: snapshot.project.title,
|
|
11249
|
+
created_at: snapshot.project.created_at
|
|
11250
|
+
});
|
|
11251
|
+
added.push(candidate.root);
|
|
11252
|
+
candidate.registered = true;
|
|
11253
|
+
candidate.title = snapshot.project.title;
|
|
11254
|
+
candidate.created_at = snapshot.project.created_at;
|
|
11255
|
+
} catch (error) {
|
|
11256
|
+
skipped.push({
|
|
11257
|
+
root: candidate.root,
|
|
11258
|
+
reason: `registry write failed: ${errorMessage(error)}`
|
|
11259
|
+
});
|
|
11260
|
+
}
|
|
11261
|
+
}
|
|
11262
|
+
});
|
|
9329
11263
|
return {
|
|
9330
|
-
|
|
9331
|
-
|
|
11264
|
+
cwd,
|
|
11265
|
+
candidates,
|
|
11266
|
+
truncated: scan.truncated,
|
|
11267
|
+
added,
|
|
11268
|
+
already,
|
|
11269
|
+
skipped,
|
|
11270
|
+
identity_changed: identityChanged
|
|
11271
|
+
};
|
|
11272
|
+
}
|
|
11273
|
+
/** Current session binding, or the inferred fallback when none is set. */
|
|
11274
|
+
async currentProject() {
|
|
11275
|
+
const caller = this.caller();
|
|
11276
|
+
if (caller?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "currentProject requires an Agent session", "run /novel current from an Agent conversation");
|
|
11277
|
+
const binding = await this.bindings().get(caller.sessionId);
|
|
11278
|
+
if (binding !== void 0) {
|
|
11279
|
+
const inspection = await inspectProject(binding.root).catch(() => null);
|
|
11280
|
+
return {
|
|
11281
|
+
selection: {
|
|
11282
|
+
root: binding.root,
|
|
11283
|
+
source: "session",
|
|
11284
|
+
title: binding.title,
|
|
11285
|
+
created_at: binding.project_created_at
|
|
11286
|
+
},
|
|
11287
|
+
bound: true,
|
|
11288
|
+
...inspection === null ? {} : { state: inspection.state }
|
|
11289
|
+
};
|
|
11290
|
+
}
|
|
11291
|
+
try {
|
|
11292
|
+
return {
|
|
11293
|
+
selection: await this.resolveRoot(void 0),
|
|
11294
|
+
bound: false
|
|
11295
|
+
};
|
|
11296
|
+
} catch (error) {
|
|
11297
|
+
return {
|
|
11298
|
+
selection: null,
|
|
11299
|
+
bound: false,
|
|
11300
|
+
error: errorMessage(error)
|
|
11301
|
+
};
|
|
11302
|
+
}
|
|
11303
|
+
}
|
|
11304
|
+
/** Explicitly switch this session; failure leaves the old binding untouched. */
|
|
11305
|
+
async useProject(input) {
|
|
11306
|
+
const caller = this.caller();
|
|
11307
|
+
if (caller?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "useProject requires an Agent session", "run /novel use from an Agent conversation");
|
|
11308
|
+
const target = input.target.trim().replace(/^(["'])(.*)\1$/u, "$2");
|
|
11309
|
+
if (target === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "use requires a path, title or \".\"");
|
|
11310
|
+
let root;
|
|
11311
|
+
if (target === ".") {
|
|
11312
|
+
if (caller.cwd === void 0 || caller.cwd === "") throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "use \".\" requires a working directory", "run the command from a project workspace, or pass an explicit path");
|
|
11313
|
+
const found = await discoverWorkspace(caller.cwd);
|
|
11314
|
+
if (found.kind === "many") throw ambiguousError(found.roots.map((candidate) => ({ root: candidate })));
|
|
11315
|
+
if (found.kind === "none") throw projectNotFoundError(caller.cwd);
|
|
11316
|
+
root = found.root;
|
|
11317
|
+
} else {
|
|
11318
|
+
const candidate = resolve(caller.cwd ?? process.cwd(), target);
|
|
11319
|
+
const inspection = await inspectProject(candidate).catch(() => null);
|
|
11320
|
+
if (inspection !== null && inspection.state !== "not-project" && inspection.state !== "missing") root = candidate;
|
|
11321
|
+
else {
|
|
11322
|
+
const matches = (await this.registry().read()).projects.filter((entry) => entry.title === target);
|
|
11323
|
+
if (matches.length > 1) throw new EngineInputError(ERROR_CODES.PROJECT_AMBIGUOUS, `multiple registered projects are named "${target}":\n${describeCandidates(matches)}`, "use the full project path instead of the title");
|
|
11324
|
+
root = matches[0]?.root;
|
|
11325
|
+
}
|
|
11326
|
+
}
|
|
11327
|
+
if (root === void 0) throw new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no registered project or readable directory matches "${target}"`, "run /novel projects or /novel scan to see available projects");
|
|
11328
|
+
const selection = {
|
|
11329
|
+
root,
|
|
11330
|
+
source: "explicit"
|
|
11331
|
+
};
|
|
11332
|
+
const snapshot = await this.loadAt(selection);
|
|
11333
|
+
this.assertProjectHealthy(snapshot, "novel_use");
|
|
11334
|
+
await this.bindSelection(snapshot.root, snapshot.project.title, snapshot.project.created_at, "command");
|
|
11335
|
+
await this.autoRegister(snapshot);
|
|
11336
|
+
return {
|
|
11337
|
+
selection: {
|
|
11338
|
+
root: snapshot.root,
|
|
11339
|
+
source: "explicit",
|
|
11340
|
+
title: snapshot.project.title,
|
|
11341
|
+
created_at: snapshot.project.created_at
|
|
11342
|
+
},
|
|
11343
|
+
title: snapshot.project.title,
|
|
11344
|
+
created_at: snapshot.project.created_at
|
|
9332
11345
|
};
|
|
9333
11346
|
}
|
|
11347
|
+
/** Clear only this session's choice; never touches a project or registry row. */
|
|
11348
|
+
async unbindProject() {
|
|
11349
|
+
const caller = this.caller();
|
|
11350
|
+
if (caller?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "unbindProject requires an Agent session", "run /novel unbind from an Agent conversation");
|
|
11351
|
+
return { cleared: (await this.withMetadataLock(() => this.bindings().clear(caller.sessionId))).changed };
|
|
11352
|
+
}
|
|
9334
11353
|
/**
|
|
9335
11354
|
* Import one ledger from a settings pack. Always previews with a diff; a
|
|
9336
11355
|
* dry run never writes, and an apply is a normal transaction.
|
|
@@ -9448,7 +11467,7 @@ var EngineService = class {
|
|
|
9448
11467
|
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
11468
|
}
|
|
9450
11469
|
bookStatus(snapshot) {
|
|
9451
|
-
const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
|
|
11470
|
+
const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
|
|
9452
11471
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
9453
11472
|
return {
|
|
9454
11473
|
premise: snapshot.outline.book.premise,
|
|
@@ -9456,10 +11475,15 @@ var EngineService = class {
|
|
|
9456
11475
|
strict: snapshot.outline.book.strict,
|
|
9457
11476
|
outline: {
|
|
9458
11477
|
present: snapshot.outlinePresent && summary.present,
|
|
11478
|
+
target: summary.target,
|
|
9459
11479
|
total: summary.total,
|
|
9460
11480
|
planned: summary.planned,
|
|
9461
11481
|
archived: summary.archived,
|
|
11482
|
+
missing_cards: summary.missingCards,
|
|
11483
|
+
planned_coverage: summary.plannedCoverage,
|
|
11484
|
+
archived_coverage: summary.archivedCoverage,
|
|
9462
11485
|
coverage: summary.coverage,
|
|
11486
|
+
card_coverage: summary.cardCoverage,
|
|
9463
11487
|
volumes: summary.volumes,
|
|
9464
11488
|
current: outlineCard(snapshot.outline, snapshot.pipeline.chapter)
|
|
9465
11489
|
},
|
|
@@ -9470,12 +11494,14 @@ var EngineService = class {
|
|
|
9470
11494
|
factions: counts.factions,
|
|
9471
11495
|
rules: counts.rules,
|
|
9472
11496
|
glossary: counts.glossary,
|
|
9473
|
-
backstory: counts.backstory
|
|
11497
|
+
backstory: counts.backstory,
|
|
11498
|
+
facts: counts.facts
|
|
9474
11499
|
}
|
|
9475
11500
|
};
|
|
9476
11501
|
}
|
|
9477
11502
|
async panelSnapshot(root) {
|
|
9478
|
-
const
|
|
11503
|
+
const selection = await this.resolveRoot(root);
|
|
11504
|
+
const snapshot = await this.loadAt(selection);
|
|
9479
11505
|
const archived = snapshot.chapters.chapters;
|
|
9480
11506
|
const archivedCurrent = archived.some((chapter) => chapter.num === snapshot.pipeline.chapter);
|
|
9481
11507
|
const target = snapshot.project.targets.total_chapters;
|
|
@@ -9483,7 +11509,7 @@ var EngineService = class {
|
|
|
9483
11509
|
const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
|
|
9484
11510
|
const journal = await readJournal(this.storeFor(snapshot.root));
|
|
9485
11511
|
const journalBlocking = await this.journalBlocking(snapshot);
|
|
9486
|
-
const outline = outlineSummary(snapshot.outline, archived.length);
|
|
11512
|
+
const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
|
|
9487
11513
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
9488
11514
|
const scores = archived.slice(-10).map((chapter) => chapter.score);
|
|
9489
11515
|
const blocking = [...this.effectiveBlocking(snapshot), ...journalBlocking];
|
|
@@ -9496,10 +11522,15 @@ var EngineService = class {
|
|
|
9496
11522
|
target,
|
|
9497
11523
|
outline: {
|
|
9498
11524
|
present: snapshot.outlinePresent && outline.present,
|
|
11525
|
+
target: outline.target,
|
|
9499
11526
|
total: outline.total,
|
|
9500
11527
|
planned: outline.planned,
|
|
9501
11528
|
archived: outline.archived,
|
|
11529
|
+
missing_cards: outline.missingCards,
|
|
11530
|
+
planned_coverage: outline.plannedCoverage,
|
|
11531
|
+
archived_coverage: outline.archivedCoverage,
|
|
9502
11532
|
coverage: outline.coverage,
|
|
11533
|
+
card_coverage: outline.cardCoverage,
|
|
9503
11534
|
volumes: snapshot.outline.volumes.map((volume) => {
|
|
9504
11535
|
const cards = snapshot.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
|
|
9505
11536
|
return {
|
|
@@ -9518,7 +11549,8 @@ var EngineService = class {
|
|
|
9518
11549
|
factions: counts.factions,
|
|
9519
11550
|
rules: counts.rules,
|
|
9520
11551
|
glossary: counts.glossary,
|
|
9521
|
-
backstory: counts.backstory
|
|
11552
|
+
backstory: counts.backstory,
|
|
11553
|
+
facts: counts.facts
|
|
9522
11554
|
}
|
|
9523
11555
|
};
|
|
9524
11556
|
const qualityScores = scores.slice(-5);
|
|
@@ -9609,6 +11641,16 @@ var EngineService = class {
|
|
|
9609
11641
|
to_stage: target
|
|
9610
11642
|
};
|
|
9611
11643
|
}
|
|
11644
|
+
const draft = snapshot.pipeline.draft;
|
|
11645
|
+
if (draft !== void 0 && (draft.chapter !== snapshot.pipeline.chapter || draft.content_hash !== sha256(text))) return {
|
|
11646
|
+
item: {
|
|
11647
|
+
code: "DRAFT_STALE",
|
|
11648
|
+
scope: "archive",
|
|
11649
|
+
detail: "the manuscript or active chapter no longer matches its draft summary",
|
|
11650
|
+
hint: "call novel_rollback to P3 and submit novel_write_draft again"
|
|
11651
|
+
},
|
|
11652
|
+
to_stage: "P3"
|
|
11653
|
+
};
|
|
9612
11654
|
const summary = snapshot.plan?.summary ?? null;
|
|
9613
11655
|
const blockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
|
|
9614
11656
|
if (blockers.length > 0) return {
|
|
@@ -10030,8 +12072,31 @@ var EngineService = class {
|
|
|
10030
12072
|
}));
|
|
10031
12073
|
}
|
|
10032
12074
|
/** Whether the stored brief still signs the current snapshot. */
|
|
12075
|
+
async readBriefReceipts(store) {
|
|
12076
|
+
const raw = await store.readText(BRIEF_RECEIPTS_FILE);
|
|
12077
|
+
if (raw === null) return {
|
|
12078
|
+
schema_version: 1,
|
|
12079
|
+
receipts: []
|
|
12080
|
+
};
|
|
12081
|
+
try {
|
|
12082
|
+
const parsed = parse(raw);
|
|
12083
|
+
if (parsed === null || !Array.isArray(parsed.receipts)) return {
|
|
12084
|
+
schema_version: 1,
|
|
12085
|
+
receipts: []
|
|
12086
|
+
};
|
|
12087
|
+
return {
|
|
12088
|
+
schema_version: 1,
|
|
12089
|
+
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")
|
|
12090
|
+
};
|
|
12091
|
+
} catch {
|
|
12092
|
+
return {
|
|
12093
|
+
schema_version: 1,
|
|
12094
|
+
receipts: []
|
|
12095
|
+
};
|
|
12096
|
+
}
|
|
12097
|
+
}
|
|
10033
12098
|
briefIsCurrent(snapshot, stage) {
|
|
10034
|
-
const record = snapshot.
|
|
12099
|
+
const record = snapshot.briefs[stage];
|
|
10035
12100
|
if (record === void 0) return false;
|
|
10036
12101
|
const context = this.briefContextFor(stage, snapshot, snapshot.pipeline.chapter);
|
|
10037
12102
|
return record.digest === this.briefDigest(stage, snapshot, context);
|
|
@@ -10117,6 +12182,8 @@ var EngineService = class {
|
|
|
10117
12182
|
genre: snapshot.project.genre,
|
|
10118
12183
|
sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
|
|
10119
12184
|
rulepack: this.rulepack,
|
|
12185
|
+
targets: snapshot.project.targets,
|
|
12186
|
+
facts: snapshot.worldbook.facts ?? [],
|
|
10120
12187
|
summary: extra.summary ?? snapshot.plan?.summary ?? null,
|
|
10121
12188
|
evidence: {
|
|
10122
12189
|
characters: snapshot.characters.characters.length > 0 ? snapshot.characters : null,
|
|
@@ -10222,13 +12289,16 @@ var EngineService = class {
|
|
|
10222
12289
|
text,
|
|
10223
12290
|
chapter: snapshot.pipeline.chapter,
|
|
10224
12291
|
project: snapshot.project,
|
|
10225
|
-
pipeline:
|
|
12292
|
+
pipeline: {
|
|
12293
|
+
...snapshot.pipeline,
|
|
12294
|
+
briefs: snapshot.briefs
|
|
12295
|
+
},
|
|
10226
12296
|
chapters: snapshot.chapters,
|
|
10227
12297
|
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
12298
|
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,
|
|
12299
|
+
draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
|
|
12300
|
+
sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
|
|
12301
|
+
summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
|
|
10232
12302
|
summaryProvided: extras.summaryProvided,
|
|
10233
12303
|
review: extras.review ?? snapshot.review,
|
|
10234
12304
|
check: extras.check ?? snapshot.check,
|
|
@@ -10282,22 +12352,6 @@ var EngineService = class {
|
|
|
10282
12352
|
this.assertChapterParameter(chapter);
|
|
10283
12353
|
if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not the active chapter (${snapshot.pipeline.chapter})`, "archive the active chapter before starting the next one");
|
|
10284
12354
|
}
|
|
10285
|
-
/** True when anything survives in the project directory besides novel.yaml. */
|
|
10286
|
-
async projectStateExists(store) {
|
|
10287
|
-
const stateFiles = [
|
|
10288
|
-
STATE_FILES.pipeline,
|
|
10289
|
-
STATE_FILES.chapters,
|
|
10290
|
-
STATE_FILES.characters,
|
|
10291
|
-
STATE_FILES.world,
|
|
10292
|
-
STATE_FILES.locks,
|
|
10293
|
-
STATE_FILES.timeline,
|
|
10294
|
-
STATE_FILES.foreshadows,
|
|
10295
|
-
STATE_FILES.relationships,
|
|
10296
|
-
STATE_FILES.handoff
|
|
10297
|
-
].map((file) => joinRel(STATE_DIR, file));
|
|
10298
|
-
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
|
|
10299
|
-
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
|
-
}
|
|
10301
12355
|
/** A live writer lock makes a read-only snapshot potentially torn. */
|
|
10302
12356
|
async lockIsLive(store) {
|
|
10303
12357
|
const text = await store.readText(LOCK_FILE);
|
|
@@ -10308,8 +12362,34 @@ var EngineService = class {
|
|
|
10308
12362
|
const info = await store.stat(LOCK_FILE);
|
|
10309
12363
|
return info !== null && Date.now() - info.mtimeMs < 1e3;
|
|
10310
12364
|
}
|
|
10311
|
-
|
|
10312
|
-
|
|
12365
|
+
/** Resolve a selection, load it, then persist the explicit choice (best effort). */
|
|
12366
|
+
async load(root) {
|
|
12367
|
+
const selection = await this.resolveRoot(root);
|
|
12368
|
+
const snapshot = await this.loadAt(selection);
|
|
12369
|
+
await this.recordSelectionBestEffort(selection, snapshot);
|
|
12370
|
+
return snapshot;
|
|
12371
|
+
}
|
|
12372
|
+
/** Load exactly one already-resolved selection; never writes a binding. */
|
|
12373
|
+
async movedProjectHint(selection) {
|
|
12374
|
+
if (selection.created_at === void 0 || selection.created_at === "") return void 0;
|
|
12375
|
+
try {
|
|
12376
|
+
return (await this.registry().read()).projects.find((entry) => entry.root !== selection.root && entry.created_at !== void 0 && entry.created_at === selection.created_at)?.root;
|
|
12377
|
+
} catch {
|
|
12378
|
+
return;
|
|
12379
|
+
}
|
|
12380
|
+
}
|
|
12381
|
+
async loadAt(selection, retries = 1, allowLock = false) {
|
|
12382
|
+
const store = this.storeFor(selection.root);
|
|
12383
|
+
if (selection.source === "session") {
|
|
12384
|
+
const inspection = await inspectProject(selection.root);
|
|
12385
|
+
if (inspection.state === "missing") throw missingProjectError({
|
|
12386
|
+
selection,
|
|
12387
|
+
lastTitle: selection.title,
|
|
12388
|
+
movedTo: await this.movedProjectHint(selection)
|
|
12389
|
+
});
|
|
12390
|
+
if (inspection.state === "not-project") throw projectNotFoundError(selection.root);
|
|
12391
|
+
if (inspection.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the project bound to this session is unreadable: ${inspection.error ?? selection.root}`, "fix the directory permissions, or explicitly rebind with /novel use after the project is readable");
|
|
12392
|
+
}
|
|
10313
12393
|
if (!allowLock && await this.lockIsLive(store)) {
|
|
10314
12394
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
10315
12395
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
@@ -10318,10 +12398,17 @@ var EngineService = class {
|
|
|
10318
12398
|
if (await this.lockIsLive(store)) throw new EngineFaultError(ERROR_CODES.TXN_IN_PROGRESS, "a writer is changing this project; the read-only snapshot would be torn", "retry after the write finishes; do not interpret this snapshot as stable");
|
|
10319
12399
|
}
|
|
10320
12400
|
const projectExists = await store.exists(PROJECT_FILE);
|
|
10321
|
-
if (!projectExists && !await
|
|
12401
|
+
if (!projectExists && !await hasProjectState(store)) {
|
|
12402
|
+
if (selection.source === "session") throw missingProjectError({
|
|
12403
|
+
selection,
|
|
12404
|
+
lastTitle: selection.title
|
|
12405
|
+
});
|
|
12406
|
+
throw projectNotFoundError(store.root);
|
|
12407
|
+
}
|
|
10322
12408
|
let project = {
|
|
10323
12409
|
...ENGINE_PROJECT_INITIAL,
|
|
10324
|
-
title: "(项目文件不可读)"
|
|
12410
|
+
title: "(项目文件不可读)",
|
|
12411
|
+
created_at: ""
|
|
10325
12412
|
};
|
|
10326
12413
|
let projectError = projectExists ? null : `${PROJECT_FILE} is missing`;
|
|
10327
12414
|
if (projectExists) try {
|
|
@@ -10332,8 +12419,28 @@ var EngineService = class {
|
|
|
10332
12419
|
if (error instanceof EngineFaultError && (error.code === ERROR_CODES.SCHEMA_TOO_NEW || error.code === ERROR_CODES.PATH_ESCAPE)) throw error;
|
|
10333
12420
|
projectError = errorMessage(error);
|
|
10334
12421
|
}
|
|
12422
|
+
if (projectError === null && selection.created_at !== void 0 && selection.created_at !== "") {
|
|
12423
|
+
if (project.created_at === "" || project.created_at !== selection.created_at) throw identityMismatchError({
|
|
12424
|
+
selection,
|
|
12425
|
+
actualCreatedAt: project.created_at,
|
|
12426
|
+
actualTitle: project.title
|
|
12427
|
+
});
|
|
12428
|
+
}
|
|
10335
12429
|
const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
10336
12430
|
if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
|
|
12431
|
+
let briefs = pipeline.briefs;
|
|
12432
|
+
const briefsRaw = await store.readText(BRIEF_RECEIPTS_FILE);
|
|
12433
|
+
if (briefsRaw !== null) try {
|
|
12434
|
+
const parsed = parse(briefsRaw);
|
|
12435
|
+
if (parsed !== null && Array.isArray(parsed.receipts)) {
|
|
12436
|
+
const next = {};
|
|
12437
|
+
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] = {
|
|
12438
|
+
digest: receipt.digest,
|
|
12439
|
+
issued_at: receipt.issued_at
|
|
12440
|
+
};
|
|
12441
|
+
briefs = next;
|
|
12442
|
+
}
|
|
12443
|
+
} catch {}
|
|
10337
12444
|
const ledgerErrors = [];
|
|
10338
12445
|
const ledger = async (path, kind, code, fallback) => {
|
|
10339
12446
|
const read = await this.recoverableDoc(store, path, kind);
|
|
@@ -10422,6 +12529,10 @@ var EngineService = class {
|
|
|
10422
12529
|
const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
|
|
10423
12530
|
validateStoredChapterPlan(parsed, planChapterFile(chapter));
|
|
10424
12531
|
plan = parsed;
|
|
12532
|
+
if (pipeline.draft !== void 0 && pipeline.draft.chapter === chapter) plan = {
|
|
12533
|
+
...plan,
|
|
12534
|
+
summary: pipeline.draft.summary
|
|
12535
|
+
};
|
|
10425
12536
|
} catch (error) {
|
|
10426
12537
|
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
10427
12538
|
planError = errorMessage(error);
|
|
@@ -10451,19 +12562,20 @@ var EngineService = class {
|
|
|
10451
12562
|
if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
|
|
10452
12563
|
const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
10453
12564
|
if (latest === null || latest.revision !== pipeline.revision) {
|
|
10454
|
-
if (retries > 0) return this.
|
|
12565
|
+
if (retries > 0) return this.loadAt(selection, retries - 1, allowLock);
|
|
10455
12566
|
throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project changed while its snapshot was being read", "retry the read; if it keeps moving, wait for the writer to finish");
|
|
10456
12567
|
}
|
|
10457
12568
|
if (!allowLock && await this.lockIsLive(store)) {
|
|
10458
|
-
if (retries > 0) return this.
|
|
12569
|
+
if (retries > 0) return this.loadAt(selection, retries - 1, false);
|
|
10459
12570
|
throw new EngineFaultError(ERROR_CODES.TXN_IN_PROGRESS, "a writer started while the snapshot was being read; the snapshot is torn", "retry after the write finishes; do not interpret this snapshot as stable");
|
|
10460
12571
|
}
|
|
10461
|
-
this.lastRoot = store.root;
|
|
10462
12572
|
return {
|
|
10463
12573
|
root: store.root,
|
|
12574
|
+
selection,
|
|
10464
12575
|
project,
|
|
10465
12576
|
projectError,
|
|
10466
12577
|
pipeline,
|
|
12578
|
+
briefs,
|
|
10467
12579
|
chapters,
|
|
10468
12580
|
characters,
|
|
10469
12581
|
world,
|
|
@@ -10524,7 +12636,8 @@ var EngineService = class {
|
|
|
10524
12636
|
}
|
|
10525
12637
|
}
|
|
10526
12638
|
async mutate(options) {
|
|
10527
|
-
const
|
|
12639
|
+
const selection = options.selection ?? await this.resolveRoot(options.root);
|
|
12640
|
+
const root = selection.root;
|
|
10528
12641
|
const signal = this.signalContext.getStore();
|
|
10529
12642
|
const abortIfNeeded = () => {
|
|
10530
12643
|
if (signal?.aborted === true) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `${options.op} was aborted before commit`, "retry the call if you still want the write");
|
|
@@ -10533,9 +12646,24 @@ var EngineService = class {
|
|
|
10533
12646
|
return this.mutex.run(root, async () => {
|
|
10534
12647
|
if (options.emptyAllowed === true) ensureDirAt(root);
|
|
10535
12648
|
const store = this.storeFor(root);
|
|
12649
|
+
if (options.emptyAllowed !== true && selection.source === "session") {
|
|
12650
|
+
const inspection = await inspectProject(selection.root);
|
|
12651
|
+
if (inspection.state === "missing") throw missingProjectError({
|
|
12652
|
+
selection,
|
|
12653
|
+
lastTitle: selection.title,
|
|
12654
|
+
movedTo: await this.movedProjectHint(selection)
|
|
12655
|
+
});
|
|
12656
|
+
if (inspection.state === "not-project") throw projectNotFoundError(selection.root);
|
|
12657
|
+
if (inspection.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the project bound to this session is unreadable: ${inspection.error ?? selection.root}`, "fix the directory permissions, or explicitly rebind with /novel use after the project is readable");
|
|
12658
|
+
if (inspection.state === "damaged" && selection.created_at !== void 0 && selection.created_at !== "") throw identityMismatchError({
|
|
12659
|
+
selection,
|
|
12660
|
+
actualTitle: inspection.title
|
|
12661
|
+
});
|
|
12662
|
+
}
|
|
10536
12663
|
const journalPath = joinRel(STATE_DIR, STATE_FILES.journal);
|
|
10537
12664
|
const journalWasPresent = await store.exists(journalPath);
|
|
10538
12665
|
const projectExists = await store.exists(PROJECT_FILE);
|
|
12666
|
+
if (options.emptyAllowed !== true && !projectExists && !await hasProjectState(store)) throw projectNotFoundError(root);
|
|
10539
12667
|
if (options.emptyAllowed === true && projectExists) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, `a project already exists at ${store.root}`, "use a different directory, or call novel_status to inspect the existing project");
|
|
10540
12668
|
if (options.emptyAllowed !== true && projectExists && !await store.exists(joinRel("state", STATE_FILES.journal))) throw new EngineFaultError(ERROR_CODES.JOURNAL_MISSING, "state/journal.jsonl is missing; journal integrity and crash recovery are unavailable", "restore state/journal.jsonl from a backup before writing; a project with novel.yaml must have its audit journal");
|
|
10541
12669
|
const lock = new FileLock(join(root, LOCK_FILE), void 0, void 0, async (record) => {
|
|
@@ -10565,11 +12693,11 @@ var EngineService = class {
|
|
|
10565
12693
|
STATE_FILES.relationships,
|
|
10566
12694
|
STATE_FILES.handoff
|
|
10567
12695
|
].map((file) => joinRel(STATE_DIR, file));
|
|
10568
|
-
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
|
|
12696
|
+
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
|
|
10569
12697
|
const stateCandidates = journalWasPresent ? stateFiles : stateFiles.filter((path) => path !== joinRel(STATE_DIR, STATE_FILES.journal));
|
|
10570
12698
|
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
12699
|
await assertJournalConsistent(store);
|
|
10572
|
-
const created = await options.run(this.emptySnapshot(root), {
|
|
12700
|
+
const created = await options.run(this.emptySnapshot(root, selection), {
|
|
10573
12701
|
store,
|
|
10574
12702
|
revision: 1
|
|
10575
12703
|
});
|
|
@@ -10584,7 +12712,6 @@ var EngineService = class {
|
|
|
10584
12712
|
...options.reason === void 0 ? {} : { reason: options.reason }
|
|
10585
12713
|
});
|
|
10586
12714
|
this.replay.clear();
|
|
10587
|
-
this.lastRoot = root;
|
|
10588
12715
|
return {
|
|
10589
12716
|
data: created.data,
|
|
10590
12717
|
revision: 1,
|
|
@@ -10593,7 +12720,11 @@ var EngineService = class {
|
|
|
10593
12720
|
}
|
|
10594
12721
|
}
|
|
10595
12722
|
await assertJournalConsistent(store, options.repairPaths ?? []);
|
|
10596
|
-
const snapshot = await this.
|
|
12723
|
+
const snapshot = await this.loadAt(selection, 1, true);
|
|
12724
|
+
if (selection.source === "session" && selection.created_at !== void 0 && selection.created_at !== "" && snapshot.projectError !== null) throw identityMismatchError({
|
|
12725
|
+
selection,
|
|
12726
|
+
actualTitle: snapshot.project.title
|
|
12727
|
+
});
|
|
10597
12728
|
const key = options.key?.(snapshot);
|
|
10598
12729
|
if (key !== void 0) {
|
|
10599
12730
|
const cached = this.replay.get(`${root}|${options.op}|${key}`);
|
|
@@ -10603,12 +12734,32 @@ var EngineService = class {
|
|
|
10603
12734
|
replayed: true
|
|
10604
12735
|
};
|
|
10605
12736
|
}
|
|
10606
|
-
const revision = snapshot.pipeline.revision + 1;
|
|
12737
|
+
const revision = options.advanceRevision === false ? snapshot.pipeline.revision : snapshot.pipeline.revision + 1;
|
|
10607
12738
|
abortIfNeeded();
|
|
10608
|
-
|
|
10609
|
-
|
|
10610
|
-
|
|
10611
|
-
|
|
12739
|
+
let outcome;
|
|
12740
|
+
try {
|
|
12741
|
+
outcome = await options.run(snapshot, {
|
|
12742
|
+
store,
|
|
12743
|
+
revision
|
|
12744
|
+
});
|
|
12745
|
+
} catch (error) {
|
|
12746
|
+
await appendJournal(store, {
|
|
12747
|
+
txid: randomUUID(),
|
|
12748
|
+
at: this.now().toISOString(),
|
|
12749
|
+
op: options.op,
|
|
12750
|
+
chapter: options.chapter ?? snapshot.pipeline.chapter,
|
|
12751
|
+
revision: snapshot.pipeline.revision,
|
|
12752
|
+
files: [],
|
|
12753
|
+
status: "aborted",
|
|
12754
|
+
reason: errorMessage(error),
|
|
12755
|
+
attempt: true,
|
|
12756
|
+
stage: snapshot.pipeline.stage,
|
|
12757
|
+
...error instanceof EngineError ? { code: error.code } : {},
|
|
12758
|
+
...error instanceof EngineError && error.hint !== void 0 ? { hint: error.hint } : {},
|
|
12759
|
+
...error instanceof EngineBlockedError ? { blocking: error.blocking } : {}
|
|
12760
|
+
}).catch(() => void 0);
|
|
12761
|
+
throw error;
|
|
12762
|
+
}
|
|
10612
12763
|
if (outcome.replay === true) return {
|
|
10613
12764
|
data: outcome.data,
|
|
10614
12765
|
revision: snapshot.pipeline.revision,
|
|
@@ -10619,7 +12770,7 @@ var EngineService = class {
|
|
|
10619
12770
|
await runTransaction(store, {
|
|
10620
12771
|
txid: randomUUID(),
|
|
10621
12772
|
op: options.op,
|
|
10622
|
-
chapter: snapshot.pipeline.chapter,
|
|
12773
|
+
chapter: options.chapter ?? snapshot.pipeline.chapter,
|
|
10623
12774
|
revision,
|
|
10624
12775
|
files: outcome.files,
|
|
10625
12776
|
backup: outcome.backup ?? false,
|
|
@@ -10632,6 +12783,21 @@ var EngineService = class {
|
|
|
10632
12783
|
revision,
|
|
10633
12784
|
warnings: outcome.warnings ?? []
|
|
10634
12785
|
};
|
|
12786
|
+
let metadataSnapshot = snapshot;
|
|
12787
|
+
if ((options.bindOnSuccess !== false || options.registerOnSuccess !== false) && outcome.files.some((file) => file.path === "novel.yaml")) try {
|
|
12788
|
+
metadataSnapshot = await this.loadAt(selection, 1, true);
|
|
12789
|
+
} catch {}
|
|
12790
|
+
try {
|
|
12791
|
+
await this.recordSelection(selection, metadataSnapshot, {
|
|
12792
|
+
bind: options.bindOnSuccess,
|
|
12793
|
+
register: options.registerOnSuccess
|
|
12794
|
+
});
|
|
12795
|
+
} catch (error) {
|
|
12796
|
+
result.warnings.push({
|
|
12797
|
+
code: "SESSION_BINDING_FAILED",
|
|
12798
|
+
message: `project write committed, but project selection metadata could not be written: ${errorMessage(error)}`
|
|
12799
|
+
});
|
|
12800
|
+
}
|
|
10635
12801
|
if (key !== void 0) {
|
|
10636
12802
|
this.invalidateReplay(root, options.op);
|
|
10637
12803
|
this.replay.set(`${root}|${options.op}|${key}`, result);
|
|
@@ -10651,9 +12817,10 @@ var EngineService = class {
|
|
|
10651
12817
|
if (contentChanging || !key.includes("|export|")) this.replay.delete(key);
|
|
10652
12818
|
}
|
|
10653
12819
|
}
|
|
10654
|
-
emptySnapshot(root) {
|
|
12820
|
+
emptySnapshot(root, selection) {
|
|
10655
12821
|
return {
|
|
10656
12822
|
root,
|
|
12823
|
+
selection,
|
|
10657
12824
|
project: { ...ENGINE_PROJECT_INITIAL },
|
|
10658
12825
|
projectError: null,
|
|
10659
12826
|
pipeline: emptyPipeline({
|
|
@@ -10661,6 +12828,7 @@ var EngineService = class {
|
|
|
10661
12828
|
chapter: 1,
|
|
10662
12829
|
stage: "idle"
|
|
10663
12830
|
}),
|
|
12831
|
+
briefs: {},
|
|
10664
12832
|
chapters: emptyChapters(),
|
|
10665
12833
|
characters: emptyCharacters(),
|
|
10666
12834
|
world: emptyWorld(),
|
|
@@ -10728,6 +12896,38 @@ const STATUS_BRIEF = {
|
|
|
10728
12896
|
done: "P1"
|
|
10729
12897
|
};
|
|
10730
12898
|
const yaml = (value) => stringify(value, { lineWidth: 0 });
|
|
12899
|
+
/** Apply a patch only to the exact manuscript the caller hashed. */
|
|
12900
|
+
function applyRevisionPatch(text, patch) {
|
|
12901
|
+
const actual = sha256(text);
|
|
12902
|
+
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");
|
|
12903
|
+
const planned = [];
|
|
12904
|
+
for (const [index, edit] of patch.edits.entries()) {
|
|
12905
|
+
const positions = [];
|
|
12906
|
+
let cursor = 0;
|
|
12907
|
+
while (cursor <= text.length) {
|
|
12908
|
+
const found = text.indexOf(edit.old_text, cursor);
|
|
12909
|
+
if (found === -1) break;
|
|
12910
|
+
positions.push(found);
|
|
12911
|
+
cursor = found + 1;
|
|
12912
|
+
}
|
|
12913
|
+
const expectedOccurrence = edit.occurrence ?? 1;
|
|
12914
|
+
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");
|
|
12915
|
+
if (positions.length < expectedOccurrence) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}].occurrence ${expectedOccurrence} exceeds ${positions.length} match(es)`);
|
|
12916
|
+
const start = positions[expectedOccurrence - 1];
|
|
12917
|
+
const current = {
|
|
12918
|
+
index,
|
|
12919
|
+
start,
|
|
12920
|
+
end: start + edit.old_text.length,
|
|
12921
|
+
newText: edit.new_text
|
|
12922
|
+
};
|
|
12923
|
+
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");
|
|
12924
|
+
planned.push(current);
|
|
12925
|
+
}
|
|
12926
|
+
planned.sort((left, right) => right.start - left.start);
|
|
12927
|
+
let next = text;
|
|
12928
|
+
for (const edit of planned) next = `${next.slice(0, edit.start)}${edit.newText}${next.slice(edit.end)}`;
|
|
12929
|
+
return next;
|
|
12930
|
+
}
|
|
10731
12931
|
const emptyChapters = () => ({
|
|
10732
12932
|
schema_version: 1,
|
|
10733
12933
|
chapters: []
|
|
@@ -10752,6 +12952,7 @@ const pipelineWith = (snapshot, revision, patch) => ({
|
|
|
10752
12952
|
rounds: snapshot.pipeline.rounds,
|
|
10753
12953
|
briefs: snapshot.pipeline.briefs,
|
|
10754
12954
|
blocking: snapshot.pipeline.blocking,
|
|
12955
|
+
...snapshot.pipeline.draft === void 0 ? {} : { draft: snapshot.pipeline.draft },
|
|
10755
12956
|
review_digest: snapshot.pipeline.review_digest,
|
|
10756
12957
|
last_action: snapshot.pipeline.last_action,
|
|
10757
12958
|
...patch
|
|
@@ -10775,7 +12976,7 @@ const action = (op, at, digest) => ({
|
|
|
10775
12976
|
at: at.toISOString(),
|
|
10776
12977
|
digest
|
|
10777
12978
|
});
|
|
10778
|
-
const machineWarnings = (machine) => machine.violations.filter((violation) => violation.severity === "advisory").slice(0, 5).map((violation) => ({
|
|
12979
|
+
const machineWarnings = (machine, acknowledged = []) => machine.violations.filter((violation) => violation.severity === "advisory" && !acknowledged.includes(violation.code)).slice(0, 5).map((violation) => ({
|
|
10779
12980
|
code: violation.code,
|
|
10780
12981
|
message: `${violation.detail} (paragraph ${violation.location.paragraph})`
|
|
10781
12982
|
}));
|
|
@@ -10940,7 +13141,10 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
10940
13141
|
"narrator",
|
|
10941
13142
|
"score",
|
|
10942
13143
|
"status",
|
|
10943
|
-
"summary"
|
|
13144
|
+
"summary",
|
|
13145
|
+
"rounds",
|
|
13146
|
+
"final_check",
|
|
13147
|
+
"archived_at"
|
|
10944
13148
|
]) if (stableJson(before[field]) !== stableJson(after[field])) problems.push(`archived chapter ${String(before.num)} cannot change its ${field}`);
|
|
10945
13149
|
}
|
|
10946
13150
|
return problems;
|
|
@@ -11354,14 +13558,22 @@ const fail = (error) => {
|
|
|
11354
13558
|
}
|
|
11355
13559
|
throw error;
|
|
11356
13560
|
};
|
|
11357
|
-
/** Wrap an execute body with cancellation and a machine-code error. */
|
|
11358
|
-
const makeGuarded = (engine) => (body) => async (args, exec) => {
|
|
13561
|
+
/** Wrap an execute body with caller context, cancellation and a machine-code error. */
|
|
13562
|
+
const makeGuarded = (engine, workingDirectory) => (body) => async (args, exec) => {
|
|
13563
|
+
const caller = {};
|
|
13564
|
+
if (typeof exec?.agent?.id === "string" && exec.agent.id !== "") caller.sessionId = exec.agent.id;
|
|
13565
|
+
if (exec?.agent !== void 0 && workingDirectory !== void 0) try {
|
|
13566
|
+
caller.cwd = await workingDirectory.ensure(exec.agent, exec.signal);
|
|
13567
|
+
} catch {}
|
|
11359
13568
|
try {
|
|
11360
|
-
|
|
13569
|
+
const invoke = () => engine.withSignal(exec?.signal, () => body(args));
|
|
13570
|
+
const withCaller = engine.withCaller;
|
|
13571
|
+
return await (typeof withCaller === "function" ? withCaller.call(engine, caller, invoke) : invoke());
|
|
11361
13572
|
} catch (error) {
|
|
11362
13573
|
return fail(error);
|
|
11363
13574
|
}
|
|
11364
13575
|
};
|
|
13576
|
+
const percent = (value) => `${Math.round(value * 1e3) / 10}%`;
|
|
11365
13577
|
const text = (value) => [{
|
|
11366
13578
|
type: "text",
|
|
11367
13579
|
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
@@ -11405,14 +13617,22 @@ const stringArray = {
|
|
|
11405
13617
|
};
|
|
11406
13618
|
const root = {
|
|
11407
13619
|
type: "string",
|
|
11408
|
-
description: "Project root.
|
|
13620
|
+
description: "Project root. Passing it selects this project for the current session and persists the choice. When omitted: session binding -> unique project in the workspace -> single registered project -> configured projectRoot -> error. Multiple projects require an explicit choice."
|
|
13621
|
+
};
|
|
13622
|
+
const initRoot = {
|
|
13623
|
+
type: "string",
|
|
13624
|
+
description: "Project root for the new project. Passing it creates the project there. When omitted, only the configured projectRoot is used; novel_init does not follow the session binding, workspace discovery or registry active. The created project is bound to the current session on success."
|
|
13625
|
+
};
|
|
13626
|
+
const utilityRoot = {
|
|
13627
|
+
type: "string",
|
|
13628
|
+
description: "Project root for the project-backed mode of this tool. Text-only word_count/diff calls ignore it and do not create a session binding; use a project-backed call when you want to select a book."
|
|
11409
13629
|
};
|
|
11410
13630
|
/**
|
|
11411
|
-
* The twenty-
|
|
13631
|
+
* The twenty-eight tools are thin adapters: narrow parameters, call one service
|
|
11412
13632
|
* operation, render the canonical value. They never read a file.
|
|
11413
13633
|
*/
|
|
11414
|
-
function createNovelTools(engine) {
|
|
11415
|
-
const guarded = makeGuarded(engine);
|
|
13634
|
+
function createNovelTools(engine, options = {}) {
|
|
13635
|
+
const guarded = makeGuarded(engine, options.workingDirectory);
|
|
11416
13636
|
return [
|
|
11417
13637
|
defineTool({
|
|
11418
13638
|
name: "novel_init",
|
|
@@ -11451,7 +13671,7 @@ function createNovelTools(engine) {
|
|
|
11451
13671
|
selling_points: stringArray,
|
|
11452
13672
|
ending: { type: "string" },
|
|
11453
13673
|
strict_outline: { type: "boolean" },
|
|
11454
|
-
project_root:
|
|
13674
|
+
project_root: initRoot
|
|
11455
13675
|
},
|
|
11456
13676
|
output: jsonOutput,
|
|
11457
13677
|
execute: guarded(async (args) => engine.initProject({
|
|
@@ -11489,10 +13709,12 @@ function createNovelTools(engine) {
|
|
|
11489
13709
|
const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
|
|
11490
13710
|
return text([
|
|
11491
13711
|
`《${value.project.title}》 ${where} · ${value.stage}`,
|
|
13712
|
+
...value.selection === void 0 ? [] : [`项目根 ${value.selection.root} · 选择来源 ${value.selection.source}`],
|
|
11492
13713
|
...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
|
|
11493
13714
|
value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
|
|
11494
13715
|
...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
|
|
11495
|
-
...value.
|
|
13716
|
+
...value.metrics === null || value.metrics === void 0 ? [] : [`字数:${value.metrics.total_word_count}(汉字 ${value.metrics.cjk_chars} + 标点 ${value.metrics.punctuation_chars} + 英文词/数字,含标点)`],
|
|
13717
|
+
...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
13718
|
`下一步:${value.next.tool} — ${value.next.reason}`
|
|
11497
13719
|
].join("\n"));
|
|
11498
13720
|
}
|
|
@@ -11701,7 +13923,11 @@ function createNovelTools(engine) {
|
|
|
11701
13923
|
},
|
|
11702
13924
|
content: {
|
|
11703
13925
|
type: "string",
|
|
11704
|
-
|
|
13926
|
+
description: "Full revised text; mutually exclusive with patch."
|
|
13927
|
+
},
|
|
13928
|
+
patch: {
|
|
13929
|
+
type: "json",
|
|
13930
|
+
description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
|
|
11705
13931
|
},
|
|
11706
13932
|
summary: { type: "json" },
|
|
11707
13933
|
issues: {
|
|
@@ -11718,7 +13944,8 @@ function createNovelTools(engine) {
|
|
|
11718
13944
|
output: jsonOutput,
|
|
11719
13945
|
execute: guarded(async (args) => engine.submitRevision({
|
|
11720
13946
|
chapter: args.chapter,
|
|
11721
|
-
content: args.content,
|
|
13947
|
+
...args.content === void 0 ? {} : { content: args.content },
|
|
13948
|
+
...args.patch === void 0 ? {} : { patch: asJson(args.patch, "patch") },
|
|
11722
13949
|
summary: asJson(args.summary, "summary"),
|
|
11723
13950
|
issues: asJson(args.issues, "issues"),
|
|
11724
13951
|
proof: asJson(args.proof, "proof"),
|
|
@@ -11739,6 +13966,11 @@ function createNovelTools(engine) {
|
|
|
11739
13966
|
required: true,
|
|
11740
13967
|
description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
|
|
11741
13968
|
},
|
|
13969
|
+
acknowledge: {
|
|
13970
|
+
type: "array",
|
|
13971
|
+
items: { type: "string" },
|
|
13972
|
+
description: "Advisory codes read and accepted; blockers cannot be acknowledged."
|
|
13973
|
+
},
|
|
11742
13974
|
project_root: root
|
|
11743
13975
|
},
|
|
11744
13976
|
output: jsonOutput,
|
|
@@ -11746,12 +13978,13 @@ function createNovelTools(engine) {
|
|
|
11746
13978
|
chapter: args.chapter,
|
|
11747
13979
|
scope: args.scope,
|
|
11748
13980
|
semantic: asJson(args.semantic, "semantic"),
|
|
13981
|
+
...args.acknowledge === void 0 ? {} : { acknowledge: args.acknowledge },
|
|
11749
13982
|
root: args.project_root
|
|
11750
13983
|
}))
|
|
11751
13984
|
}),
|
|
11752
13985
|
defineTool({
|
|
11753
13986
|
name: "novel_archive",
|
|
11754
|
-
description: "Archive the active chapter: all gates run, then one transaction writes the index,
|
|
13987
|
+
description: "Archive the active chapter: all gates run, then one transaction writes the index, eight ledgers and handoff.",
|
|
11755
13988
|
parameters: {
|
|
11756
13989
|
chapter: {
|
|
11757
13990
|
type: "integer",
|
|
@@ -11788,7 +14021,7 @@ function createNovelTools(engine) {
|
|
|
11788
14021
|
parameters: {
|
|
11789
14022
|
text: { type: "string" },
|
|
11790
14023
|
chapter: { type: "integer" },
|
|
11791
|
-
project_root:
|
|
14024
|
+
project_root: utilityRoot
|
|
11792
14025
|
},
|
|
11793
14026
|
output: jsonOutput,
|
|
11794
14027
|
execute: guarded(async (args) => engine.countWords({
|
|
@@ -11799,7 +14032,7 @@ function createNovelTools(engine) {
|
|
|
11799
14032
|
}),
|
|
11800
14033
|
defineTool({
|
|
11801
14034
|
name: "novel_state",
|
|
11802
|
-
description: "Read a state document (ledgers, plans, review, check, outline, worldbook) without touching the manuscript.",
|
|
14035
|
+
description: "Read a state document (ledgers, plans, review, check, outline, worldbook), list brief receipts, revision history or backup manifests without touching the manuscript.",
|
|
11803
14036
|
parameters: {
|
|
11804
14037
|
kind: {
|
|
11805
14038
|
type: "string",
|
|
@@ -11820,10 +14053,14 @@ function createNovelTools(engine) {
|
|
|
11820
14053
|
"planWorld",
|
|
11821
14054
|
"planChapter",
|
|
11822
14055
|
"outline",
|
|
11823
|
-
"worldbook"
|
|
14056
|
+
"worldbook",
|
|
14057
|
+
"history",
|
|
14058
|
+
"backups",
|
|
14059
|
+
"briefs"
|
|
11824
14060
|
]
|
|
11825
14061
|
},
|
|
11826
14062
|
chapter: { type: "integer" },
|
|
14063
|
+
revision: { type: "integer" },
|
|
11827
14064
|
include_summary: {
|
|
11828
14065
|
type: "boolean",
|
|
11829
14066
|
description: "For kind=chapters: include the full ten-field summary (default compact)."
|
|
@@ -11839,6 +14076,7 @@ function createNovelTools(engine) {
|
|
|
11839
14076
|
execute: guarded(async (args) => engine.queryState({
|
|
11840
14077
|
kind: args.kind,
|
|
11841
14078
|
chapter: args.chapter,
|
|
14079
|
+
...args.revision === void 0 ? {} : { revision: args.revision },
|
|
11842
14080
|
...args.include_summary === void 0 ? {} : { include_summary: args.include_summary },
|
|
11843
14081
|
...args.chapter_range === void 0 ? {} : { chapter_range: args.chapter_range },
|
|
11844
14082
|
root: args.project_root
|
|
@@ -11921,7 +14159,7 @@ function createNovelTools(engine) {
|
|
|
11921
14159
|
chapter: { type: "integer" },
|
|
11922
14160
|
before: { type: "string" },
|
|
11923
14161
|
after: { type: "string" },
|
|
11924
|
-
project_root:
|
|
14162
|
+
project_root: utilityRoot
|
|
11925
14163
|
},
|
|
11926
14164
|
output: jsonOutput,
|
|
11927
14165
|
execute: guarded(async (args) => engine.diff({
|
|
@@ -11959,14 +14197,29 @@ function createNovelTools(engine) {
|
|
|
11959
14197
|
}),
|
|
11960
14198
|
defineTool({
|
|
11961
14199
|
name: "novel_audit",
|
|
11962
|
-
description: "Read the append-only journal
|
|
14200
|
+
description: "Read the append-only journal, including refused gate attempts, with chapter/op/status/time filters and a revision cursor.",
|
|
11963
14201
|
parameters: {
|
|
11964
14202
|
limit: { type: "integer" },
|
|
14203
|
+
cursor: { type: "integer" },
|
|
14204
|
+
chapter: { type: "integer" },
|
|
14205
|
+
op: { type: "string" },
|
|
14206
|
+
status: {
|
|
14207
|
+
type: "string",
|
|
14208
|
+
enum: ["committed", "aborted"]
|
|
14209
|
+
},
|
|
14210
|
+
since: { type: "string" },
|
|
14211
|
+
until: { type: "string" },
|
|
11965
14212
|
project_root: root
|
|
11966
14213
|
},
|
|
11967
14214
|
output: jsonOutput,
|
|
11968
14215
|
execute: guarded(async (args) => engine.auditHistory({
|
|
11969
14216
|
limit: args.limit,
|
|
14217
|
+
cursor: args.cursor,
|
|
14218
|
+
chapter: args.chapter,
|
|
14219
|
+
op: args.op,
|
|
14220
|
+
status: args.status,
|
|
14221
|
+
since: args.since,
|
|
14222
|
+
until: args.until,
|
|
11970
14223
|
root: args.project_root
|
|
11971
14224
|
}))
|
|
11972
14225
|
}),
|
|
@@ -12083,6 +14336,10 @@ function createNovelTools(engine) {
|
|
|
12083
14336
|
characters: { type: "json" },
|
|
12084
14337
|
glossary: { type: "json" },
|
|
12085
14338
|
backstory: { type: "json" },
|
|
14339
|
+
facts: {
|
|
14340
|
+
type: "json",
|
|
14341
|
+
description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
|
|
14342
|
+
},
|
|
12086
14343
|
themes: stringArray,
|
|
12087
14344
|
project_root: root
|
|
12088
14345
|
},
|
|
@@ -12096,6 +14353,7 @@ function createNovelTools(engine) {
|
|
|
12096
14353
|
characters: asJson(args.characters, "characters"),
|
|
12097
14354
|
glossary: asJson(args.glossary, "glossary"),
|
|
12098
14355
|
backstory: asJson(args.backstory, "backstory"),
|
|
14356
|
+
...args.facts === void 0 ? {} : { facts: asJson(args.facts, "facts") },
|
|
12099
14357
|
themes: args.themes,
|
|
12100
14358
|
root: args.project_root
|
|
12101
14359
|
}))
|
|
@@ -12171,6 +14429,30 @@ function createNovelTools(engine) {
|
|
|
12171
14429
|
reason: args.reason,
|
|
12172
14430
|
root: args.project_root
|
|
12173
14431
|
}))
|
|
14432
|
+
}),
|
|
14433
|
+
defineTool({
|
|
14434
|
+
name: "novel_restore",
|
|
14435
|
+
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.",
|
|
14436
|
+
parameters: {
|
|
14437
|
+
txid: {
|
|
14438
|
+
type: "string",
|
|
14439
|
+
required: true
|
|
14440
|
+
},
|
|
14441
|
+
chapter: { type: "integer" },
|
|
14442
|
+
paths: {
|
|
14443
|
+
type: "array",
|
|
14444
|
+
items: { type: "string" },
|
|
14445
|
+
description: "Project-relative chapter files; omitted restores every restorable chapter file that the backup manifest records."
|
|
14446
|
+
},
|
|
14447
|
+
project_root: root
|
|
14448
|
+
},
|
|
14449
|
+
output: jsonOutput,
|
|
14450
|
+
execute: guarded(async (args) => engine.restoreBackup({
|
|
14451
|
+
txid: args.txid,
|
|
14452
|
+
chapter: args.chapter,
|
|
14453
|
+
...args.paths === void 0 ? {} : { paths: args.paths },
|
|
14454
|
+
root: args.project_root
|
|
14455
|
+
}))
|
|
12174
14456
|
})
|
|
12175
14457
|
];
|
|
12176
14458
|
}
|
|
@@ -12214,7 +14496,7 @@ const rootArg = (args) => {
|
|
|
12214
14496
|
return args.root;
|
|
12215
14497
|
};
|
|
12216
14498
|
/**
|
|
12217
|
-
* The
|
|
14499
|
+
* The eighteen tools hidden when `tools.mode=core`, plus the explicit
|
|
12218
14500
|
* registry/settings-pack operations that are command-only by design (§6.8).
|
|
12219
14501
|
* They stay reachable through `/novel admin <operation> <json>`; the caller
|
|
12220
14502
|
* supplies the same arguments the tool schema would have accepted.
|
|
@@ -12242,9 +14524,23 @@ const OPERATIONS$1 = {
|
|
|
12242
14524
|
novel_report: (engine, args) => engine.report(args),
|
|
12243
14525
|
novel_guide: (engine, args) => engine.guide(args),
|
|
12244
14526
|
novel_reopen: (engine, args) => engine.reopenChapter(args),
|
|
14527
|
+
novel_restore: (engine, args) => engine.restoreBackup(args),
|
|
12245
14528
|
novel_registry_list: (engine) => engine.listProjects(),
|
|
12246
|
-
novel_registry_register: (engine, args) =>
|
|
12247
|
-
|
|
14529
|
+
novel_registry_register: async (engine, args) => {
|
|
14530
|
+
const result = await engine.registerProject({ root: rootArg(args) });
|
|
14531
|
+
return {
|
|
14532
|
+
...result,
|
|
14533
|
+
...result.replaced ? { message: "原路径已换成另一本书;登记行已更新。已有会话绑定仍会按 created_at 拒绝,请重新 /novel use。" } : {}
|
|
14534
|
+
};
|
|
14535
|
+
},
|
|
14536
|
+
novel_registry_activate: async (engine, args) => {
|
|
14537
|
+
const result = await engine.setActiveProject({ root: rootArg(args) });
|
|
14538
|
+
return {
|
|
14539
|
+
...result,
|
|
14540
|
+
message: "active 只影响面板默认;当前会话没有切换。写作请用 /novel use 或显式 project_root。",
|
|
14541
|
+
...result.replaced ? { replacement: "原路径已换成另一本书;登记行已更新,旧会话绑定仍需重新 /novel use。" } : {}
|
|
14542
|
+
};
|
|
14543
|
+
},
|
|
12248
14544
|
novel_registry_forget: (engine, args) => engine.forgetProject({ root: rootArg(args) }),
|
|
12249
14545
|
novel_import: (engine, args) => engine.importPack(args)
|
|
12250
14546
|
};
|
|
@@ -12305,16 +14601,18 @@ const bad = (text) => ({
|
|
|
12305
14601
|
text
|
|
12306
14602
|
});
|
|
12307
14603
|
const failure = (error) => error instanceof EngineError ? bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`) : bad(String(error));
|
|
14604
|
+
const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
|
|
12308
14605
|
const statusText = async (engine) => {
|
|
12309
14606
|
const { data } = await engine.getStatus({});
|
|
12310
14607
|
return [
|
|
12311
14608
|
`《${data.project.title}》 ${data.project.genre} · ${data.project.platform}`,
|
|
12312
14609
|
`第 ${data.chapter} 章 · 阶段 ${data.stage}`,
|
|
14610
|
+
...data.selection === void 0 ? [] : [`项目根 ${data.selection.root} · 选择来源 ${data.selection.source}`],
|
|
12313
14611
|
`轮次 评审 ${data.rounds.review} / 修订 ${data.rounds.revise} / 校验 ${data.rounds.check}`,
|
|
12314
|
-
data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}
|
|
14612
|
+
data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}(汉字 ${data.metrics.cjk_chars} + 标点 ${data.metrics.punctuation_chars},含标点)`,
|
|
12315
14613
|
data.blocking.length === 0 ? "门控 全部通过" : `门控 ${data.blocking.length} 条阻塞:`,
|
|
12316
14614
|
...data.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
|
|
12317
|
-
...data.book === void 0 ? [] : [`全书:大纲 ${data.book.outline.total}
|
|
14615
|
+
...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
14616
|
`下一步 ${data.next.tool}:${data.next.reason}`
|
|
12319
14617
|
].join("\n");
|
|
12320
14618
|
};
|
|
@@ -12328,6 +14626,12 @@ const usage = () => [
|
|
|
12328
14626
|
"/novel doctor 项目体检",
|
|
12329
14627
|
"/novel search <词> 搜索正文/大纲/世界观/台账",
|
|
12330
14628
|
"/novel export md|txt|epub|outline|worldbook|proposal",
|
|
14629
|
+
"/novel projects 列出项目与目录状态",
|
|
14630
|
+
"/novel current 当前会话绑定与选择来源",
|
|
14631
|
+
"/novel use <path|title|.> 切换当前会话项目",
|
|
14632
|
+
"/novel unbind 清除当前会话绑定(不删项目)",
|
|
14633
|
+
"/novel scan [--register] 扫描工作区候选",
|
|
14634
|
+
"/novel resume 输出可粘贴的续写指令",
|
|
12331
14635
|
"/novel admin 隐藏操作与参数说明",
|
|
12332
14636
|
"/novel admin <op> <json> 执行隐藏操作",
|
|
12333
14637
|
"/novel-next 等价于 /novel next",
|
|
@@ -12335,9 +14639,10 @@ const usage = () => [
|
|
|
12335
14639
|
"/novel-export <kind>",
|
|
12336
14640
|
"/novel-admin [<op> <json>]"
|
|
12337
14641
|
].join("\n");
|
|
12338
|
-
/**
|
|
12339
|
-
function createCommands(engine) {
|
|
12340
|
-
const dispatch = async (
|
|
14642
|
+
/** Every command calls a service operation; none touches a file directly. */
|
|
14643
|
+
function createCommands(engine, host = {}) {
|
|
14644
|
+
const dispatch = async (invocation) => {
|
|
14645
|
+
const { rawInput: raw, signal } = invocation;
|
|
12341
14646
|
if (signal.aborted) return bad("已取消");
|
|
12342
14647
|
const [head = "", ...rest] = raw.trim().split(/\s+/).filter((part) => part !== "");
|
|
12343
14648
|
try {
|
|
@@ -12351,7 +14656,7 @@ function createCommands(engine) {
|
|
|
12351
14656
|
}
|
|
12352
14657
|
case "stats": {
|
|
12353
14658
|
const stats = await engine.stats({});
|
|
12354
|
-
return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.
|
|
14659
|
+
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
14660
|
}
|
|
12356
14661
|
case "outline": {
|
|
12357
14662
|
const status = await engine.getStatus({});
|
|
@@ -12365,7 +14670,7 @@ function createCommands(engine) {
|
|
|
12365
14670
|
if (book === void 0) return ok("这本书还没有书级规划数据。");
|
|
12366
14671
|
return ok([
|
|
12367
14672
|
`蓝图:${book.premise === "" ? "(空)" : book.premise}`,
|
|
12368
|
-
`大纲:${book.outline.total} 张卡 ·
|
|
14673
|
+
`大纲:${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
14674
|
`当前章卡:${book.outline.current === null ? "(无)" : `第${book.outline.current.num}章 ${book.outline.current.title} / ${book.outline.current.scene_type}`}`,
|
|
12370
14675
|
`世界观:${book.worldbook.characters} 角色 · ${book.worldbook.locations} 地点 · ${book.worldbook.factions} 势力 · ${book.worldbook.rules} 规则`
|
|
12371
14676
|
].join("\n"));
|
|
@@ -12411,6 +14716,78 @@ function createCommands(engine) {
|
|
|
12411
14716
|
const { data } = await engine.export({ kind });
|
|
12412
14717
|
return ok(`已导出 ${data.kind}:${data.files.join(", ")}`);
|
|
12413
14718
|
}
|
|
14719
|
+
case "projects": {
|
|
14720
|
+
const list = await engine.listProjects();
|
|
14721
|
+
let current;
|
|
14722
|
+
try {
|
|
14723
|
+
const selection = await engine.currentProject();
|
|
14724
|
+
current = selection.bound ? selection.selection?.root : void 0;
|
|
14725
|
+
} catch {}
|
|
14726
|
+
const lines = [
|
|
14727
|
+
`项目 ${list.projects.length} 个${list.active === null ? "" : ` · 面板默认 ${list.active}`}`,
|
|
14728
|
+
...list.projects.map((project) => {
|
|
14729
|
+
const marks = [
|
|
14730
|
+
...project.root === current ? ["*"] : [],
|
|
14731
|
+
...project.root === list.active ? ["P"] : [],
|
|
14732
|
+
...project.state === "ok" ? [] : ["!"]
|
|
14733
|
+
].join("");
|
|
14734
|
+
const state = project.state === "ok" ? "" : ` [${project.state}]`;
|
|
14735
|
+
return `${marks === "" ? " " : marks} 《${project.title}》 — ${project.root}${state}`;
|
|
14736
|
+
}),
|
|
14737
|
+
"* 当前会话 · P 面板默认 · ! 目录缺失/待修复"
|
|
14738
|
+
];
|
|
14739
|
+
return ok(lines.join("\n"));
|
|
14740
|
+
}
|
|
14741
|
+
case "current": {
|
|
14742
|
+
const current = await engine.currentProject();
|
|
14743
|
+
if (current.selection === null) return ok(`当前会话未绑定;也没有可唯一推导的项目。${current.error === void 0 ? "" : `\n${current.error}`}\n运行 /novel use <path|title|.> 选择项目。`);
|
|
14744
|
+
return ok(current.bound ? [
|
|
14745
|
+
`当前会话绑定《${current.selection.title ?? ""}》${current.state === void 0 || current.state === "ok" ? "" : ` [${current.state}]`}`,
|
|
14746
|
+
`项目根 ${current.selection.root}`,
|
|
14747
|
+
...current.state === "missing" ? ["项目目录不在了;绑定保留,恢复目录后可继续,或 /novel unbind 后重选。"] : current.state === "not-project" ? ["目录存在,但没有 novel.yaml 或项目状态;请恢复项目定义或重新选择。"] : current.state === "damaged" ? ["项目定义不可读;先用显式 project_root 的 novel_amend 修复。"] : current.state === "unreadable" ? ["项目目录不可读;修复权限或 IO 错误后再试。"] : []
|
|
14748
|
+
].join("\n") : `当前会话未绑定;将按 ${current.selection.source} 推导到 ${current.selection.root}\n要固定选择,运行 /novel use ${current.selection.root}`);
|
|
14749
|
+
}
|
|
14750
|
+
case "use": {
|
|
14751
|
+
const target = raw.trim().slice(3).trim();
|
|
14752
|
+
if (target === "") return bad("用法:/novel use <path|title|.>");
|
|
14753
|
+
const result = await engine.useProject({ target });
|
|
14754
|
+
return ok(`当前会话已切换到《${result.title}》\n项目根 ${result.selection.root}\n面板 active 未改变;后续工具调用会默认使用这本书。`);
|
|
14755
|
+
}
|
|
14756
|
+
case "unbind": {
|
|
14757
|
+
const result = await engine.unbindProject();
|
|
14758
|
+
return ok(result.cleared ? "已清除当前会话绑定;项目和注册表未改动。" : "当前会话没有绑定;项目和注册表未改动。");
|
|
14759
|
+
}
|
|
14760
|
+
case "scan": {
|
|
14761
|
+
const flags = rest.filter((part) => part.startsWith("--"));
|
|
14762
|
+
const positional = rest.filter((part) => !part.startsWith("--"));
|
|
14763
|
+
if (flags.filter((flag) => flag !== "--register").length > 0 || positional.length > 0) return bad("用法:/novel scan [--register];scan 不接受路径参数");
|
|
14764
|
+
const cwd = invocation.cwd;
|
|
14765
|
+
if (cwd === void 0 || cwd === "") return bad("请在项目工作区中执行 /novel scan");
|
|
14766
|
+
const register = flags.includes("--register");
|
|
14767
|
+
const result = await engine.scanProjects({
|
|
14768
|
+
cwd,
|
|
14769
|
+
register
|
|
14770
|
+
});
|
|
14771
|
+
const lines = [`扫描到 ${result.candidates.length} 个候选;${result.candidates.filter((row) => row.registered).length} 个已登记${result.truncated ? "(只显示前 50 个,请缩小工作区)" : ""}`, ...result.candidates.map((candidate) => {
|
|
14772
|
+
const state = candidate.state === "ok" ? "" : ` [${candidate.state}]`;
|
|
14773
|
+
const registered = candidate.registered ? " 已登记" : "";
|
|
14774
|
+
const identity = candidate.identity_changed === true ? " ! identity_changed" : "";
|
|
14775
|
+
return `- 《${candidate.title}》 — ${candidate.root}${state}${registered}${identity}`;
|
|
14776
|
+
})];
|
|
14777
|
+
if (result.candidates.length === 0) lines.push("未发现项目;检查当前目录,或用 novel_init 创建第一本书。");
|
|
14778
|
+
if (register) {
|
|
14779
|
+
lines.push(`登记结果:新增 ${result.added.length} · 已存在 ${result.already.length} · 跳过 ${result.skipped.length}`);
|
|
14780
|
+
if (result.identity_changed.length > 0) lines.push(`identity_changed:${result.identity_changed.join(", ")};这些行没有被覆盖,请用显式 register/activate 确认替换。`);
|
|
14781
|
+
for (const skipped of result.skipped) lines.push(`跳过 ${skipped.root}:${skipped.reason}`);
|
|
14782
|
+
lines.push("登记不等于选择,写作请用 /novel use 或 project_root。");
|
|
14783
|
+
}
|
|
14784
|
+
return ok(lines.join("\n"));
|
|
14785
|
+
}
|
|
14786
|
+
case "resume": {
|
|
14787
|
+
const { data } = await engine.resumePrompt({});
|
|
14788
|
+
if (data.text === null) return bad("当前项目没有可续写的下一步;先用 /novel status 查看项目状态。");
|
|
14789
|
+
return ok(data.text);
|
|
14790
|
+
}
|
|
12414
14791
|
case "admin": {
|
|
12415
14792
|
if (rest.length === 0) return ok(adminHelp());
|
|
12416
14793
|
const text = raw.trim();
|
|
@@ -12427,12 +14804,27 @@ function createCommands(engine) {
|
|
|
12427
14804
|
return failure(error);
|
|
12428
14805
|
}
|
|
12429
14806
|
};
|
|
14807
|
+
const withInvocation = async (invocation, task) => {
|
|
14808
|
+
const caller = {};
|
|
14809
|
+
if (typeof invocation.agent?.id === "string" && invocation.agent.id !== "") caller.sessionId = invocation.agent.id;
|
|
14810
|
+
if (invocation.cwd !== void 0 && invocation.cwd !== "") caller.cwd = invocation.cwd;
|
|
14811
|
+
else if (invocation.agent !== void 0 && host.workingDirectory !== void 0) try {
|
|
14812
|
+
caller.cwd = await host.workingDirectory.ensure(invocation.agent, invocation.signal);
|
|
14813
|
+
} catch {}
|
|
14814
|
+
const enriched = {
|
|
14815
|
+
...invocation,
|
|
14816
|
+
...caller.cwd === void 0 ? {} : { cwd: caller.cwd }
|
|
14817
|
+
};
|
|
14818
|
+
const run = () => task(enriched);
|
|
14819
|
+
const withCaller = engine.withCaller;
|
|
14820
|
+
return typeof withCaller === "function" ? withCaller.call(engine, caller, run) : run();
|
|
14821
|
+
};
|
|
12430
14822
|
return [
|
|
12431
14823
|
{
|
|
12432
14824
|
name: "novel",
|
|
12433
14825
|
description: "Novel engine: status, next step, stats, export and admin help.",
|
|
12434
|
-
input: { hint: "[status|next|stats|export <kind>|admin]" },
|
|
12435
|
-
handler: (invocation) => dispatch(invocation
|
|
14826
|
+
input: { hint: "[status|next|stats|projects|current|use|unbind|scan|resume|export <kind>|admin]" },
|
|
14827
|
+
handler: (invocation) => dispatch(invocation)
|
|
12436
14828
|
},
|
|
12437
14829
|
{
|
|
12438
14830
|
name: "novel-next",
|
|
@@ -12454,7 +14846,7 @@ function createCommands(engine) {
|
|
|
12454
14846
|
if (invocation.signal.aborted) return bad("已取消");
|
|
12455
14847
|
try {
|
|
12456
14848
|
const stats = await engine.stats({});
|
|
12457
|
-
return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.
|
|
14849
|
+
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
14850
|
} catch (error) {
|
|
12459
14851
|
return failure(error);
|
|
12460
14852
|
}
|
|
@@ -12501,7 +14893,10 @@ function createCommands(engine) {
|
|
|
12501
14893
|
}
|
|
12502
14894
|
}
|
|
12503
14895
|
}
|
|
12504
|
-
]
|
|
14896
|
+
].map((command) => ({
|
|
14897
|
+
...command,
|
|
14898
|
+
handler: (invocation) => withInvocation(invocation, command.handler)
|
|
14899
|
+
}));
|
|
12505
14900
|
}
|
|
12506
14901
|
// src/skills/index.ts
|
|
12507
14902
|
/** Read once at module load; `apply()` itself performs no IO. */
|
|
@@ -12517,13 +14912,22 @@ function createSkill() {
|
|
|
12517
14912
|
};
|
|
12518
14913
|
}
|
|
12519
14914
|
// src/remote/index.ts
|
|
12520
|
-
/** Build the payload
|
|
12521
|
-
async function panelPayload(engine) {
|
|
14915
|
+
/** Build the payload for one optional panel query root. */
|
|
14916
|
+
async function panelPayload(engine, root) {
|
|
12522
14917
|
return {
|
|
12523
|
-
|
|
12524
|
-
|
|
14918
|
+
...await engine.panelBundle(root === void 0 ? {} : { root }),
|
|
14919
|
+
schemaVersion: 4
|
|
12525
14920
|
};
|
|
12526
14921
|
}
|
|
14922
|
+
const queryRoot = (url) => {
|
|
14923
|
+
if (url === void 0 || url === "") return void 0;
|
|
14924
|
+
try {
|
|
14925
|
+
const value = new URL(url, "http://panel.local").searchParams.get("root");
|
|
14926
|
+
return value === null || value.trim() === "" ? void 0 : value;
|
|
14927
|
+
} catch {
|
|
14928
|
+
return;
|
|
14929
|
+
}
|
|
14930
|
+
};
|
|
12527
14931
|
const denyAll = () => 503;
|
|
12528
14932
|
const rejectionText = {
|
|
12529
14933
|
401: "unauthorized",
|
|
@@ -12550,7 +14954,7 @@ function panelHandler(engine, guard = denyAll) {
|
|
|
12550
14954
|
return;
|
|
12551
14955
|
}
|
|
12552
14956
|
try {
|
|
12553
|
-
const payload = await panelPayload(engine);
|
|
14957
|
+
const payload = await panelPayload(engine, queryRoot(request.url));
|
|
12554
14958
|
const body = method === "HEAD" ? "" : JSON.stringify(payload);
|
|
12555
14959
|
response.statusCode = 200;
|
|
12556
14960
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
@@ -12562,7 +14966,12 @@ function panelHandler(engine, guard = denyAll) {
|
|
|
12562
14966
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
12563
14967
|
response.setHeader("cache-control", "no-store");
|
|
12564
14968
|
response.end(JSON.stringify({
|
|
12565
|
-
schemaVersion:
|
|
14969
|
+
schemaVersion: 4,
|
|
14970
|
+
projects: [],
|
|
14971
|
+
selection: {
|
|
14972
|
+
root: null,
|
|
14973
|
+
source: "none"
|
|
14974
|
+
},
|
|
12566
14975
|
error: "还没有项目:调用 novel_init 创建。",
|
|
12567
14976
|
empty: true
|
|
12568
14977
|
}));
|
|
@@ -12572,7 +14981,12 @@ function panelHandler(engine, guard = denyAll) {
|
|
|
12572
14981
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
12573
14982
|
response.setHeader("cache-control", "no-store");
|
|
12574
14983
|
response.end(JSON.stringify({
|
|
12575
|
-
schemaVersion:
|
|
14984
|
+
schemaVersion: 4,
|
|
14985
|
+
projects: [],
|
|
14986
|
+
selection: {
|
|
14987
|
+
root: null,
|
|
14988
|
+
source: "none"
|
|
14989
|
+
},
|
|
12576
14990
|
error: error instanceof Error ? error.message : String(error)
|
|
12577
14991
|
}));
|
|
12578
14992
|
}
|
|
@@ -13053,13 +15467,15 @@ function apply(ctx, config) {
|
|
|
13053
15467
|
const engine = new EngineService(engineConfig, () => loadRulepack(), { onEvent: emit });
|
|
13054
15468
|
ctx.effect(() => () => engine.dispose(), `${name}: engine dispose`);
|
|
13055
15469
|
ctx.provide?.("novelEngine", engine);
|
|
13056
|
-
|
|
15470
|
+
const serviceOf = ctx.get;
|
|
15471
|
+
const workingDirectory = typeof serviceOf === "function" ? serviceOf.call(ctx, "workingDirectory") : void 0;
|
|
15472
|
+
for (const tool of createNovelTools(engine, { workingDirectory })) {
|
|
13057
15473
|
if (toolsMode === "core" && !CORE_TOOLS.includes(tool.name)) continue;
|
|
13058
15474
|
ctx.tools.register(tool);
|
|
13059
15475
|
}
|
|
13060
15476
|
const optional = ctx;
|
|
13061
15477
|
optional.inject(["commands"], (commandsCtx) => {
|
|
13062
|
-
for (const command of createCommands(engine)) commandsCtx.effect(() => commandsCtx.commands.register(command), `${name}: command ${command.name}`);
|
|
15478
|
+
for (const command of createCommands(engine, { workingDirectory })) commandsCtx.effect(() => commandsCtx.commands.register(command), `${name}: command ${command.name}`);
|
|
13063
15479
|
});
|
|
13064
15480
|
optional.inject(["skills"], (skillsCtx) => {
|
|
13065
15481
|
skillsCtx.effect(() => skillsCtx.skills.register(createSkill()), `${name}: entry skill`);
|
|
@@ -13093,4 +15509,4 @@ function connectionFence(scope) {
|
|
|
13093
15509
|
}
|
|
13094
15510
|
if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
|
|
13095
15511
|
}
|
|
13096
|
-
export { BriefLibrary, Config, ERROR_CODES, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, OPERATIONS, PLATFORMS, SCENE_TYPES, SCHEMA_VERSION, applicableGates, apply, blockedError, briefStageFor, chapterHeading, evaluateGates, faultError, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, verifyReference };
|
|
15512
|
+
export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
|