dsh-novel-write-engine 0.2.3 → 0.2.5
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 +50 -0
- package/README.md +49 -11
- package/briefs/P2.md +1 -0
- package/briefs/P5.md +2 -0
- package/briefs/P6.md +3 -2
- package/briefs/P8.md +5 -4
- package/lib/client.js +18 -3
- package/lib/index.d.ts +102 -16
- package/lib/index.js +1398 -334
- package/lib/types/client/dto.d.ts +6 -0
- package/lib/types/client/viewmodel.d.ts +6 -0
- package/lib/types/constants.d.ts +2 -0
- package/lib/types/types.d.ts +14 -0
- package/package.json +1 -1
- package/skills/entry.md +1 -1
package/lib/index.js
CHANGED
|
@@ -20,6 +20,8 @@ const PANEL_CHAPTER_LIMIT = 30;
|
|
|
20
20
|
/** Project-relative locations. Every write goes through these constants. */
|
|
21
21
|
const PROJECT_FILE = "novel.yaml";
|
|
22
22
|
const OUTLINE_FILE = "outline/outline.yaml";
|
|
23
|
+
/** Last outline replaced by a normal planning write; one level is enough for repair. */
|
|
24
|
+
const OUTLINE_DEPRECATED_FILE = "outline/outline.deprecated.yaml";
|
|
23
25
|
const WORLDBOOK_FILE = "outline/worldbook.yaml";
|
|
24
26
|
const STATE_DIR = "state";
|
|
25
27
|
const CHAPTERS_DIR = "chapters";
|
|
@@ -243,6 +245,9 @@ const ERROR_CODES = {
|
|
|
243
245
|
/** The exact same keyed submission already failed in the same context. */
|
|
244
246
|
RETRY_UNCHANGED: "ENGINE_RETRY_UNCHANGED",
|
|
245
247
|
GATE_BLOCKED: "ENGINE_GATE_BLOCKED",
|
|
248
|
+
CHECK_SUBMISSION_INVALID: "ENGINE_CHECK_SUBMISSION_INVALID",
|
|
249
|
+
PENDING_PARTIAL: "ENGINE_PENDING_PARTIAL",
|
|
250
|
+
IO_PERMISSION: "ENGINE_IO_PERMISSION",
|
|
246
251
|
READING_PROOF_INVALID: "ENGINE_READING_PROOF_INVALID",
|
|
247
252
|
SCORE_MISMATCH: "ENGINE_SCORE_MISMATCH",
|
|
248
253
|
/** A revision tried to rewrite reviewer-owned issue fields. */
|
|
@@ -1391,7 +1396,7 @@ const duplicateParagraph = (ctx) => {
|
|
|
1391
1396
|
}
|
|
1392
1397
|
const blocker = ctx.rulepack.crosschapter.duplicate_similarity;
|
|
1393
1398
|
const severity = best.score >= blocker ? "blocker" : "advisory";
|
|
1394
|
-
return [v(CROSS_RULES.DUPLICATE_PARAGRAPH, severity, currentParagraph + 1, `paragraph ${currentParagraph + 1} reuses ${Math.round(best.score * 100)}% of paragraph ${previousParagraph
|
|
1399
|
+
return [v(CROSS_RULES.DUPLICATE_PARAGRAPH, severity, currentParagraph + 1, `paragraph ${currentParagraph + 1} reuses ${Math.round(best.score * 100)}% of paragraph ${previousParagraph + 1} from previous source ${matched.source} (advisory ≥${Math.round(advisory * 100)}%, blocker ≥${Math.round(blocker * 100)}%)`)];
|
|
1395
1400
|
};
|
|
1396
1401
|
const foreshadowDormant = (ctx) => {
|
|
1397
1402
|
const ledger = ctx.evidence.foreshadows;
|
|
@@ -1506,11 +1511,12 @@ const timeRegression = (ctx) => {
|
|
|
1506
1511
|
const ordered = [...ledger.items].sort((left, right) => left.chapter - right.chapter);
|
|
1507
1512
|
const last = ordered[ordered.length - 1];
|
|
1508
1513
|
const previous = ordered.length >= 2 ? ordered[ordered.length - 2] : null;
|
|
1509
|
-
const
|
|
1514
|
+
const currentMatch = /(?:Day\s*(\d+)|第\s*(\d+)\s*[天日])/i.exec(ctx.text);
|
|
1515
|
+
const currentFromText = currentMatch === null ? null : Number(currentMatch[1] ?? currentMatch[2]);
|
|
1510
1516
|
const current = currentFromText ?? dayOf(last.story_time);
|
|
1511
1517
|
const before = previous === null ? null : dayOf(previous.story_time);
|
|
1512
1518
|
if (current === null) return [];
|
|
1513
|
-
if (currentFromText !== null && dayOf(last.story_time) !== null && currentFromText < dayOf(last.story_time)) return [v(CROSS_RULES.TIME_REGRESSION, "advisory", ctx.
|
|
1519
|
+
if (currentFromText !== null && dayOf(last.story_time) !== null && currentFromText < dayOf(last.story_time)) return [v(CROSS_RULES.TIME_REGRESSION, "advisory", paragraphOfIndex(ctx.text, currentMatch.index), `story time goes back from ${last.story_time} to Day ${currentFromText}`)];
|
|
1514
1520
|
if (before === null || current >= before) return [];
|
|
1515
1521
|
return [v(CROSS_RULES.TIME_REGRESSION, "advisory", last.chapter, `story time goes back from ${previous.story_time} to ${last.story_time}`)];
|
|
1516
1522
|
};
|
|
@@ -1920,6 +1926,8 @@ const SCHEMAS = {
|
|
|
1920
1926
|
machine: req("object"),
|
|
1921
1927
|
semantic: req("object"),
|
|
1922
1928
|
acknowledged: f("array"),
|
|
1929
|
+
failure_class: f("string", { enum: ["submission", "content"] }),
|
|
1930
|
+
counted: f("boolean"),
|
|
1923
1931
|
verdict: req("string", { enum: ["pass", "fail"] })
|
|
1924
1932
|
},
|
|
1925
1933
|
worldPlan: {
|
|
@@ -2002,6 +2010,15 @@ const nullableInteger = (kind, value, path) => {
|
|
|
2002
2010
|
const validateRecords = (kind, value, path, validate) => {
|
|
2003
2011
|
for (const [index, raw] of listAt(kind, value, path).entries()) validate(objectAt(kind, raw, `${path}[${index}]`), `${path}[${index}]`);
|
|
2004
2012
|
};
|
|
2013
|
+
const duplicateNames = (kind, rows, path, nameOf) => {
|
|
2014
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2015
|
+
for (const row of rows) {
|
|
2016
|
+
const name = nameOf(row);
|
|
2017
|
+
if (typeof name !== "string" || name === "") continue;
|
|
2018
|
+
if (seen.has(name)) invalid$1(kind, `${path} contains duplicate name "${name}"`);
|
|
2019
|
+
seen.add(name);
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2005
2022
|
const validateSummary = (kind, value, path) => {
|
|
2006
2023
|
const summary = objectAt(kind, value, path);
|
|
2007
2024
|
for (const key of [
|
|
@@ -2095,6 +2112,7 @@ const validateNested = (kind, record) => {
|
|
|
2095
2112
|
listAt(kind, row.growth, `${path}.growth`);
|
|
2096
2113
|
integerAt(kind, row.updated_at, `${path}.updated_at`);
|
|
2097
2114
|
});
|
|
2115
|
+
duplicateNames(kind, record.characters, "characters", (row) => row.name);
|
|
2098
2116
|
return;
|
|
2099
2117
|
case "world":
|
|
2100
2118
|
validateRecords(kind, record.locations, "locations", (row, path) => {
|
|
@@ -2112,6 +2130,20 @@ const validateNested = (kind, record) => {
|
|
|
2112
2130
|
"factions",
|
|
2113
2131
|
"rules"
|
|
2114
2132
|
]) listAt(kind, record[key], key);
|
|
2133
|
+
{
|
|
2134
|
+
const names = [];
|
|
2135
|
+
for (const key of [
|
|
2136
|
+
"locations",
|
|
2137
|
+
"abilities",
|
|
2138
|
+
"artifacts",
|
|
2139
|
+
"factions",
|
|
2140
|
+
"rules"
|
|
2141
|
+
]) {
|
|
2142
|
+
const rows = Array.isArray(record[key]) ? record[key] : [];
|
|
2143
|
+
for (const row of rows) if (typeof row.name === "string") names.push(row.name);
|
|
2144
|
+
}
|
|
2145
|
+
if (new Set(names).size !== names.length) invalid$1(kind, "world entities must have unique names");
|
|
2146
|
+
}
|
|
2115
2147
|
return;
|
|
2116
2148
|
case "locks": {
|
|
2117
2149
|
integerAt(kind, record.locked_at_chapter, "locked_at_chapter");
|
|
@@ -2185,6 +2217,12 @@ const validateNested = (kind, record) => {
|
|
|
2185
2217
|
stringAt(kind, row.evidence, `${path}.evidence`);
|
|
2186
2218
|
if (row.note !== void 0) stringAt(kind, row.note, `${path}.note`);
|
|
2187
2219
|
});
|
|
2220
|
+
if (semantic.findings !== void 0) validateRecords(kind, semantic.findings, "semantic.findings", (row, path) => {
|
|
2221
|
+
stringAt(kind, row.code, `${path}.code`);
|
|
2222
|
+
stringAt(kind, row.detail, `${path}.detail`);
|
|
2223
|
+
if (row.scope !== void 0) stringAt(kind, row.scope, `${path}.scope`);
|
|
2224
|
+
if (row.hint !== void 0) stringAt(kind, row.hint, `${path}.hint`);
|
|
2225
|
+
});
|
|
2188
2226
|
return;
|
|
2189
2227
|
}
|
|
2190
2228
|
case "outline": {
|
|
@@ -2384,7 +2422,7 @@ var FsStore = class {
|
|
|
2384
2422
|
if ((await promises.lstat(current)).isSymbolicLink()) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": "${segment}" is a symlink`);
|
|
2385
2423
|
} catch (error) {
|
|
2386
2424
|
if (isMissing(error)) break;
|
|
2387
|
-
throw error;
|
|
2425
|
+
throw ioFailure(error);
|
|
2388
2426
|
}
|
|
2389
2427
|
}
|
|
2390
2428
|
return absolute;
|
|
@@ -2401,7 +2439,7 @@ var FsStore = class {
|
|
|
2401
2439
|
return true;
|
|
2402
2440
|
} catch (error) {
|
|
2403
2441
|
if (isMissing(error)) return false;
|
|
2404
|
-
throw error;
|
|
2442
|
+
throw ioFailure(error);
|
|
2405
2443
|
}
|
|
2406
2444
|
}
|
|
2407
2445
|
async stat(relativePath) {
|
|
@@ -2414,7 +2452,7 @@ var FsStore = class {
|
|
|
2414
2452
|
};
|
|
2415
2453
|
} catch (error) {
|
|
2416
2454
|
if (isMissing(error)) return null;
|
|
2417
|
-
throw error;
|
|
2455
|
+
throw ioFailure(error);
|
|
2418
2456
|
}
|
|
2419
2457
|
}
|
|
2420
2458
|
async list(relativeDir) {
|
|
@@ -2422,7 +2460,7 @@ var FsStore = class {
|
|
|
2422
2460
|
return (await promises.readdir(await this.safePath(relativeDir))).sort();
|
|
2423
2461
|
} catch (error) {
|
|
2424
2462
|
if (isMissing(error)) return [];
|
|
2425
|
-
throw error;
|
|
2463
|
+
throw ioFailure(error);
|
|
2426
2464
|
}
|
|
2427
2465
|
}
|
|
2428
2466
|
/**
|
|
@@ -2439,7 +2477,7 @@ var FsStore = class {
|
|
|
2439
2477
|
})).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
2440
2478
|
} catch (error) {
|
|
2441
2479
|
if (isMissing(error)) return [];
|
|
2442
|
-
throw error;
|
|
2480
|
+
throw ioFailure(error);
|
|
2443
2481
|
}
|
|
2444
2482
|
}
|
|
2445
2483
|
/**
|
|
@@ -2457,7 +2495,7 @@ var FsStore = class {
|
|
|
2457
2495
|
};
|
|
2458
2496
|
} catch (error) {
|
|
2459
2497
|
if (isMissing(error)) return null;
|
|
2460
|
-
throw error;
|
|
2498
|
+
throw ioFailure(error);
|
|
2461
2499
|
}
|
|
2462
2500
|
}
|
|
2463
2501
|
/**
|
|
@@ -2477,7 +2515,7 @@ var FsStore = class {
|
|
|
2477
2515
|
};
|
|
2478
2516
|
} catch (error) {
|
|
2479
2517
|
if (isMissing(error)) return null;
|
|
2480
|
-
throw error;
|
|
2518
|
+
throw ioFailure(error);
|
|
2481
2519
|
}
|
|
2482
2520
|
}
|
|
2483
2521
|
async readText(relativePath) {
|
|
@@ -2485,7 +2523,7 @@ var FsStore = class {
|
|
|
2485
2523
|
return await promises.readFile(await this.safePath(relativePath), "utf8");
|
|
2486
2524
|
} catch (error) {
|
|
2487
2525
|
if (isMissing(error)) return null;
|
|
2488
|
-
throw error;
|
|
2526
|
+
throw ioFailure(error);
|
|
2489
2527
|
}
|
|
2490
2528
|
}
|
|
2491
2529
|
/** Read and narrow one of the twelve engine documents. */
|
|
@@ -2519,7 +2557,7 @@ var FsStore = class {
|
|
|
2519
2557
|
await promises.mkdir(current);
|
|
2520
2558
|
} catch (error) {
|
|
2521
2559
|
if (error.code === "EEXIST") continue;
|
|
2522
|
-
throw error;
|
|
2560
|
+
throw ioFailure(error);
|
|
2523
2561
|
}
|
|
2524
2562
|
}
|
|
2525
2563
|
}
|
|
@@ -2540,7 +2578,7 @@ var FsStore = class {
|
|
|
2540
2578
|
await syncDirectory(dirname(absolute));
|
|
2541
2579
|
} catch (error) {
|
|
2542
2580
|
await promises.rm(temp, { force: true }).catch(() => void 0);
|
|
2543
|
-
throw error;
|
|
2581
|
+
throw ioFailure(error);
|
|
2544
2582
|
}
|
|
2545
2583
|
}
|
|
2546
2584
|
async writeDoc(relativePath, document) {
|
|
@@ -2585,7 +2623,7 @@ var FsStore = class {
|
|
|
2585
2623
|
return await promises.readFile(await this.safePath(relativePath));
|
|
2586
2624
|
} catch (error) {
|
|
2587
2625
|
if (isMissing(error)) return null;
|
|
2588
|
-
throw error;
|
|
2626
|
+
throw ioFailure(error);
|
|
2589
2627
|
}
|
|
2590
2628
|
}
|
|
2591
2629
|
async writeBytes(relativePath, bytes) {
|
|
@@ -2604,7 +2642,7 @@ var FsStore = class {
|
|
|
2604
2642
|
await syncDirectory(dirname(absolute));
|
|
2605
2643
|
} catch (error) {
|
|
2606
2644
|
await promises.rm(temp, { force: true }).catch(() => void 0);
|
|
2607
|
-
throw error;
|
|
2645
|
+
throw ioFailure(error);
|
|
2608
2646
|
}
|
|
2609
2647
|
}
|
|
2610
2648
|
/** Content hash of a file, or null when missing. */
|
|
@@ -2632,6 +2670,12 @@ async function syncDirectory(directory) {
|
|
|
2632
2670
|
function isMissing(error) {
|
|
2633
2671
|
return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
2634
2672
|
}
|
|
2673
|
+
function ioFailure(error) {
|
|
2674
|
+
if (error instanceof EngineFaultError) return error;
|
|
2675
|
+
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
|
2676
|
+
if (code === "EACCES" || code === "EPERM" || code === "EROFS") return new EngineFaultError(ERROR_CODES.IO_PERMISSION, `filesystem permission denied (${code}): ${error instanceof Error ? error.message : String(error)}`, "fix the project directory permissions, or restore a readable copy before retrying", { cause: error });
|
|
2677
|
+
return error;
|
|
2678
|
+
}
|
|
2635
2679
|
/** Join project-relative segments with forward slashes. */
|
|
2636
2680
|
function joinRel(...parts) {
|
|
2637
2681
|
return parts.filter((part) => part !== "").join("/");
|
|
@@ -2755,21 +2799,18 @@ async function reconcileJournal(store) {
|
|
|
2755
2799
|
};
|
|
2756
2800
|
}
|
|
2757
2801
|
/**
|
|
2758
|
-
*
|
|
2759
|
-
*
|
|
2760
|
-
*
|
|
2761
|
-
* before touching a target". A fully matching pending transaction is promoted
|
|
2762
|
-
* to committed; a transaction whose targets all still match their pre-images
|
|
2763
|
-
* is closed as aborted; any mixture is refused as a partial commit.
|
|
2802
|
+
* Read-only classification of the last pending transaction. Status/doctor/
|
|
2803
|
+
* audit must never call recoverPending: recovery writes a terminal journal row,
|
|
2804
|
+
* and a read command is not allowed to mutate the project.
|
|
2764
2805
|
*/
|
|
2765
|
-
async function
|
|
2806
|
+
async function inspectPending(store) {
|
|
2766
2807
|
const { records } = await readJournal(store);
|
|
2767
2808
|
const last = [...records].reverse().find((record) => record.op !== "lock_reclaimed");
|
|
2768
|
-
if (last?.status !== "pending") return;
|
|
2809
|
+
if (last?.status !== "pending") return { state: "none" };
|
|
2769
2810
|
const matchesExpected = async () => {
|
|
2770
2811
|
if (last.hashes === void 0) return false;
|
|
2771
2812
|
for (const [path, expected] of Object.entries(last.hashes)) if (await store.hashBytes(path) !== expected) return false;
|
|
2772
|
-
return
|
|
2813
|
+
return Object.keys(last.hashes).length > 0;
|
|
2773
2814
|
};
|
|
2774
2815
|
const matchesBefore = async () => {
|
|
2775
2816
|
if (last.before === void 0) return false;
|
|
@@ -2777,9 +2818,45 @@ async function recoverPending(store) {
|
|
|
2777
2818
|
const actual = await store.hashBytes(path);
|
|
2778
2819
|
if (expected === null ? actual !== null : actual !== expected) return false;
|
|
2779
2820
|
}
|
|
2780
|
-
return
|
|
2821
|
+
return Object.keys(last.before).length > 0;
|
|
2822
|
+
};
|
|
2823
|
+
if (await matchesExpected()) return {
|
|
2824
|
+
state: "complete",
|
|
2825
|
+
txid: last.txid,
|
|
2826
|
+
files: last.files,
|
|
2827
|
+
detail: "all target files match the pending hashes; the next write can promote this transaction",
|
|
2828
|
+
stagingDir: joinRel(STAGING_DIR, last.txid)
|
|
2829
|
+
};
|
|
2830
|
+
if (await matchesBefore()) return {
|
|
2831
|
+
state: "untouched",
|
|
2832
|
+
txid: last.txid,
|
|
2833
|
+
files: last.files,
|
|
2834
|
+
detail: "no target changed; the next write can close this transaction as aborted",
|
|
2835
|
+
stagingDir: joinRel(STAGING_DIR, last.txid)
|
|
2781
2836
|
};
|
|
2782
|
-
|
|
2837
|
+
return {
|
|
2838
|
+
state: "partial",
|
|
2839
|
+
txid: last.txid,
|
|
2840
|
+
files: last.files,
|
|
2841
|
+
detail: "some targets changed and others did not; automatic recovery is unsafe",
|
|
2842
|
+
stagingDir: joinRel(STAGING_DIR, last.txid)
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Resolve a pending transaction left by a crash. The pending row is written
|
|
2847
|
+
* after the pre-images are captured and before the first rename, so it is the
|
|
2848
|
+
* only record that can distinguish "renamed but not journaled" from "crashed
|
|
2849
|
+
* before touching a target". A fully matching pending transaction is promoted
|
|
2850
|
+
* to committed; a transaction whose targets all still match their pre-images
|
|
2851
|
+
* is closed as aborted; any mixture is refused as a partial commit.
|
|
2852
|
+
*/
|
|
2853
|
+
async function recoverPending(store) {
|
|
2854
|
+
const inspection = await inspectPending(store);
|
|
2855
|
+
if (inspection.state === "none" || inspection.txid === void 0) return;
|
|
2856
|
+
const { records } = await readJournal(store);
|
|
2857
|
+
const last = [...records].reverse().find((record) => record.txid === inspection.txid);
|
|
2858
|
+
if (last === void 0 || last.status !== "pending") return;
|
|
2859
|
+
if (inspection.state === "complete") {
|
|
2783
2860
|
await appendJournal(store, {
|
|
2784
2861
|
...last,
|
|
2785
2862
|
status: "committed"
|
|
@@ -2787,7 +2864,7 @@ async function recoverPending(store) {
|
|
|
2787
2864
|
await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
|
|
2788
2865
|
return;
|
|
2789
2866
|
}
|
|
2790
|
-
if (
|
|
2867
|
+
if (inspection.state === "untouched") {
|
|
2791
2868
|
await appendJournal(store, {
|
|
2792
2869
|
...last,
|
|
2793
2870
|
status: "aborted",
|
|
@@ -2797,7 +2874,7 @@ async function recoverPending(store) {
|
|
|
2797
2874
|
await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
|
|
2798
2875
|
return;
|
|
2799
2876
|
}
|
|
2800
|
-
throw new EngineFaultError(ERROR_CODES.
|
|
2877
|
+
throw new EngineFaultError(ERROR_CODES.PENDING_PARTIAL, `pending transaction ${last.txid} partially changed the project; automatic recovery is unsafe`, `inspect ${STAGING_DIR}/${last.txid}/before/ and state/journal.jsonl before restoring anything`);
|
|
2801
2878
|
}
|
|
2802
2879
|
/**
|
|
2803
2880
|
* A crash during append can leave one JSONL fragment without a newline. Since
|
|
@@ -2989,22 +3066,43 @@ var FileLock = class {
|
|
|
2989
3066
|
if (Date.now() - mtimeMs <= this.staleMs) return false;
|
|
2990
3067
|
} else if (lockRecordIsLive(record)) return false;
|
|
2991
3068
|
const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
|
|
3069
|
+
let claimByRename = false;
|
|
2992
3070
|
try {
|
|
2993
3071
|
await promises.link(this.absolutePath, claimPath);
|
|
2994
3072
|
} catch (error) {
|
|
2995
3073
|
const code = error.code;
|
|
2996
3074
|
if (code === "ENOENT") return true;
|
|
2997
3075
|
if (code === "EPERM" || code === "EACCES") return false;
|
|
2998
|
-
|
|
3076
|
+
if ([
|
|
3077
|
+
"ENOSYS",
|
|
3078
|
+
"ENOTSUP",
|
|
3079
|
+
"EOPNOTSUPP",
|
|
3080
|
+
"EXDEV",
|
|
3081
|
+
"EINVAL"
|
|
3082
|
+
].includes(code ?? "")) try {
|
|
3083
|
+
await promises.rename(this.absolutePath, claimPath);
|
|
3084
|
+
claimByRename = true;
|
|
3085
|
+
} catch (renameError) {
|
|
3086
|
+
if (renameError.code === "ENOENT") return true;
|
|
3087
|
+
throw renameError;
|
|
3088
|
+
}
|
|
3089
|
+
else throw error;
|
|
2999
3090
|
}
|
|
3000
3091
|
const moved = await this.readAt(claimPath);
|
|
3001
3092
|
const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
|
|
3002
3093
|
if (!(record === null ? moved === null && Date.now() - claimMtime > this.staleMs : moved !== null && moved.pid === record.pid && moved.startedAt === record.startedAt && moved.nonce === record.nonce)) {
|
|
3003
|
-
await promises.
|
|
3094
|
+
if (claimByRename) await promises.access(this.absolutePath).then(async () => {
|
|
3095
|
+
await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
3096
|
+
}, async () => {
|
|
3097
|
+
await promises.rename(claimPath, this.absolutePath).catch(async () => {
|
|
3098
|
+
await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
3099
|
+
});
|
|
3100
|
+
});
|
|
3101
|
+
else await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
3004
3102
|
return false;
|
|
3005
3103
|
}
|
|
3006
|
-
const current = await this.readAt(this.absolutePath);
|
|
3007
|
-
if (!(record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce)) {
|
|
3104
|
+
const current = claimByRename ? null : await this.readAt(this.absolutePath);
|
|
3105
|
+
if (!(claimByRename || (record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce))) {
|
|
3008
3106
|
await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
3009
3107
|
return false;
|
|
3010
3108
|
}
|
|
@@ -3023,6 +3121,7 @@ const BACKUP_MANIFEST = "manifest.json";
|
|
|
3023
3121
|
async function listBackups(store) {
|
|
3024
3122
|
const entries = [];
|
|
3025
3123
|
for (const name of await store.list(BACKUP_DIR)) {
|
|
3124
|
+
if (name === "migrations") continue;
|
|
3026
3125
|
const info = await store.stat(joinRel(BACKUP_DIR, name));
|
|
3027
3126
|
if (info === null || !info.isDirectory) continue;
|
|
3028
3127
|
if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
|
|
@@ -3810,7 +3909,7 @@ function buildEpub(project, chapters) {
|
|
|
3810
3909
|
const escapeXml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3811
3910
|
// src/engine/book.ts
|
|
3812
3911
|
const LENIENT_TITLE_PREFIX$1 = /^第\s*\d+\s*章\s*/;
|
|
3813
|
-
const text$
|
|
3912
|
+
const text$2 = (value, where, max = LIMITS.maxOutlineTextFieldChars) => {
|
|
3814
3913
|
if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
|
|
3815
3914
|
const trimmed = value.trim();
|
|
3816
3915
|
if (Array.from(trimmed).length > max) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} is ${Array.from(trimmed).length} characters; limit is ${max}`);
|
|
@@ -3829,13 +3928,13 @@ const stringArray$1 = (value, where, maxItems = 20) => {
|
|
|
3829
3928
|
if (value.length > maxItems) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains ${value.length} items; limit is ${maxItems}`);
|
|
3830
3929
|
const seen = /* @__PURE__ */ new Set();
|
|
3831
3930
|
return value.map((item, index) => {
|
|
3832
|
-
const entry = text$
|
|
3931
|
+
const entry = text$2(item, `${where}[${index}]`, 200);
|
|
3833
3932
|
if (seen.has(entry)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains duplicate "${entry}"`);
|
|
3834
3933
|
seen.add(entry);
|
|
3835
3934
|
return entry;
|
|
3836
3935
|
});
|
|
3837
3936
|
};
|
|
3838
|
-
const positiveInteger = (value, where) => {
|
|
3937
|
+
const positiveInteger$1 = (value, where) => {
|
|
3839
3938
|
if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
|
|
3840
3939
|
return value;
|
|
3841
3940
|
};
|
|
@@ -3845,7 +3944,7 @@ const wordPair = (value, where) => {
|
|
|
3845
3944
|
if (!Number.isInteger(min) || !Number.isInteger(max) || min <= 0 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be [min, max] positive integers with min <= max`);
|
|
3846
3945
|
return [min, max];
|
|
3847
3946
|
};
|
|
3848
|
-
const isRecord$
|
|
3947
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3849
3948
|
/** Remove a leading 第N章 prefix; stored titles are bodies. */
|
|
3850
3949
|
function stripChapterPrefix(title) {
|
|
3851
3950
|
return title.replace(LENIENT_TITLE_PREFIX$1, "").trim();
|
|
@@ -3947,7 +4046,7 @@ const BOOK_PATCH_KEYS = /* @__PURE__ */ new Set([
|
|
|
3947
4046
|
/** Apply a partial book patch; omitted fields keep their existing value. */
|
|
3948
4047
|
function mergeBookPlan(existing, patch) {
|
|
3949
4048
|
if (patch === void 0) return { ...existing };
|
|
3950
|
-
if (!isRecord$
|
|
4049
|
+
if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
|
|
3951
4050
|
for (const key of Object.keys(patch)) if (!BOOK_PATCH_KEYS.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `book has unknown field "${key}"`, `allowed: ${[...BOOK_PATCH_KEYS].join(", ")}`);
|
|
3952
4051
|
const result = { ...existing };
|
|
3953
4052
|
for (const key of [
|
|
@@ -3975,15 +4074,15 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
|
|
|
3975
4074
|
if (!Array.isArray(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
|
|
3976
4075
|
if (input.length > LIMITS.maxOutlineVolumes) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains ${input.length} items; limit is ${LIMITS.maxOutlineVolumes}`);
|
|
3977
4076
|
const volumes = input.map((raw, index) => {
|
|
3978
|
-
if (!isRecord$
|
|
3979
|
-
const start = positiveInteger(raw.start_chapter, `volumes[${index}].start_chapter`);
|
|
3980
|
-
const end = positiveInteger(raw.end_chapter, `volumes[${index}].end_chapter`);
|
|
4077
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
|
|
4078
|
+
const start = positiveInteger$1(raw.start_chapter, `volumes[${index}].start_chapter`);
|
|
4079
|
+
const end = positiveInteger$1(raw.end_chapter, `volumes[${index}].end_chapter`);
|
|
3981
4080
|
if (end < start) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] end_chapter ${end} is before start_chapter ${start}`);
|
|
3982
4081
|
const status = raw.status ?? "planned";
|
|
3983
4082
|
if (status !== "planned" && status !== "writing" && status !== "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}].status must be planned|writing|done`);
|
|
3984
4083
|
return {
|
|
3985
|
-
id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() :
|
|
3986
|
-
title: text$
|
|
4084
|
+
id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : "",
|
|
4085
|
+
title: text$2(raw.title, `volumes[${index}].title`, 120),
|
|
3987
4086
|
start_chapter: start,
|
|
3988
4087
|
end_chapter: end,
|
|
3989
4088
|
goal: optionalText(raw.goal, `volumes[${index}].goal`),
|
|
@@ -3992,6 +4091,23 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
|
|
|
3992
4091
|
status
|
|
3993
4092
|
};
|
|
3994
4093
|
});
|
|
4094
|
+
const usedIds = /* @__PURE__ */ new Set();
|
|
4095
|
+
const usedTitles = /* @__PURE__ */ new Set();
|
|
4096
|
+
for (const volume of volumes) {
|
|
4097
|
+
if (usedTitles.has(volume.title) && options.rejectDuplicateTitles !== false) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume title "${volume.title}"`);
|
|
4098
|
+
usedTitles.add(volume.title);
|
|
4099
|
+
if (volume.id === "") continue;
|
|
4100
|
+
if (usedIds.has(volume.id)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume id "${volume.id}"`);
|
|
4101
|
+
usedIds.add(volume.id);
|
|
4102
|
+
}
|
|
4103
|
+
let nextId = 1;
|
|
4104
|
+
for (const volume of volumes) {
|
|
4105
|
+
if (volume.id !== "") continue;
|
|
4106
|
+
while (usedIds.has(`V${nextId}`)) nextId += 1;
|
|
4107
|
+
volume.id = `V${nextId}`;
|
|
4108
|
+
usedIds.add(volume.id);
|
|
4109
|
+
nextId += 1;
|
|
4110
|
+
}
|
|
3995
4111
|
volumes.sort((left, right) => left.start_chapter - right.start_chapter || left.end_chapter - right.end_chapter);
|
|
3996
4112
|
const ids = /* @__PURE__ */ new Set();
|
|
3997
4113
|
for (const [index, volume] of volumes.entries()) {
|
|
@@ -4075,8 +4191,8 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4075
4191
|
const archived = new Map(options.archived.map((record) => [record.num, record]));
|
|
4076
4192
|
const normalized = /* @__PURE__ */ new Map();
|
|
4077
4193
|
for (const [index, raw] of input.entries()) {
|
|
4078
|
-
if (!isRecord$
|
|
4079
|
-
const num = positiveInteger(raw.num, `chapters[${index}].num`);
|
|
4194
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
|
|
4195
|
+
const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
|
|
4080
4196
|
if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`, "raise total_chapters with novel_blueprint before planning a longer book");
|
|
4081
4197
|
if (normalized.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
|
|
4082
4198
|
const record = archived.get(num);
|
|
@@ -4093,13 +4209,13 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4093
4209
|
if (cast.length !== base.cast.length || cast.some((name) => !base.cast.includes(name))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} is archived; its cast cannot change`, "call novel_reopen first if the manuscript must be revised");
|
|
4094
4210
|
normalized.set(num, {
|
|
4095
4211
|
...base,
|
|
4096
|
-
goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$
|
|
4097
|
-
conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$
|
|
4098
|
-
hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$
|
|
4212
|
+
goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$2(raw.goal, `chapter ${num} goal`),
|
|
4213
|
+
conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$2(raw.conflict, `chapter ${num} conflict`),
|
|
4214
|
+
hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$2(raw.hook, `chapter ${num} hook`),
|
|
4099
4215
|
notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
|
|
4100
4216
|
volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
|
|
4101
4217
|
status: "archived",
|
|
4102
|
-
archived_at: base.archived_at ??
|
|
4218
|
+
archived_at: base.archived_at ?? record.archived_at ?? null,
|
|
4103
4219
|
word_count: record.word_count,
|
|
4104
4220
|
score: record.score
|
|
4105
4221
|
});
|
|
@@ -4114,18 +4230,19 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4114
4230
|
"情感爆发章",
|
|
4115
4231
|
"纯叙事章"
|
|
4116
4232
|
].includes(raw.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}].scene_type is invalid`);
|
|
4117
|
-
const
|
|
4233
|
+
const previousCard = existing.get(num);
|
|
4234
|
+
const cast = raw.cast == null && previousCard?.status === "planned" ? previousCard.cast : stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
|
|
4118
4235
|
normalized.set(num, {
|
|
4119
4236
|
num,
|
|
4120
|
-
volume: volumeNumberFor(options.volumes, num, raw.volume),
|
|
4237
|
+
volume: raw.volume !== void 0 ? volumeNumberFor(options.volumes, num, raw.volume) : previousCard?.status === "planned" ? volumeForChapter(options.volumes, num, previousCard.volume) : volumeNumberFor(options.volumes, num, void 0),
|
|
4121
4238
|
title,
|
|
4122
4239
|
scene_type: raw.scene_type,
|
|
4123
|
-
goal: text$
|
|
4124
|
-
conflict: text$
|
|
4125
|
-
hook: text$
|
|
4240
|
+
goal: text$2(raw.goal, `chapters[${index}].goal`),
|
|
4241
|
+
conflict: text$2(raw.conflict, `chapters[${index}].conflict`),
|
|
4242
|
+
hook: text$2(raw.hook, `chapters[${index}].hook`),
|
|
4126
4243
|
cast,
|
|
4127
4244
|
target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
|
|
4128
|
-
notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
|
|
4245
|
+
notes: raw.notes == null && previousCard?.status === "planned" ? previousCard.notes : optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
|
|
4129
4246
|
status: "planned",
|
|
4130
4247
|
archived_at: null,
|
|
4131
4248
|
word_count: null,
|
|
@@ -4139,7 +4256,7 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4139
4256
|
...base,
|
|
4140
4257
|
volume: volumeForChapter(options.volumes, record.num, base.volume),
|
|
4141
4258
|
status: "archived",
|
|
4142
|
-
archived_at: base.archived_at ??
|
|
4259
|
+
archived_at: base.archived_at ?? record.archived_at ?? null,
|
|
4143
4260
|
word_count: record.word_count,
|
|
4144
4261
|
score: record.score
|
|
4145
4262
|
});
|
|
@@ -4148,7 +4265,7 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4148
4265
|
}
|
|
4149
4266
|
/** Validate a document loaded from disk. Throws a fault, not an input error. */
|
|
4150
4267
|
function validateOutlineDocument(value, options) {
|
|
4151
|
-
if (!isRecord$
|
|
4268
|
+
if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
|
|
4152
4269
|
try {
|
|
4153
4270
|
if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml schema_version must be 1, got ${String(value.schema_version)}`);
|
|
4154
4271
|
const allowedTopLevel = /* @__PURE__ */ new Set([
|
|
@@ -4160,7 +4277,7 @@ function validateOutlineDocument(value, options) {
|
|
|
4160
4277
|
]);
|
|
4161
4278
|
for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml has unknown key "${key}"`);
|
|
4162
4279
|
if (typeof value.updated_at !== "string" || value.updated_at.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml updated_at must be a string");
|
|
4163
|
-
if (!isRecord$
|
|
4280
|
+
if (!isRecord$2(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
|
|
4164
4281
|
const allowedBook = /* @__PURE__ */ new Set([
|
|
4165
4282
|
"premise",
|
|
4166
4283
|
"logline",
|
|
@@ -4175,14 +4292,17 @@ function validateOutlineDocument(value, options) {
|
|
|
4175
4292
|
]);
|
|
4176
4293
|
for (const key of Object.keys(value.book)) if (!allowedBook.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml book has unknown key "${key}"`);
|
|
4177
4294
|
const book = mergeBookPlan(emptyBookPlan(), value.book);
|
|
4178
|
-
const volumes = normalizeVolumes(value.volumes, {
|
|
4295
|
+
const volumes = normalizeVolumes(value.volumes, {
|
|
4296
|
+
totalChapters: options.totalChapters,
|
|
4297
|
+
rejectDuplicateTitles: false
|
|
4298
|
+
});
|
|
4179
4299
|
if (!Array.isArray(value.chapters)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapters must be an array");
|
|
4180
4300
|
if (value.chapters.length > LIMITS.maxOutlineChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters contains ${value.chapters.length} items; limit is ${LIMITS.maxOutlineChapters}`);
|
|
4181
4301
|
const chapters = [];
|
|
4182
4302
|
const seen = /* @__PURE__ */ new Set();
|
|
4183
4303
|
for (const [index, raw] of value.chapters.entries()) {
|
|
4184
|
-
if (!isRecord$
|
|
4185
|
-
const num = positiveInteger(raw.num, `chapters[${index}].num`);
|
|
4304
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
|
|
4305
|
+
const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
|
|
4186
4306
|
if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`);
|
|
4187
4307
|
if (seen.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
|
|
4188
4308
|
seen.add(num);
|
|
@@ -4208,9 +4328,9 @@ function validateOutlineDocument(value, options) {
|
|
|
4208
4328
|
volume: volumeNumberFor(volumes, num, raw.volume),
|
|
4209
4329
|
title: chapterTitleBody(raw.title, options.titleRule, `chapters[${index}].title`),
|
|
4210
4330
|
scene_type: scene,
|
|
4211
|
-
goal: planned ? text$
|
|
4212
|
-
conflict: planned ? text$
|
|
4213
|
-
hook: planned ? text$
|
|
4331
|
+
goal: planned ? text$2(raw.goal, `chapters[${index}].goal`) : optionalText(raw.goal, `chapters[${index}].goal`),
|
|
4332
|
+
conflict: planned ? text$2(raw.conflict, `chapters[${index}].conflict`) : optionalText(raw.conflict, `chapters[${index}].conflict`),
|
|
4333
|
+
hook: planned ? text$2(raw.hook, `chapters[${index}].hook`) : optionalText(raw.hook, `chapters[${index}].hook`),
|
|
4214
4334
|
cast: stringArray$1(raw.cast, `chapters[${index}].cast`, 8),
|
|
4215
4335
|
target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
|
|
4216
4336
|
notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
|
|
@@ -4247,7 +4367,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4247
4367
|
...existing,
|
|
4248
4368
|
updated_at: now
|
|
4249
4369
|
};
|
|
4250
|
-
if (!isRecord$
|
|
4370
|
+
if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
|
|
4251
4371
|
const result = {
|
|
4252
4372
|
...existing,
|
|
4253
4373
|
updated_at: now,
|
|
@@ -4267,21 +4387,21 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4267
4387
|
if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} must be an array`);
|
|
4268
4388
|
if (value.length > LIMITS.maxWorldbookEntries) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} contains ${value.length} items; limit is ${LIMITS.maxWorldbookEntries}`);
|
|
4269
4389
|
return value.map((raw, index) => {
|
|
4270
|
-
if (!isRecord$
|
|
4390
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
|
|
4271
4391
|
return raw;
|
|
4272
4392
|
});
|
|
4273
4393
|
};
|
|
4274
4394
|
if (patch.rules !== void 0) {
|
|
4275
4395
|
result.rules = records(patch.rules, "rules").map((raw, index) => ({
|
|
4276
|
-
name: text$
|
|
4277
|
-
description: text$
|
|
4396
|
+
name: text$2(raw.name, `rules[${index}].name`, 120),
|
|
4397
|
+
description: text$2(raw.description, `rules[${index}].description`, LIMITS.maxWorldbookTextChars)
|
|
4278
4398
|
}));
|
|
4279
4399
|
uniqueNames(result.rules, "rules");
|
|
4280
4400
|
}
|
|
4281
4401
|
if (patch.factions !== void 0) {
|
|
4282
4402
|
result.factions = records(patch.factions, "factions").map((raw, index) => ({
|
|
4283
|
-
name: text$
|
|
4284
|
-
description: text$
|
|
4403
|
+
name: text$2(raw.name, `factions[${index}].name`, 120),
|
|
4404
|
+
description: text$2(raw.description, `factions[${index}].description`, LIMITS.maxWorldbookTextChars),
|
|
4285
4405
|
goal: optionalText(raw.goal, `factions[${index}].goal`)
|
|
4286
4406
|
}));
|
|
4287
4407
|
uniqueNames(result.factions, "factions");
|
|
@@ -4291,8 +4411,8 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4291
4411
|
const capacity = raw.capacity ?? 0;
|
|
4292
4412
|
if (!Number.isInteger(capacity) || capacity < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a non-negative integer`);
|
|
4293
4413
|
return {
|
|
4294
|
-
name: text$
|
|
4295
|
-
description: text$
|
|
4414
|
+
name: text$2(raw.name, `locations[${index}].name`, 120),
|
|
4415
|
+
description: text$2(raw.description, `locations[${index}].description`, LIMITS.maxWorldbookTextChars),
|
|
4296
4416
|
layout: optionalText(raw.layout, `locations[${index}].layout`),
|
|
4297
4417
|
capacity
|
|
4298
4418
|
};
|
|
@@ -4301,17 +4421,17 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4301
4421
|
}
|
|
4302
4422
|
if (patch.characters !== void 0) {
|
|
4303
4423
|
result.characters = records(patch.characters, "characters").map((raw, index) => ({
|
|
4304
|
-
name: text$
|
|
4305
|
-
role: text$
|
|
4306
|
-
description: text$
|
|
4424
|
+
name: text$2(raw.name, `characters[${index}].name`, 120),
|
|
4425
|
+
role: text$2(raw.role, `characters[${index}].role`, 60),
|
|
4426
|
+
description: text$2(raw.description, `characters[${index}].description`, LIMITS.maxWorldbookTextChars),
|
|
4307
4427
|
arc: optionalText(raw.arc, `characters[${index}].arc`)
|
|
4308
4428
|
}));
|
|
4309
4429
|
uniqueNames(result.characters, "characters");
|
|
4310
4430
|
}
|
|
4311
4431
|
if (patch.glossary !== void 0) {
|
|
4312
4432
|
result.glossary = records(patch.glossary, "glossary").map((raw, index) => ({
|
|
4313
|
-
term: text$
|
|
4314
|
-
definition: text$
|
|
4433
|
+
term: text$2(raw.term, `glossary[${index}].term`, 120),
|
|
4434
|
+
definition: text$2(raw.definition, `glossary[${index}].definition`, LIMITS.maxWorldbookTextChars)
|
|
4315
4435
|
}));
|
|
4316
4436
|
const seen = /* @__PURE__ */ new Set();
|
|
4317
4437
|
for (const entry of result.glossary) {
|
|
@@ -4320,8 +4440,8 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4320
4440
|
}
|
|
4321
4441
|
}
|
|
4322
4442
|
if (patch.backstory !== void 0) result.backstory = records(patch.backstory, "backstory").map((raw, index) => ({
|
|
4323
|
-
time: text$
|
|
4324
|
-
event: text$
|
|
4443
|
+
time: text$2(raw.time, `backstory[${index}].time`, 120),
|
|
4444
|
+
event: text$2(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
|
|
4325
4445
|
}));
|
|
4326
4446
|
if (patch.facts !== void 0) {
|
|
4327
4447
|
result.facts = records(patch.facts, "facts").map((raw, index) => {
|
|
@@ -4330,9 +4450,9 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4330
4450
|
const tolerance = raw.tolerance ?? 0;
|
|
4331
4451
|
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`);
|
|
4332
4452
|
return {
|
|
4333
|
-
alias: text$
|
|
4453
|
+
alias: text$2(raw.alias, `facts[${index}].alias`, 120),
|
|
4334
4454
|
value,
|
|
4335
|
-
unit: text$
|
|
4455
|
+
unit: text$2(raw.unit, `facts[${index}].unit`, 40),
|
|
4336
4456
|
tolerance,
|
|
4337
4457
|
...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
|
|
4338
4458
|
};
|
|
@@ -4347,7 +4467,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4347
4467
|
}
|
|
4348
4468
|
/** Validate a worldbook loaded from disk. */
|
|
4349
4469
|
function validateWorldbookDocument(value, now) {
|
|
4350
|
-
if (!isRecord$
|
|
4470
|
+
if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
|
|
4351
4471
|
try {
|
|
4352
4472
|
if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml schema_version must be 1, got ${String(value.schema_version)}`);
|
|
4353
4473
|
const allowedTopLevel = /* @__PURE__ */ new Set([
|
|
@@ -4758,7 +4878,7 @@ function parseOutlineData(content, format) {
|
|
|
4758
4878
|
} catch (error) {
|
|
4759
4879
|
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
4760
4880
|
}
|
|
4761
|
-
if (!isRecord$
|
|
4881
|
+
if (!isRecord$2(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
|
|
4762
4882
|
if (parsed.schema_version !== void 0 && parsed.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} schema_version must be 1, got ${String(parsed.schema_version)}`);
|
|
4763
4883
|
const warnings = [];
|
|
4764
4884
|
const allowed = /* @__PURE__ */ new Set([
|
|
@@ -4853,8 +4973,238 @@ function renderWorldbookMarkdown(project, document) {
|
|
|
4853
4973
|
section("角色", document.characters.map((entry) => `- **${oneLine(entry.name)}**(${oneLine(entry.role)}):${oneLine(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine(entry.arc)}`}`));
|
|
4854
4974
|
section("术语", document.glossary.map((entry) => `- **${oneLine(entry.term)}**:${oneLine(entry.definition)}`));
|
|
4855
4975
|
section("前史", document.backstory.map((entry) => `- ${oneLine(entry.time)}:${oneLine(entry.event)}`));
|
|
4976
|
+
section("数值事实", (document.facts ?? []).map((fact) => `- **${oneLine(fact.alias)}**:${fact.value}${oneLine(fact.unit)}${fact.tolerance === void 0 ? "" : `(容差 ${fact.tolerance})`}${fact.note === void 0 || fact.note === "" ? "" : `;${oneLine(fact.note)}`}`));
|
|
4856
4977
|
return parts.join("\n");
|
|
4857
4978
|
}
|
|
4979
|
+
// src/engine/outline-merge.ts
|
|
4980
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4981
|
+
const text$1 = (value, where) => {
|
|
4982
|
+
if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
|
|
4983
|
+
return value.trim();
|
|
4984
|
+
};
|
|
4985
|
+
const positiveInteger = (value, where) => {
|
|
4986
|
+
if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
|
|
4987
|
+
return value;
|
|
4988
|
+
};
|
|
4989
|
+
const parseChapterNumbers = (value, field) => {
|
|
4990
|
+
if (value === void 0) return /* @__PURE__ */ new Set();
|
|
4991
|
+
if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of chapter numbers`);
|
|
4992
|
+
const result = /* @__PURE__ */ new Set();
|
|
4993
|
+
for (const [index, raw] of value.entries()) {
|
|
4994
|
+
const num = positiveInteger(raw, `${field}[${index}]`);
|
|
4995
|
+
if (result.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate chapter ${num}`);
|
|
4996
|
+
result.add(num);
|
|
4997
|
+
}
|
|
4998
|
+
return result;
|
|
4999
|
+
};
|
|
5000
|
+
const parseVolumeTitles = (value, field) => {
|
|
5001
|
+
if (value === void 0) return /* @__PURE__ */ new Set();
|
|
5002
|
+
if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of volume titles`);
|
|
5003
|
+
const result = /* @__PURE__ */ new Set();
|
|
5004
|
+
for (const [index, raw] of value.entries()) {
|
|
5005
|
+
const title = text$1(raw, `${field}[${index}]`);
|
|
5006
|
+
if (result.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate "${title}"`);
|
|
5007
|
+
result.add(title);
|
|
5008
|
+
}
|
|
5009
|
+
return result;
|
|
5010
|
+
};
|
|
5011
|
+
const submittedNumbers = (input) => {
|
|
5012
|
+
const result = /* @__PURE__ */ new Set();
|
|
5013
|
+
if (!Array.isArray(input)) return result;
|
|
5014
|
+
for (const raw of input) {
|
|
5015
|
+
if (!isRecord$1(raw) || !Number.isInteger(raw.num) || raw.num < 1) continue;
|
|
5016
|
+
result.add(raw.num);
|
|
5017
|
+
}
|
|
5018
|
+
return result;
|
|
5019
|
+
};
|
|
5020
|
+
const volumeByTitle = (volumes) => new Map(volumes.map((volume) => [volume.title, volume]));
|
|
5021
|
+
/**
|
|
5022
|
+
* `volumes` is an upsert keyed by title: omitted volumes survive, and removal
|
|
5023
|
+
* requires naming the volume in `remove_volumes`. This deliberately does not
|
|
5024
|
+
* accept a positional or full-list replacement.
|
|
5025
|
+
*/
|
|
5026
|
+
const mergeVolumes = (options) => {
|
|
5027
|
+
const removed = parseVolumeTitles(options.remove_volumes, "remove_volumes");
|
|
5028
|
+
if (options.volumes === void 0 && removed.size === 0) return {
|
|
5029
|
+
volumes: [...options.existingVolumes],
|
|
5030
|
+
added: [],
|
|
5031
|
+
updated: [],
|
|
5032
|
+
removed: []
|
|
5033
|
+
};
|
|
5034
|
+
if (options.volumes !== void 0 && !Array.isArray(options.volumes)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
|
|
5035
|
+
if (options.volumes !== void 0 || removed.size > 0) {
|
|
5036
|
+
const existingTitles = /* @__PURE__ */ new Set();
|
|
5037
|
+
for (const volume of options.existingVolumes) {
|
|
5038
|
+
if (existingTitles.has(volume.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline has duplicate volume title "${volume.title}"`, "repair the volume titles with novel_amend before submitting volumes");
|
|
5039
|
+
existingTitles.add(volume.title);
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
5042
|
+
const byTitle = volumeByTitle(options.existingVolumes);
|
|
5043
|
+
const usedIds = new Set(options.existingVolumes.map((volume) => volume.id));
|
|
5044
|
+
let nextGeneratedId = 1;
|
|
5045
|
+
const generatedId = () => {
|
|
5046
|
+
while (usedIds.has(`V${nextGeneratedId}`)) nextGeneratedId += 1;
|
|
5047
|
+
const id = `V${nextGeneratedId}`;
|
|
5048
|
+
usedIds.add(id);
|
|
5049
|
+
nextGeneratedId += 1;
|
|
5050
|
+
return id;
|
|
5051
|
+
};
|
|
5052
|
+
const submitted = /* @__PURE__ */ new Set();
|
|
5053
|
+
for (const [index, raw] of (options.volumes ?? []).entries()) {
|
|
5054
|
+
if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
|
|
5055
|
+
const title = text$1(raw.title, `volumes[${index}].title`);
|
|
5056
|
+
if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
|
|
5057
|
+
submitted.add(title);
|
|
5058
|
+
const base = byTitle.get(title);
|
|
5059
|
+
const explicitId = typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : void 0;
|
|
5060
|
+
const id = base?.id ?? explicitId ?? generatedId();
|
|
5061
|
+
if (!usedIds.has(id)) usedIds.add(id);
|
|
5062
|
+
byTitle.set(title, {
|
|
5063
|
+
...base ?? {},
|
|
5064
|
+
...raw,
|
|
5065
|
+
id,
|
|
5066
|
+
title
|
|
5067
|
+
});
|
|
5068
|
+
}
|
|
5069
|
+
for (const title of removed) {
|
|
5070
|
+
if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${title}" cannot be both submitted and removed`);
|
|
5071
|
+
const existing = byTitle.get(title);
|
|
5072
|
+
if (existing === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": it is not in the plan`);
|
|
5073
|
+
if (existing.status === "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": its status is done`, "a completed volume is archive evidence; remove only planned/writing volumes");
|
|
5074
|
+
byTitle.delete(title);
|
|
5075
|
+
}
|
|
5076
|
+
const volumes = normalizeVolumes([...byTitle.values()], { totalChapters: options.totalChapters });
|
|
5077
|
+
const before = volumeByTitle(options.existingVolumes);
|
|
5078
|
+
const after = volumeByTitle(volumes);
|
|
5079
|
+
return {
|
|
5080
|
+
volumes,
|
|
5081
|
+
added: [...after.keys()].filter((title) => !before.has(title)),
|
|
5082
|
+
updated: [...after.keys()].filter((title) => before.has(title) && stableJson(before.get(title)) !== stableJson(after.get(title))),
|
|
5083
|
+
removed: [...removed]
|
|
5084
|
+
};
|
|
5085
|
+
};
|
|
5086
|
+
const remapVolume = (card, oldVolumes, newVolumes) => {
|
|
5087
|
+
if (card.volume === null || card.volume === void 0) return card;
|
|
5088
|
+
const previous = oldVolumes[card.volume - 1];
|
|
5089
|
+
if (previous === void 0) return {
|
|
5090
|
+
...card,
|
|
5091
|
+
volume: null
|
|
5092
|
+
};
|
|
5093
|
+
const nextIndex = newVolumes.findIndex((volume) => volume.title === previous.title);
|
|
5094
|
+
if (nextIndex < 0) return {
|
|
5095
|
+
...card,
|
|
5096
|
+
volume: null
|
|
5097
|
+
};
|
|
5098
|
+
const next = newVolumes[nextIndex];
|
|
5099
|
+
return {
|
|
5100
|
+
...card,
|
|
5101
|
+
volume: card.num >= next.start_chapter && card.num <= next.end_chapter ? nextIndex + 1 : null
|
|
5102
|
+
};
|
|
5103
|
+
};
|
|
5104
|
+
const assertArchivedCardVolumes = (archivedChapters, cards, oldVolumes, newVolumes, operation = "outline write") => {
|
|
5105
|
+
const byNum = new Map(cards.map((card) => [card.num, card]));
|
|
5106
|
+
const newByTitle = volumeByTitle(newVolumes);
|
|
5107
|
+
for (const record of archivedChapters) {
|
|
5108
|
+
const card = byNum.get(record.num);
|
|
5109
|
+
const previous = card !== void 0 && card.volume !== null && card.volume !== void 0 ? oldVolumes[card.volume - 1] : oldVolumes.find((volume) => record.num >= volume.start_chapter && record.num <= volume.end_chapter);
|
|
5110
|
+
if (previous === void 0) continue;
|
|
5111
|
+
const next = newByTitle.get(previous.title);
|
|
5112
|
+
if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} would leave archived chapter ${record.num} outside its volume "${previous.title}"`, "keep the volume/range that covers archived cards, or call novel_reopen first");
|
|
5113
|
+
}
|
|
5114
|
+
};
|
|
5115
|
+
/**
|
|
5116
|
+
* Final-document guard: every archived chapter with a volume-aware outline
|
|
5117
|
+
* must point at a volume that actually covers it. The old-card guard above
|
|
5118
|
+
* only proves the volume list did not drop the old range; an amend can still
|
|
5119
|
+
* write a card with `volume:null` or a stale index.
|
|
5120
|
+
*/
|
|
5121
|
+
const assertArchivedCardsInVolumes = (archivedChapters, cards, volumes, operation = "outline write") => {
|
|
5122
|
+
if (volumes.length === 0) return;
|
|
5123
|
+
const byNum = new Map(cards.map((card) => [card.num, card]));
|
|
5124
|
+
for (const record of archivedChapters) {
|
|
5125
|
+
const card = byNum.get(record.num);
|
|
5126
|
+
const volume = card !== void 0 && card.volume !== null && card.volume !== void 0 ? volumes[card.volume - 1] : void 0;
|
|
5127
|
+
if (volume === void 0 || record.num < volume.start_chapter || record.num > volume.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} would leave archived chapter ${record.num} without a covering volume`, "keep a volume/range that covers every archived card, or call novel_reopen first");
|
|
5128
|
+
}
|
|
5129
|
+
};
|
|
5130
|
+
/**
|
|
5131
|
+
* Merge chapter cards by num. Existing planned cards are appended back when
|
|
5132
|
+
* omitted; removal is name-only (`remove`), and archived cards are always
|
|
5133
|
+
* backfilled from the chapter index.
|
|
5134
|
+
*/
|
|
5135
|
+
const mergeOutline = (options) => {
|
|
5136
|
+
const submitted = submittedNumbers(options.chapters);
|
|
5137
|
+
const removed = parseChapterNumbers(options.remove, "remove");
|
|
5138
|
+
const volumeChanges = mergeVolumes(options);
|
|
5139
|
+
assertArchivedCardVolumes(options.archivedChapters, options.existing, options.existingVolumes, volumeChanges.volumes, "novel_outline");
|
|
5140
|
+
const existingByNum = new Map(options.existing.map((card) => [card.num, card]));
|
|
5141
|
+
const archivedByNum = new Map(options.archivedChapters.map((record) => [record.num, record]));
|
|
5142
|
+
const archivedNums = new Set(archivedByNum.keys());
|
|
5143
|
+
for (const num of removed) {
|
|
5144
|
+
if (submitted.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} cannot be both submitted and removed`);
|
|
5145
|
+
if (num === options.activeChapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove the active chapter ${num} card`, "archive or move the active chapter first; the card is required while the chapter is in flight");
|
|
5146
|
+
if (archivedByNum.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is archived`, "archived cards are evidence; call novel_reopen first");
|
|
5147
|
+
if (existingByNum.get(num)?.status !== "planned") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is not a planned card`);
|
|
5148
|
+
}
|
|
5149
|
+
const existingForNormalize = options.existing.map((card) => archivedNums.has(card.num) ? remapVolume(card, options.existingVolumes, volumeChanges.volumes) : card);
|
|
5150
|
+
const normalized = normalizeOutlineChapters(options.chapters, {
|
|
5151
|
+
volumes: volumeChanges.volumes,
|
|
5152
|
+
existing: existingForNormalize,
|
|
5153
|
+
archived: options.archivedChapters,
|
|
5154
|
+
titleRule: options.titleRule,
|
|
5155
|
+
totalChapters: options.totalChapters,
|
|
5156
|
+
defaultTargetWords: options.defaultTargetWords,
|
|
5157
|
+
now: options.now
|
|
5158
|
+
});
|
|
5159
|
+
const byNum = new Map(normalized.map((card) => [card.num, card]));
|
|
5160
|
+
const updated = /* @__PURE__ */ new Set();
|
|
5161
|
+
const added = /* @__PURE__ */ new Set();
|
|
5162
|
+
const reindexed = /* @__PURE__ */ new Set();
|
|
5163
|
+
for (const card of normalized) {
|
|
5164
|
+
if (!submitted.has(card.num)) continue;
|
|
5165
|
+
const before = existingByNum.get(card.num);
|
|
5166
|
+
if (before === void 0) added.add(card.num);
|
|
5167
|
+
else if (stableJson(before) !== stableJson(card)) updated.add(card.num);
|
|
5168
|
+
}
|
|
5169
|
+
for (const card of options.existing) {
|
|
5170
|
+
if (submitted.has(card.num) || removed.has(card.num)) continue;
|
|
5171
|
+
if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${card.num} has an archived outline card but is not in the chapter index`, "repair the outline or reopen/archive the chapter so the card status matches the index");
|
|
5172
|
+
if (archivedNums.has(card.num)) {
|
|
5173
|
+
if (!byNum.has(card.num)) byNum.set(card.num, {
|
|
5174
|
+
...card,
|
|
5175
|
+
status: "archived"
|
|
5176
|
+
});
|
|
5177
|
+
const current = byNum.get(card.num);
|
|
5178
|
+
if (stableJson(card) !== stableJson(current)) reindexed.add(card.num);
|
|
5179
|
+
continue;
|
|
5180
|
+
}
|
|
5181
|
+
const remapped = remapVolume(card, options.existingVolumes, volumeChanges.volumes);
|
|
5182
|
+
if (byNum.has(card.num)) {
|
|
5183
|
+
const current = byNum.get(card.num);
|
|
5184
|
+
if (stableJson(current) !== stableJson(remapped)) {
|
|
5185
|
+
byNum.set(card.num, remapped);
|
|
5186
|
+
reindexed.add(card.num);
|
|
5187
|
+
}
|
|
5188
|
+
} else {
|
|
5189
|
+
byNum.set(card.num, remapped);
|
|
5190
|
+
if (stableJson(card) !== stableJson(remapped)) reindexed.add(card.num);
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
for (const num of removed) byNum.delete(num);
|
|
5194
|
+
const chapters = [...byNum.values()].sort((left, right) => left.num - right.num);
|
|
5195
|
+
assertArchivedCardsInVolumes(options.archivedChapters, chapters, volumeChanges.volumes, "novel_outline");
|
|
5196
|
+
return {
|
|
5197
|
+
chapters,
|
|
5198
|
+
volumes: volumeChanges.volumes,
|
|
5199
|
+
added: [...added].sort((left, right) => left - right),
|
|
5200
|
+
updated: [...updated].sort((left, right) => left - right),
|
|
5201
|
+
reindexed: [...reindexed].sort((left, right) => left - right),
|
|
5202
|
+
removed: [...removed].sort((left, right) => left - right),
|
|
5203
|
+
volumes_added: volumeChanges.added,
|
|
5204
|
+
volumes_updated: volumeChanges.updated,
|
|
5205
|
+
volumes_removed: volumeChanges.removed
|
|
5206
|
+
};
|
|
5207
|
+
};
|
|
4858
5208
|
// src/engine/state-delta.ts
|
|
4859
5209
|
const SECTIONS = [
|
|
4860
5210
|
"characters",
|
|
@@ -4878,6 +5228,28 @@ const setOwn = (record, key, value) => {
|
|
|
4878
5228
|
writable: true
|
|
4879
5229
|
});
|
|
4880
5230
|
};
|
|
5231
|
+
/** Validate resource balances without accepting numeric strings or fractions. */
|
|
5232
|
+
const validateResources = (state, who) => {
|
|
5233
|
+
if (state.resources === void 0) return;
|
|
5234
|
+
if (!isRecord(state.resources)) fail$1(`${who}.current_state.resources must be an object`);
|
|
5235
|
+
for (const [name, value] of Object.entries(state.resources)) {
|
|
5236
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) fail$1(`${who}.current_state.resources.${name} must be an integer`);
|
|
5237
|
+
if (value < 0) throw new EngineInputError(ERROR_CODES.RESOURCE_NEGATIVE_BALANCE, `${who}'s ${name} balance is ${value}`, "state a non-negative integer balance; spend the resource before the balance reaches zero");
|
|
5238
|
+
}
|
|
5239
|
+
};
|
|
5240
|
+
/** Current state is a patch; resources are a nested map, not a whole replacement. */
|
|
5241
|
+
const mergeCurrentState = (base, patch) => {
|
|
5242
|
+
const merged = {
|
|
5243
|
+
...base,
|
|
5244
|
+
...patch
|
|
5245
|
+
};
|
|
5246
|
+
if (patch.resources !== void 0 && !isRecord(patch.resources)) fail$1("current_state.resources must be an object");
|
|
5247
|
+
if (patch.resources !== void 0) merged.resources = {
|
|
5248
|
+
...isRecord(base.resources) ? base.resources : {},
|
|
5249
|
+
...patch.resources
|
|
5250
|
+
};
|
|
5251
|
+
return merged;
|
|
5252
|
+
};
|
|
4881
5253
|
function fail$1(detail) {
|
|
4882
5254
|
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");
|
|
4883
5255
|
}
|
|
@@ -4971,6 +5343,20 @@ function applyStateDelta(input) {
|
|
|
4971
5343
|
const timeline = structuredClone(input.timeline);
|
|
4972
5344
|
const relationships = structuredClone(input.relationships);
|
|
4973
5345
|
const assigned = Object.create(null);
|
|
5346
|
+
const warnings = [];
|
|
5347
|
+
const normalizedWorld = Object.create(null);
|
|
5348
|
+
const warnReclass = (name, from, to, reason) => {
|
|
5349
|
+
normalizedWorld[name] = {
|
|
5350
|
+
from,
|
|
5351
|
+
to,
|
|
5352
|
+
reason
|
|
5353
|
+
};
|
|
5354
|
+
const code = reason === "existing-created-as-updated" ? "WORLD_ELEMENT_RECLASSIFIED_CREATED_TO_UPDATED" : reason === "missing-updated-as-created" ? "WORLD_ELEMENT_RECLASSIFIED_UPDATED_TO_CREATED" : "WORLD_ELEMENT_RECLASSIFIED_NO_CHANGE_TO_CREATED";
|
|
5355
|
+
warnings.push({
|
|
5356
|
+
code,
|
|
5357
|
+
message: `world.${name}: ${from} normalized to ${to} against state/world.yaml (${reason})`
|
|
5358
|
+
});
|
|
5359
|
+
};
|
|
4974
5360
|
const appeared = [...new Set(summary.characters_appeared)];
|
|
4975
5361
|
const byName = new Map(characters.characters.map((character) => [character.name, character]));
|
|
4976
5362
|
const narrator = input.narrator ?? "";
|
|
@@ -4991,7 +5377,7 @@ function applyStateDelta(input) {
|
|
|
4991
5377
|
"profile",
|
|
4992
5378
|
"current_state",
|
|
4993
5379
|
"growth"
|
|
4994
|
-
]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"港币\"
|
|
5380
|
+
]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"港币\":3},\"status\":\"...\"}" : field === "profile" ? "{\"age\":31,\"occupation\":\"...\"}" : "{\"change\":\"...\",\"status_change\":\"...\"}"}`);
|
|
4995
5381
|
if (declared.growth !== void 0) {
|
|
4996
5382
|
const growth = declared.growth;
|
|
4997
5383
|
for (const field of ["change", "status_change"]) if (typeof growth[field] !== "string" || growth[field].trim() === "") fail$1(`characters.${name}.growth.${field} must be a non-empty string`);
|
|
@@ -5002,12 +5388,14 @@ function applyStateDelta(input) {
|
|
|
5002
5388
|
if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
|
|
5003
5389
|
const rawHint = input.roleHints?.[name];
|
|
5004
5390
|
const hinted = canonicalRole(rawHint) ?? (rawHint === void 0 && (name === narratorName || name === narrator) ? "protagonist" : "extra");
|
|
5391
|
+
const currentState = declared.current_state ?? {};
|
|
5392
|
+
validateResources(currentState, `characters.${name}`);
|
|
5005
5393
|
const created = {
|
|
5006
5394
|
name,
|
|
5007
5395
|
role: normalizeRole(declared.role, hinted),
|
|
5008
5396
|
first_appearance: chapter,
|
|
5009
5397
|
profile: declared.profile ?? {},
|
|
5010
|
-
current_state:
|
|
5398
|
+
current_state: currentState,
|
|
5011
5399
|
growth: [],
|
|
5012
5400
|
updated_at: chapter
|
|
5013
5401
|
};
|
|
@@ -5028,10 +5416,11 @@ function applyStateDelta(input) {
|
|
|
5028
5416
|
...existing.profile,
|
|
5029
5417
|
...declared.profile
|
|
5030
5418
|
};
|
|
5031
|
-
if (declared.current_state !== void 0)
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5419
|
+
if (declared.current_state !== void 0) {
|
|
5420
|
+
const merged = mergeCurrentState(existing.current_state, declared.current_state);
|
|
5421
|
+
validateResources(merged, `characters.${name}`);
|
|
5422
|
+
existing.current_state = merged;
|
|
5423
|
+
}
|
|
5035
5424
|
if (declared.growth !== void 0) existing.growth.push({
|
|
5036
5425
|
...declared.growth,
|
|
5037
5426
|
chapter
|
|
@@ -5055,6 +5444,7 @@ function applyStateDelta(input) {
|
|
|
5055
5444
|
...state,
|
|
5056
5445
|
resources
|
|
5057
5446
|
};
|
|
5447
|
+
validateResources(character.current_state, `characters.${change.character}`);
|
|
5058
5448
|
}
|
|
5059
5449
|
const findWorld = (name) => {
|
|
5060
5450
|
const location = world.locations.find((candidate) => candidate.name === name);
|
|
@@ -5090,8 +5480,11 @@ function applyStateDelta(input) {
|
|
|
5090
5480
|
if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
|
|
5091
5481
|
if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
|
|
5092
5482
|
const declared = delta.world[name];
|
|
5093
|
-
if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
|
|
5094
5483
|
if (declared.kind !== void 0 && declared.kind !== kind) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${kind}`);
|
|
5484
|
+
if (findWorld(name) !== null) warnings.push({
|
|
5485
|
+
code: "WORLD_ELEMENT_ALREADY_IN_LEDGER",
|
|
5486
|
+
message: `summary.new_elements lists "${name}", but state/world.yaml already has it; ledger existence wins`
|
|
5487
|
+
});
|
|
5095
5488
|
}
|
|
5096
5489
|
for (const [name, raw] of Object.entries(delta.world)) {
|
|
5097
5490
|
const declared = entry(raw, `world.${name}`);
|
|
@@ -5100,22 +5493,33 @@ function applyStateDelta(input) {
|
|
|
5100
5493
|
if (declared.layout !== void 0 && typeof declared.layout !== "string") fail$1(`world.${name}.layout must be a string`);
|
|
5101
5494
|
if (declared.capacity !== void 0 && (!Number.isInteger(declared.capacity) || declared.capacity < 0)) fail$1(`world.${name}.capacity must be a non-negative integer`);
|
|
5102
5495
|
if (declared.detail !== void 0 && !isRecord(declared.detail)) fail$1(`world.${name}.detail must be an object`);
|
|
5103
|
-
if (declared.detail !== void 0) {
|
|
5104
|
-
if (declared.action !== "created") {
|
|
5105
|
-
for (const reserved of [
|
|
5106
|
-
"name",
|
|
5107
|
-
"first_appearance",
|
|
5108
|
-
"description"
|
|
5109
|
-
]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
|
|
5110
|
-
}
|
|
5111
|
-
}
|
|
5112
5496
|
const expected = expectedKind(name);
|
|
5113
|
-
if (expected !== null) {
|
|
5114
|
-
if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
|
|
5115
|
-
if (declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
|
|
5116
|
-
}
|
|
5497
|
+
if (expected !== null && declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
|
|
5117
5498
|
const existing = findWorld(name);
|
|
5118
|
-
|
|
5499
|
+
let action = declared.action;
|
|
5500
|
+
if (action === "created" && existing !== null) {
|
|
5501
|
+
if (declared.kind !== void 0 && declared.kind !== existing.kind) fail$1(`world.${name} is declared as ${declared.kind} but the ledger has ${existing.kind}; kind cannot change`);
|
|
5502
|
+
if (expected !== null && expected !== existing.kind) fail$1(`world.${name} is listed as ${expected} but the ledger has ${existing.kind}`);
|
|
5503
|
+
action = "updated";
|
|
5504
|
+
warnReclass(name, "created", "updated", "existing-created-as-updated");
|
|
5505
|
+
} else if (action === "updated" && existing === null) {
|
|
5506
|
+
if ((declared.kind ?? expected) === null) fail$1(`world.${name} does not exist; declare kind (location|ability|artifact|faction|rule) or list it in summary.new_elements`);
|
|
5507
|
+
if (!input.text.includes(name)) fail$1(`world entity "${name}" is new but does not appear in the manuscript`);
|
|
5508
|
+
action = "created";
|
|
5509
|
+
warnReclass(name, "updated", "created", "missing-updated-as-created");
|
|
5510
|
+
} else if (action === "no_change" && existing === null && expected !== null) {
|
|
5511
|
+
if (!input.text.includes(name)) fail$1(`world entity "${name}" is new but does not appear in the manuscript`);
|
|
5512
|
+
action = "created";
|
|
5513
|
+
warnReclass(name, "no_change", "created", "missing-no-change-as-created");
|
|
5514
|
+
}
|
|
5515
|
+
if (declared.detail !== void 0 && action !== "created") {
|
|
5516
|
+
for (const reserved of [
|
|
5517
|
+
"name",
|
|
5518
|
+
"first_appearance",
|
|
5519
|
+
"description"
|
|
5520
|
+
]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
|
|
5521
|
+
}
|
|
5522
|
+
if (action === "created") {
|
|
5119
5523
|
if (name.trim() === "") fail$1("a created world entity has an empty name");
|
|
5120
5524
|
if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
|
|
5121
5525
|
if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
|
|
@@ -5128,7 +5532,7 @@ function applyStateDelta(input) {
|
|
|
5128
5532
|
"faction",
|
|
5129
5533
|
"rule"
|
|
5130
5534
|
].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
|
|
5131
|
-
if (expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
|
|
5535
|
+
if (declared.action === "created" && expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
|
|
5132
5536
|
if (kind === "location") world.locations.push({
|
|
5133
5537
|
name,
|
|
5134
5538
|
first_appearance: chapter,
|
|
@@ -5147,7 +5551,8 @@ function applyStateDelta(input) {
|
|
|
5147
5551
|
continue;
|
|
5148
5552
|
}
|
|
5149
5553
|
if (existing === null) fail$1(`world entity "${name}" does not exist; created must be used first`);
|
|
5150
|
-
if (declared.
|
|
5554
|
+
if (declared.kind !== void 0 && existing.kind !== declared.kind) fail$1(`world.${name} is declared as ${declared.kind} but the ledger has ${existing.kind}; kind cannot change`);
|
|
5555
|
+
if (action === "updated") {
|
|
5151
5556
|
if (existing.kind === "location") {
|
|
5152
5557
|
const location = existing.value;
|
|
5153
5558
|
if (declared.description !== void 0) location.description = declared.description;
|
|
@@ -5290,6 +5695,7 @@ function applyStateDelta(input) {
|
|
|
5290
5695
|
const declared = entry(delta.timeline[key], `timeline.${key}`);
|
|
5291
5696
|
if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
|
|
5292
5697
|
if (declared.characters !== void 0 && (!Array.isArray(declared.characters) || declared.characters.some((character) => typeof character !== "string"))) fail$1(`timeline.${key}.characters must be a string array`);
|
|
5698
|
+
for (const character of declared.characters ?? []) if (byName.get(character) === void 0) fail$1(`timeline.${key} names unknown character "${character}"`);
|
|
5293
5699
|
const allocated = allocateIds("TE", timeline.next_seq, 1);
|
|
5294
5700
|
const id = allocated.ids[0];
|
|
5295
5701
|
if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
|
|
@@ -5356,7 +5762,9 @@ function applyStateDelta(input) {
|
|
|
5356
5762
|
foreshadows,
|
|
5357
5763
|
timeline,
|
|
5358
5764
|
relationships,
|
|
5359
|
-
assigned
|
|
5765
|
+
assigned,
|
|
5766
|
+
warnings,
|
|
5767
|
+
normalized_world: normalizedWorld
|
|
5360
5768
|
};
|
|
5361
5769
|
}
|
|
5362
5770
|
// src/engine/insight.ts
|
|
@@ -6692,6 +7100,26 @@ const SEMANTIC_CHECK_IDS = [
|
|
|
6692
7100
|
"L2-VOICE",
|
|
6693
7101
|
"L2-POV"
|
|
6694
7102
|
];
|
|
7103
|
+
/**
|
|
7104
|
+
* A caller supplied evidence that cannot be evaluated as a content judgement.
|
|
7105
|
+
* It must never be persisted as a failed check or consume a content round.
|
|
7106
|
+
*/
|
|
7107
|
+
var CheckSubmissionError = class extends EngineInputError {
|
|
7108
|
+
items;
|
|
7109
|
+
passed;
|
|
7110
|
+
failed;
|
|
7111
|
+
rounds_consumed = 0;
|
|
7112
|
+
failure_class = "submission";
|
|
7113
|
+
constructor(items) {
|
|
7114
|
+
const passed = items.filter((item) => item.state === "pass").length;
|
|
7115
|
+
const failed = items.filter((item) => item.state !== "pass" && item.state !== "fail").length;
|
|
7116
|
+
const first = items.find((item) => item.state !== "pass" && item.state !== "fail");
|
|
7117
|
+
super(ERROR_CODES.CHECK_SUBMISSION_INVALID, `semantic submission is invalid: ${first?.id ?? "semantic"} ${first?.state ?? "invalid"}${first?.detail === void 0 ? "" : ` (${first.detail})`}; ${passed} passed, ${failed} failed; no check was written and no round was consumed`, "resubmit novel_check with all five semantic items; each needs a distinct verbatim quote of at least 8 characters");
|
|
7118
|
+
this.items = items;
|
|
7119
|
+
this.passed = passed;
|
|
7120
|
+
this.failed = failed;
|
|
7121
|
+
}
|
|
7122
|
+
};
|
|
6695
7123
|
/** Extract the quoted part of an evidence string, tolerating "第 N 段:" labels. */
|
|
6696
7124
|
function evidenceQuote(value) {
|
|
6697
7125
|
let candidate = value.trim();
|
|
@@ -6701,151 +7129,312 @@ function evidenceQuote(value) {
|
|
|
6701
7129
|
candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
|
|
6702
7130
|
return candidate;
|
|
6703
7131
|
}
|
|
6704
|
-
|
|
7132
|
+
const CLOSING_PUNCTUATION = /* @__PURE__ */ new Set([
|
|
7133
|
+
"」",
|
|
7134
|
+
"』",
|
|
7135
|
+
"”",
|
|
7136
|
+
"\"",
|
|
7137
|
+
"’",
|
|
7138
|
+
"'",
|
|
7139
|
+
")",
|
|
7140
|
+
")",
|
|
7141
|
+
"】",
|
|
7142
|
+
"]"
|
|
7143
|
+
]);
|
|
7144
|
+
/**
|
|
7145
|
+
* Split text into sentence spans while treating every non-empty line as a
|
|
7146
|
+
* paragraph boundary. A quote may cover multiple sentences or paragraphs; the
|
|
7147
|
+
* spans are only used to prove that no two evidence items reuse the same
|
|
7148
|
+
* passage. Trailing closing quotes belong to the sentence they terminate.
|
|
7149
|
+
*/
|
|
6705
7150
|
function sentenceSpans(text) {
|
|
6706
7151
|
const spans = [];
|
|
6707
|
-
let
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
|
|
7152
|
+
let paragraph = 1;
|
|
7153
|
+
let lineStart = 0;
|
|
7154
|
+
let sentenceStart = 0;
|
|
7155
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
7156
|
+
const char = text[index];
|
|
7157
|
+
if (char === "\n") {
|
|
7158
|
+
if (text.slice(lineStart, index).trim() !== "") {
|
|
7159
|
+
if (index > sentenceStart) spans.push({
|
|
7160
|
+
start: sentenceStart,
|
|
7161
|
+
end: index,
|
|
7162
|
+
paragraph
|
|
7163
|
+
});
|
|
7164
|
+
paragraph += 1;
|
|
7165
|
+
}
|
|
7166
|
+
lineStart = index + 1;
|
|
7167
|
+
sentenceStart = index + 1;
|
|
7168
|
+
continue;
|
|
7169
|
+
}
|
|
7170
|
+
if ("。!?!?".includes(char)) {
|
|
7171
|
+
let end = index + 1;
|
|
7172
|
+
while (end < text.length && CLOSING_PUNCTUATION.has(text[end])) end += 1;
|
|
7173
|
+
spans.push({
|
|
7174
|
+
start: sentenceStart,
|
|
7175
|
+
end,
|
|
7176
|
+
paragraph
|
|
7177
|
+
});
|
|
7178
|
+
sentenceStart = end;
|
|
7179
|
+
index = end - 1;
|
|
7180
|
+
}
|
|
6714
7181
|
}
|
|
6715
|
-
if (
|
|
6716
|
-
start,
|
|
6717
|
-
end: text.length
|
|
7182
|
+
if (sentenceStart < text.length) spans.push({
|
|
7183
|
+
start: sentenceStart,
|
|
7184
|
+
end: text.length,
|
|
7185
|
+
paragraph
|
|
6718
7186
|
});
|
|
6719
7187
|
return spans;
|
|
6720
7188
|
}
|
|
7189
|
+
function nearestParagraph(text, quote) {
|
|
7190
|
+
const paragraphs = splitParagraphs(text);
|
|
7191
|
+
let best = null;
|
|
7192
|
+
for (const [index, paragraph] of paragraphs.entries()) {
|
|
7193
|
+
const score = textSimilarity(paragraph, quote);
|
|
7194
|
+
if (best === null || score > best.score) best = {
|
|
7195
|
+
index,
|
|
7196
|
+
paragraph,
|
|
7197
|
+
score
|
|
7198
|
+
};
|
|
7199
|
+
}
|
|
7200
|
+
if (best === null) return null;
|
|
7201
|
+
return {
|
|
7202
|
+
paragraph: best.index + 1,
|
|
7203
|
+
quote: Array.from(best.paragraph).slice(0, 120).join(""),
|
|
7204
|
+
score: Math.round(best.score * 1e3) / 1e3
|
|
7205
|
+
};
|
|
7206
|
+
}
|
|
7207
|
+
const submissionState = (state) => state !== "pass" && state !== "fail";
|
|
7208
|
+
const codeForState = (state) => {
|
|
7209
|
+
switch (state) {
|
|
7210
|
+
case "not_found": return "SEMANTIC_EVIDENCE_NOT_FOUND";
|
|
7211
|
+
case "too_short": return "SEMANTIC_EVIDENCE_TOO_SHORT";
|
|
7212
|
+
case "duplicate": return "SEMANTIC_EVIDENCE_DUPLICATE";
|
|
7213
|
+
case "overlap": return "SEMANTIC_EVIDENCE_OVERLAP";
|
|
7214
|
+
case "same_sentence": return "SEMANTIC_EVIDENCE_SAME_SENTENCE";
|
|
7215
|
+
case "missing": return "SEMANTIC_MISSING";
|
|
7216
|
+
case "boundary": return "SEMANTIC_EVIDENCE_BOUNDARY";
|
|
7217
|
+
default: return "SEMANTIC_ITEM_INVALID";
|
|
7218
|
+
}
|
|
7219
|
+
};
|
|
7220
|
+
const detailFor = (result) => {
|
|
7221
|
+
if (result.detail !== void 0) return result.detail;
|
|
7222
|
+
switch (result.state) {
|
|
7223
|
+
case "not_found": return `${result.id} cites a quote that does not appear verbatim in the manuscript`;
|
|
7224
|
+
case "too_short": return `${result.id} quotes fewer than 8 characters`;
|
|
7225
|
+
case "duplicate": return `${result.id} repeats ${result.conflict_with ?? "another item"}'s quote`;
|
|
7226
|
+
case "overlap": return `${result.id} overlaps ${result.conflict_with ?? "another item"}'s accepted evidence interval`;
|
|
7227
|
+
case "same_sentence": return `${result.id} reuses a sentence already cited by ${result.conflict_with ?? "another item"}`;
|
|
7228
|
+
case "boundary": return `${result.id} cannot be mapped onto a sentence boundary`;
|
|
7229
|
+
case "missing": return `${result.id} is required by the P6 task book`;
|
|
7230
|
+
default: return `${result.id} is invalid`;
|
|
7231
|
+
}
|
|
7232
|
+
};
|
|
6721
7233
|
/**
|
|
6722
|
-
* Validate
|
|
6723
|
-
*
|
|
6724
|
-
*
|
|
7234
|
+
* Validate and evaluate all five L2 semantic items.
|
|
7235
|
+
*
|
|
7236
|
+
* Evidence is located by character range, not by requiring one sentence. Two
|
|
7237
|
+
* items may not overlap or reuse a sentence; a quote that exists but fails
|
|
7238
|
+
* those rules is reported as OVERLAP/SAME_SENTENCE instead of the old generic
|
|
7239
|
+
* "overlapping text or the same sentence" message.
|
|
6725
7240
|
*/
|
|
6726
|
-
function evaluateSemantic(text,
|
|
6727
|
-
const
|
|
6728
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6729
|
-
const seenQuotes = /* @__PURE__ */ new Set();
|
|
7241
|
+
function evaluateSemantic(text, input) {
|
|
7242
|
+
const items = [];
|
|
6730
7243
|
const spans = [];
|
|
6731
7244
|
const sentences = sentenceSpans(text);
|
|
6732
|
-
const usedSentences = /* @__PURE__ */ new
|
|
6733
|
-
|
|
7245
|
+
const usedSentences = /* @__PURE__ */ new Map();
|
|
7246
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
7247
|
+
const acceptedQuotes = /* @__PURE__ */ new Map();
|
|
7248
|
+
const rawItems = Array.isArray(input) ? input : [];
|
|
7249
|
+
if (!Array.isArray(input)) items.push({
|
|
7250
|
+
id: "semantic",
|
|
7251
|
+
state: "invalid",
|
|
7252
|
+
detail: "semantic must be an array of five items"
|
|
7253
|
+
});
|
|
7254
|
+
for (const [index, raw] of rawItems.entries()) {
|
|
6734
7255
|
const where = `semantic[${index}]`;
|
|
6735
|
-
if (typeof
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
7256
|
+
if (typeof raw !== "object" || raw === null) {
|
|
7257
|
+
items.push({
|
|
7258
|
+
id: where,
|
|
7259
|
+
state: "invalid",
|
|
6739
7260
|
detail: `${where} is not an object`
|
|
6740
7261
|
});
|
|
6741
7262
|
continue;
|
|
6742
7263
|
}
|
|
7264
|
+
const item = raw;
|
|
6743
7265
|
if (typeof item.id !== "string" || item.id.trim() === "") {
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
7266
|
+
items.push({
|
|
7267
|
+
id: where,
|
|
7268
|
+
state: "invalid",
|
|
6747
7269
|
detail: `${where} has no id`
|
|
6748
7270
|
});
|
|
6749
7271
|
continue;
|
|
6750
7272
|
}
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
7273
|
+
const id = item.id.trim();
|
|
7274
|
+
if (seenIds.has(id)) {
|
|
7275
|
+
items.push({
|
|
7276
|
+
id,
|
|
7277
|
+
state: "invalid",
|
|
7278
|
+
detail: `${id} is declared twice`
|
|
6756
7279
|
});
|
|
6757
7280
|
continue;
|
|
6758
7281
|
}
|
|
6759
|
-
|
|
7282
|
+
seenIds.add(id);
|
|
6760
7283
|
if (item.verdict !== "pass" && item.verdict !== "fail") {
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
detail: `${
|
|
7284
|
+
items.push({
|
|
7285
|
+
id,
|
|
7286
|
+
state: "invalid",
|
|
7287
|
+
detail: `${id} has verdict ${String(item.verdict)}; expected pass|fail`
|
|
6765
7288
|
});
|
|
6766
7289
|
continue;
|
|
6767
7290
|
}
|
|
6768
7291
|
if (typeof item.evidence !== "string" || item.evidence.trim() === "") {
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
detail: `${
|
|
7292
|
+
items.push({
|
|
7293
|
+
id,
|
|
7294
|
+
state: "invalid",
|
|
7295
|
+
detail: `${id} has no evidence quote`
|
|
6773
7296
|
});
|
|
6774
7297
|
continue;
|
|
6775
7298
|
}
|
|
6776
7299
|
const quote = evidenceQuote(item.evidence);
|
|
6777
7300
|
if (Array.from(quote).length < 8) {
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
7301
|
+
items.push({
|
|
7302
|
+
id,
|
|
7303
|
+
state: "too_short",
|
|
7304
|
+
quote,
|
|
7305
|
+
detail: `${id} quotes fewer than 8 characters`
|
|
6782
7306
|
});
|
|
6783
7307
|
continue;
|
|
6784
7308
|
}
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
7309
|
+
const duplicate = acceptedQuotes.get(quote);
|
|
7310
|
+
if (duplicate !== void 0) {
|
|
7311
|
+
items.push({
|
|
7312
|
+
id,
|
|
7313
|
+
state: "duplicate",
|
|
7314
|
+
quote,
|
|
7315
|
+
conflict_with: duplicate,
|
|
7316
|
+
detail: `${id} repeats ${duplicate}'s quote exactly`
|
|
6790
7317
|
});
|
|
6791
7318
|
continue;
|
|
6792
7319
|
}
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
7320
|
+
const occurrences = [];
|
|
7321
|
+
let found = text.indexOf(quote);
|
|
7322
|
+
while (found !== -1) {
|
|
7323
|
+
occurrences.push(found);
|
|
7324
|
+
found = text.indexOf(quote, found + 1);
|
|
7325
|
+
}
|
|
7326
|
+
if (occurrences.length === 0) {
|
|
7327
|
+
const nearest = nearestParagraph(text, quote);
|
|
7328
|
+
items.push({
|
|
7329
|
+
id,
|
|
7330
|
+
state: "not_found",
|
|
7331
|
+
quote,
|
|
7332
|
+
...nearest === null ? {} : { nearest },
|
|
7333
|
+
detail: `${id} cites "${Array.from(quote).slice(0, 40).join("")}", which does not appear verbatim in the manuscript`
|
|
6798
7334
|
});
|
|
6799
7335
|
continue;
|
|
6800
7336
|
}
|
|
6801
|
-
let
|
|
6802
|
-
let
|
|
6803
|
-
|
|
7337
|
+
let accepted = null;
|
|
7338
|
+
let overlapWith;
|
|
7339
|
+
let sameSentenceWith;
|
|
7340
|
+
let boundary = false;
|
|
7341
|
+
for (const start of occurrences) {
|
|
6804
7342
|
const end = start + quote.length;
|
|
6805
|
-
const
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
7343
|
+
const conflict = spans.find((span) => start < span.end && span.start < end);
|
|
7344
|
+
if (conflict !== void 0) {
|
|
7345
|
+
overlapWith ??= conflict.id;
|
|
7346
|
+
continue;
|
|
7347
|
+
}
|
|
7348
|
+
const covered = sentences.map((sentence, sentenceIndex) => ({
|
|
7349
|
+
sentence,
|
|
7350
|
+
sentenceIndex
|
|
7351
|
+
})).filter(({ sentence }) => start < sentence.end && sentence.start < end);
|
|
7352
|
+
if (covered.length === 0) {
|
|
7353
|
+
boundary = true;
|
|
7354
|
+
continue;
|
|
6810
7355
|
}
|
|
6811
|
-
|
|
7356
|
+
const used = covered.find(({ sentenceIndex }) => usedSentences.has(sentenceIndex));
|
|
7357
|
+
if (used !== void 0) {
|
|
7358
|
+
sameSentenceWith ??= usedSentences.get(used.sentenceIndex);
|
|
7359
|
+
continue;
|
|
7360
|
+
}
|
|
7361
|
+
accepted = {
|
|
7362
|
+
start,
|
|
7363
|
+
end,
|
|
7364
|
+
id
|
|
7365
|
+
};
|
|
7366
|
+
for (const { sentenceIndex } of covered) usedSentences.set(sentenceIndex, id);
|
|
7367
|
+
break;
|
|
6812
7368
|
}
|
|
6813
|
-
if (
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
7369
|
+
if (accepted === null) {
|
|
7370
|
+
if (overlapWith !== void 0) items.push({
|
|
7371
|
+
id,
|
|
7372
|
+
state: "overlap",
|
|
7373
|
+
quote,
|
|
7374
|
+
conflict_with: overlapWith,
|
|
7375
|
+
detail: `${id} overlaps ${overlapWith}'s accepted evidence interval`
|
|
7376
|
+
});
|
|
7377
|
+
else if (sameSentenceWith !== void 0) items.push({
|
|
7378
|
+
id,
|
|
7379
|
+
state: "same_sentence",
|
|
7380
|
+
quote,
|
|
7381
|
+
conflict_with: sameSentenceWith,
|
|
7382
|
+
detail: `${id} reuses a sentence already cited by ${sameSentenceWith}`
|
|
7383
|
+
});
|
|
7384
|
+
else items.push({
|
|
7385
|
+
id,
|
|
7386
|
+
state: boundary ? "boundary" : "same_sentence",
|
|
7387
|
+
quote,
|
|
7388
|
+
...boundary ? {} : { conflict_with: id },
|
|
7389
|
+
detail: boundary ? `${id} cannot be mapped onto a sentence boundary` : `${id} reuses a sentence already cited by another item`
|
|
6818
7390
|
});
|
|
6819
7391
|
continue;
|
|
6820
7392
|
}
|
|
6821
|
-
spans.push(
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
code: "SEMANTIC_CHECK_FAILED",
|
|
6829
|
-
scope: "check",
|
|
6830
|
-
detail: `${item.id}: ${typeof item.note === "string" && item.note.trim() !== "" ? item.note : "semantic check failed"}`
|
|
7393
|
+
spans.push(accepted);
|
|
7394
|
+
acceptedQuotes.set(quote, id);
|
|
7395
|
+
items.push({
|
|
7396
|
+
id,
|
|
7397
|
+
state: item.verdict === "fail" ? "fail" : "pass",
|
|
7398
|
+
quote,
|
|
7399
|
+
...item.verdict === "fail" ? { detail: `${id}: ${typeof item.note === "string" && item.note.trim() !== "" ? item.note : "semantic check failed"}` } : {}
|
|
6831
7400
|
});
|
|
6832
7401
|
}
|
|
6833
|
-
const
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
scope: "check",
|
|
7402
|
+
for (const id of SEMANTIC_CHECK_IDS) if (!seenIds.has(id)) items.push({
|
|
7403
|
+
id,
|
|
7404
|
+
state: "missing",
|
|
6837
7405
|
detail: `${id} is required by the P6 task book`
|
|
6838
7406
|
});
|
|
6839
|
-
for (const id of
|
|
6840
|
-
|
|
6841
|
-
|
|
7407
|
+
for (const id of seenIds) if (!SEMANTIC_CHECK_IDS.includes(id)) items.push({
|
|
7408
|
+
id,
|
|
7409
|
+
state: "invalid",
|
|
6842
7410
|
detail: `${id} is not one of the required P6 semantic checks`
|
|
6843
7411
|
});
|
|
7412
|
+
const findings = items.filter((item) => item.state === "fail").map((item) => ({
|
|
7413
|
+
code: "SEMANTIC_CHECK_FAILED",
|
|
7414
|
+
scope: "check",
|
|
7415
|
+
detail: item.detail ?? `${item.id}: semantic check failed`
|
|
7416
|
+
}));
|
|
7417
|
+
const submissionItems = items.filter((item) => submissionState(item.state));
|
|
7418
|
+
const blocking = [...submissionItems.map((item) => ({
|
|
7419
|
+
code: codeForState(item.state),
|
|
7420
|
+
scope: "check",
|
|
7421
|
+
detail: detailFor(item)
|
|
7422
|
+
})), ...findings];
|
|
6844
7423
|
return {
|
|
7424
|
+
items,
|
|
7425
|
+
findings,
|
|
6845
7426
|
blocking,
|
|
7427
|
+
submission_failed: submissionItems.length > 0,
|
|
7428
|
+
content_failed: findings.length > 0,
|
|
6846
7429
|
failed: blocking.length > 0
|
|
6847
7430
|
};
|
|
6848
7431
|
}
|
|
7432
|
+
/** Throw a typed submission error when shape/evidence errors exist. */
|
|
7433
|
+
function validateSemanticSubmission(text, input) {
|
|
7434
|
+
const evaluation = evaluateSemantic(text, input);
|
|
7435
|
+
if (evaluation.submission_failed) throw new CheckSubmissionError(evaluation.items);
|
|
7436
|
+
return evaluation;
|
|
7437
|
+
}
|
|
6849
7438
|
// src/engine/gates.ts
|
|
6850
7439
|
const block = (code, detail, hint, scope) => ({
|
|
6851
7440
|
code,
|
|
@@ -6855,6 +7444,7 @@ const block = (code, detail, hint, scope) => ({
|
|
|
6855
7444
|
});
|
|
6856
7445
|
/** PG-01: the model must have received the current brief before submitting. */
|
|
6857
7446
|
const briefRequired = (input) => {
|
|
7447
|
+
if (input.summaryOnlyRevision) return [];
|
|
6858
7448
|
if (input.briefStage === null) return [block("PG-01", "this operation needs a brief, but no brief stage is mapped")];
|
|
6859
7449
|
const record = input.pipeline.briefs[input.briefStage];
|
|
6860
7450
|
if (record === void 0) return [block("PG-01", `brief ${input.briefStage} has not been issued`, `call novel_brief with stage ${input.briefStage} first`)];
|
|
@@ -6951,7 +7541,29 @@ const summarySchema = (input) => {
|
|
|
6951
7541
|
"locations",
|
|
6952
7542
|
"abilities",
|
|
6953
7543
|
"artifacts"
|
|
6954
|
-
])
|
|
7544
|
+
]) {
|
|
7545
|
+
if (!textArray(newElements[key])) problems.push(`new_elements.${key} must be a string array`);
|
|
7546
|
+
for (const name of textArray(newElements[key]) ? newElements[key] : []) {
|
|
7547
|
+
if (!input.text.includes(name)) problems.push(`new_elements.${key} lists "${name}", but the manuscript never mentions it`);
|
|
7548
|
+
if (input.operation !== "archiveChapter") {
|
|
7549
|
+
const ledger = input.evidence.world;
|
|
7550
|
+
if (ledger !== null && [
|
|
7551
|
+
ledger.locations,
|
|
7552
|
+
ledger.abilities,
|
|
7553
|
+
ledger.artifacts,
|
|
7554
|
+
ledger.factions,
|
|
7555
|
+
ledger.rules
|
|
7556
|
+
].some((rows) => rows.some((row) => row.name === name))) problems.push(`new_elements.${key} lists "${name}", but state/world.yaml already has it; remove it or use updated/no_change`);
|
|
7557
|
+
}
|
|
7558
|
+
}
|
|
7559
|
+
}
|
|
7560
|
+
if (textArray(summary.characters_appeared)) {
|
|
7561
|
+
const narrator = input.pipeline.draft?.narrator ?? "";
|
|
7562
|
+
for (const name of summary.characters_appeared) {
|
|
7563
|
+
const alias = narrator.includes(name) && (narrator.includes("第一人称") || narrator.includes("「我」")) && input.text.includes("我");
|
|
7564
|
+
if (!input.text.includes(name) && !alias) problems.push(`characters_appeared lists "${name}", but the manuscript never mentions it`);
|
|
7565
|
+
}
|
|
7566
|
+
}
|
|
6955
7567
|
const resources = summary.resource_changes;
|
|
6956
7568
|
if (!Array.isArray(resources) || !resources.every((item) => typeof item === "object" && item !== null && isText(item.character) && isText(item.resource) && Number.isInteger(item.delta) && item.delta !== 0 && isText(item.reason))) problems.push("resource_changes must contain character/resource/non-zero delta/reason");
|
|
6957
7569
|
if (!isText(summary.opening)) problems.push("opening must be text");
|
|
@@ -6994,7 +7606,7 @@ const outlineAlignment = (input) => {
|
|
|
6994
7606
|
const card = outline.chapters.find((candidate) => candidate.num === input.chapter);
|
|
6995
7607
|
if (card === void 0) {
|
|
6996
7608
|
if (!outline.book.strict) return [];
|
|
6997
|
-
return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit
|
|
7609
|
+
return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit only the missing card with novel_outline (upsert by num; omitted cards are kept); do not turn strict off")];
|
|
6998
7610
|
}
|
|
6999
7611
|
const problems = [];
|
|
7000
7612
|
if (card.status === "archived") problems.push(`chapter ${input.chapter} is already archived but is still the active chapter; call novel_reopen first`);
|
|
@@ -7150,6 +7762,7 @@ const consistency = (input, options) => {
|
|
|
7150
7762
|
};
|
|
7151
7763
|
/** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
|
|
7152
7764
|
const rounds = (input) => {
|
|
7765
|
+
if (input.summaryOnly === true) return [];
|
|
7153
7766
|
if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
|
|
7154
7767
|
const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
|
|
7155
7768
|
if (!roundsExhausted(input.rounds, counter)) return [];
|
|
@@ -7215,7 +7828,13 @@ function applicableGates(input) {
|
|
|
7215
7828
|
"PG-06",
|
|
7216
7829
|
"PG-13"
|
|
7217
7830
|
];
|
|
7218
|
-
case "submitRevision": return [
|
|
7831
|
+
case "submitRevision": return input.summaryOnly === true ? [
|
|
7832
|
+
...always,
|
|
7833
|
+
"PG-04",
|
|
7834
|
+
"PG-03",
|
|
7835
|
+
"PG-05",
|
|
7836
|
+
"PG-06"
|
|
7837
|
+
] : [
|
|
7219
7838
|
...always,
|
|
7220
7839
|
"PG-04",
|
|
7221
7840
|
"PG-03",
|
|
@@ -7783,6 +8402,8 @@ var EngineService = class {
|
|
|
7783
8402
|
stores = /* @__PURE__ */ new Map();
|
|
7784
8403
|
mutex = new KeyedMutex();
|
|
7785
8404
|
replay = /* @__PURE__ */ new Map();
|
|
8405
|
+
/** Bumped when an export artifact disappeared; makes the replay key miss. */
|
|
8406
|
+
exportArtifactEpoch = /* @__PURE__ */ new Map();
|
|
7786
8407
|
/** Status, panel and gates ask for the same stage context repeatedly. */
|
|
7787
8408
|
briefContextCache = /* @__PURE__ */ new WeakMap();
|
|
7788
8409
|
briefs;
|
|
@@ -7810,6 +8431,7 @@ var EngineService = class {
|
|
|
7810
8431
|
this.disposed = true;
|
|
7811
8432
|
this.stores.clear();
|
|
7812
8433
|
this.replay.clear();
|
|
8434
|
+
this.exportArtifactEpoch.clear();
|
|
7813
8435
|
this.compiled = null;
|
|
7814
8436
|
}
|
|
7815
8437
|
get rulepack() {
|
|
@@ -8212,6 +8834,7 @@ var EngineService = class {
|
|
|
8212
8834
|
"ending",
|
|
8213
8835
|
"strict",
|
|
8214
8836
|
"volumes",
|
|
8837
|
+
"confirm_remove",
|
|
8215
8838
|
"root"
|
|
8216
8839
|
]);
|
|
8217
8840
|
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_blueprint argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
|
|
@@ -8228,9 +8851,12 @@ var EngineService = class {
|
|
|
8228
8851
|
this.assertPlanningDocumentReadable(snapshot, "outline", "novel_blueprint");
|
|
8229
8852
|
this.assertArchiveIndexReadable(snapshot, "novel_blueprint");
|
|
8230
8853
|
const now = this.now().toISOString();
|
|
8231
|
-
const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "root"));
|
|
8854
|
+
const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "confirm_remove" && key !== "root"));
|
|
8232
8855
|
const book = mergeBookPlan(snapshot.outline.book, bookPatch);
|
|
8233
8856
|
const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
|
|
8857
|
+
const removedVolumes = snapshot.outline.volumes.filter((volume) => !volumes.some((next) => next.title === volume.title)).map((volume) => volume.title);
|
|
8858
|
+
if (removedVolumes.length > 0 && input.confirm_remove !== true) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel_blueprint would remove volumes: ${removedVolumes.join(", ")}`, "pass confirm_remove:true to replace the volume list, or omit volumes to keep them");
|
|
8859
|
+
assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, volumes, "novel_blueprint");
|
|
8234
8860
|
let chapters = snapshot.outline.chapters.map((card) => ({
|
|
8235
8861
|
...card,
|
|
8236
8862
|
volume: volumeForChapter(volumes, card.num, card.volume)
|
|
@@ -8245,6 +8871,7 @@ var EngineService = class {
|
|
|
8245
8871
|
chapters
|
|
8246
8872
|
}, record, now, snapshot.project.targets.words_per_chapter).chapters;
|
|
8247
8873
|
}
|
|
8874
|
+
assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_blueprint");
|
|
8248
8875
|
const outline = {
|
|
8249
8876
|
...snapshot.outline,
|
|
8250
8877
|
updated_at: now,
|
|
@@ -8284,19 +8911,113 @@ var EngineService = class {
|
|
|
8284
8911
|
}
|
|
8285
8912
|
});
|
|
8286
8913
|
}
|
|
8914
|
+
outlinePlanChange(snapshot, input, now) {
|
|
8915
|
+
const book = mergeBookPlan(snapshot.outline.book, input.book === void 0 ? void 0 : {
|
|
8916
|
+
...input.book,
|
|
8917
|
+
...input.strict === void 0 ? {} : { strict: input.strict }
|
|
8918
|
+
});
|
|
8919
|
+
if (input.strict !== void 0) {
|
|
8920
|
+
book.strict = input.strict;
|
|
8921
|
+
book.strict_explicit = true;
|
|
8922
|
+
} else if (input.book?.strict === void 0) {
|
|
8923
|
+
if ((input.chapters?.length ?? 0) > 0 && snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
|
|
8924
|
+
book.strict = true;
|
|
8925
|
+
book.strict_explicit = true;
|
|
8926
|
+
}
|
|
8927
|
+
}
|
|
8928
|
+
const merged = mergeOutline({
|
|
8929
|
+
chapters: input.chapters ?? [],
|
|
8930
|
+
remove: input.remove,
|
|
8931
|
+
volumes: input.volumes,
|
|
8932
|
+
remove_volumes: input.remove_volumes,
|
|
8933
|
+
existing: snapshot.outline.chapters,
|
|
8934
|
+
archivedChapters: snapshot.chapters.chapters,
|
|
8935
|
+
existingVolumes: snapshot.outline.volumes,
|
|
8936
|
+
titleRule: this.rulepack.format.title,
|
|
8937
|
+
totalChapters: snapshot.project.targets.total_chapters,
|
|
8938
|
+
defaultTargetWords: snapshot.project.targets.words_per_chapter,
|
|
8939
|
+
now,
|
|
8940
|
+
activeChapter: snapshot.pipeline.chapter
|
|
8941
|
+
});
|
|
8942
|
+
return {
|
|
8943
|
+
outline: {
|
|
8944
|
+
schema_version: 1,
|
|
8945
|
+
updated_at: now,
|
|
8946
|
+
book,
|
|
8947
|
+
volumes: merged.volumes,
|
|
8948
|
+
chapters: merged.chapters
|
|
8949
|
+
},
|
|
8950
|
+
data: {
|
|
8951
|
+
planned: merged.chapters.filter((card) => card.status === "planned").length,
|
|
8952
|
+
archived: merged.chapters.filter((card) => card.status === "archived").length,
|
|
8953
|
+
total: merged.chapters.length,
|
|
8954
|
+
strict: book.strict,
|
|
8955
|
+
volumes: merged.volumes.length,
|
|
8956
|
+
added: merged.added,
|
|
8957
|
+
updated: merged.updated,
|
|
8958
|
+
reindexed: merged.reindexed,
|
|
8959
|
+
removed: merged.removed,
|
|
8960
|
+
volumes_added: merged.volumes_added,
|
|
8961
|
+
volumes_updated: merged.volumes_updated,
|
|
8962
|
+
volumes_removed: merged.volumes_removed
|
|
8963
|
+
}
|
|
8964
|
+
};
|
|
8965
|
+
}
|
|
8287
8966
|
async submitOutline(input) {
|
|
8288
8967
|
const allowed = /* @__PURE__ */ new Set([
|
|
8289
8968
|
"book",
|
|
8290
8969
|
"volumes",
|
|
8291
8970
|
"chapters",
|
|
8292
8971
|
"strict",
|
|
8972
|
+
"remove",
|
|
8973
|
+
"remove_volumes",
|
|
8974
|
+
"dry_run",
|
|
8293
8975
|
"root"
|
|
8294
8976
|
]);
|
|
8295
8977
|
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
|
|
8296
8978
|
const current = await this.load(input.root);
|
|
8979
|
+
this.assertPublicBookHasNoStrictExplicit(input.book, "novel_outline");
|
|
8297
8980
|
this.assertProjectReadable(current, "novel_outline");
|
|
8298
8981
|
this.assertPlanningDocumentReadable(current, "outline", "novel_outline");
|
|
8299
8982
|
this.assertArchiveIndexReadable(current, "novel_outline");
|
|
8983
|
+
if ((input.chapters === void 0 || Array.isArray(input.chapters) && input.chapters.length === 0) && (input.remove === void 0 || input.remove.length === 0) && (input.remove_volumes === void 0 || input.remove_volumes.length === 0) && (input.volumes === void 0 || Array.isArray(input.volumes) && input.volumes.length === 0) && (input.book === void 0 || typeof input.book === "object" && input.book !== null && Object.keys(input.book).length === 0) && input.strict === void 0 && input.dry_run !== true) return {
|
|
8984
|
+
data: {
|
|
8985
|
+
planned: current.outline.chapters.filter((card) => card.status === "planned").length,
|
|
8986
|
+
archived: current.outline.chapters.filter((card) => card.status === "archived").length,
|
|
8987
|
+
total: current.outline.chapters.length,
|
|
8988
|
+
strict: current.outline.book.strict,
|
|
8989
|
+
volumes: current.outline.volumes.length,
|
|
8990
|
+
added: [],
|
|
8991
|
+
updated: [],
|
|
8992
|
+
reindexed: [],
|
|
8993
|
+
removed: [],
|
|
8994
|
+
volumes_added: [],
|
|
8995
|
+
volumes_updated: [],
|
|
8996
|
+
volumes_removed: []
|
|
8997
|
+
},
|
|
8998
|
+
revision: current.pipeline.revision,
|
|
8999
|
+
warnings: []
|
|
9000
|
+
};
|
|
9001
|
+
if (input.dry_run === true) {
|
|
9002
|
+
const plan = this.outlinePlanChange(current, input, this.now().toISOString());
|
|
9003
|
+
this.assertOutlineWritable(current, plan.outline, "novel_outline");
|
|
9004
|
+
return {
|
|
9005
|
+
data: {
|
|
9006
|
+
...plan.data,
|
|
9007
|
+
added: [],
|
|
9008
|
+
updated: [],
|
|
9009
|
+
reindexed: [],
|
|
9010
|
+
removed: [],
|
|
9011
|
+
dry_run: true,
|
|
9012
|
+
would_add: plan.data.added,
|
|
9013
|
+
would_update: plan.data.updated,
|
|
9014
|
+
would_reindex: plan.data.reindexed,
|
|
9015
|
+
would_remove: plan.data.removed
|
|
9016
|
+
},
|
|
9017
|
+
revision: current.pipeline.revision,
|
|
9018
|
+
warnings: []
|
|
9019
|
+
};
|
|
9020
|
+
}
|
|
8300
9021
|
return this.mutate({
|
|
8301
9022
|
root: input.root,
|
|
8302
9023
|
op: "submitOutline",
|
|
@@ -8305,72 +9026,34 @@ var EngineService = class {
|
|
|
8305
9026
|
this.assertProjectReadable(snapshot, "novel_outline");
|
|
8306
9027
|
this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline");
|
|
8307
9028
|
this.assertArchiveIndexReadable(snapshot, "novel_outline");
|
|
8308
|
-
const
|
|
8309
|
-
|
|
8310
|
-
...input.book,
|
|
8311
|
-
...input.strict === void 0 ? {} : { strict: input.strict }
|
|
8312
|
-
});
|
|
8313
|
-
if (input.strict !== void 0) {
|
|
8314
|
-
book.strict = input.strict;
|
|
8315
|
-
book.strict_explicit = true;
|
|
8316
|
-
} else if (input.book?.strict === void 0) {
|
|
8317
|
-
if (snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
|
|
8318
|
-
book.strict = true;
|
|
8319
|
-
book.strict_explicit = true;
|
|
8320
|
-
}
|
|
8321
|
-
}
|
|
8322
|
-
const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
|
|
8323
|
-
const chapters = normalizeOutlineChapters(input.chapters, {
|
|
8324
|
-
volumes,
|
|
8325
|
-
existing: snapshot.outline.chapters,
|
|
8326
|
-
archived: snapshot.chapters.chapters,
|
|
8327
|
-
titleRule: this.rulepack.format.title,
|
|
8328
|
-
totalChapters: snapshot.project.targets.total_chapters,
|
|
8329
|
-
defaultTargetWords: snapshot.project.targets.words_per_chapter,
|
|
8330
|
-
now
|
|
8331
|
-
});
|
|
8332
|
-
const outline = {
|
|
8333
|
-
schema_version: 1,
|
|
8334
|
-
updated_at: now,
|
|
8335
|
-
book,
|
|
8336
|
-
volumes,
|
|
8337
|
-
chapters
|
|
8338
|
-
};
|
|
8339
|
-
this.assertActiveCardPreserved(snapshot, book.strict, chapters, "novel_outline");
|
|
8340
|
-
this.assertOutlineWritable(snapshot, outline, "novel_outline");
|
|
8341
|
-
const planned = chapters.filter((card) => card.status === "planned").length;
|
|
8342
|
-
const archived = chapters.filter((card) => card.status === "archived").length;
|
|
8343
|
-
const kept = new Set(chapters.filter((card) => card.status === "planned").map((card) => card.num));
|
|
8344
|
-
const dropped = snapshot.outline.chapters.filter((card) => card.status === "planned" && !kept.has(card.num)).map((card) => card.num);
|
|
9029
|
+
const plan = this.outlinePlanChange(snapshot, input, this.now().toISOString());
|
|
9030
|
+
this.assertOutlineWritable(snapshot, plan.outline, "novel_outline");
|
|
8345
9031
|
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_outline", this.now(), shortDigest(stableJson(input))) });
|
|
9032
|
+
const files = [{
|
|
9033
|
+
path: OUTLINE_FILE,
|
|
9034
|
+
text: yaml(plan.outline)
|
|
9035
|
+
}, {
|
|
9036
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9037
|
+
text: yaml(nextPipeline)
|
|
9038
|
+
}];
|
|
9039
|
+
const prior = await context.store.readText(OUTLINE_FILE);
|
|
9040
|
+
if (prior !== null) files.push({
|
|
9041
|
+
path: OUTLINE_DEPRECATED_FILE,
|
|
9042
|
+
text: prior
|
|
9043
|
+
});
|
|
8346
9044
|
return {
|
|
8347
|
-
files
|
|
8348
|
-
path: OUTLINE_FILE,
|
|
8349
|
-
text: yaml(outline)
|
|
8350
|
-
}, {
|
|
8351
|
-
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8352
|
-
text: yaml(nextPipeline)
|
|
8353
|
-
}],
|
|
9045
|
+
files,
|
|
8354
9046
|
backup: true,
|
|
8355
|
-
data:
|
|
8356
|
-
|
|
8357
|
-
archived,
|
|
8358
|
-
total: chapters.length,
|
|
8359
|
-
strict: book.strict,
|
|
8360
|
-
volumes: volumes.length
|
|
8361
|
-
},
|
|
8362
|
-
warnings: dropped.length === 0 ? [] : [{
|
|
8363
|
-
code: "OUTLINE_PLANNED_DROPPED",
|
|
8364
|
-
message: `this replacement dropped planned cards: ${dropped.join(", ")}; resubmit every existing card when adding one`
|
|
8365
|
-
}],
|
|
9047
|
+
data: plan.data,
|
|
9048
|
+
warnings: [],
|
|
8366
9049
|
event: {
|
|
8367
9050
|
name: "novel/planned",
|
|
8368
9051
|
payload: {
|
|
8369
9052
|
schemaVersion: 1,
|
|
8370
9053
|
projectRoot: snapshot.root,
|
|
8371
9054
|
kind: "outline",
|
|
8372
|
-
chapters:
|
|
8373
|
-
volumes: volumes
|
|
9055
|
+
chapters: plan.data.total,
|
|
9056
|
+
volumes: plan.data.volumes
|
|
8374
9057
|
}
|
|
8375
9058
|
}
|
|
8376
9059
|
};
|
|
@@ -8386,6 +9069,7 @@ var EngineService = class {
|
|
|
8386
9069
|
]);
|
|
8387
9070
|
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline_import argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
|
|
8388
9071
|
const parsed = parseOutlineInput(input.content, input.format);
|
|
9072
|
+
this.assertPublicBookHasNoStrictExplicit(parsed.book, "novel_outline_import");
|
|
8389
9073
|
const snapshot = await this.load(input.root);
|
|
8390
9074
|
this.assertProjectReadable(snapshot, "novel_outline_import");
|
|
8391
9075
|
this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline_import");
|
|
@@ -8411,6 +9095,7 @@ var EngineService = class {
|
|
|
8411
9095
|
defaultTargetWords: snapshot.project.targets.words_per_chapter,
|
|
8412
9096
|
now
|
|
8413
9097
|
});
|
|
9098
|
+
assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_outline_import");
|
|
8414
9099
|
const candidate = {
|
|
8415
9100
|
schema_version: 1,
|
|
8416
9101
|
updated_at: now,
|
|
@@ -8418,6 +9103,9 @@ var EngineService = class {
|
|
|
8418
9103
|
volumes,
|
|
8419
9104
|
chapters
|
|
8420
9105
|
};
|
|
9106
|
+
assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, volumes, "novel_outline_import");
|
|
9107
|
+
this.assertActiveCardPreserved(snapshot, chapters, "novel_outline_import");
|
|
9108
|
+
this.assertOutlineWritable(snapshot, candidate, "novel_outline_import");
|
|
8421
9109
|
const preview = diffLines(yaml(snapshot.outline), yaml(candidate)).hunks.filter((hunk) => hunk.kind !== "same").slice(0, 80).map((hunk) => `${hunk.kind === "add" ? "+" : "-"} ${hunk.line}`).join("\n");
|
|
8422
9110
|
const summary = {
|
|
8423
9111
|
format: parsed.format,
|
|
@@ -8461,6 +9149,7 @@ var EngineService = class {
|
|
|
8461
9149
|
defaultTargetWords: fresh.project.targets.words_per_chapter,
|
|
8462
9150
|
now
|
|
8463
9151
|
});
|
|
9152
|
+
assertArchivedCardVolumes(fresh.chapters.chapters, fresh.outline.chapters, fresh.outline.volumes, nextVolumes, "novel_outline_import");
|
|
8464
9153
|
const outline = {
|
|
8465
9154
|
schema_version: 1,
|
|
8466
9155
|
updated_at: now,
|
|
@@ -8468,7 +9157,7 @@ var EngineService = class {
|
|
|
8468
9157
|
volumes: nextVolumes,
|
|
8469
9158
|
chapters: nextChapters
|
|
8470
9159
|
};
|
|
8471
|
-
this.assertActiveCardPreserved(fresh,
|
|
9160
|
+
this.assertActiveCardPreserved(fresh, nextChapters, "novel_outline_import");
|
|
8472
9161
|
this.assertOutlineWritable(fresh, outline, "novel_outline_import");
|
|
8473
9162
|
const appliedPreview = diffLines(yaml(fresh.outline), yaml(outline)).hunks.filter((hunk) => hunk.kind !== "same").slice(0, 80).map((hunk) => `${hunk.kind === "add" ? "+" : "-"} ${hunk.line}`).join("\n");
|
|
8474
9163
|
const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import_outline", this.now(), shortDigest(input.content)) });
|
|
@@ -8737,6 +9426,53 @@ var EngineService = class {
|
|
|
8737
9426
|
run: async (snapshot, context) => {
|
|
8738
9427
|
const manifest = await readBackupManifest(context.store, input.txid);
|
|
8739
9428
|
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");
|
|
9429
|
+
if (input.paths?.length === 1 && input.paths[0] === "outline/outline.yaml") {
|
|
9430
|
+
this.assertArchiveIndexReadable(snapshot, "novel_restore");
|
|
9431
|
+
if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring outline/outline.yaml does not accept a chapter", "omit chapter when paths is exactly [outline/outline.yaml]");
|
|
9432
|
+
if (!manifest.files.includes("outline/outline.yaml")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "choose a backup created by novel_outline, novel_blueprint or a planning amendment");
|
|
9433
|
+
const text = await context.store.readText(`backups/${input.txid}/${OUTLINE_FILE}`);
|
|
9434
|
+
if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "the file was created by that transaction, so it has no pre-image to restore");
|
|
9435
|
+
let document;
|
|
9436
|
+
try {
|
|
9437
|
+
document = validateOutlineDocument(parse(text), {
|
|
9438
|
+
titleRule: this.rulepack.format.title,
|
|
9439
|
+
totalChapters: snapshot.project.targets.total_chapters
|
|
9440
|
+
});
|
|
9441
|
+
} catch (error) {
|
|
9442
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid outline: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
|
|
9443
|
+
}
|
|
9444
|
+
const restoredCards = new Map(document.chapters.map((card) => [card.num, card]));
|
|
9445
|
+
const archivedNums = new Set(snapshot.chapters.chapters.map((record) => record.num));
|
|
9446
|
+
for (const record of snapshot.chapters.chapters) {
|
|
9447
|
+
const restoredCard = restoredCards.get(record.num);
|
|
9448
|
+
if (restoredCard?.status !== "archived") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would make archived chapter ${record.num} ${restoredCard === void 0 ? "missing its card" : "a planned card"}`, "call novel_reopen before restoring a pre-archive outline, or choose an outline backup that already contains the archived card");
|
|
9449
|
+
}
|
|
9450
|
+
for (const card of document.chapters) if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would restore an archived card for chapter ${card.num}, but that chapter is not archived`, "choose an outline backup that matches the current chapter index, or reopen/archive the chapter first");
|
|
9451
|
+
const files = [{
|
|
9452
|
+
path: OUTLINE_FILE,
|
|
9453
|
+
text
|
|
9454
|
+
}];
|
|
9455
|
+
const prior = await context.store.readText(OUTLINE_FILE);
|
|
9456
|
+
if (prior !== null) files.push({
|
|
9457
|
+
path: OUTLINE_DEPRECATED_FILE,
|
|
9458
|
+
text: prior
|
|
9459
|
+
});
|
|
9460
|
+
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
|
|
9461
|
+
files.push({
|
|
9462
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9463
|
+
text: yaml(nextPipeline)
|
|
9464
|
+
});
|
|
9465
|
+
return {
|
|
9466
|
+
files,
|
|
9467
|
+
backup: true,
|
|
9468
|
+
data: {
|
|
9469
|
+
txid: input.txid,
|
|
9470
|
+
chapter: snapshot.pipeline.chapter,
|
|
9471
|
+
restored: [OUTLINE_FILE]
|
|
9472
|
+
},
|
|
9473
|
+
warnings: []
|
|
9474
|
+
};
|
|
9475
|
+
}
|
|
8740
9476
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
8741
9477
|
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");
|
|
8742
9478
|
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");
|
|
@@ -8848,6 +9584,13 @@ var EngineService = class {
|
|
|
8848
9584
|
async journalBlocking(snapshot) {
|
|
8849
9585
|
const store = this.storeFor(snapshot.root);
|
|
8850
9586
|
if (await this.lockIsLive(store)) return [];
|
|
9587
|
+
const pending = await inspectPending(store);
|
|
9588
|
+
if (pending.state === "partial") return [{
|
|
9589
|
+
code: ERROR_CODES.PENDING_PARTIAL,
|
|
9590
|
+
scope: "journal",
|
|
9591
|
+
detail: `pending transaction ${pending.txid ?? "unknown"} partially changed the project: ${pending.detail ?? "mixed before/after hashes"}`,
|
|
9592
|
+
hint: `do not restore the whole project; inspect ${pending.stagingDir ?? "staging"}/before/ or state/journal.jsonl first`
|
|
9593
|
+
}];
|
|
8851
9594
|
if (await store.exists("novel.yaml")) {
|
|
8852
9595
|
const journalPresent = await store.exists(joinRel(STATE_DIR, STATE_FILES.journal));
|
|
8853
9596
|
const journal = journalPresent ? await readJournal(store) : null;
|
|
@@ -8945,6 +9688,11 @@ var EngineService = class {
|
|
|
8945
9688
|
async statusFor(snapshot, chapter) {
|
|
8946
9689
|
const metrics = await this.metricsFor(snapshot, chapter);
|
|
8947
9690
|
const journal = await this.journalBlocking(snapshot);
|
|
9691
|
+
const pending = await inspectPending(this.storeFor(snapshot.root));
|
|
9692
|
+
const pendingWarnings = pending.state === "complete" || pending.state === "untouched" ? [{
|
|
9693
|
+
code: pending.state === "complete" ? "PENDING_RECOVERABLE" : "PENDING_ABORTABLE",
|
|
9694
|
+
message: `${pending.detail ?? "pending transaction detected"} (${pending.txid ?? "unknown"})`
|
|
9695
|
+
}] : [];
|
|
8948
9696
|
const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
|
|
8949
9697
|
tool: "novel_audit",
|
|
8950
9698
|
args: {},
|
|
@@ -8964,6 +9712,7 @@ var EngineService = class {
|
|
|
8964
9712
|
stage: snapshot.pipeline.stage,
|
|
8965
9713
|
rounds: { ...snapshot.pipeline.rounds },
|
|
8966
9714
|
blocking: [...this.effectiveBlocking(snapshot), ...journal],
|
|
9715
|
+
...pendingWarnings.length === 0 ? {} : { warnings: pendingWarnings },
|
|
8967
9716
|
state_errors: this.stateErrors(snapshot),
|
|
8968
9717
|
selection: {
|
|
8969
9718
|
source: snapshot.selection.source,
|
|
@@ -9499,6 +10248,21 @@ var EngineService = class {
|
|
|
9499
10248
|
detail: snapshot.outlineError ?? (snapshot.outlinePresent ? `${snapshot.outline.chapters.length} 张章卡 / ${snapshot.outline.volumes.length} 卷 / strict=${String(snapshot.outline.book.strict)}` : "outline/outline.yaml is missing"),
|
|
9500
10249
|
...snapshot.outlineError !== null ? { hint: "repair outline/outline.yaml with novel_amend" } : snapshot.outlinePresent ? {} : { hint: "create a card with novel_outline before relying on PG-13" }
|
|
9501
10250
|
});
|
|
10251
|
+
if (snapshot.handoff !== null && snapshot.outlineError === null && snapshot.outline.volumes.length > 0) {
|
|
10252
|
+
const chapter = snapshot.pipeline.chapter;
|
|
10253
|
+
const expectedIndex = snapshot.outline.volumes.findIndex((volume) => chapter >= volume.start_chapter && chapter <= volume.end_chapter);
|
|
10254
|
+
if (expectedIndex >= 0) {
|
|
10255
|
+
const expected = snapshot.outline.volumes[expectedIndex];
|
|
10256
|
+
const position = projectHandoff(snapshot.handoff, chapter);
|
|
10257
|
+
if (position.volume !== expectedIndex + 1 || position.volume_name !== expected.title) add({
|
|
10258
|
+
id: "handoff-outline",
|
|
10259
|
+
severity: "warning",
|
|
10260
|
+
title: "交接卷与大纲不一致",
|
|
10261
|
+
detail: `handoff 指向第 ${position.volume} 卷「${position.volume_name}」,当前章 ${chapter} 的大纲卷是第 ${expectedIndex + 1} 卷「${expected.title}」`,
|
|
10262
|
+
hint: "用 novel_amend 修正 state/handoff.yaml,或重新归档以推进交接;归档前请确认卷/幕是作者想要的"
|
|
10263
|
+
});
|
|
10264
|
+
}
|
|
10265
|
+
}
|
|
9502
10266
|
add({
|
|
9503
10267
|
id: "worldbook",
|
|
9504
10268
|
severity: snapshot.worldbookError !== null ? "error" : snapshot.worldbookPresent ? "ok" : "info",
|
|
@@ -9518,6 +10282,14 @@ var EngineService = class {
|
|
|
9518
10282
|
const journal = await readJournal(store);
|
|
9519
10283
|
const journalCommitted = hasCommittedJournal(journal.records);
|
|
9520
10284
|
const health = await reconcileJournal(store);
|
|
10285
|
+
const pending = await inspectPending(store);
|
|
10286
|
+
if (pending.state !== "none") add({
|
|
10287
|
+
id: "pending-transaction",
|
|
10288
|
+
severity: pending.state === "partial" ? "error" : "warning",
|
|
10289
|
+
title: pending.state === "partial" ? "事务只完成了一部分" : pending.state === "complete" ? "事务可安全提升" : "事务可安全回滚",
|
|
10290
|
+
detail: `${pending.txid ?? "unknown"}: ${pending.detail ?? pending.state}`,
|
|
10291
|
+
hint: pending.state === "partial" ? `先检查 ${pending.stagingDir ?? "staging"}/before/;不要从整库备份恢复` : "下一次写操作会自动完成恢复;只读命令不会改盘"
|
|
10292
|
+
});
|
|
9521
10293
|
add({
|
|
9522
10294
|
id: "journal",
|
|
9523
10295
|
severity: !journalPresent || !journalCommitted || journal.malformed > 0 || health.mismatches.length > 0 ? "error" : "ok",
|
|
@@ -10413,7 +11185,11 @@ var EngineService = class {
|
|
|
10413
11185
|
async submitRevision(input) {
|
|
10414
11186
|
const hasContent = input.content !== void 0;
|
|
10415
11187
|
const hasPatch = input.patch !== void 0;
|
|
10416
|
-
|
|
11188
|
+
const summaryOnly = !hasContent && !hasPatch && input.summary !== void 0;
|
|
11189
|
+
if (!hasContent && !hasPatch && !summaryOnly) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires content, patch, or summary", "send revised text, a patch, or a full summary for a summary-only repair");
|
|
11190
|
+
if (hasContent && hasPatch) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise accepts only one of content or patch", "send the full revised text, or a patch with expected_hash and unique edits");
|
|
11191
|
+
if (summaryOnly && (input.issues !== void 0 || input.proof !== void 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary-only repair cannot change review issues or reading proof", "send only chapter and summary; use content/patch for a prose revision that needs a new proof");
|
|
11192
|
+
if (!summaryOnly && input.proof === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires proof for a content or patch revision", "provide opening/middle/ending quotes from the revised text");
|
|
10417
11193
|
if (hasPatch) {
|
|
10418
11194
|
const patch = input.patch;
|
|
10419
11195
|
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");
|
|
@@ -10434,7 +11210,13 @@ var EngineService = class {
|
|
|
10434
11210
|
}))}`,
|
|
10435
11211
|
run: async (snapshot, context) => {
|
|
10436
11212
|
this.assertChapter(snapshot, input.chapter);
|
|
10437
|
-
|
|
11213
|
+
if (summaryOnly && ![
|
|
11214
|
+
"P5",
|
|
11215
|
+
"P6",
|
|
11216
|
+
"ready_to_archive"
|
|
11217
|
+
].includes(snapshot.pipeline.stage)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `summary-only repair is not available at ${snapshot.pipeline.stage}`, "use novel_revise with content/patch for an early-stage rewrite, or wait until P5/P6");
|
|
11218
|
+
const revisedContent = summaryOnly ? snapshot.text ?? "" : input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
|
|
11219
|
+
if (summaryOnly && snapshot.text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary-only repair requires an existing draft", "write the draft first, then repair its summary");
|
|
10438
11220
|
if (typeof revisedContent !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must be a string", "send the full revised text as a string, or a patch object");
|
|
10439
11221
|
if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
|
|
10440
11222
|
let patchedReview = null;
|
|
@@ -10497,8 +11279,10 @@ var EngineService = class {
|
|
|
10497
11279
|
text: revisedContent,
|
|
10498
11280
|
summary: input.summary,
|
|
10499
11281
|
summaryProvided: input.summary !== void 0,
|
|
11282
|
+
summaryOnly,
|
|
11283
|
+
summaryOnlyRevision: summaryOnly,
|
|
10500
11284
|
review: patchedReview,
|
|
10501
|
-
proof: input.proof
|
|
11285
|
+
proof: input.proof ?? snapshot.review?.proof ?? null
|
|
10502
11286
|
});
|
|
10503
11287
|
const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
|
|
10504
11288
|
if (input.summary === void 0 && inheritedSummary !== void 0) {
|
|
@@ -10517,24 +11301,27 @@ var EngineService = class {
|
|
|
10517
11301
|
content_hash: sha256(snapshot.text ?? ""),
|
|
10518
11302
|
updated_at: this.now().toISOString()
|
|
10519
11303
|
};
|
|
10520
|
-
const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
|
|
11304
|
+
const nextStage$1 = summaryOnly ? snapshot.pipeline.stage === "P5" ? "P6" : snapshot.pipeline.stage : nextStage(snapshot.pipeline.stage, "submitRevision", 0);
|
|
10521
11305
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
10522
11306
|
stage: nextStage$1,
|
|
10523
11307
|
draft: {
|
|
10524
11308
|
...baseDraft,
|
|
10525
11309
|
summary: effectiveSummary,
|
|
10526
|
-
content_hash: sha256(revisedContent),
|
|
11310
|
+
content_hash: summaryOnly ? baseDraft.content_hash : sha256(revisedContent),
|
|
10527
11311
|
updated_at: this.now().toISOString()
|
|
10528
11312
|
},
|
|
10529
|
-
rounds: {
|
|
11313
|
+
rounds: summaryOnly ? { ...snapshot.pipeline.rounds } : {
|
|
10530
11314
|
...snapshot.pipeline.rounds,
|
|
10531
11315
|
revise: snapshot.pipeline.rounds.revise + 1
|
|
10532
11316
|
},
|
|
10533
|
-
blocking: [],
|
|
11317
|
+
blocking: summaryOnly ? snapshot.pipeline.blocking.filter((item) => item.code !== "PG-05") : [],
|
|
10534
11318
|
...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
|
|
10535
|
-
last_action: action("novel_revise", this.now(), sha256(revisedContent))
|
|
11319
|
+
last_action: action(summaryOnly ? "novel_revise_summary" : "novel_revise", this.now(), sha256(revisedContent))
|
|
10536
11320
|
});
|
|
10537
|
-
const files = [
|
|
11321
|
+
const files = summaryOnly ? [{
|
|
11322
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
11323
|
+
text: yaml(nextPipeline)
|
|
11324
|
+
}] : [
|
|
10538
11325
|
...snapshot.text === null ? [] : [{
|
|
10539
11326
|
path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
10540
11327
|
text: snapshot.text
|
|
@@ -10552,17 +11339,19 @@ var EngineService = class {
|
|
|
10552
11339
|
text: yaml(nextPipeline)
|
|
10553
11340
|
}
|
|
10554
11341
|
];
|
|
10555
|
-
if (reviewChanged && patchedReview !== null) files.push({
|
|
11342
|
+
if (!summaryOnly && reviewChanged && patchedReview !== null) files.push({
|
|
10556
11343
|
path: reviewFile(input.chapter),
|
|
10557
11344
|
text: yaml(patchedReview)
|
|
10558
11345
|
});
|
|
10559
11346
|
return {
|
|
10560
11347
|
files,
|
|
10561
|
-
backup:
|
|
11348
|
+
backup: !summaryOnly,
|
|
10562
11349
|
data: {
|
|
10563
11350
|
chapter: input.chapter,
|
|
10564
11351
|
stage: nextStage$1,
|
|
10565
|
-
metrics: machine.metrics
|
|
11352
|
+
metrics: machine.metrics,
|
|
11353
|
+
summary_only: summaryOnly,
|
|
11354
|
+
rounds_consumed: summaryOnly ? 0 : 1
|
|
10566
11355
|
},
|
|
10567
11356
|
warnings: machineWarnings(machine)
|
|
10568
11357
|
};
|
|
@@ -10583,7 +11372,9 @@ var EngineService = class {
|
|
|
10583
11372
|
run: async (snapshot, context) => {
|
|
10584
11373
|
this.assertChapter(snapshot, input.chapter);
|
|
10585
11374
|
const text = snapshot.text ?? "";
|
|
10586
|
-
|
|
11375
|
+
if (input.acknowledge !== void 0 && !Array.isArray(input.acknowledge)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "acknowledge must be an array of advisory codes", "omit acknowledge or pass an array of advisory codes");
|
|
11376
|
+
const semanticEvaluation = validateSemanticSubmission(text, input.semantic);
|
|
11377
|
+
const semantic = input.semantic;
|
|
10587
11378
|
await this.assertGates(snapshot, "runCheck", {
|
|
10588
11379
|
briefStage: "P6",
|
|
10589
11380
|
text,
|
|
@@ -10604,8 +11395,8 @@ var EngineService = class {
|
|
|
10604
11395
|
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`);
|
|
10605
11396
|
const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
|
|
10606
11397
|
const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
|
|
10607
|
-
const semanticResult =
|
|
10608
|
-
const verdict = machineBlocking.length === 0 && !semanticResult.
|
|
11398
|
+
const semanticResult = semanticEvaluation;
|
|
11399
|
+
const verdict = machineBlocking.length === 0 && !semanticResult.content_failed ? "pass" : "fail";
|
|
10609
11400
|
const check = {
|
|
10610
11401
|
schema_version: 1,
|
|
10611
11402
|
chapter: input.chapter,
|
|
@@ -10616,8 +11407,15 @@ var EngineService = class {
|
|
|
10616
11407
|
advisories: machineAdvisories,
|
|
10617
11408
|
metrics: scopedMachine.metrics
|
|
10618
11409
|
},
|
|
10619
|
-
semantic: {
|
|
11410
|
+
semantic: {
|
|
11411
|
+
items: semantic,
|
|
11412
|
+
...semanticResult.findings.length === 0 ? {} : { findings: semanticResult.findings }
|
|
11413
|
+
},
|
|
10620
11414
|
...acknowledged.length === 0 ? {} : { acknowledged },
|
|
11415
|
+
...verdict === "fail" ? {
|
|
11416
|
+
failure_class: "content",
|
|
11417
|
+
counted: true
|
|
11418
|
+
} : {},
|
|
10621
11419
|
verdict
|
|
10622
11420
|
};
|
|
10623
11421
|
try {
|
|
@@ -10631,12 +11429,12 @@ var EngineService = class {
|
|
|
10631
11429
|
code: violation.code,
|
|
10632
11430
|
scope: "check",
|
|
10633
11431
|
detail: violation.detail
|
|
10634
|
-
})), ...semanticResult.
|
|
11432
|
+
})), ...semanticResult.findings] : [];
|
|
10635
11433
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
10636
11434
|
stage: nextStage$2,
|
|
10637
11435
|
rounds: {
|
|
10638
11436
|
...snapshot.pipeline.rounds,
|
|
10639
|
-
check: snapshot.pipeline.rounds.check + (verdict === "fail"
|
|
11437
|
+
check: snapshot.pipeline.rounds.check + (verdict === "fail" ? 1 : 0)
|
|
10640
11438
|
},
|
|
10641
11439
|
blocking: blockingItems,
|
|
10642
11440
|
last_action: action("novel_check", this.now(), sha256(text))
|
|
@@ -10665,7 +11463,13 @@ var EngineService = class {
|
|
|
10665
11463
|
advisory_codes: advisoryCodes,
|
|
10666
11464
|
acknowledged,
|
|
10667
11465
|
metrics: scopedMachine.metrics,
|
|
10668
|
-
stage: nextStage$2
|
|
11466
|
+
stage: nextStage$2,
|
|
11467
|
+
semantic: {
|
|
11468
|
+
items: semanticResult.items,
|
|
11469
|
+
findings: semanticResult.findings
|
|
11470
|
+
},
|
|
11471
|
+
rounds: { check: nextPipeline.rounds.check },
|
|
11472
|
+
counted: verdict === "fail"
|
|
10669
11473
|
},
|
|
10670
11474
|
warnings: [
|
|
10671
11475
|
...machineWarnings({
|
|
@@ -10713,7 +11517,9 @@ var EngineService = class {
|
|
|
10713
11517
|
chapter: input.chapter,
|
|
10714
11518
|
assigned_ids: already.assigned_ids ?? {},
|
|
10715
11519
|
metrics: countWordMetrics(archivedText),
|
|
10716
|
-
score: already.score
|
|
11520
|
+
score: already.score,
|
|
11521
|
+
delta_warnings: [],
|
|
11522
|
+
normalized_world: {}
|
|
10717
11523
|
},
|
|
10718
11524
|
warnings: [],
|
|
10719
11525
|
replay: true
|
|
@@ -10906,8 +11712,11 @@ var EngineService = class {
|
|
|
10906
11712
|
chapter,
|
|
10907
11713
|
assigned_ids: ledger.assigned,
|
|
10908
11714
|
metrics,
|
|
10909
|
-
score
|
|
11715
|
+
score,
|
|
11716
|
+
delta_warnings: ledger.warnings,
|
|
11717
|
+
normalized_world: ledger.normalized_world
|
|
10910
11718
|
},
|
|
11719
|
+
warnings: ledger.warnings,
|
|
10911
11720
|
event: {
|
|
10912
11721
|
name: "novel/archived",
|
|
10913
11722
|
payload: {
|
|
@@ -11190,6 +11999,15 @@ var EngineService = class {
|
|
|
11190
11999
|
code: "JOURNAL_HASH_MISMATCH",
|
|
11191
12000
|
message: `${health.mismatches.length} file(s) changed after the last commit; first: ${health.mismatches[0].path}`
|
|
11192
12001
|
});
|
|
12002
|
+
const pending = await inspectPending(store);
|
|
12003
|
+
if (pending.state === "partial") warnings.push({
|
|
12004
|
+
code: ERROR_CODES.PENDING_PARTIAL,
|
|
12005
|
+
message: `pending transaction ${pending.txid ?? "unknown"} is partial: ${pending.detail ?? "mixed hashes"}; do not restore the whole project`
|
|
12006
|
+
});
|
|
12007
|
+
else if (pending.state === "complete" || pending.state === "untouched") warnings.push({
|
|
12008
|
+
code: pending.state === "complete" ? "PENDING_RECOVERABLE" : "PENDING_ABORTABLE",
|
|
12009
|
+
message: `${pending.txid ?? "unknown"}: ${pending.detail ?? pending.state}`
|
|
12010
|
+
});
|
|
11193
12011
|
const requested = input.limit ?? 50;
|
|
11194
12012
|
const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
|
|
11195
12013
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
@@ -11232,10 +12050,22 @@ var EngineService = class {
|
|
|
11232
12050
|
const current = await this.load(input.root);
|
|
11233
12051
|
this.assertExportIndexReadable(current, input.kind);
|
|
11234
12052
|
}
|
|
12053
|
+
const artifactSnapshot = await this.load(input.root);
|
|
12054
|
+
const artifactStore = this.storeFor(artifactSnapshot.root);
|
|
12055
|
+
const suffix = input.chapter === void 0 ? "" : `-ch${String(input.chapter).padStart(3, "0")}`;
|
|
12056
|
+
const expectedArtifacts = input.kind === "outline" ? [joinRel(EXPORT_DIR, "outline.md")] : input.kind === "worldbook" ? [joinRel(EXPORT_DIR, "worldbook.md")] : input.kind === "proposal" ? [joinRel(EXPORT_DIR, "proposal.md")] : [
|
|
12057
|
+
joinRel(EXPORT_DIR, `book${suffix}.${input.kind}`),
|
|
12058
|
+
joinRel(EXPORT_DIR, "cover.svg"),
|
|
12059
|
+
joinRel(EXPORT_DIR, "blurb.txt")
|
|
12060
|
+
];
|
|
12061
|
+
const artifactsPresent = (await Promise.all(expectedArtifacts.map((path) => artifactStore.exists(path)))).every(Boolean);
|
|
12062
|
+
const artifactKey = `${artifactSnapshot.root}|${input.kind}|${input.chapter ?? "all"}`;
|
|
12063
|
+
if (!artifactsPresent) this.exportArtifactEpoch.set(artifactKey, (this.exportArtifactEpoch.get(artifactKey) ?? 0) + 1);
|
|
12064
|
+
const artifactEpoch = this.exportArtifactEpoch.get(artifactKey) ?? 0;
|
|
11235
12065
|
return this.mutate({
|
|
11236
12066
|
root: input.root,
|
|
11237
12067
|
op: "export",
|
|
11238
|
-
key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
|
|
12068
|
+
key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${artifactEpoch}:${sha256(stableJson({
|
|
11239
12069
|
chapter: snapshot.pipeline.chapter,
|
|
11240
12070
|
text: sha256(snapshot.text ?? ""),
|
|
11241
12071
|
plan_title: stripTitlePrefix(snapshot.plan?.title ?? ""),
|
|
@@ -11457,14 +12287,14 @@ var EngineService = class {
|
|
|
11457
12287
|
root: active,
|
|
11458
12288
|
source: "active"
|
|
11459
12289
|
};
|
|
11460
|
-
else if (registry.projects.length === 1) selection = {
|
|
11461
|
-
root: registry.projects[0].root,
|
|
11462
|
-
source: "single"
|
|
11463
|
-
};
|
|
11464
12290
|
else if (rows.has(configured) && !registry.projects.some((entry) => entry.root === configured)) selection = {
|
|
11465
12291
|
root: configured,
|
|
11466
12292
|
source: "config"
|
|
11467
12293
|
};
|
|
12294
|
+
else if (registry.projects.length === 1) selection = {
|
|
12295
|
+
root: registry.projects[0].root,
|
|
12296
|
+
source: "single"
|
|
12297
|
+
};
|
|
11468
12298
|
else selection = {
|
|
11469
12299
|
root: null,
|
|
11470
12300
|
source: "none"
|
|
@@ -11963,17 +12793,23 @@ var EngineService = class {
|
|
|
11963
12793
|
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is unreadable; ${operation} would overwrite the unreadable planning document`, `read ${path} with novel_state and repair it with novel_amend before ${operation}`);
|
|
11964
12794
|
}
|
|
11965
12795
|
/**
|
|
11966
|
-
*
|
|
11967
|
-
*
|
|
11968
|
-
*
|
|
11969
|
-
|
|
12796
|
+
* `strict_explicit` is an internal provenance bit, not an author choice.
|
|
12797
|
+
* Exposing it lets a caller forge "the author chose strict:false" without
|
|
12798
|
+
* going through the strict option. Stored documents may still contain it.
|
|
12799
|
+
*/
|
|
12800
|
+
assertPublicBookHasNoStrictExplicit(book, operation) {
|
|
12801
|
+
if (typeof book !== "object" || book === null || Array.isArray(book)) return;
|
|
12802
|
+
if (Object.hasOwn(book, "strict_explicit")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot set book.strict_explicit`, "use the public strict boolean; strict_explicit is an internal provenance marker");
|
|
12803
|
+
}
|
|
12804
|
+
/**
|
|
12805
|
+
* An import is a full replacement, unlike novel_outline's keyed upsert. It
|
|
12806
|
+
* must not silently lose the active card while strict mode still requires it.
|
|
11970
12807
|
*/
|
|
11971
|
-
assertActiveCardPreserved(snapshot,
|
|
11972
|
-
if (!strict) return;
|
|
12808
|
+
assertActiveCardPreserved(snapshot, chapters, operation) {
|
|
11973
12809
|
const chapter = snapshot.pipeline.chapter;
|
|
11974
12810
|
if (!snapshot.outline.chapters.some((card) => card.num === chapter)) return;
|
|
11975
12811
|
if (chapters.some((card) => card.num === chapter)) return;
|
|
11976
|
-
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card
|
|
12812
|
+
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card`, "include the active card in the imported outline; import replaces the whole plan and cannot remove an in-flight card");
|
|
11977
12813
|
}
|
|
11978
12814
|
/**
|
|
11979
12815
|
* The outline is a derived progress view and the PG-13 input. Every write
|
|
@@ -12053,6 +12889,7 @@ var EngineService = class {
|
|
|
12053
12889
|
const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
|
|
12054
12890
|
const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
|
|
12055
12891
|
const journal = await readJournal(this.storeFor(snapshot.root));
|
|
12892
|
+
const pending = await inspectPending(this.storeFor(snapshot.root));
|
|
12056
12893
|
const journalBlocking = await this.journalBlocking(snapshot);
|
|
12057
12894
|
const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
|
|
12058
12895
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
@@ -12186,7 +13023,12 @@ var EngineService = class {
|
|
|
12186
13023
|
activity,
|
|
12187
13024
|
...advisories.length === 0 ? {} : { advisories },
|
|
12188
13025
|
...acknowledgedAdvisories === 0 ? {} : { acknowledgedAdvisories },
|
|
12189
|
-
...legacy.length === 0 ? {} : { legacy }
|
|
13026
|
+
...legacy.length === 0 ? {} : { legacy },
|
|
13027
|
+
...pending.state === "none" ? {} : { pending: {
|
|
13028
|
+
state: pending.state,
|
|
13029
|
+
txid: pending.txid ?? "",
|
|
13030
|
+
detail: pending.detail ?? ""
|
|
13031
|
+
} }
|
|
12190
13032
|
};
|
|
12191
13033
|
}
|
|
12192
13034
|
/**
|
|
@@ -12864,6 +13706,13 @@ var EngineService = class {
|
|
|
12864
13706
|
rounds: snapshot.pipeline.rounds,
|
|
12865
13707
|
min_score: this.config.quality.minScore,
|
|
12866
13708
|
rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
|
|
13709
|
+
documents: sha256(stableJson({
|
|
13710
|
+
project: snapshot.project,
|
|
13711
|
+
outline: snapshot.outline,
|
|
13712
|
+
worldbook: snapshot.worldbook,
|
|
13713
|
+
plan: snapshot.plan,
|
|
13714
|
+
worldPlan: snapshot.worldPlan
|
|
13715
|
+
})),
|
|
12867
13716
|
context
|
|
12868
13717
|
}));
|
|
12869
13718
|
}
|
|
@@ -12909,6 +13758,8 @@ var EngineService = class {
|
|
|
12909
13758
|
sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
|
|
12910
13759
|
summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
|
|
12911
13760
|
summaryProvided: extras.summaryProvided,
|
|
13761
|
+
summaryOnly: extras.summaryOnly,
|
|
13762
|
+
summaryOnlyRevision: extras.summaryOnlyRevision ?? snapshot.pipeline.last_action?.op === "novel_revise_summary",
|
|
12912
13763
|
review: extras.review ?? snapshot.review,
|
|
12913
13764
|
check: extras.check ?? snapshot.check,
|
|
12914
13765
|
semantic: extras.semantic ?? [],
|
|
@@ -13309,7 +14160,14 @@ var EngineService = class {
|
|
|
13309
14160
|
STATE_FILES.handoff
|
|
13310
14161
|
].map((file) => joinRel(STATE_DIR, file));
|
|
13311
14162
|
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
|
|
13312
|
-
const
|
|
14163
|
+
const pending = await inspectPending(store);
|
|
14164
|
+
if (pending.state === "partial") throw new EngineFaultError(ERROR_CODES.PENDING_PARTIAL, `pending transaction ${pending.txid ?? "unknown"} partially changed this directory`, `inspect ${pending.stagingDir ?? ".staging"}/ before initializing over it`);
|
|
14165
|
+
if (pending.state === "complete" || pending.state === "untouched") {
|
|
14166
|
+
await assertJournalConsistent(store);
|
|
14167
|
+
if (await store.exists("novel.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, "a project already exists at this root after pending recovery", "use a different directory, or call novel_status to inspect the existing project");
|
|
14168
|
+
}
|
|
14169
|
+
const journalPath = joinRel(STATE_DIR, STATE_FILES.journal);
|
|
14170
|
+
const stateCandidates = journalWasPresent && (await readJournal(store)).records.some((record) => record.status === "committed" && record.op !== "lock_reclaimed") ? stateFiles : stateFiles.filter((path) => path !== journalPath);
|
|
13313
14171
|
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");
|
|
13314
14172
|
await assertJournalConsistent(store);
|
|
13315
14173
|
const created = await options.run(this.emptySnapshot(root, selection), {
|
|
@@ -13824,7 +14682,9 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
13824
14682
|
if (kind !== "relationships") return problems;
|
|
13825
14683
|
}
|
|
13826
14684
|
if (kind === "characters") {
|
|
13827
|
-
const
|
|
14685
|
+
const rows = Array.isArray(nextDocument.characters) ? nextDocument.characters : [];
|
|
14686
|
+
const names = new Set(rows.map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
|
|
14687
|
+
if (names.size !== rows.length) problems.push("characters must have unique names");
|
|
13828
14688
|
if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
|
|
13829
14689
|
for (const name of Object.keys(snapshot.locks.characters)) if (!names.has(name)) problems.push(`lock for removed character "${name}" still exists`);
|
|
13830
14690
|
}
|
|
@@ -13834,6 +14694,21 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
13834
14694
|
return problems;
|
|
13835
14695
|
}
|
|
13836
14696
|
if (kind === "world") {
|
|
14697
|
+
const allNames = [];
|
|
14698
|
+
for (const section of [
|
|
14699
|
+
"locations",
|
|
14700
|
+
"abilities",
|
|
14701
|
+
"artifacts",
|
|
14702
|
+
"factions",
|
|
14703
|
+
"rules"
|
|
14704
|
+
]) {
|
|
14705
|
+
const rows = Array.isArray(nextDocument[section]) ? nextDocument[section] : [];
|
|
14706
|
+
for (const raw of rows) {
|
|
14707
|
+
const name = recordOf(raw)?.name;
|
|
14708
|
+
if (typeof name === "string") allNames.push(name);
|
|
14709
|
+
}
|
|
14710
|
+
}
|
|
14711
|
+
if (new Set(allNames).size !== allNames.length) problems.push("world entities must have unique names");
|
|
13837
14712
|
const locations = new Set((Array.isArray(nextDocument.locations) ? nextDocument.locations : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
|
|
13838
14713
|
if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
|
|
13839
14714
|
for (const name of Object.keys(snapshot.locks.locations)) if (!locations.has(name)) problems.push(`lock for removed location "${name}" still exists`);
|
|
@@ -13923,6 +14798,19 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
13923
14798
|
const row = recordOf(raw);
|
|
13924
14799
|
if (row === null || !Number.isInteger(row.start_chapter) || !Number.isInteger(row.end_chapter) || row.end_chapter < row.start_chapter) problems.push(`volumes[${index}] must have start_chapter <= end_chapter`);
|
|
13925
14800
|
}
|
|
14801
|
+
if (snapshot.outlineError === null) try {
|
|
14802
|
+
const nextVolumes = normalizeVolumes(volumes, {
|
|
14803
|
+
totalChapters: snapshot.project.targets.total_chapters,
|
|
14804
|
+
rejectDuplicateTitles: false
|
|
14805
|
+
});
|
|
14806
|
+
assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, nextVolumes, "novel_amend");
|
|
14807
|
+
assertArchivedCardsInVolumes(snapshot.chapters.chapters, [...byNum.values()].map((row) => ({
|
|
14808
|
+
num: row.num,
|
|
14809
|
+
volume: row.volume ?? null
|
|
14810
|
+
})), nextVolumes, "novel_amend");
|
|
14811
|
+
} catch (error) {
|
|
14812
|
+
problems.push(error instanceof Error ? error.message : String(error));
|
|
14813
|
+
}
|
|
13926
14814
|
for (const record of snapshot.chapters.chapters) {
|
|
13927
14815
|
const card = byNum.get(record.num);
|
|
13928
14816
|
if (card === void 0) {
|
|
@@ -14164,6 +15052,8 @@ function diffLines(before, after) {
|
|
|
14164
15052
|
hunks
|
|
14165
15053
|
};
|
|
14166
15054
|
}
|
|
15055
|
+
// src/tools/semantics.ts
|
|
15056
|
+
const arraySemanticsDescription = (semantics, description) => `[x-semantics: ${semantics}] ${description}`;
|
|
14167
15057
|
// src/tools/index.ts
|
|
14168
15058
|
/**
|
|
14169
15059
|
* Providers sometimes serialize a free-form `json` parameter as a JSON string
|
|
@@ -14182,7 +15072,8 @@ const asJson = (value, field) => {
|
|
|
14182
15072
|
const fail = (error) => {
|
|
14183
15073
|
if (error instanceof EngineError) {
|
|
14184
15074
|
const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
|
|
14185
|
-
|
|
15075
|
+
const details = "items" in error && Array.isArray(error.items) ? `\nitems: ${JSON.stringify(error.items)}` : "";
|
|
15076
|
+
throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}${details}`);
|
|
14186
15077
|
}
|
|
14187
15078
|
throw error;
|
|
14188
15079
|
};
|
|
@@ -14239,10 +15130,11 @@ const jsonOutput = {
|
|
|
14239
15130
|
schema: { type: "json" },
|
|
14240
15131
|
render: (_args, raw) => withReplay(text(payload(raw)), raw)
|
|
14241
15132
|
};
|
|
14242
|
-
const stringArray = {
|
|
15133
|
+
const stringArray = (semantics, description) => ({
|
|
14243
15134
|
type: "array",
|
|
14244
|
-
items: { type: "string" }
|
|
14245
|
-
|
|
15135
|
+
items: { type: "string" },
|
|
15136
|
+
description: arraySemanticsDescription(semantics, description)
|
|
15137
|
+
});
|
|
14246
15138
|
const root = {
|
|
14247
15139
|
type: "string",
|
|
14248
15140
|
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."
|
|
@@ -14284,7 +15176,8 @@ function createNovelTools(engine, options = {}) {
|
|
|
14284
15176
|
description: { type: "string" },
|
|
14285
15177
|
words_per_chapter: {
|
|
14286
15178
|
type: "array",
|
|
14287
|
-
items: { type: "integer" }
|
|
15179
|
+
items: { type: "integer" },
|
|
15180
|
+
description: arraySemanticsDescription("replace", "Fixed [min,max] word target for chapters.")
|
|
14288
15181
|
},
|
|
14289
15182
|
total_chapters: { type: "integer" },
|
|
14290
15183
|
premise: {
|
|
@@ -14292,11 +15185,11 @@ function createNovelTools(engine, options = {}) {
|
|
|
14292
15185
|
description: "Optional one-sentence premise for the book plan."
|
|
14293
15186
|
},
|
|
14294
15187
|
logline: { type: "string" },
|
|
14295
|
-
themes: stringArray,
|
|
15188
|
+
themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
|
|
14296
15189
|
tone: { type: "string" },
|
|
14297
15190
|
pov: { type: "string" },
|
|
14298
15191
|
audience: { type: "string" },
|
|
14299
|
-
selling_points: stringArray,
|
|
15192
|
+
selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
|
|
14300
15193
|
ending: { type: "string" },
|
|
14301
15194
|
strict_outline: { type: "boolean" },
|
|
14302
15195
|
project_root: initRoot
|
|
@@ -14393,10 +15286,16 @@ function createNovelTools(engine, options = {}) {
|
|
|
14393
15286
|
type: "string",
|
|
14394
15287
|
required: true
|
|
14395
15288
|
},
|
|
14396
|
-
constraints: stringArray,
|
|
14397
|
-
characters: {
|
|
14398
|
-
|
|
14399
|
-
|
|
15289
|
+
constraints: stringArray("replace", "P1 constraints; supplied list replaces the plan list."),
|
|
15290
|
+
characters: {
|
|
15291
|
+
type: "json",
|
|
15292
|
+
description: arraySemanticsDescription("replace", "P1 cast; supplied list replaces the cast.")
|
|
15293
|
+
},
|
|
15294
|
+
locations: {
|
|
15295
|
+
type: "json",
|
|
15296
|
+
description: arraySemanticsDescription("replace", "P1 locations; supplied list replaces them.")
|
|
15297
|
+
},
|
|
15298
|
+
continuity_notes: stringArray("replace", "P1 continuity notes; supplied list replaces them."),
|
|
14400
15299
|
project_root: root
|
|
14401
15300
|
},
|
|
14402
15301
|
output: jsonOutput,
|
|
@@ -14428,7 +15327,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
14428
15327
|
required: true
|
|
14429
15328
|
},
|
|
14430
15329
|
beats: {
|
|
14431
|
-
...stringArray,
|
|
15330
|
+
...stringArray("replace", "Ordered plan beats; supplied list replaces the beats."),
|
|
14432
15331
|
required: true
|
|
14433
15332
|
},
|
|
14434
15333
|
opening: {
|
|
@@ -14442,11 +15341,12 @@ function createNovelTools(engine, options = {}) {
|
|
|
14442
15341
|
target_words: {
|
|
14443
15342
|
type: "array",
|
|
14444
15343
|
items: { type: "integer" },
|
|
14445
|
-
required: true
|
|
15344
|
+
required: true,
|
|
15345
|
+
description: arraySemanticsDescription("replace", "Plan [min,max] word target.")
|
|
14446
15346
|
},
|
|
14447
15347
|
cast: {
|
|
14448
15348
|
type: "json",
|
|
14449
|
-
description: "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger."
|
|
15349
|
+
description: arraySemanticsDescription("replace", "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger.")
|
|
14450
15350
|
},
|
|
14451
15351
|
project_root: root
|
|
14452
15352
|
},
|
|
@@ -14517,9 +15417,13 @@ function createNovelTools(engine, options = {}) {
|
|
|
14517
15417
|
},
|
|
14518
15418
|
dimensions: {
|
|
14519
15419
|
type: "json",
|
|
14520
|
-
required: true
|
|
15420
|
+
required: true,
|
|
15421
|
+
description: arraySemanticsDescription("replace", "Fifteen scored dimensions.")
|
|
15422
|
+
},
|
|
15423
|
+
issues: {
|
|
15424
|
+
type: "json",
|
|
15425
|
+
description: arraySemanticsDescription("replace", "Review issue list.")
|
|
14521
15426
|
},
|
|
14522
|
-
issues: { type: "json" },
|
|
14523
15427
|
total: {
|
|
14524
15428
|
type: "integer",
|
|
14525
15429
|
required: true,
|
|
@@ -14527,7 +15431,8 @@ function createNovelTools(engine, options = {}) {
|
|
|
14527
15431
|
},
|
|
14528
15432
|
proof: {
|
|
14529
15433
|
type: "json",
|
|
14530
|
-
required: true
|
|
15434
|
+
required: true,
|
|
15435
|
+
description: arraySemanticsDescription("replace", "Three reading-proof quotes.")
|
|
14531
15436
|
},
|
|
14532
15437
|
project_root: root
|
|
14533
15438
|
},
|
|
@@ -14557,15 +15462,17 @@ function createNovelTools(engine, options = {}) {
|
|
|
14557
15462
|
type: "json",
|
|
14558
15463
|
description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
|
|
14559
15464
|
},
|
|
14560
|
-
summary: {
|
|
15465
|
+
summary: {
|
|
15466
|
+
type: "json",
|
|
15467
|
+
description: "Full ten-field summary. Omit content+patch with a summary to repair only pipeline.draft.summary; this does not consume a revise round."
|
|
15468
|
+
},
|
|
14561
15469
|
issues: {
|
|
14562
15470
|
type: "json",
|
|
14563
|
-
description: "Updated issue statuses and evidence for PG-08."
|
|
15471
|
+
description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
|
|
14564
15472
|
},
|
|
14565
15473
|
proof: {
|
|
14566
15474
|
type: "json",
|
|
14567
|
-
|
|
14568
|
-
description: "Three quotes from the revised text; PG-09 verifies them."
|
|
15475
|
+
description: arraySemanticsDescription("replace", "Three quotes from the revised text; required unless this is a summary-only repair. PG-09 verifies them.")
|
|
14569
15476
|
},
|
|
14570
15477
|
project_root: root
|
|
14571
15478
|
},
|
|
@@ -14588,16 +15495,16 @@ function createNovelTools(engine, options = {}) {
|
|
|
14588
15495
|
type: "integer",
|
|
14589
15496
|
required: true
|
|
14590
15497
|
},
|
|
14591
|
-
scope: stringArray,
|
|
15498
|
+
scope: stringArray("replace", "Requested check scopes; supplied list replaces the request."),
|
|
14592
15499
|
semantic: {
|
|
14593
15500
|
type: "json",
|
|
14594
15501
|
required: true,
|
|
14595
|
-
description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
|
|
15502
|
+
description: arraySemanticsDescription("replace", "Exactly the five P6 semantic items, each with a verbatim evidence quote.")
|
|
14596
15503
|
},
|
|
14597
15504
|
acknowledge: {
|
|
14598
15505
|
type: "array",
|
|
14599
15506
|
items: { type: "string" },
|
|
14600
|
-
description: "Advisory codes read and accepted; blockers cannot be acknowledged."
|
|
15507
|
+
description: arraySemanticsDescription("replace", "Advisory codes read and accepted; blockers cannot be acknowledged.")
|
|
14601
15508
|
},
|
|
14602
15509
|
project_root: root
|
|
14603
15510
|
},
|
|
@@ -14696,7 +15603,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
14696
15603
|
chapter_range: {
|
|
14697
15604
|
type: "array",
|
|
14698
15605
|
items: { type: "integer" },
|
|
14699
|
-
description: "For kind=chapters: inclusive [start, end] range."
|
|
15606
|
+
description: arraySemanticsDescription("replace", "For kind=chapters: inclusive [start, end] range.")
|
|
14700
15607
|
},
|
|
14701
15608
|
project_root: root
|
|
14702
15609
|
},
|
|
@@ -14864,11 +15771,11 @@ function createNovelTools(engine, options = {}) {
|
|
|
14864
15771
|
parameters: {
|
|
14865
15772
|
premise: { type: "string" },
|
|
14866
15773
|
logline: { type: "string" },
|
|
14867
|
-
themes: stringArray,
|
|
15774
|
+
themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
|
|
14868
15775
|
tone: { type: "string" },
|
|
14869
15776
|
pov: { type: "string" },
|
|
14870
15777
|
audience: { type: "string" },
|
|
14871
|
-
selling_points: stringArray,
|
|
15778
|
+
selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
|
|
14872
15779
|
ending: { type: "string" },
|
|
14873
15780
|
strict: {
|
|
14874
15781
|
type: "boolean",
|
|
@@ -14876,7 +15783,11 @@ function createNovelTools(engine, options = {}) {
|
|
|
14876
15783
|
},
|
|
14877
15784
|
volumes: {
|
|
14878
15785
|
type: "json",
|
|
14879
|
-
description: "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes."
|
|
15786
|
+
description: arraySemanticsDescription("replace", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes; dropping any volume requires confirm_remove:true.")
|
|
15787
|
+
},
|
|
15788
|
+
confirm_remove: {
|
|
15789
|
+
type: "boolean",
|
|
15790
|
+
description: "Explicitly confirm a volume-list replacement that drops existing volumes."
|
|
14880
15791
|
},
|
|
14881
15792
|
project_root: root
|
|
14882
15793
|
},
|
|
@@ -14892,12 +15803,13 @@ function createNovelTools(engine, options = {}) {
|
|
|
14892
15803
|
ending: args.ending,
|
|
14893
15804
|
strict: args.strict,
|
|
14894
15805
|
volumes: asJson(args.volumes, "volumes"),
|
|
15806
|
+
confirm_remove: args.confirm_remove,
|
|
14895
15807
|
root: args.project_root
|
|
14896
15808
|
}))
|
|
14897
15809
|
}),
|
|
14898
15810
|
defineTool({
|
|
14899
15811
|
name: "novel_outline",
|
|
14900
|
-
description: "
|
|
15812
|
+
description: "Upsert volume/chapter cards by key: chapters by num, volumes by title. Omitted cards are preserved; deletion requires remove/remove_volumes. Archived cards are preserved and cannot be rewritten. Use dry_run to preview. On the first submission strict mode defaults on; with an existing card, PG-13 checks title, scene, target words and cast even in non-strict mode.",
|
|
14901
15813
|
parameters: {
|
|
14902
15814
|
book: {
|
|
14903
15815
|
type: "json",
|
|
@@ -14905,12 +15817,25 @@ function createNovelTools(engine, options = {}) {
|
|
|
14905
15817
|
},
|
|
14906
15818
|
volumes: {
|
|
14907
15819
|
type: "json",
|
|
14908
|
-
description: "
|
|
15820
|
+
description: arraySemanticsDescription("upsert", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Upserts by title; omitted volumes are kept.")
|
|
14909
15821
|
},
|
|
14910
15822
|
chapters: {
|
|
14911
15823
|
type: "json",
|
|
14912
|
-
|
|
14913
|
-
|
|
15824
|
+
description: arraySemanticsDescription("upsert", "Chapter cards: [{num,title,scene_type,goal,conflict,hook,cast?,target_words:[min,max],volume?,notes?}]. Upserts by num; omitted cards are kept.")
|
|
15825
|
+
},
|
|
15826
|
+
remove: {
|
|
15827
|
+
type: "array",
|
|
15828
|
+
items: { type: "integer" },
|
|
15829
|
+
description: arraySemanticsDescription("remove", "Explicit chapter numbers to delete.")
|
|
15830
|
+
},
|
|
15831
|
+
remove_volumes: {
|
|
15832
|
+
type: "array",
|
|
15833
|
+
items: { type: "string" },
|
|
15834
|
+
description: arraySemanticsDescription("remove", "Explicit volume titles to delete.")
|
|
15835
|
+
},
|
|
15836
|
+
dry_run: {
|
|
15837
|
+
type: "boolean",
|
|
15838
|
+
description: "Preview changes without writing or journaling."
|
|
14914
15839
|
},
|
|
14915
15840
|
strict: { type: "boolean" },
|
|
14916
15841
|
project_root: root
|
|
@@ -14920,6 +15845,9 @@ function createNovelTools(engine, options = {}) {
|
|
|
14920
15845
|
book: asJson(args.book, "book"),
|
|
14921
15846
|
volumes: asJson(args.volumes, "volumes"),
|
|
14922
15847
|
chapters: asJson(args.chapters, "chapters"),
|
|
15848
|
+
remove: args.remove,
|
|
15849
|
+
remove_volumes: args.remove_volumes,
|
|
15850
|
+
dry_run: args.dry_run,
|
|
14923
15851
|
strict: args.strict,
|
|
14924
15852
|
root: args.project_root
|
|
14925
15853
|
}))
|
|
@@ -14958,17 +15886,35 @@ function createNovelTools(engine, options = {}) {
|
|
|
14958
15886
|
parameters: {
|
|
14959
15887
|
premise: { type: "string" },
|
|
14960
15888
|
setting: { type: "string" },
|
|
14961
|
-
rules: {
|
|
14962
|
-
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
|
|
15889
|
+
rules: {
|
|
15890
|
+
type: "json",
|
|
15891
|
+
description: arraySemanticsDescription("replace", "Rules section list.")
|
|
15892
|
+
},
|
|
15893
|
+
factions: {
|
|
15894
|
+
type: "json",
|
|
15895
|
+
description: arraySemanticsDescription("replace", "Factions section list.")
|
|
15896
|
+
},
|
|
15897
|
+
locations: {
|
|
15898
|
+
type: "json",
|
|
15899
|
+
description: arraySemanticsDescription("replace", "Locations section list.")
|
|
15900
|
+
},
|
|
15901
|
+
characters: {
|
|
15902
|
+
type: "json",
|
|
15903
|
+
description: arraySemanticsDescription("replace", "Characters section list.")
|
|
15904
|
+
},
|
|
15905
|
+
glossary: {
|
|
15906
|
+
type: "json",
|
|
15907
|
+
description: arraySemanticsDescription("replace", "Glossary section list.")
|
|
15908
|
+
},
|
|
15909
|
+
backstory: {
|
|
15910
|
+
type: "json",
|
|
15911
|
+
description: arraySemanticsDescription("replace", "Backstory section list.")
|
|
15912
|
+
},
|
|
14967
15913
|
facts: {
|
|
14968
15914
|
type: "json",
|
|
14969
|
-
description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
|
|
15915
|
+
description: arraySemanticsDescription("replace", "Declared numeric canon: [{alias,value,unit,tolerance?,note?}].")
|
|
14970
15916
|
},
|
|
14971
|
-
themes: stringArray,
|
|
15917
|
+
themes: stringArray("replace", "Worldbook themes; supplied list replaces the list."),
|
|
14972
15918
|
project_root: root
|
|
14973
15919
|
},
|
|
14974
15920
|
output: jsonOutput,
|
|
@@ -15060,7 +16006,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
15060
16006
|
}),
|
|
15061
16007
|
defineTool({
|
|
15062
16008
|
name: "novel_restore",
|
|
15063
|
-
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.",
|
|
16009
|
+
description: "Restore an active chapter file, or outline/outline.yaml, from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
|
|
15064
16010
|
parameters: {
|
|
15065
16011
|
txid: {
|
|
15066
16012
|
type: "string",
|
|
@@ -15070,7 +16016,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
15070
16016
|
paths: {
|
|
15071
16017
|
type: "array",
|
|
15072
16018
|
items: { type: "string" },
|
|
15073
|
-
description: "Project-relative chapter files; omitted restores every restorable chapter file
|
|
16019
|
+
description: arraySemanticsDescription("replace", "Project-relative chapter files, or exactly [outline/outline.yaml]; omitted restores every restorable chapter file the manifest records.")
|
|
15074
16020
|
},
|
|
15075
16021
|
project_root: root
|
|
15076
16022
|
},
|
|
@@ -15228,7 +16174,12 @@ const bad = (text) => ({
|
|
|
15228
16174
|
kind: "error",
|
|
15229
16175
|
text
|
|
15230
16176
|
});
|
|
15231
|
-
const failure = (error) =>
|
|
16177
|
+
const failure = (error) => {
|
|
16178
|
+
if (!(error instanceof EngineError)) return bad(String(error));
|
|
16179
|
+
const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
|
|
16180
|
+
const items = "items" in error && Array.isArray(error.items) ? `\nitems: ${JSON.stringify(error.items)}` : "";
|
|
16181
|
+
return bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}${items}`);
|
|
16182
|
+
};
|
|
15232
16183
|
const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
|
|
15233
16184
|
const statusText = async (engine) => {
|
|
15234
16185
|
const { data } = await engine.getStatus({});
|
|
@@ -15818,6 +16769,119 @@ function compileRulepack(raw) {
|
|
|
15818
16769
|
"exclusions",
|
|
15819
16770
|
"exemptions"
|
|
15820
16771
|
], "root");
|
|
16772
|
+
const section = (name, keys) => {
|
|
16773
|
+
const value = root[name];
|
|
16774
|
+
if (value === void 0) return null;
|
|
16775
|
+
const objectValue = object(value, name);
|
|
16776
|
+
knownKeys(objectValue, keys, name);
|
|
16777
|
+
return objectValue;
|
|
16778
|
+
};
|
|
16779
|
+
const nested = (value, key, keys) => {
|
|
16780
|
+
if (value === null || value[key] === void 0) return null;
|
|
16781
|
+
const objectValue = object(value[key], key);
|
|
16782
|
+
knownKeys(objectValue, keys, key);
|
|
16783
|
+
return objectValue;
|
|
16784
|
+
};
|
|
16785
|
+
nested(section("format", [
|
|
16786
|
+
"paragraph_max_words",
|
|
16787
|
+
"forbid_markdown",
|
|
16788
|
+
"forbid_semicolon",
|
|
16789
|
+
"forbid_english_ellipsis",
|
|
16790
|
+
"title",
|
|
16791
|
+
"system_labels",
|
|
16792
|
+
"scene_separators",
|
|
16793
|
+
"markdown_markers"
|
|
16794
|
+
]), "title", [
|
|
16795
|
+
"pattern",
|
|
16796
|
+
"min_chars",
|
|
16797
|
+
"max_chars"
|
|
16798
|
+
]);
|
|
16799
|
+
const punctuation = section("punctuation", [
|
|
16800
|
+
"dash",
|
|
16801
|
+
"period_density",
|
|
16802
|
+
"ellipsis_run",
|
|
16803
|
+
"uniform_sentence",
|
|
16804
|
+
"consecutive_le",
|
|
16805
|
+
"dialogue_dominant_ratio"
|
|
16806
|
+
]);
|
|
16807
|
+
const dash = nested(punctuation, "dash", [
|
|
16808
|
+
"base",
|
|
16809
|
+
"scale_words",
|
|
16810
|
+
"token"
|
|
16811
|
+
]);
|
|
16812
|
+
if (dash !== null && dash.base !== void 0) {
|
|
16813
|
+
const base = object(dash.base, "punctuation.dash.base");
|
|
16814
|
+
knownKeys(base, SCENE_TYPES, "punctuation.dash.base");
|
|
16815
|
+
}
|
|
16816
|
+
nested(punctuation, "period_density", ["min", "max"]);
|
|
16817
|
+
nested(punctuation, "ellipsis_run", ["max", "token"]);
|
|
16818
|
+
nested(punctuation, "uniform_sentence", ["min_sentences", "ratio"]);
|
|
16819
|
+
nested(punctuation, "consecutive_le", ["max", "char"]);
|
|
16820
|
+
const semantics = section("semantics", [
|
|
16821
|
+
"adjective_density",
|
|
16822
|
+
"pleasure_density",
|
|
16823
|
+
"sensory_density",
|
|
16824
|
+
"water_words"
|
|
16825
|
+
]);
|
|
16826
|
+
nested(semantics, "adjective_density", ["max", "adjectives"]);
|
|
16827
|
+
nested(semantics, "pleasure_density", [
|
|
16828
|
+
"min",
|
|
16829
|
+
"keywords",
|
|
16830
|
+
"window"
|
|
16831
|
+
]);
|
|
16832
|
+
nested(semantics, "sensory_density", [
|
|
16833
|
+
"min",
|
|
16834
|
+
"senses",
|
|
16835
|
+
"window"
|
|
16836
|
+
]);
|
|
16837
|
+
const waterWords = nested(semantics, "water_words", [
|
|
16838
|
+
"window",
|
|
16839
|
+
"threshold",
|
|
16840
|
+
"scene_thresholds",
|
|
16841
|
+
"terms"
|
|
16842
|
+
]);
|
|
16843
|
+
if (waterWords !== null && waterWords.scene_thresholds !== void 0) knownKeys(object(waterWords.scene_thresholds, "semantics.water_words.scene_thresholds"), SCENE_TYPES, "semantics.water_words.scene_thresholds");
|
|
16844
|
+
const lexicon = section("lexicon", [
|
|
16845
|
+
"zero_tolerance",
|
|
16846
|
+
"soft",
|
|
16847
|
+
"contextual",
|
|
16848
|
+
"meta_narration",
|
|
16849
|
+
"reader_intrusion",
|
|
16850
|
+
"god_view",
|
|
16851
|
+
"conclusion_first"
|
|
16852
|
+
]);
|
|
16853
|
+
nested(lexicon, "conclusion_first", ["verbs", "causals"]);
|
|
16854
|
+
for (const key of ["reader_intrusion", "god_view"]) {
|
|
16855
|
+
if (lexicon === null || lexicon[key] === void 0) continue;
|
|
16856
|
+
for (const [index, raw] of array(lexicon[key], `lexicon.${key}`).entries()) knownKeys(object(raw, `lexicon.${key}[${index}]`), ["pattern"], `lexicon.${key}[${index}]`);
|
|
16857
|
+
}
|
|
16858
|
+
const platform = object(root.platform, "platform");
|
|
16859
|
+
knownKeys(platform, PLATFORMS, "platform");
|
|
16860
|
+
for (const name of PLATFORMS) {
|
|
16861
|
+
const profile = object(platform[name], `platform.${name}`);
|
|
16862
|
+
knownKeys(profile, ["words_per_chapter", "dialogue_ratio"], `platform.${name}`);
|
|
16863
|
+
}
|
|
16864
|
+
const genres = object(root.genres, "genres");
|
|
16865
|
+
knownKeys(genres, GENRES, "genres");
|
|
16866
|
+
for (const name of GENRES) knownKeys(object(genres[name], `genres.${name}`), ["note"], `genres.${name}`);
|
|
16867
|
+
section("crosschapter", [
|
|
16868
|
+
"duplicate_similarity",
|
|
16869
|
+
"duplicate_advisory",
|
|
16870
|
+
"duplicate_min_chars",
|
|
16871
|
+
"foreshadow_min_dormancy",
|
|
16872
|
+
"foreshadow_dormant_after",
|
|
16873
|
+
"check_time",
|
|
16874
|
+
"lock_similarity"
|
|
16875
|
+
]);
|
|
16876
|
+
const quality = section("quality", ["dimensions"]);
|
|
16877
|
+
if (quality?.dimensions !== void 0) for (const [index, rawDimension] of array(quality.dimensions, "quality.dimensions").entries()) {
|
|
16878
|
+
const dimension = object(rawDimension, `quality.dimensions[${index}]`);
|
|
16879
|
+
knownKeys(dimension, ["id", "max"], `quality.dimensions[${index}]`);
|
|
16880
|
+
}
|
|
16881
|
+
if (root.exclusions !== void 0) for (const [index, rawEntry] of array(root.exclusions, "exclusions").entries()) {
|
|
16882
|
+
const entry = object(rawEntry, `exclusions[${index}]`);
|
|
16883
|
+
knownKeys(entry, ["pair", "family"], `exclusions[${index}]`);
|
|
16884
|
+
}
|
|
15821
16885
|
const pack = normalize(raw);
|
|
15822
16886
|
const rawPlatform = object(root.platform, "platform");
|
|
15823
16887
|
for (const platform of PLATFORMS) if (rawPlatform[platform] === void 0) invalid(`platform.${platform} is missing`);
|
|
@@ -16153,4 +17217,4 @@ function connectionFence(scope) {
|
|
|
16153
17217
|
}
|
|
16154
17218
|
if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
|
|
16155
17219
|
}
|
|
16156
|
-
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 };
|
|
17220
|
+
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_DEPRECATED_FILE, 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 };
|