dsh-novel-write-engine 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +38 -31
- package/briefs/P4.md +1 -1
- package/briefs/P5.md +1 -1
- package/briefs/P6.md +2 -2
- package/lib/index.d.ts +22 -3
- package/lib/index.js +413 -188
- package/lib/types/types.d.ts +5 -0
- package/package.json +3 -2
- package/skills/entry.md +1 -1
package/lib/index.js
CHANGED
|
@@ -350,7 +350,7 @@ function applyExclusions(dimensions, exclusions) {
|
|
|
350
350
|
const canonical = (location) => {
|
|
351
351
|
if (typeof location !== "string") return "";
|
|
352
352
|
const paragraph = /第\s*(\d+)\s*段/.exec(location);
|
|
353
|
-
return paragraph === null ? location.trim() : `paragraph:${paragraph[1]}`;
|
|
353
|
+
return paragraph === null ? location.trim() : `paragraph:${Number(paragraph[1])}`;
|
|
354
354
|
};
|
|
355
355
|
const deductionsOf = (dimension) => {
|
|
356
356
|
if (typeof dimension !== "object" || dimension === null) return [];
|
|
@@ -648,27 +648,28 @@ function nextStage(stage, op, openBlockers) {
|
|
|
648
648
|
function availableOps(stage) {
|
|
649
649
|
return TRANSITIONS.filter((transition) => transition.from === stage).map((transition) => transition.op);
|
|
650
650
|
}
|
|
651
|
-
/** Whether
|
|
652
|
-
function roundsExhausted(rounds) {
|
|
651
|
+
/** Whether one retry budget (or, without a counter, any budget) is spent. */
|
|
652
|
+
function roundsExhausted(rounds, counter) {
|
|
653
|
+
if (counter !== void 0) return rounds[counter] >= LIMITS.maxRounds;
|
|
653
654
|
return rounds.review >= LIMITS.maxRounds || rounds.revise >= LIMITS.maxRounds || rounds.check >= LIMITS.maxRounds;
|
|
654
655
|
}
|
|
655
656
|
/** The operation a stage expects next, used by `advise` and the status `next` block. */
|
|
656
657
|
const STAGE_NEXT = {
|
|
657
658
|
idle: {
|
|
658
659
|
tool: "novel_plan_world",
|
|
659
|
-
reason: "
|
|
660
|
+
reason: "submit the world plan"
|
|
660
661
|
},
|
|
661
662
|
P2: {
|
|
662
663
|
tool: "novel_plan_chapter",
|
|
663
|
-
reason: "
|
|
664
|
+
reason: "submit the chapter plan"
|
|
664
665
|
},
|
|
665
666
|
P3: {
|
|
666
667
|
tool: "novel_write_draft",
|
|
667
|
-
reason: "
|
|
668
|
+
reason: "submit the draft"
|
|
668
669
|
},
|
|
669
670
|
P4: {
|
|
670
671
|
tool: "novel_review",
|
|
671
|
-
reason: "
|
|
672
|
+
reason: "submit the scored review"
|
|
672
673
|
},
|
|
673
674
|
P5: {
|
|
674
675
|
tool: "novel_revise",
|
|
@@ -676,11 +677,11 @@ const STAGE_NEXT = {
|
|
|
676
677
|
},
|
|
677
678
|
P6: {
|
|
678
679
|
tool: "novel_check",
|
|
679
|
-
reason: "run the consistency check
|
|
680
|
+
reason: "run the consistency check"
|
|
680
681
|
},
|
|
681
682
|
ready_to_archive: {
|
|
682
|
-
tool: "
|
|
683
|
-
reason: "all gates pass;
|
|
683
|
+
tool: "novel_archive",
|
|
684
|
+
reason: "all gates pass; archive the chapter with its state_delta"
|
|
684
685
|
},
|
|
685
686
|
done: {
|
|
686
687
|
tool: "novel_plan_world",
|
|
@@ -1063,7 +1064,23 @@ const duplicateParagraph = (ctx) => {
|
|
|
1063
1064
|
const previous = ctx.evidence.previousTexts.length > 0 ? ctx.evidence.previousTexts : ctx.evidence.previousText === null ? [] : [ctx.evidence.previousText];
|
|
1064
1065
|
if (previous.length === 0) return [];
|
|
1065
1066
|
const minChars = ctx.rulepack.crosschapter.duplicate_min_chars;
|
|
1066
|
-
const longParagraphs = (text) =>
|
|
1067
|
+
const longParagraphs = (text) => {
|
|
1068
|
+
const units = [];
|
|
1069
|
+
let buffer = "";
|
|
1070
|
+
for (const paragraph of splitParagraphs(text)) {
|
|
1071
|
+
buffer += paragraph;
|
|
1072
|
+
if (Array.from(buffer.replace(/\s+/g, "")).length >= minChars) {
|
|
1073
|
+
units.push(buffer);
|
|
1074
|
+
buffer = "";
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (buffer !== "") {
|
|
1078
|
+
if (units.length === 0) {
|
|
1079
|
+
if (Array.from(buffer.replace(/\s+/g, "")).length >= minChars) units.push(buffer);
|
|
1080
|
+
} else units[units.length - 1] += buffer;
|
|
1081
|
+
}
|
|
1082
|
+
return units.join("\n\n");
|
|
1083
|
+
};
|
|
1067
1084
|
const best = maxParagraphSimilarity(longParagraphs(ctx.text), previous.map((text) => longParagraphs(text)).filter((text) => text !== ""));
|
|
1068
1085
|
if (best < ctx.rulepack.crosschapter.duplicate_advisory) return [];
|
|
1069
1086
|
const severity = best >= ctx.rulepack.crosschapter.duplicate_similarity ? "blocker" : "advisory";
|
|
@@ -1340,7 +1357,7 @@ function resolveInside(root, relativePath) {
|
|
|
1340
1357
|
const absoluteRoot = resolve(root);
|
|
1341
1358
|
const absolute = resolve(absoluteRoot, relativePath);
|
|
1342
1359
|
const inside = relative(absoluteRoot, absolute);
|
|
1343
|
-
if (inside === "" || inside.startsWith(
|
|
1360
|
+
if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": escapes project root`);
|
|
1344
1361
|
return absolute;
|
|
1345
1362
|
}
|
|
1346
1363
|
// src/store/migrate.ts
|
|
@@ -1415,6 +1432,7 @@ const SCHEMAS = {
|
|
|
1415
1432
|
rounds: req("object"),
|
|
1416
1433
|
briefs: req("object"),
|
|
1417
1434
|
blocking: req("array"),
|
|
1435
|
+
review_digest: f("string"),
|
|
1418
1436
|
last_action: f("any")
|
|
1419
1437
|
},
|
|
1420
1438
|
chapters: { chapters: req("array") },
|
|
@@ -1726,10 +1744,11 @@ function narrow(kind, value) {
|
|
|
1726
1744
|
if (typeof version !== "number" || !Number.isInteger(version)) return invalid$1(kind, "missing schema_version");
|
|
1727
1745
|
if (version > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `refusing ${kind}: schema_version ${version} is newer than supported 1`, "upgrade the plugin; the file was left untouched");
|
|
1728
1746
|
if (version !== 1) invalid$1(kind, `unsupported schema_version ${version}`);
|
|
1747
|
+
if (!Object.hasOwn(SCHEMAS, kind)) invalid$1(kind, `unknown document kind "${String(kind)}"`);
|
|
1729
1748
|
const schema = SCHEMAS[kind];
|
|
1730
1749
|
for (const key of Object.keys(record)) {
|
|
1731
1750
|
if (key === "schema_version") continue;
|
|
1732
|
-
if (!(key
|
|
1751
|
+
if (!Object.hasOwn(schema, key)) invalid$1(kind, `unknown key "${key}"`);
|
|
1733
1752
|
}
|
|
1734
1753
|
for (const [key, field] of Object.entries(schema)) {
|
|
1735
1754
|
const fieldValue = record[key];
|
|
@@ -1836,7 +1855,12 @@ var FsStore = class {
|
|
|
1836
1855
|
async readDoc(relativePath, kind) {
|
|
1837
1856
|
const text = await this.readText(relativePath);
|
|
1838
1857
|
if (text === null) return null;
|
|
1839
|
-
|
|
1858
|
+
let parsed;
|
|
1859
|
+
try {
|
|
1860
|
+
parsed = parse(text);
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${relativePath} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix the YAML syntax; the engine will not rewrite a document it cannot parse", { cause: error });
|
|
1863
|
+
}
|
|
1840
1864
|
return narrow(kind, (typeof parsed === "object" && parsed !== null && typeof parsed.schema_version === "number" ? parsed.schema_version : 1) < 1 ? migrateDocument(parsed, MIGRATIONS, 1) : parsed);
|
|
1841
1865
|
}
|
|
1842
1866
|
/** Write text atomically: temp file, fsync, rename, directory fsync. */
|
|
@@ -2080,6 +2104,9 @@ async function recoverPending(store) {
|
|
|
2080
2104
|
/** Throw before a write when the journal or the last commit is inconsistent. */
|
|
2081
2105
|
async function assertJournalConsistent(store) {
|
|
2082
2106
|
await recoverPending(store);
|
|
2107
|
+
const { records } = await readJournal(store);
|
|
2108
|
+
const committed = new Set(records.filter((record) => record.status === "committed" && record.op !== "lock_reclaimed").map((record) => record.txid));
|
|
2109
|
+
for (const entry of await store.list(STAGING_DIR)) if (committed.has(entry)) await store.removeTree(joinRel(STAGING_DIR, entry)).catch(() => void 0);
|
|
2083
2110
|
const { malformed, mismatches } = await reconcileJournal(store);
|
|
2084
2111
|
assertJournalHealthy(malformed);
|
|
2085
2112
|
if (mismatches.length > 0) {
|
|
@@ -2113,6 +2140,8 @@ var KeyedMutex = class {
|
|
|
2113
2140
|
return this.tails.size;
|
|
2114
2141
|
}
|
|
2115
2142
|
};
|
|
2143
|
+
/** Nonces this process currently holds; same-pid locks not in this set are stale. */
|
|
2144
|
+
const liveNonces = /* @__PURE__ */ new Set();
|
|
2116
2145
|
const isLockRecord = (value) => typeof value === "object" && value !== null && typeof value.pid === "number" && typeof value.startedAt === "string" && (value.nonce === void 0 || typeof value.nonce === "string");
|
|
2117
2146
|
const processAlive = (pid) => {
|
|
2118
2147
|
if (pid === process.pid) return true;
|
|
@@ -2155,6 +2184,7 @@ var FileLock = class {
|
|
|
2155
2184
|
}
|
|
2156
2185
|
await handle.close();
|
|
2157
2186
|
this.held = record;
|
|
2187
|
+
if (record.nonce !== void 0) liveNonces.add(record.nonce);
|
|
2158
2188
|
return;
|
|
2159
2189
|
} catch (error) {
|
|
2160
2190
|
if (error.code !== "EEXIST") throw error;
|
|
@@ -2168,7 +2198,8 @@ var FileLock = class {
|
|
|
2168
2198
|
const record = await this.read();
|
|
2169
2199
|
const held = this.held;
|
|
2170
2200
|
this.held = null;
|
|
2171
|
-
if (
|
|
2201
|
+
if (held?.nonce !== void 0) liveNonces.delete(held.nonce);
|
|
2202
|
+
if (record !== null && held !== null && record.pid === held.pid && record.nonce === held.nonce) await promises.rm(this.absolutePath, { force: true }).catch(() => void 0);
|
|
2172
2203
|
}
|
|
2173
2204
|
/** Read the current lock record, or null when absent/held by a foreign process. */
|
|
2174
2205
|
async read() {
|
|
@@ -2199,30 +2230,25 @@ var FileLock = class {
|
|
|
2199
2230
|
}
|
|
2200
2231
|
if (record === null) {
|
|
2201
2232
|
if (Date.now() - mtimeMs <= this.staleMs) return false;
|
|
2202
|
-
} else {
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
}
|
|
2233
|
+
} else if (record.pid === process.pid) {
|
|
2234
|
+
if (record.nonce !== void 0 && liveNonces.has(record.nonce)) return false;
|
|
2235
|
+
} else if (processAlive(record.pid)) return false;
|
|
2206
2236
|
const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
|
|
2207
2237
|
try {
|
|
2208
|
-
await promises.
|
|
2238
|
+
await promises.link(this.absolutePath, claimPath);
|
|
2209
2239
|
} catch (error) {
|
|
2210
2240
|
if (error.code === "ENOENT") return true;
|
|
2211
2241
|
throw error;
|
|
2212
2242
|
}
|
|
2213
|
-
|
|
2214
|
-
moved = await this.readAt(claimPath);
|
|
2243
|
+
const moved = await this.readAt(claimPath);
|
|
2215
2244
|
const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
|
|
2216
2245
|
if (!(record === null ? moved === null && Date.now() - claimMtime > this.staleMs : moved !== null && moved.pid === record.pid && moved.startedAt === record.startedAt && moved.nonce === record.nonce)) {
|
|
2217
|
-
|
|
2218
|
-
await promises.link(claimPath, this.absolutePath);
|
|
2219
|
-
await promises.rm(claimPath, { force: true });
|
|
2220
|
-
} catch {
|
|
2221
|
-
await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
2222
|
-
}
|
|
2246
|
+
await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
2223
2247
|
return false;
|
|
2224
2248
|
}
|
|
2225
|
-
await
|
|
2249
|
+
const current = await this.readAt(this.absolutePath);
|
|
2250
|
+
if (record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce) await promises.rm(this.absolutePath, { force: true }).catch(() => void 0);
|
|
2251
|
+
await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
2226
2252
|
await this.onReclaim?.(record);
|
|
2227
2253
|
return true;
|
|
2228
2254
|
}
|
|
@@ -2294,6 +2320,12 @@ async function restoreTargets(store, renamed, before) {
|
|
|
2294
2320
|
* that terminal row).
|
|
2295
2321
|
*/
|
|
2296
2322
|
async function runTransaction(store, input) {
|
|
2323
|
+
const staged = /* @__PURE__ */ new Set();
|
|
2324
|
+
for (const file of input.files) {
|
|
2325
|
+
if (staged.has(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `transaction ${input.txid} stages ${file.path} more than once`, "each target may appear once per transaction; merge the writes before staging");
|
|
2326
|
+
if (file.text !== void 0 && file.bytes !== void 0) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `transaction ${input.txid} stages both text and bytes for ${file.path}`, "choose one representation per staged file");
|
|
2327
|
+
staged.add(file.path);
|
|
2328
|
+
}
|
|
2297
2329
|
const staging = stagingDir(input.txid);
|
|
2298
2330
|
const expected = /* @__PURE__ */ new Map();
|
|
2299
2331
|
const before = /* @__PURE__ */ new Map();
|
|
@@ -2416,8 +2448,13 @@ var ProjectRegistry = class {
|
|
|
2416
2448
|
async read() {
|
|
2417
2449
|
const text = await this.store.readText(REGISTRY_FILE);
|
|
2418
2450
|
if (text === null) return empty();
|
|
2419
|
-
|
|
2420
|
-
|
|
2451
|
+
let raw;
|
|
2452
|
+
try {
|
|
2453
|
+
raw = parse(text);
|
|
2454
|
+
} catch (error) {
|
|
2455
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix or delete the registry file; projects on disk are unaffected", { cause: error });
|
|
2456
|
+
}
|
|
2457
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
|
|
2421
2458
|
const record = raw;
|
|
2422
2459
|
if (record.schema_version !== 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} has an unsupported schema_version`);
|
|
2423
2460
|
return {
|
|
@@ -2786,11 +2823,7 @@ var BriefLibrary = class {
|
|
|
2786
2823
|
};
|
|
2787
2824
|
}
|
|
2788
2825
|
};
|
|
2789
|
-
const hash = (text) => {
|
|
2790
|
-
let value = 0;
|
|
2791
|
-
for (let index = 0; index < text.length; index += 1) value = Math.imul(31, value) + text.charCodeAt(index) | 0;
|
|
2792
|
-
return `fnv:${(value >>> 0).toString(16)}`;
|
|
2793
|
-
};
|
|
2826
|
+
const hash = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
2794
2827
|
// src/engine/context.ts
|
|
2795
2828
|
const compactSummary = (row) => ({
|
|
2796
2829
|
num: row.num,
|
|
@@ -2825,7 +2858,8 @@ function briefContext(input) {
|
|
|
2825
2858
|
chapter: input.chapter,
|
|
2826
2859
|
stage: input.pipeline.stage,
|
|
2827
2860
|
scene_distribution: sceneDistribution(input.chapters),
|
|
2828
|
-
previous_ending: previousEnding
|
|
2861
|
+
previous_ending: previousEnding,
|
|
2862
|
+
...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error }
|
|
2829
2863
|
};
|
|
2830
2864
|
switch (input.stage) {
|
|
2831
2865
|
case "P1": return {
|
|
@@ -2911,6 +2945,15 @@ const ACTIONS = [
|
|
|
2911
2945
|
"no_change"
|
|
2912
2946
|
];
|
|
2913
2947
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2948
|
+
/** Define an own property even for names such as `__proto__` or `constructor`. */
|
|
2949
|
+
const setOwn = (record, key, value) => {
|
|
2950
|
+
Object.defineProperty(record, key, {
|
|
2951
|
+
value,
|
|
2952
|
+
enumerable: true,
|
|
2953
|
+
configurable: true,
|
|
2954
|
+
writable: true
|
|
2955
|
+
});
|
|
2956
|
+
};
|
|
2914
2957
|
function fail$1(detail) {
|
|
2915
2958
|
throw new EngineInputError(ERROR_CODES.DELTA_UNVERIFIED, `state_delta is incomplete: ${detail}`, "declare every entity the chapter touched as created, updated, or no_change with a reason");
|
|
2916
2959
|
}
|
|
@@ -2961,7 +3004,7 @@ function applyStateDelta(input) {
|
|
|
2961
3004
|
const foreshadows = structuredClone(input.foreshadows);
|
|
2962
3005
|
const timeline = structuredClone(input.timeline);
|
|
2963
3006
|
const relationships = structuredClone(input.relationships);
|
|
2964
|
-
const assigned =
|
|
3007
|
+
const assigned = Object.create(null);
|
|
2965
3008
|
const appeared = [...new Set(summary.characters_appeared)];
|
|
2966
3009
|
const byName = new Map(characters.characters.map((character) => [character.name, character]));
|
|
2967
3010
|
const narrator = input.narrator ?? "";
|
|
@@ -2973,7 +3016,7 @@ function applyStateDelta(input) {
|
|
|
2973
3016
|
};
|
|
2974
3017
|
for (const name of appeared) {
|
|
2975
3018
|
if (!appearsInManuscript(name)) fail$1(`character "${name}" is listed in characters_appeared but does not appear in the manuscript`);
|
|
2976
|
-
if (delta.characters
|
|
3019
|
+
if (!Object.hasOwn(delta.characters, name)) fail$1(`character "${name}" appeared in the chapter but is not declared in characters`);
|
|
2977
3020
|
}
|
|
2978
3021
|
for (const [name, raw] of Object.entries(delta.characters)) {
|
|
2979
3022
|
const declared = entry(raw, `characters.${name}`);
|
|
@@ -3020,7 +3063,7 @@ function applyStateDelta(input) {
|
|
|
3020
3063
|
}
|
|
3021
3064
|
for (const change of summary.resource_changes) {
|
|
3022
3065
|
if (!isRecord(change) || typeof change.character !== "string" || change.character.trim() === "" || typeof change.resource !== "string" || change.resource.trim() === "" || !Number.isInteger(change.delta) || typeof change.reason !== "string" || change.reason.trim() === "") fail$1("every resource_changes entry must declare character/resource/delta/reason");
|
|
3023
|
-
if (delta.characters
|
|
3066
|
+
if (!Object.hasOwn(delta.characters, change.character)) fail$1(`resource change for "${change.character}" has no character declaration`);
|
|
3024
3067
|
if (delta.characters[change.character].action === "no_change") fail$1(`resource change for "${change.character}" conflicts with a no_change declaration`);
|
|
3025
3068
|
const character = byName.get(change.character);
|
|
3026
3069
|
if (character === void 0) fail$1(`resource change names unknown character "${change.character}"`);
|
|
@@ -3067,8 +3110,8 @@ function applyStateDelta(input) {
|
|
|
3067
3110
|
["artifact", summary.new_elements.artifacts]
|
|
3068
3111
|
]) for (const name of names) {
|
|
3069
3112
|
if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
|
|
3113
|
+
if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
|
|
3070
3114
|
const declared = delta.world[name];
|
|
3071
|
-
if (declared === void 0) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
|
|
3072
3115
|
if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
|
|
3073
3116
|
if (declared.kind !== void 0 && declared.kind !== kind) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${kind}`);
|
|
3074
3117
|
}
|
|
@@ -3086,9 +3129,19 @@ function applyStateDelta(input) {
|
|
|
3086
3129
|
}
|
|
3087
3130
|
const existing = findWorld(name);
|
|
3088
3131
|
if (declared.action === "created") {
|
|
3132
|
+
if (name.trim() === "") fail$1("a created world entity has an empty name");
|
|
3089
3133
|
if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
|
|
3134
|
+
if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
|
|
3090
3135
|
const kind = declared.kind ?? expected;
|
|
3091
3136
|
if (kind === null) fail$1(`world.${name} must declare kind (location|ability|artifact|faction|rule)`);
|
|
3137
|
+
if (![
|
|
3138
|
+
"location",
|
|
3139
|
+
"ability",
|
|
3140
|
+
"artifact",
|
|
3141
|
+
"faction",
|
|
3142
|
+
"rule"
|
|
3143
|
+
].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
|
|
3144
|
+
if (expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
|
|
3092
3145
|
if (kind === "location") world.locations.push({
|
|
3093
3146
|
name,
|
|
3094
3147
|
first_appearance: chapter,
|
|
@@ -3099,10 +3152,10 @@ function applyStateDelta(input) {
|
|
|
3099
3152
|
changes: []
|
|
3100
3153
|
});
|
|
3101
3154
|
else world[kind === "ability" ? "abilities" : kind === "artifact" ? "artifacts" : kind === "faction" ? "factions" : "rules"].push({
|
|
3155
|
+
...declared.detail ?? {},
|
|
3102
3156
|
name,
|
|
3103
3157
|
first_appearance: chapter,
|
|
3104
|
-
description: declared.description ?? ""
|
|
3105
|
-
...declared.detail ?? {}
|
|
3158
|
+
description: declared.description ?? ""
|
|
3106
3159
|
});
|
|
3107
3160
|
continue;
|
|
3108
3161
|
}
|
|
@@ -3124,35 +3177,45 @@ function applyStateDelta(input) {
|
|
|
3124
3177
|
}
|
|
3125
3178
|
}
|
|
3126
3179
|
}
|
|
3127
|
-
for (const name of appeared) if (locks.characters
|
|
3180
|
+
for (const name of appeared) if (!Object.hasOwn(locks.characters, name)) {
|
|
3128
3181
|
const character = byName.get(name);
|
|
3129
|
-
locks.characters
|
|
3182
|
+
setOwn(locks.characters, name, {
|
|
3130
3183
|
first_appearance: character.first_appearance,
|
|
3131
3184
|
locked_at: chapter,
|
|
3132
3185
|
appearance: appearanceOf(input.text, name, name === narratorName ? narratorAliases : [])
|
|
3133
|
-
};
|
|
3186
|
+
});
|
|
3134
3187
|
}
|
|
3135
|
-
for (const location of world.locations) if (locks.locations
|
|
3188
|
+
for (const location of world.locations) if (!Object.hasOwn(locks.locations, location.name)) setOwn(locks.locations, location.name, {
|
|
3136
3189
|
first_appearance: location.first_appearance,
|
|
3137
3190
|
locked_at: chapter,
|
|
3138
3191
|
layout: location.layout,
|
|
3139
3192
|
capacity: location.capacity
|
|
3140
|
-
};
|
|
3193
|
+
});
|
|
3141
3194
|
locks.locked_at_chapter = Math.max(locks.locked_at_chapter, chapter);
|
|
3142
3195
|
const byId = new Map(foreshadows.items.map((item) => [item.id, item]));
|
|
3143
3196
|
const plantedKeys = /* @__PURE__ */ new Set();
|
|
3144
3197
|
for (const plant of summary.foreshadows.planted) {
|
|
3145
3198
|
if (plant.key.trim() === "") fail$1("a planted foreshadow has an empty key");
|
|
3199
|
+
if (byId.has(plant.key)) fail$1(`foreshadow key "${plant.key}" collides with an existing id`);
|
|
3146
3200
|
if (plantedKeys.has(plant.key)) fail$1(`foreshadow key "${plant.key}" is planted twice`);
|
|
3147
3201
|
plantedKeys.add(plant.key);
|
|
3202
|
+
if (!Object.hasOwn(delta.foreshadows, plant.key)) fail$1(`foreshadows.${plant.key} is not declared`);
|
|
3148
3203
|
const declared = entry(delta.foreshadows[plant.key], `foreshadows.${plant.key}`);
|
|
3149
3204
|
requireReason(declared, `foreshadows.${plant.key}`);
|
|
3150
3205
|
if (declared.action !== "created") fail$1(`foreshadows.${plant.key} is planted; its action must be created`);
|
|
3206
|
+
if (typeof plant.title !== "string" || plant.title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty summary title`);
|
|
3207
|
+
if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" does not appear in the manuscript`);
|
|
3208
|
+
if (declared.title !== void 0 && typeof declared.title !== "string") fail$1(`foreshadows.${plant.key}.title must be a string`);
|
|
3209
|
+
const title = declared.title ?? plant.title;
|
|
3210
|
+
if (title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty title`);
|
|
3211
|
+
if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" does not appear in the manuscript`);
|
|
3151
3212
|
const allocated = allocateIds("FS", foreshadows.next_seq, 1);
|
|
3213
|
+
const id = allocated.ids[0];
|
|
3214
|
+
if (byId.has(id)) fail$1(`foreshadows.next_seq is behind the ledger; ${id} already exists`);
|
|
3152
3215
|
foreshadows.next_seq = allocated.nextSeq;
|
|
3153
3216
|
const item = {
|
|
3154
|
-
id
|
|
3155
|
-
title
|
|
3217
|
+
id,
|
|
3218
|
+
title,
|
|
3156
3219
|
planted: chapter,
|
|
3157
3220
|
last_touched: chapter,
|
|
3158
3221
|
status: "planted",
|
|
@@ -3165,12 +3228,14 @@ function applyStateDelta(input) {
|
|
|
3165
3228
|
assigned[plant.key] = item.id;
|
|
3166
3229
|
}
|
|
3167
3230
|
for (const advance of summary.foreshadows.advanced) {
|
|
3231
|
+
if (!Object.hasOwn(delta.foreshadows, advance.key)) fail$1(`foreshadows.${advance.key} is not declared`);
|
|
3168
3232
|
const declared = entry(delta.foreshadows[advance.key], `foreshadows.${advance.key}`);
|
|
3169
3233
|
requireReason(declared, `foreshadows.${advance.key}`);
|
|
3170
3234
|
if (declared.action !== "updated") fail$1(`foreshadows.${advance.key} is advanced; its action must be updated`);
|
|
3171
3235
|
const id = assigned[advance.key] ?? advance.key;
|
|
3172
3236
|
const item = byId.get(id);
|
|
3173
3237
|
if (item === void 0) fail$1(`foreshadow "${advance.key}" does not exist; no fallback is allowed`);
|
|
3238
|
+
if (item.status === "resolved") fail$1(`foreshadow "${advance.key}" is already resolved`);
|
|
3174
3239
|
item.last_touched = chapter;
|
|
3175
3240
|
item.status = "advanced";
|
|
3176
3241
|
item.advance_log.push({
|
|
@@ -3181,12 +3246,14 @@ function applyStateDelta(input) {
|
|
|
3181
3246
|
assigned[advance.key] = item.id;
|
|
3182
3247
|
}
|
|
3183
3248
|
for (const key of summary.foreshadows.resolved) {
|
|
3249
|
+
if (!Object.hasOwn(delta.foreshadows, key)) fail$1(`foreshadows.${key} is not declared`);
|
|
3184
3250
|
const declared = entry(delta.foreshadows[key], `foreshadows.${key}`);
|
|
3185
3251
|
requireReason(declared, `foreshadows.${key}`);
|
|
3186
3252
|
if (declared.action !== "updated") fail$1(`foreshadows.${key} is resolved; its action must be updated`);
|
|
3187
3253
|
const id = assigned[key] ?? key;
|
|
3188
3254
|
const item = byId.get(id);
|
|
3189
3255
|
if (item === void 0) fail$1(`foreshadow "${key}" does not exist; no fallback is allowed`);
|
|
3256
|
+
if (item.status === "resolved") fail$1(`foreshadow "${key}" is already resolved`);
|
|
3190
3257
|
item.status = "resolved";
|
|
3191
3258
|
item.last_touched = chapter;
|
|
3192
3259
|
item.resolved_at = chapter;
|
|
@@ -3218,8 +3285,9 @@ function applyStateDelta(input) {
|
|
|
3218
3285
|
const declared = entry(delta.timeline[key], `timeline.${key}`);
|
|
3219
3286
|
if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
|
|
3220
3287
|
const allocated = allocateIds("TE", timeline.next_seq, 1);
|
|
3221
|
-
timeline.next_seq = allocated.nextSeq;
|
|
3222
3288
|
const id = allocated.ids[0];
|
|
3289
|
+
if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
|
|
3290
|
+
timeline.next_seq = allocated.nextSeq;
|
|
3223
3291
|
timeline.items.push({
|
|
3224
3292
|
id,
|
|
3225
3293
|
chapter,
|
|
@@ -3246,8 +3314,9 @@ function applyStateDelta(input) {
|
|
|
3246
3314
|
if (existing !== void 0) fail$1(`relationship ${pairKey(a, b)} already exists`);
|
|
3247
3315
|
if (typeof declared.type !== "string" || declared.type.trim() === "") fail$1(`relationships.${key} is created but has no type`);
|
|
3248
3316
|
const allocated = allocateIds("RL", relationships.next_seq, 1);
|
|
3249
|
-
relationships.next_seq = allocated.nextSeq;
|
|
3250
3317
|
const id = allocated.ids[0];
|
|
3318
|
+
if (relationships.items.some((item) => item.id === id)) fail$1(`relationships.next_seq is behind the ledger; ${id} already exists`);
|
|
3319
|
+
relationships.next_seq = allocated.nextSeq;
|
|
3251
3320
|
relationships.items.push({
|
|
3252
3321
|
id,
|
|
3253
3322
|
a,
|
|
@@ -3301,6 +3370,23 @@ function evidenceQuote(value) {
|
|
|
3301
3370
|
candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
|
|
3302
3371
|
return candidate;
|
|
3303
3372
|
}
|
|
3373
|
+
/** Sentence spans, including the terminating punctuation, for evidence de-duplication. */
|
|
3374
|
+
function sentenceSpans(text) {
|
|
3375
|
+
const spans = [];
|
|
3376
|
+
let start = 0;
|
|
3377
|
+
for (let index = 0; index < text.length; index += 1) if ("。!?!?".includes(text[index])) {
|
|
3378
|
+
spans.push({
|
|
3379
|
+
start,
|
|
3380
|
+
end: index + 1
|
|
3381
|
+
});
|
|
3382
|
+
start = index + 1;
|
|
3383
|
+
}
|
|
3384
|
+
if (start < text.length) spans.push({
|
|
3385
|
+
start,
|
|
3386
|
+
end: text.length
|
|
3387
|
+
});
|
|
3388
|
+
return spans;
|
|
3389
|
+
}
|
|
3304
3390
|
/**
|
|
3305
3391
|
* Validate the model-supplied L2 semantic items. Evidence must be a real
|
|
3306
3392
|
* substring of the manuscript; a failed verdict is itself a blocking finding.
|
|
@@ -3309,6 +3395,10 @@ function evidenceQuote(value) {
|
|
|
3309
3395
|
function evaluateSemantic(text, items) {
|
|
3310
3396
|
const blocking = [];
|
|
3311
3397
|
const seen = /* @__PURE__ */ new Set();
|
|
3398
|
+
const seenQuotes = /* @__PURE__ */ new Set();
|
|
3399
|
+
const spans = [];
|
|
3400
|
+
const sentences = sentenceSpans(text);
|
|
3401
|
+
const usedSentences = /* @__PURE__ */ new Set();
|
|
3312
3402
|
for (const [index, item] of items.entries()) {
|
|
3313
3403
|
const where = `semantic[${index}]`;
|
|
3314
3404
|
if (typeof item !== "object" || item === null) {
|
|
@@ -3353,11 +3443,11 @@ function evaluateSemantic(text, items) {
|
|
|
3353
3443
|
continue;
|
|
3354
3444
|
}
|
|
3355
3445
|
const quote = evidenceQuote(item.evidence);
|
|
3356
|
-
if (Array.from(quote).length <
|
|
3446
|
+
if (Array.from(quote).length < 8) {
|
|
3357
3447
|
blocking.push({
|
|
3358
3448
|
code: "SEMANTIC_EVIDENCE_INVALID",
|
|
3359
3449
|
scope: "check",
|
|
3360
|
-
detail: `${item.id} quotes fewer than
|
|
3450
|
+
detail: `${item.id} quotes fewer than 8 characters`
|
|
3361
3451
|
});
|
|
3362
3452
|
continue;
|
|
3363
3453
|
}
|
|
@@ -3369,6 +3459,40 @@ function evaluateSemantic(text, items) {
|
|
|
3369
3459
|
});
|
|
3370
3460
|
continue;
|
|
3371
3461
|
}
|
|
3462
|
+
if (seenQuotes.has(quote)) {
|
|
3463
|
+
blocking.push({
|
|
3464
|
+
code: "SEMANTIC_EVIDENCE_INVALID",
|
|
3465
|
+
scope: "check",
|
|
3466
|
+
detail: `${item.id} repeats another semantic item's quote; each check needs distinct evidence`
|
|
3467
|
+
});
|
|
3468
|
+
continue;
|
|
3469
|
+
}
|
|
3470
|
+
let start = text.indexOf(quote);
|
|
3471
|
+
let sentence = -1;
|
|
3472
|
+
while (start !== -1) {
|
|
3473
|
+
const end = start + quote.length;
|
|
3474
|
+
const overlaps = spans.some((span) => start < span.end && span.start < end);
|
|
3475
|
+
const sentenceIndex = sentences.findIndex((candidate) => start >= candidate.start && end <= candidate.end);
|
|
3476
|
+
if (!overlaps && sentenceIndex !== -1 && !usedSentences.has(sentenceIndex)) {
|
|
3477
|
+
sentence = sentenceIndex;
|
|
3478
|
+
break;
|
|
3479
|
+
}
|
|
3480
|
+
start = text.indexOf(quote, start + 1);
|
|
3481
|
+
}
|
|
3482
|
+
if (start === -1 || sentence === -1) {
|
|
3483
|
+
blocking.push({
|
|
3484
|
+
code: "SEMANTIC_EVIDENCE_INVALID",
|
|
3485
|
+
scope: "check",
|
|
3486
|
+
detail: `${item.id} cites overlapping text or the same sentence as another check; each check needs a distinct passage`
|
|
3487
|
+
});
|
|
3488
|
+
continue;
|
|
3489
|
+
}
|
|
3490
|
+
spans.push({
|
|
3491
|
+
start,
|
|
3492
|
+
end: start + quote.length
|
|
3493
|
+
});
|
|
3494
|
+
usedSentences.add(sentence);
|
|
3495
|
+
seenQuotes.add(quote);
|
|
3372
3496
|
if (item.verdict === "fail") blocking.push({
|
|
3373
3497
|
code: "SEMANTIC_CHECK_FAILED",
|
|
3374
3498
|
scope: "check",
|
|
@@ -3501,6 +3625,7 @@ const summarySchema = (input) => {
|
|
|
3501
3625
|
if (!isText(summary.opening)) problems.push("opening must be text");
|
|
3502
3626
|
else {
|
|
3503
3627
|
const opening = summary.opening;
|
|
3628
|
+
if (Array.from(opening).length < LIMITS.minQuoteChars) problems.push(`opening must be at least ${LIMITS.minQuoteChars} characters`);
|
|
3504
3629
|
if (!splitParagraphs(input.text).slice(0, 2).some((paragraph) => paragraph.includes(opening))) problems.push("opening must be quoted from the first two paragraphs");
|
|
3505
3630
|
}
|
|
3506
3631
|
const continuity = summary.continuity;
|
|
@@ -3537,6 +3662,7 @@ const score = (input, options) => {
|
|
|
3537
3662
|
if (review === null) return [block("PG-07", "no review document supplied")];
|
|
3538
3663
|
if (!Array.isArray(review.dimensions)) return [block("PG-07", "review dimensions must be an array")];
|
|
3539
3664
|
const problems = reviewArithmeticProblems(review.dimensions, review.total);
|
|
3665
|
+
if (applyExclusions(review.dimensions, options.rulepack.exclusions).dropped > 0) problems.push("review contains mutually exclusive or duplicate deductions; resubmit it through novel_review");
|
|
3540
3666
|
if (review.content_hash !== sha256(input.text)) problems.push("review content_hash does not match the current manuscript");
|
|
3541
3667
|
const paragraphCount = splitParagraphs(input.text).length;
|
|
3542
3668
|
for (const dimension of review.dimensions) {
|
|
@@ -3616,13 +3742,16 @@ const readingProof = (input) => {
|
|
|
3616
3742
|
ending
|
|
3617
3743
|
]) if (problem !== null) problems.push(problem);
|
|
3618
3744
|
const paragraphs = splitParagraphs(input.text).length;
|
|
3619
|
-
if (input.proof.opening.paragraph > 2) problems.push("the opening quote must come from the first two paragraphs");
|
|
3620
|
-
if (input.proof.ending.paragraph < Math.max(1, paragraphs - 1)) problems.push("the ending quote must come from the last two paragraphs");
|
|
3621
3745
|
const actuals = [
|
|
3622
3746
|
input.proof.opening,
|
|
3623
3747
|
input.proof.middle,
|
|
3624
3748
|
input.proof.ending
|
|
3625
3749
|
].map((reference) => actualParagraph(input.text, reference));
|
|
3750
|
+
if (actuals[0] > 2) problems.push("the opening quote must come from the first two paragraphs");
|
|
3751
|
+
const middleMin = paragraphs <= 4 ? 2 : 3;
|
|
3752
|
+
const middleMax = paragraphs <= 4 ? Math.max(2, paragraphs - 1) : paragraphs - 2;
|
|
3753
|
+
if (actuals[1] > 0 && (actuals[1] < middleMin || actuals[1] > middleMax)) problems.push("the middle quote must come from the body of the chapter, not its first or last edges");
|
|
3754
|
+
if (actuals[2] > 0 && actuals[2] < Math.max(1, paragraphs - 1)) problems.push("the ending quote must come from the last two paragraphs");
|
|
3626
3755
|
if (new Set(actuals.filter((index) => index > 0)).size < 3) problems.push("the three quotes must come from three distinct paragraphs");
|
|
3627
3756
|
return problems.length === 0 ? [] : [block("PG-09", problems.join("; "), "quote at least 12 characters from the real paragraph you read")];
|
|
3628
3757
|
};
|
|
@@ -3662,8 +3791,9 @@ const consistency = (input, options) => {
|
|
|
3662
3791
|
/** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
|
|
3663
3792
|
const rounds = (input) => {
|
|
3664
3793
|
if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
|
|
3665
|
-
|
|
3666
|
-
|
|
3794
|
+
const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
|
|
3795
|
+
if (!roundsExhausted(input.rounds, counter)) return [];
|
|
3796
|
+
return [block("PG-11", `round limits reached for ${counter} (review ${input.rounds.review}, revise ${input.rounds.revise}, check ${input.rounds.check})`, "call novel_rollback to restart the chapter; the manuscript is kept for reference")];
|
|
3667
3797
|
};
|
|
3668
3798
|
/** PG-12: the archive transaction's preconditions, checked before staging. */
|
|
3669
3799
|
const archiveAtomic = (input) => {
|
|
@@ -3753,6 +3883,7 @@ function applicableGates(input) {
|
|
|
3753
3883
|
"PG-06",
|
|
3754
3884
|
"PG-07",
|
|
3755
3885
|
"PG-08",
|
|
3886
|
+
"PG-09",
|
|
3756
3887
|
"PG-10",
|
|
3757
3888
|
"PG-12"
|
|
3758
3889
|
];
|
|
@@ -3818,23 +3949,6 @@ function projectChapter(record) {
|
|
|
3818
3949
|
details: record.summary.events.join(";").slice(0, 240)
|
|
3819
3950
|
};
|
|
3820
3951
|
}
|
|
3821
|
-
/** Status report used by the tool and by commands; one function, three callers. */
|
|
3822
|
-
function projectStatus(snapshot, next) {
|
|
3823
|
-
return {
|
|
3824
|
-
project: {
|
|
3825
|
-
title: snapshot.title,
|
|
3826
|
-
genre: snapshot.genre,
|
|
3827
|
-
platform: snapshot.platform,
|
|
3828
|
-
root: snapshot.root
|
|
3829
|
-
},
|
|
3830
|
-
chapter: snapshot.chapter,
|
|
3831
|
-
stage: snapshot.stage,
|
|
3832
|
-
rounds: { ...snapshot.rounds },
|
|
3833
|
-
blocking: snapshot.blocking,
|
|
3834
|
-
metrics: null,
|
|
3835
|
-
next
|
|
3836
|
-
};
|
|
3837
|
-
}
|
|
3838
3952
|
function projectSummary(snapshot) {
|
|
3839
3953
|
return {
|
|
3840
3954
|
schemaVersion: 1,
|
|
@@ -4013,6 +4127,20 @@ var EngineService = class {
|
|
|
4013
4127
|
/** Compiled on first use so `apply()` performs no IO. */
|
|
4014
4128
|
rulepackSource;
|
|
4015
4129
|
compiled = null;
|
|
4130
|
+
disposed = false;
|
|
4131
|
+
/**
|
|
4132
|
+
* Release in-process caches on host teardown. The engine owns no file
|
|
4133
|
+
* handles, so this is deliberately synchronous and idempotent; it prevents a
|
|
4134
|
+
* disposed instance from keeping project stores and replay results alive.
|
|
4135
|
+
*/
|
|
4136
|
+
dispose() {
|
|
4137
|
+
if (this.disposed) return;
|
|
4138
|
+
this.disposed = true;
|
|
4139
|
+
this.stores.clear();
|
|
4140
|
+
this.replay.clear();
|
|
4141
|
+
this.lastRoot = null;
|
|
4142
|
+
this.compiled = null;
|
|
4143
|
+
}
|
|
4016
4144
|
get rulepack() {
|
|
4017
4145
|
this.compiled ??= this.rulepackSource();
|
|
4018
4146
|
return this.compiled;
|
|
@@ -4119,6 +4247,7 @@ var EngineService = class {
|
|
|
4119
4247
|
});
|
|
4120
4248
|
}
|
|
4121
4249
|
async getStatus(input = {}) {
|
|
4250
|
+
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
4122
4251
|
const snapshot = await this.load(input.root);
|
|
4123
4252
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4124
4253
|
const metrics = await this.metricsFor(snapshot, chapter);
|
|
@@ -4132,9 +4261,10 @@ var EngineService = class {
|
|
|
4132
4261
|
root: snapshot.root
|
|
4133
4262
|
},
|
|
4134
4263
|
chapter,
|
|
4264
|
+
active_chapter: snapshot.pipeline.chapter,
|
|
4135
4265
|
stage: snapshot.pipeline.stage,
|
|
4136
4266
|
rounds: { ...snapshot.pipeline.rounds },
|
|
4137
|
-
blocking: snapshot
|
|
4267
|
+
blocking: this.effectiveBlocking(snapshot),
|
|
4138
4268
|
metrics,
|
|
4139
4269
|
next
|
|
4140
4270
|
},
|
|
@@ -4148,6 +4278,7 @@ var EngineService = class {
|
|
|
4148
4278
|
revision: 1,
|
|
4149
4279
|
warnings: []
|
|
4150
4280
|
};
|
|
4281
|
+
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
4151
4282
|
const snapshot = await this.load(input.root);
|
|
4152
4283
|
return {
|
|
4153
4284
|
data: countWordMetrics((input.chapter === void 0 ? snapshot.text : await this.readChapter(snapshot, input.chapter)) ?? ""),
|
|
@@ -4156,6 +4287,7 @@ var EngineService = class {
|
|
|
4156
4287
|
};
|
|
4157
4288
|
}
|
|
4158
4289
|
async queryState(input) {
|
|
4290
|
+
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
4159
4291
|
const snapshot = await this.load(input.root);
|
|
4160
4292
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4161
4293
|
const store = this.storeFor(snapshot.root);
|
|
@@ -4206,12 +4338,12 @@ var EngineService = class {
|
|
|
4206
4338
|
warnings: []
|
|
4207
4339
|
};
|
|
4208
4340
|
case "review": return {
|
|
4209
|
-
data: snapshot.review,
|
|
4341
|
+
data: chapter === snapshot.pipeline.chapter ? snapshot.review : await store.readDoc(reviewFile(chapter), "review"),
|
|
4210
4342
|
revision: snapshot.pipeline.revision,
|
|
4211
4343
|
warnings: []
|
|
4212
4344
|
};
|
|
4213
4345
|
case "check": return {
|
|
4214
|
-
data: snapshot.check,
|
|
4346
|
+
data: chapter === snapshot.pipeline.chapter ? snapshot.check : await store.readDoc(checkFile(chapter), "check"),
|
|
4215
4347
|
revision: snapshot.pipeline.revision,
|
|
4216
4348
|
warnings: []
|
|
4217
4349
|
};
|
|
@@ -4223,7 +4355,7 @@ var EngineService = class {
|
|
|
4223
4355
|
case "planWorld": {
|
|
4224
4356
|
const raw = await store.readText(planWorldFile(chapter));
|
|
4225
4357
|
return {
|
|
4226
|
-
data: raw === null ? null :
|
|
4358
|
+
data: raw === null ? null : parsePlanYaml(raw, planWorldFile(chapter)),
|
|
4227
4359
|
revision: snapshot.pipeline.revision,
|
|
4228
4360
|
warnings: []
|
|
4229
4361
|
};
|
|
@@ -4231,7 +4363,7 @@ var EngineService = class {
|
|
|
4231
4363
|
case "planChapter": {
|
|
4232
4364
|
const raw = await store.readText(planChapterFile(chapter));
|
|
4233
4365
|
return {
|
|
4234
|
-
data: raw === null ? null :
|
|
4366
|
+
data: raw === null ? null : parsePlanYaml(raw, planChapterFile(chapter)),
|
|
4235
4367
|
revision: snapshot.pipeline.revision,
|
|
4236
4368
|
warnings: []
|
|
4237
4369
|
};
|
|
@@ -4249,59 +4381,62 @@ var EngineService = class {
|
|
|
4249
4381
|
tool: next.tool,
|
|
4250
4382
|
args: next.args,
|
|
4251
4383
|
reason: next.reason,
|
|
4252
|
-
blocking: snapshot
|
|
4384
|
+
blocking: this.effectiveBlocking(snapshot)
|
|
4253
4385
|
},
|
|
4254
4386
|
revision: snapshot.pipeline.revision,
|
|
4255
4387
|
warnings: []
|
|
4256
4388
|
};
|
|
4257
4389
|
}
|
|
4258
4390
|
async getBrief(input) {
|
|
4259
|
-
|
|
4260
|
-
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4261
|
-
const context = this.briefContextFor(input.stage, snapshot, chapter);
|
|
4262
|
-
const digest = this.briefDigest(input.stage, snapshot, context);
|
|
4263
|
-
const variables = this.briefVariables(snapshot, chapter);
|
|
4264
|
-
const rendered = this.briefs.render(input.stage, variables);
|
|
4265
|
-
const fields = {
|
|
4266
|
-
stage: input.stage,
|
|
4267
|
-
chapter,
|
|
4268
|
-
digest,
|
|
4269
|
-
min_words: snapshot.project.targets.words_per_chapter[0],
|
|
4270
|
-
min_score: this.config.quality.minScore,
|
|
4271
|
-
scene_types: [
|
|
4272
|
-
"战斗/动作章",
|
|
4273
|
-
"日常过渡章",
|
|
4274
|
-
"情感爆发章",
|
|
4275
|
-
"纯叙事章"
|
|
4276
|
-
],
|
|
4277
|
-
platform_style: this.rulepack.platform[snapshot.project.platform],
|
|
4278
|
-
genre_guide: this.rulepack.genres[snapshot.project.genre],
|
|
4279
|
-
/** P4 must apply exclusions before its arithmetic closes. */
|
|
4280
|
-
score_exclusions: this.rulepack.exclusions,
|
|
4281
|
-
next_tool: NEXT_TOOL_BY_BRIEF[input.stage]
|
|
4282
|
-
};
|
|
4283
|
-
const existing = snapshot.pipeline.briefs[input.stage];
|
|
4284
|
-
if (existing !== void 0 && existing.digest === digest) return {
|
|
4285
|
-
data: {
|
|
4286
|
-
stage: input.stage,
|
|
4287
|
-
chapter,
|
|
4288
|
-
digest,
|
|
4289
|
-
title: rendered.title,
|
|
4290
|
-
content: rendered.content,
|
|
4291
|
-
content_hash: rendered.contentHash,
|
|
4292
|
-
fields,
|
|
4293
|
-
context
|
|
4294
|
-
},
|
|
4295
|
-
revision: snapshot.pipeline.revision,
|
|
4296
|
-
warnings: []
|
|
4297
|
-
};
|
|
4391
|
+
await this.load(input.root);
|
|
4298
4392
|
return this.mutate({
|
|
4299
4393
|
root: input.root,
|
|
4300
4394
|
op: "getBrief",
|
|
4301
|
-
run: async (
|
|
4302
|
-
const
|
|
4395
|
+
run: async (snapshot, context) => {
|
|
4396
|
+
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4397
|
+
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}`);
|
|
4398
|
+
const contextPack = this.briefContextFor(input.stage, snapshot, chapter);
|
|
4399
|
+
const digest = this.briefDigest(input.stage, snapshot, contextPack);
|
|
4400
|
+
const variables = this.briefVariables(snapshot, chapter);
|
|
4401
|
+
const rendered = this.briefs.render(input.stage, variables);
|
|
4402
|
+
const fields = {
|
|
4403
|
+
stage: input.stage,
|
|
4404
|
+
chapter,
|
|
4405
|
+
digest,
|
|
4406
|
+
min_words: snapshot.project.targets.words_per_chapter[0],
|
|
4407
|
+
min_score: this.config.quality.minScore,
|
|
4408
|
+
scene_types: [
|
|
4409
|
+
"战斗/动作章",
|
|
4410
|
+
"日常过渡章",
|
|
4411
|
+
"情感爆发章",
|
|
4412
|
+
"纯叙事章"
|
|
4413
|
+
],
|
|
4414
|
+
platform_style: this.rulepack.platform[snapshot.project.platform],
|
|
4415
|
+
genre_guide: this.rulepack.genres[snapshot.project.genre],
|
|
4416
|
+
/** P4 must apply exclusions before its arithmetic closes. */
|
|
4417
|
+
score_exclusions: this.rulepack.exclusions,
|
|
4418
|
+
next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
|
|
4419
|
+
...snapshot.planError === null ? {} : { plan_error: snapshot.planError }
|
|
4420
|
+
};
|
|
4421
|
+
const data = {
|
|
4422
|
+
stage: input.stage,
|
|
4423
|
+
chapter,
|
|
4424
|
+
digest,
|
|
4425
|
+
title: rendered.title,
|
|
4426
|
+
content: rendered.content,
|
|
4427
|
+
content_hash: rendered.contentHash,
|
|
4428
|
+
fields,
|
|
4429
|
+
context: contextPack
|
|
4430
|
+
};
|
|
4431
|
+
const existing = snapshot.pipeline.briefs[input.stage];
|
|
4432
|
+
if (existing !== void 0 && existing.digest === digest) return {
|
|
4433
|
+
files: [],
|
|
4434
|
+
data,
|
|
4435
|
+
replay: true
|
|
4436
|
+
};
|
|
4437
|
+
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
4303
4438
|
briefs: {
|
|
4304
|
-
...
|
|
4439
|
+
...snapshot.pipeline.briefs,
|
|
4305
4440
|
[input.stage]: {
|
|
4306
4441
|
issued_at: this.now().toISOString(),
|
|
4307
4442
|
digest
|
|
@@ -4315,16 +4450,7 @@ var EngineService = class {
|
|
|
4315
4450
|
text: yaml(nextPipeline)
|
|
4316
4451
|
}],
|
|
4317
4452
|
backup: false,
|
|
4318
|
-
data
|
|
4319
|
-
stage: input.stage,
|
|
4320
|
-
chapter,
|
|
4321
|
-
digest,
|
|
4322
|
-
title: rendered.title,
|
|
4323
|
-
content: rendered.content,
|
|
4324
|
-
content_hash: rendered.contentHash,
|
|
4325
|
-
fields,
|
|
4326
|
-
context
|
|
4327
|
-
}
|
|
4453
|
+
data
|
|
4328
4454
|
};
|
|
4329
4455
|
}
|
|
4330
4456
|
});
|
|
@@ -4451,7 +4577,7 @@ var EngineService = class {
|
|
|
4451
4577
|
const planPath = planChapterFile(chapter);
|
|
4452
4578
|
const planText = await context.store.readText(planPath);
|
|
4453
4579
|
const mergedPlan = {
|
|
4454
|
-
...(planText === null ? null :
|
|
4580
|
+
...(planText === null ? null : parsePlanYaml(planText, planPath)) ?? {
|
|
4455
4581
|
schema_version: 1,
|
|
4456
4582
|
chapter
|
|
4457
4583
|
},
|
|
@@ -4559,6 +4685,7 @@ var EngineService = class {
|
|
|
4559
4685
|
detail: `score ${input.total} is below the minimum ${this.config.quality.minScore}`,
|
|
4560
4686
|
hint: "revise the chapter and submit a new P4 review"
|
|
4561
4687
|
});
|
|
4688
|
+
const reviewDigest = sha256(stableJson(review));
|
|
4562
4689
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
4563
4690
|
stage: next,
|
|
4564
4691
|
rounds: {
|
|
@@ -4566,7 +4693,8 @@ var EngineService = class {
|
|
|
4566
4693
|
review: snapshot.pipeline.rounds.review + 1
|
|
4567
4694
|
},
|
|
4568
4695
|
blocking,
|
|
4569
|
-
|
|
4696
|
+
review_digest: reviewDigest,
|
|
4697
|
+
last_action: action("novel_review", this.now(), reviewDigest)
|
|
4570
4698
|
});
|
|
4571
4699
|
return {
|
|
4572
4700
|
files: [{
|
|
@@ -4669,11 +4797,12 @@ var EngineService = class {
|
|
|
4669
4797
|
revise: snapshot.pipeline.rounds.revise + 1
|
|
4670
4798
|
},
|
|
4671
4799
|
blocking: [],
|
|
4800
|
+
...patchedReview === null ? {} : { review_digest: sha256(stableJson(patchedReview)) },
|
|
4672
4801
|
last_action: action("novel_revise", this.now(), sha256(input.content))
|
|
4673
4802
|
});
|
|
4674
4803
|
const planPath = planChapterFile(input.chapter);
|
|
4675
4804
|
const planText = await context.store.readText(planPath);
|
|
4676
|
-
const plan = planText === null ? null :
|
|
4805
|
+
const plan = planText === null ? null : parsePlanYaml(planText, planPath);
|
|
4677
4806
|
const files = [{
|
|
4678
4807
|
path: chapterFile(input.chapter),
|
|
4679
4808
|
text: input.content
|
|
@@ -4745,7 +4874,7 @@ var EngineService = class {
|
|
|
4745
4874
|
scope: "check",
|
|
4746
4875
|
detail: violation.detail
|
|
4747
4876
|
})), ...semanticResult.blocking] : [];
|
|
4748
|
-
|
|
4877
|
+
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
4749
4878
|
stage: nextStage,
|
|
4750
4879
|
rounds: {
|
|
4751
4880
|
...snapshot.pipeline.rounds,
|
|
@@ -4754,24 +4883,6 @@ var EngineService = class {
|
|
|
4754
4883
|
blocking: blockingItems,
|
|
4755
4884
|
last_action: action("novel_check", this.now(), sha256(text))
|
|
4756
4885
|
});
|
|
4757
|
-
if (verdict === "fail" && nextPipeline.briefs.P6 !== void 0) {
|
|
4758
|
-
const postCheck = {
|
|
4759
|
-
...snapshot,
|
|
4760
|
-
check,
|
|
4761
|
-
pipeline: nextPipeline
|
|
4762
|
-
};
|
|
4763
|
-
const digest = this.briefDigest("P6", postCheck, this.briefContextFor("P6", postCheck, input.chapter));
|
|
4764
|
-
nextPipeline = {
|
|
4765
|
-
...nextPipeline,
|
|
4766
|
-
briefs: {
|
|
4767
|
-
...nextPipeline.briefs,
|
|
4768
|
-
P6: {
|
|
4769
|
-
...nextPipeline.briefs.P6,
|
|
4770
|
-
digest
|
|
4771
|
-
}
|
|
4772
|
-
}
|
|
4773
|
-
};
|
|
4774
|
-
}
|
|
4775
4886
|
return {
|
|
4776
4887
|
files: [{
|
|
4777
4888
|
path: checkFile(input.chapter),
|
|
@@ -4808,7 +4919,8 @@ var EngineService = class {
|
|
|
4808
4919
|
run: async (snapshot, context) => {
|
|
4809
4920
|
const already = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
|
|
4810
4921
|
if (already !== void 0) {
|
|
4811
|
-
if (already.archive_digest
|
|
4922
|
+
if (already.archive_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} has no archive_digest; the index entry is not trustworthy`, "restore the chapter index from backups/ or re-archive from the original manuscript");
|
|
4923
|
+
if (already.archive_digest !== archiveDigest) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} is already archived with a different state_delta`, "archive is idempotent by chapter; start the next chapter instead of replaying old data");
|
|
4812
4924
|
const archivedText = await this.readChapter(snapshot, input.chapter);
|
|
4813
4925
|
if (archivedText === null || sha256(archivedText) !== already.content_hash) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `archived chapter ${input.chapter} no longer matches its index`, "restore the chapter from backups/ before retrying");
|
|
4814
4926
|
return {
|
|
@@ -4827,11 +4939,13 @@ var EngineService = class {
|
|
|
4827
4939
|
const text = snapshot.text ?? "";
|
|
4828
4940
|
const summary = snapshot.plan?.summary;
|
|
4829
4941
|
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");
|
|
4942
|
+
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");
|
|
4830
4943
|
await this.assertGates(snapshot, "archiveChapter", {
|
|
4831
4944
|
briefStage: "P8",
|
|
4832
4945
|
text,
|
|
4833
4946
|
summary,
|
|
4834
|
-
review: snapshot.review
|
|
4947
|
+
review: snapshot.review,
|
|
4948
|
+
proof: snapshot.review?.proof ?? null
|
|
4835
4949
|
});
|
|
4836
4950
|
if (text === "") throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${input.chapter} has no draft`);
|
|
4837
4951
|
const archiveBlockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
|
|
@@ -4992,7 +5106,7 @@ var EngineService = class {
|
|
|
4992
5106
|
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");
|
|
4993
5107
|
const resolvedRoot = await this.resolveRoot(input.root);
|
|
4994
5108
|
const absolute = this.storeFor(resolvedRoot).path(input.path);
|
|
4995
|
-
const kind = MUTABLE_STATE[input.path];
|
|
5109
|
+
const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
|
|
4996
5110
|
if (kind === void 0 || absolute.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `amend can only target state documents: ${Object.keys(MUTABLE_STATE).join(", ")}`, "edit novel content through the draft/revision tools, not amend");
|
|
4997
5111
|
return this.mutate({
|
|
4998
5112
|
root: resolvedRoot,
|
|
@@ -5259,6 +5373,7 @@ var EngineService = class {
|
|
|
5259
5373
|
revision: snapshot.pipeline.revision,
|
|
5260
5374
|
warnings: []
|
|
5261
5375
|
};
|
|
5376
|
+
if (!Object.hasOwn(STATE_FILES, input.kind)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown state document "${String(input.kind)}"`, `allowed documents: ${Object.values(STATE_FILES).join(", ")}`);
|
|
5262
5377
|
const path = joinRel(STATE_DIR, STATE_FILES[input.kind]);
|
|
5263
5378
|
return await this.mutate({
|
|
5264
5379
|
root: input.root,
|
|
@@ -5308,7 +5423,7 @@ var EngineService = class {
|
|
|
5308
5423
|
stage: snapshot.pipeline.stage,
|
|
5309
5424
|
revision: snapshot.pipeline.revision,
|
|
5310
5425
|
rounds: snapshot.pipeline.rounds,
|
|
5311
|
-
blocking: snapshot
|
|
5426
|
+
blocking: this.effectiveBlocking(snapshot),
|
|
5312
5427
|
next: finished ? void 0 : this.nextStep(snapshot),
|
|
5313
5428
|
finished,
|
|
5314
5429
|
chapters: archived,
|
|
@@ -5318,31 +5433,93 @@ var EngineService = class {
|
|
|
5318
5433
|
};
|
|
5319
5434
|
}
|
|
5320
5435
|
nextStep(snapshot) {
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
tool: "novel_status",
|
|
5325
|
-
args: {},
|
|
5326
|
-
reason: `${first.code}: ${first.detail}${first.hint === void 0 ? "" : ` (${first.hint})`}`
|
|
5327
|
-
};
|
|
5328
|
-
}
|
|
5436
|
+
const stage = snapshot.pipeline.stage;
|
|
5437
|
+
const chapter = snapshot.pipeline.chapter;
|
|
5438
|
+
const step = STAGE_NEXT[stage];
|
|
5329
5439
|
const target = snapshot.project.targets.total_chapters;
|
|
5330
|
-
if (
|
|
5440
|
+
if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
|
|
5331
5441
|
tool: "novel_export",
|
|
5332
5442
|
args: { kind: "epub" },
|
|
5333
5443
|
reason: "全书已完成;可导出成书,或结束本书"
|
|
5334
5444
|
};
|
|
5335
|
-
const
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5445
|
+
const roundsBlock = this.roundsLimitBlock(snapshot);
|
|
5446
|
+
if (roundsBlock !== null) return {
|
|
5447
|
+
tool: "novel_rollback",
|
|
5448
|
+
args: {
|
|
5449
|
+
chapter,
|
|
5450
|
+
to_stage: "P4"
|
|
5451
|
+
},
|
|
5452
|
+
reason: `${roundsBlock.code}: ${roundsBlock.detail}; ${roundsBlock.hint ?? ""}`.trim()
|
|
5453
|
+
};
|
|
5454
|
+
const first = this.effectiveBlocking(snapshot)[0];
|
|
5455
|
+
const detail = first === void 0 ? "" : `${first.code}: ${first.detail}${first.hint === void 0 ? "" : ` (${first.hint})`}`;
|
|
5456
|
+
const briefStage = STATUS_BRIEF[stage];
|
|
5457
|
+
const followUp = (stage === "P5" || stage === "P6") && snapshot.pipeline.blocking.length > 0 ? "novel_revise" : step.tool;
|
|
5458
|
+
if (briefStage !== null && !this.briefIsCurrent(snapshot, briefStage)) return {
|
|
5459
|
+
tool: "novel_brief",
|
|
5460
|
+
args: {
|
|
5461
|
+
stage: briefStage,
|
|
5462
|
+
chapter
|
|
5463
|
+
},
|
|
5464
|
+
reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
|
|
5465
|
+
};
|
|
5466
|
+
if (snapshot.pipeline.blocking.length > 0) {
|
|
5467
|
+
if (stage === "P5" || stage === "P6") return {
|
|
5468
|
+
tool: "novel_revise",
|
|
5469
|
+
args: { chapter },
|
|
5470
|
+
reason: `${detail}; fix it with novel_revise`
|
|
5471
|
+
};
|
|
5472
|
+
return {
|
|
5473
|
+
tool: step.tool,
|
|
5474
|
+
args: this.nextArgs(stage, chapter),
|
|
5475
|
+
reason: `${detail}; ${step.reason}`
|
|
5476
|
+
};
|
|
5477
|
+
}
|
|
5340
5478
|
return {
|
|
5341
5479
|
tool: step.tool,
|
|
5342
|
-
args,
|
|
5480
|
+
args: this.nextArgs(stage, chapter),
|
|
5343
5481
|
reason: step.reason
|
|
5344
5482
|
};
|
|
5345
5483
|
}
|
|
5484
|
+
/** A computed blocker for the retry budget the next operation would consume. */
|
|
5485
|
+
roundsLimitBlock(snapshot) {
|
|
5486
|
+
const stage = snapshot.pipeline.stage;
|
|
5487
|
+
if (![
|
|
5488
|
+
"P4",
|
|
5489
|
+
"P5",
|
|
5490
|
+
"P6"
|
|
5491
|
+
].includes(stage)) return null;
|
|
5492
|
+
const counter = stage === "P4" ? "review" : stage === "P5" ? "revise" : "check";
|
|
5493
|
+
if (!roundsExhausted(snapshot.pipeline.rounds, counter)) return null;
|
|
5494
|
+
return {
|
|
5495
|
+
code: "PG-11",
|
|
5496
|
+
scope: "rounds",
|
|
5497
|
+
detail: `round limit reached for ${counter} (review ${snapshot.pipeline.rounds.review}, revise ${snapshot.pipeline.rounds.revise}, check ${snapshot.pipeline.rounds.check})`,
|
|
5498
|
+
hint: "call novel_rollback with to_stage P4; the manuscript is kept for reference"
|
|
5499
|
+
};
|
|
5500
|
+
}
|
|
5501
|
+
/** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
|
|
5502
|
+
effectiveBlocking(snapshot) {
|
|
5503
|
+
const extra = this.roundsLimitBlock(snapshot);
|
|
5504
|
+
if (extra === null) return snapshot.pipeline.blocking;
|
|
5505
|
+
return snapshot.pipeline.blocking.some((item) => item.code === extra.code) ? snapshot.pipeline.blocking : [...snapshot.pipeline.blocking, extra];
|
|
5506
|
+
}
|
|
5507
|
+
/** Whether the stored brief still signs the current snapshot. */
|
|
5508
|
+
briefIsCurrent(snapshot, stage) {
|
|
5509
|
+
const record = snapshot.pipeline.briefs[stage];
|
|
5510
|
+
if (record === void 0) return false;
|
|
5511
|
+
const context = this.briefContextFor(stage, snapshot, snapshot.pipeline.chapter);
|
|
5512
|
+
return record.digest === this.briefDigest(stage, snapshot, context);
|
|
5513
|
+
}
|
|
5514
|
+
/** Arguments valid for the tool `STAGE_NEXT` recommends at this stage. */
|
|
5515
|
+
nextArgs(stage, chapter) {
|
|
5516
|
+
switch (stage) {
|
|
5517
|
+
case "idle":
|
|
5518
|
+
case "P2":
|
|
5519
|
+
case "done": return {};
|
|
5520
|
+
default: return { chapter };
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5346
5523
|
async metricsFor(snapshot, chapter) {
|
|
5347
5524
|
if (chapter === snapshot.pipeline.chapter && snapshot.text !== null) return countWordMetrics(snapshot.text);
|
|
5348
5525
|
const text = await this.readChapter(snapshot, chapter);
|
|
@@ -5360,7 +5537,8 @@ var EngineService = class {
|
|
|
5360
5537
|
}
|
|
5361
5538
|
async chaptersForExport(snapshot, chapter) {
|
|
5362
5539
|
const records = snapshot.chapters.chapters;
|
|
5363
|
-
|
|
5540
|
+
const activeDraft = chapter !== void 0 && chapter === snapshot.pipeline.chapter && snapshot.text !== null;
|
|
5541
|
+
if (chapter !== void 0 && !records.some((record) => record.num === chapter) && !activeDraft) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not archived and is not the active draft`, "archive the chapter first, or pass the active chapter to export its draft");
|
|
5364
5542
|
const selected = chapter === void 0 ? records : records.filter((record) => record.num === chapter);
|
|
5365
5543
|
const chapters = [];
|
|
5366
5544
|
for (const record of selected) {
|
|
@@ -5372,7 +5550,7 @@ var EngineService = class {
|
|
|
5372
5550
|
text
|
|
5373
5551
|
});
|
|
5374
5552
|
}
|
|
5375
|
-
if (chapters.length === 0 && snapshot.text !== null && chapter === void 0) chapters.push({
|
|
5553
|
+
if (chapters.length === 0 && snapshot.text !== null && (chapter === void 0 || chapter === snapshot.pipeline.chapter)) chapters.push({
|
|
5376
5554
|
num: snapshot.pipeline.chapter,
|
|
5377
5555
|
title: snapshot.plan?.title ?? "未命名",
|
|
5378
5556
|
text: snapshot.text
|
|
@@ -5419,6 +5597,7 @@ var EngineService = class {
|
|
|
5419
5597
|
timeline: snapshot.timeline,
|
|
5420
5598
|
relationships: snapshot.relationships,
|
|
5421
5599
|
plan: snapshot.plan,
|
|
5600
|
+
plan_error: snapshot.planError,
|
|
5422
5601
|
review: snapshot.review,
|
|
5423
5602
|
check: snapshot.check,
|
|
5424
5603
|
text: snapshot.text,
|
|
@@ -5435,6 +5614,7 @@ var EngineService = class {
|
|
|
5435
5614
|
chapter: snapshot.pipeline.chapter,
|
|
5436
5615
|
pipelineStage: snapshot.pipeline.stage,
|
|
5437
5616
|
rounds: snapshot.pipeline.rounds,
|
|
5617
|
+
min_score: this.config.quality.minScore,
|
|
5438
5618
|
rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
|
|
5439
5619
|
context
|
|
5440
5620
|
}));
|
|
@@ -5509,8 +5689,11 @@ var EngineService = class {
|
|
|
5509
5689
|
throw new EngineBlockedError(result.blocking.every((item) => item.code === "PG-09") ? ERROR_CODES.READING_PROOF_INVALID : ERROR_CODES.GATE_BLOCKED, `${operation} blocked by ${result.blocking.length} gate(s), first: ${first.code} ${first.detail}`, result.blocking, result.blocking.map((item) => `${item.code}: ${item.hint ?? item.detail}`).join(" | "));
|
|
5510
5690
|
}
|
|
5511
5691
|
}
|
|
5512
|
-
|
|
5692
|
+
assertChapterParameter(chapter) {
|
|
5513
5693
|
if (!Number.isInteger(chapter) || chapter < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter must be a positive integer (got ${String(chapter)})`);
|
|
5694
|
+
}
|
|
5695
|
+
assertChapter(snapshot, chapter) {
|
|
5696
|
+
this.assertChapterParameter(chapter);
|
|
5514
5697
|
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");
|
|
5515
5698
|
}
|
|
5516
5699
|
async load(root, retries = 1) {
|
|
@@ -5554,7 +5737,13 @@ var EngineService = class {
|
|
|
5554
5737
|
if (previous !== null) previousTexts.push(previous);
|
|
5555
5738
|
}
|
|
5556
5739
|
const planRaw = await store.readText(planChapterFile(chapter));
|
|
5557
|
-
|
|
5740
|
+
let plan = null;
|
|
5741
|
+
let planError = null;
|
|
5742
|
+
if (planRaw !== null) try {
|
|
5743
|
+
plan = parsePlanYaml(planRaw, planChapterFile(chapter));
|
|
5744
|
+
} catch (error) {
|
|
5745
|
+
planError = error instanceof Error ? error.message : String(error);
|
|
5746
|
+
}
|
|
5558
5747
|
const review = await store.readDoc(reviewFile(chapter), "review");
|
|
5559
5748
|
const check = await store.readDoc(checkFile(chapter), "check");
|
|
5560
5749
|
const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
@@ -5579,6 +5768,7 @@ var EngineService = class {
|
|
|
5579
5768
|
previousText,
|
|
5580
5769
|
previousTexts,
|
|
5581
5770
|
plan,
|
|
5771
|
+
planError,
|
|
5582
5772
|
review,
|
|
5583
5773
|
check
|
|
5584
5774
|
};
|
|
@@ -5739,6 +5929,7 @@ var EngineService = class {
|
|
|
5739
5929
|
previousText: null,
|
|
5740
5930
|
previousTexts: [],
|
|
5741
5931
|
plan: null,
|
|
5932
|
+
planError: null,
|
|
5742
5933
|
review: null,
|
|
5743
5934
|
check: null
|
|
5744
5935
|
};
|
|
@@ -5753,6 +5944,17 @@ const NEXT_TOOL_BY_BRIEF = {
|
|
|
5753
5944
|
P6: "novel_check",
|
|
5754
5945
|
P8: "novel_archive"
|
|
5755
5946
|
};
|
|
5947
|
+
/** The task book each stage must see before its next write operation. */
|
|
5948
|
+
const STATUS_BRIEF = {
|
|
5949
|
+
idle: "P1",
|
|
5950
|
+
P2: "P2",
|
|
5951
|
+
P3: "P3",
|
|
5952
|
+
P4: "P4",
|
|
5953
|
+
P5: "P5",
|
|
5954
|
+
P6: "P6",
|
|
5955
|
+
ready_to_archive: "P8",
|
|
5956
|
+
done: "P1"
|
|
5957
|
+
};
|
|
5756
5958
|
const yaml = (value) => stringify(value, { lineWidth: 0 });
|
|
5757
5959
|
const emptyChapters = () => ({
|
|
5758
5960
|
schema_version: 1,
|
|
@@ -5778,6 +5980,7 @@ const pipelineWith = (snapshot, revision, patch) => ({
|
|
|
5778
5980
|
rounds: snapshot.pipeline.rounds,
|
|
5779
5981
|
briefs: snapshot.pipeline.briefs,
|
|
5780
5982
|
blocking: snapshot.pipeline.blocking,
|
|
5983
|
+
review_digest: snapshot.pipeline.review_digest,
|
|
5781
5984
|
last_action: snapshot.pipeline.last_action,
|
|
5782
5985
|
...patch
|
|
5783
5986
|
});
|
|
@@ -5817,7 +6020,17 @@ const stableJson = (value) => {
|
|
|
5817
6020
|
};
|
|
5818
6021
|
return JSON.stringify(normalize(value));
|
|
5819
6022
|
};
|
|
5820
|
-
|
|
6023
|
+
/** Parse a plan file with a machine code instead of leaking a YAML parser error. */
|
|
6024
|
+
const parsePlanYaml = (text, path) => {
|
|
6025
|
+
let parsed;
|
|
6026
|
+
try {
|
|
6027
|
+
parsed = parse(text);
|
|
6028
|
+
} catch (error) {
|
|
6029
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix the plan YAML; the engine will not silently discard a corrupt plan", { cause: error });
|
|
6030
|
+
}
|
|
6031
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} must be a YAML mapping`, "rewrite the plan as a mapping (title, scene_type, narrator, ...)");
|
|
6032
|
+
return parsed;
|
|
6033
|
+
};
|
|
5821
6034
|
/** Line diff with a shared prefix/suffix, enough for revision review. */
|
|
5822
6035
|
function diffLines(before, after) {
|
|
5823
6036
|
const left = before === "" ? [] : before.split("\n");
|
|
@@ -5867,7 +6080,10 @@ const asJson = (value, field) => {
|
|
|
5867
6080
|
};
|
|
5868
6081
|
/** Turn an engine failure into the message shape tools surface. */
|
|
5869
6082
|
const fail = (error) => {
|
|
5870
|
-
if (error instanceof EngineError)
|
|
6083
|
+
if (error instanceof EngineError) {
|
|
6084
|
+
const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
|
|
6085
|
+
throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}`);
|
|
6086
|
+
}
|
|
5871
6087
|
throw error;
|
|
5872
6088
|
};
|
|
5873
6089
|
/** Wrap an execute body with cancellation and a machine-code error. */
|
|
@@ -5921,7 +6137,7 @@ const stringArray = {
|
|
|
5921
6137
|
};
|
|
5922
6138
|
const root = {
|
|
5923
6139
|
type: "string",
|
|
5924
|
-
description: "Project root
|
|
6140
|
+
description: "Project root. When omitted, resolves to the last active project, then the registry active root, then the configured projectRoot; pass it explicitly when several projects exist."
|
|
5925
6141
|
};
|
|
5926
6142
|
/**
|
|
5927
6143
|
* The eighteen tools are thin adapters: narrow parameters, call one service
|
|
@@ -5980,8 +6196,11 @@ function createNovelTools(engine) {
|
|
|
5980
6196
|
schema: { type: "json" },
|
|
5981
6197
|
render: (_args, raw) => {
|
|
5982
6198
|
const value = payload(raw);
|
|
6199
|
+
const active = value.active_chapter ?? value.chapter;
|
|
6200
|
+
const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
|
|
5983
6201
|
return text([
|
|
5984
|
-
`《${value.project.title}》
|
|
6202
|
+
`《${value.project.title}》 ${where} · ${value.stage}`,
|
|
6203
|
+
...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
|
|
5985
6204
|
value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
|
|
5986
6205
|
...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
|
|
5987
6206
|
`下一步:${value.next.tool} — ${value.next.reason}`
|
|
@@ -6017,7 +6236,7 @@ function createNovelTools(engine) {
|
|
|
6017
6236
|
schema: { type: "json" },
|
|
6018
6237
|
render: (_args, raw) => {
|
|
6019
6238
|
const value = payload(raw);
|
|
6020
|
-
return text(`${value.content}\n\n---\nbrief digest: ${value.digest}${value.context === void 0 ? "" : `\ncontext:\n${JSON.stringify(value.context
|
|
6239
|
+
return text(`${value.content}\n\n---\nbrief digest: ${value.digest}${value.fields === void 0 ? "" : `\nfields:\n${JSON.stringify(value.fields)}`}${value.context === void 0 ? "" : `\ncontext:\n${JSON.stringify(value.context)}`}`);
|
|
6021
6240
|
}
|
|
6022
6241
|
},
|
|
6023
6242
|
execute: guarded(async (args) => engine.getBrief({
|
|
@@ -6440,6 +6659,11 @@ function createNovelTools(engine) {
|
|
|
6440
6659
|
];
|
|
6441
6660
|
}
|
|
6442
6661
|
// src/commands/admin.ts
|
|
6662
|
+
/** Registry commands need an explicit root; do not pass undefined into path.resolve. */
|
|
6663
|
+
const rootArg = (args) => {
|
|
6664
|
+
if (typeof args.root !== "string" || args.root.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "registry operation requires a non-empty root", "pass {\"root\":\"/absolute/project/path\"}");
|
|
6665
|
+
return args.root;
|
|
6666
|
+
};
|
|
6443
6667
|
/**
|
|
6444
6668
|
* The ten tools hidden when `tools.mode=core`, plus the explicit
|
|
6445
6669
|
* registry/settings-pack operations that are command-only by design (§6.8).
|
|
@@ -6471,9 +6695,9 @@ const OPERATIONS$1 = {
|
|
|
6471
6695
|
novel_pipeline: (engine, args) => engine.advise(args),
|
|
6472
6696
|
novel_state: (engine, args) => engine.queryState(args),
|
|
6473
6697
|
novel_registry_list: (engine) => engine.listProjects(),
|
|
6474
|
-
novel_registry_register: (engine, args) => engine.registerProject({ root: args
|
|
6475
|
-
novel_registry_activate: (engine, args) => engine.setActiveProject({ root: args
|
|
6476
|
-
novel_registry_forget: (engine, args) => engine.forgetProject({ root: args
|
|
6698
|
+
novel_registry_register: (engine, args) => engine.registerProject({ root: rootArg(args) }),
|
|
6699
|
+
novel_registry_activate: (engine, args) => engine.setActiveProject({ root: rootArg(args) }),
|
|
6700
|
+
novel_registry_forget: (engine, args) => engine.forgetProject({ root: rootArg(args) }),
|
|
6477
6701
|
novel_import: (engine, args) => engine.importPack(args)
|
|
6478
6702
|
};
|
|
6479
6703
|
const ADMIN_OPERATION_NAMES = Object.keys(OPERATIONS$1);
|
|
@@ -6500,7 +6724,7 @@ function parseAdminArguments(raw, operation) {
|
|
|
6500
6724
|
}
|
|
6501
6725
|
/** Run one hidden operation and render its payload for the command result. */
|
|
6502
6726
|
async function runAdminOperation(engine, operation, rawArguments) {
|
|
6503
|
-
const handler = OPERATIONS$1[operation];
|
|
6727
|
+
const handler = Object.hasOwn(OPERATIONS$1, operation) ? OPERATIONS$1[operation] : void 0;
|
|
6504
6728
|
if (handler === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, `未知子命令 "${operation}"`, `available: ${ADMIN_OPERATION_NAMES.join(", ")}`);
|
|
6505
6729
|
const parsed = parseAdminArguments(rawArguments, operation);
|
|
6506
6730
|
const result = await handler(engine, parsed.root === void 0 && typeof parsed.project_root === "string" ? {
|
|
@@ -7200,6 +7424,7 @@ function apply(ctx, config) {
|
|
|
7200
7424
|
ctx.emit?.(event.name, event.payload);
|
|
7201
7425
|
};
|
|
7202
7426
|
const engine = new EngineService(engineConfig, () => loadRulepack(), { onEvent: emit });
|
|
7427
|
+
ctx.effect(() => () => engine.dispose(), `${name}: engine dispose`);
|
|
7203
7428
|
ctx.provide?.("novelEngine", engine);
|
|
7204
7429
|
for (const tool of createNovelTools(engine)) {
|
|
7205
7430
|
if (toolsMode === "core" && !CORE_TOOLS.includes(tool.name)) continue;
|
|
@@ -7241,4 +7466,4 @@ function connectionFence(scope) {
|
|
|
7241
7466
|
}
|
|
7242
7467
|
if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
|
|
7243
7468
|
}
|
|
7244
|
-
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,
|
|
7469
|
+
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, verifyReference };
|