dsh-novel-write-engine 0.2.4 → 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 +29 -0
- package/README.md +22 -9
- package/briefs/P5.md +2 -0
- package/briefs/P6.md +3 -2
- package/briefs/P8.md +5 -4
- package/lib/client.js +16 -1
- package/lib/index.d.ts +68 -2
- package/lib/index.js +876 -199
- package/lib/types/client/dto.d.ts +6 -0
- package/lib/types/client/viewmodel.d.ts +6 -0
- package/lib/types/types.d.ts +14 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -245,6 +245,9 @@ const ERROR_CODES = {
|
|
|
245
245
|
/** The exact same keyed submission already failed in the same context. */
|
|
246
246
|
RETRY_UNCHANGED: "ENGINE_RETRY_UNCHANGED",
|
|
247
247
|
GATE_BLOCKED: "ENGINE_GATE_BLOCKED",
|
|
248
|
+
CHECK_SUBMISSION_INVALID: "ENGINE_CHECK_SUBMISSION_INVALID",
|
|
249
|
+
PENDING_PARTIAL: "ENGINE_PENDING_PARTIAL",
|
|
250
|
+
IO_PERMISSION: "ENGINE_IO_PERMISSION",
|
|
248
251
|
READING_PROOF_INVALID: "ENGINE_READING_PROOF_INVALID",
|
|
249
252
|
SCORE_MISMATCH: "ENGINE_SCORE_MISMATCH",
|
|
250
253
|
/** A revision tried to rewrite reviewer-owned issue fields. */
|
|
@@ -1393,7 +1396,7 @@ const duplicateParagraph = (ctx) => {
|
|
|
1393
1396
|
}
|
|
1394
1397
|
const blocker = ctx.rulepack.crosschapter.duplicate_similarity;
|
|
1395
1398
|
const severity = best.score >= blocker ? "blocker" : "advisory";
|
|
1396
|
-
return [v(CROSS_RULES.DUPLICATE_PARAGRAPH, severity, currentParagraph + 1, `paragraph ${currentParagraph + 1} reuses ${Math.round(best.score * 100)}% of paragraph ${previousParagraph
|
|
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)}%)`)];
|
|
1397
1400
|
};
|
|
1398
1401
|
const foreshadowDormant = (ctx) => {
|
|
1399
1402
|
const ledger = ctx.evidence.foreshadows;
|
|
@@ -1508,11 +1511,12 @@ const timeRegression = (ctx) => {
|
|
|
1508
1511
|
const ordered = [...ledger.items].sort((left, right) => left.chapter - right.chapter);
|
|
1509
1512
|
const last = ordered[ordered.length - 1];
|
|
1510
1513
|
const previous = ordered.length >= 2 ? ordered[ordered.length - 2] : null;
|
|
1511
|
-
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]);
|
|
1512
1516
|
const current = currentFromText ?? dayOf(last.story_time);
|
|
1513
1517
|
const before = previous === null ? null : dayOf(previous.story_time);
|
|
1514
1518
|
if (current === null) return [];
|
|
1515
|
-
if (currentFromText !== null && dayOf(last.story_time) !== null && currentFromText < dayOf(last.story_time)) return [v(CROSS_RULES.TIME_REGRESSION, "advisory", ctx.
|
|
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}`)];
|
|
1516
1520
|
if (before === null || current >= before) return [];
|
|
1517
1521
|
return [v(CROSS_RULES.TIME_REGRESSION, "advisory", last.chapter, `story time goes back from ${previous.story_time} to ${last.story_time}`)];
|
|
1518
1522
|
};
|
|
@@ -1922,6 +1926,8 @@ const SCHEMAS = {
|
|
|
1922
1926
|
machine: req("object"),
|
|
1923
1927
|
semantic: req("object"),
|
|
1924
1928
|
acknowledged: f("array"),
|
|
1929
|
+
failure_class: f("string", { enum: ["submission", "content"] }),
|
|
1930
|
+
counted: f("boolean"),
|
|
1925
1931
|
verdict: req("string", { enum: ["pass", "fail"] })
|
|
1926
1932
|
},
|
|
1927
1933
|
worldPlan: {
|
|
@@ -2004,6 +2010,15 @@ const nullableInteger = (kind, value, path) => {
|
|
|
2004
2010
|
const validateRecords = (kind, value, path, validate) => {
|
|
2005
2011
|
for (const [index, raw] of listAt(kind, value, path).entries()) validate(objectAt(kind, raw, `${path}[${index}]`), `${path}[${index}]`);
|
|
2006
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
|
+
};
|
|
2007
2022
|
const validateSummary = (kind, value, path) => {
|
|
2008
2023
|
const summary = objectAt(kind, value, path);
|
|
2009
2024
|
for (const key of [
|
|
@@ -2097,6 +2112,7 @@ const validateNested = (kind, record) => {
|
|
|
2097
2112
|
listAt(kind, row.growth, `${path}.growth`);
|
|
2098
2113
|
integerAt(kind, row.updated_at, `${path}.updated_at`);
|
|
2099
2114
|
});
|
|
2115
|
+
duplicateNames(kind, record.characters, "characters", (row) => row.name);
|
|
2100
2116
|
return;
|
|
2101
2117
|
case "world":
|
|
2102
2118
|
validateRecords(kind, record.locations, "locations", (row, path) => {
|
|
@@ -2114,6 +2130,20 @@ const validateNested = (kind, record) => {
|
|
|
2114
2130
|
"factions",
|
|
2115
2131
|
"rules"
|
|
2116
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
|
+
}
|
|
2117
2147
|
return;
|
|
2118
2148
|
case "locks": {
|
|
2119
2149
|
integerAt(kind, record.locked_at_chapter, "locked_at_chapter");
|
|
@@ -2187,6 +2217,12 @@ const validateNested = (kind, record) => {
|
|
|
2187
2217
|
stringAt(kind, row.evidence, `${path}.evidence`);
|
|
2188
2218
|
if (row.note !== void 0) stringAt(kind, row.note, `${path}.note`);
|
|
2189
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
|
+
});
|
|
2190
2226
|
return;
|
|
2191
2227
|
}
|
|
2192
2228
|
case "outline": {
|
|
@@ -2386,7 +2422,7 @@ var FsStore = class {
|
|
|
2386
2422
|
if ((await promises.lstat(current)).isSymbolicLink()) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": "${segment}" is a symlink`);
|
|
2387
2423
|
} catch (error) {
|
|
2388
2424
|
if (isMissing(error)) break;
|
|
2389
|
-
throw error;
|
|
2425
|
+
throw ioFailure(error);
|
|
2390
2426
|
}
|
|
2391
2427
|
}
|
|
2392
2428
|
return absolute;
|
|
@@ -2403,7 +2439,7 @@ var FsStore = class {
|
|
|
2403
2439
|
return true;
|
|
2404
2440
|
} catch (error) {
|
|
2405
2441
|
if (isMissing(error)) return false;
|
|
2406
|
-
throw error;
|
|
2442
|
+
throw ioFailure(error);
|
|
2407
2443
|
}
|
|
2408
2444
|
}
|
|
2409
2445
|
async stat(relativePath) {
|
|
@@ -2416,7 +2452,7 @@ var FsStore = class {
|
|
|
2416
2452
|
};
|
|
2417
2453
|
} catch (error) {
|
|
2418
2454
|
if (isMissing(error)) return null;
|
|
2419
|
-
throw error;
|
|
2455
|
+
throw ioFailure(error);
|
|
2420
2456
|
}
|
|
2421
2457
|
}
|
|
2422
2458
|
async list(relativeDir) {
|
|
@@ -2424,7 +2460,7 @@ var FsStore = class {
|
|
|
2424
2460
|
return (await promises.readdir(await this.safePath(relativeDir))).sort();
|
|
2425
2461
|
} catch (error) {
|
|
2426
2462
|
if (isMissing(error)) return [];
|
|
2427
|
-
throw error;
|
|
2463
|
+
throw ioFailure(error);
|
|
2428
2464
|
}
|
|
2429
2465
|
}
|
|
2430
2466
|
/**
|
|
@@ -2441,7 +2477,7 @@ var FsStore = class {
|
|
|
2441
2477
|
})).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
2442
2478
|
} catch (error) {
|
|
2443
2479
|
if (isMissing(error)) return [];
|
|
2444
|
-
throw error;
|
|
2480
|
+
throw ioFailure(error);
|
|
2445
2481
|
}
|
|
2446
2482
|
}
|
|
2447
2483
|
/**
|
|
@@ -2459,7 +2495,7 @@ var FsStore = class {
|
|
|
2459
2495
|
};
|
|
2460
2496
|
} catch (error) {
|
|
2461
2497
|
if (isMissing(error)) return null;
|
|
2462
|
-
throw error;
|
|
2498
|
+
throw ioFailure(error);
|
|
2463
2499
|
}
|
|
2464
2500
|
}
|
|
2465
2501
|
/**
|
|
@@ -2479,7 +2515,7 @@ var FsStore = class {
|
|
|
2479
2515
|
};
|
|
2480
2516
|
} catch (error) {
|
|
2481
2517
|
if (isMissing(error)) return null;
|
|
2482
|
-
throw error;
|
|
2518
|
+
throw ioFailure(error);
|
|
2483
2519
|
}
|
|
2484
2520
|
}
|
|
2485
2521
|
async readText(relativePath) {
|
|
@@ -2487,7 +2523,7 @@ var FsStore = class {
|
|
|
2487
2523
|
return await promises.readFile(await this.safePath(relativePath), "utf8");
|
|
2488
2524
|
} catch (error) {
|
|
2489
2525
|
if (isMissing(error)) return null;
|
|
2490
|
-
throw error;
|
|
2526
|
+
throw ioFailure(error);
|
|
2491
2527
|
}
|
|
2492
2528
|
}
|
|
2493
2529
|
/** Read and narrow one of the twelve engine documents. */
|
|
@@ -2521,7 +2557,7 @@ var FsStore = class {
|
|
|
2521
2557
|
await promises.mkdir(current);
|
|
2522
2558
|
} catch (error) {
|
|
2523
2559
|
if (error.code === "EEXIST") continue;
|
|
2524
|
-
throw error;
|
|
2560
|
+
throw ioFailure(error);
|
|
2525
2561
|
}
|
|
2526
2562
|
}
|
|
2527
2563
|
}
|
|
@@ -2542,7 +2578,7 @@ var FsStore = class {
|
|
|
2542
2578
|
await syncDirectory(dirname(absolute));
|
|
2543
2579
|
} catch (error) {
|
|
2544
2580
|
await promises.rm(temp, { force: true }).catch(() => void 0);
|
|
2545
|
-
throw error;
|
|
2581
|
+
throw ioFailure(error);
|
|
2546
2582
|
}
|
|
2547
2583
|
}
|
|
2548
2584
|
async writeDoc(relativePath, document) {
|
|
@@ -2587,7 +2623,7 @@ var FsStore = class {
|
|
|
2587
2623
|
return await promises.readFile(await this.safePath(relativePath));
|
|
2588
2624
|
} catch (error) {
|
|
2589
2625
|
if (isMissing(error)) return null;
|
|
2590
|
-
throw error;
|
|
2626
|
+
throw ioFailure(error);
|
|
2591
2627
|
}
|
|
2592
2628
|
}
|
|
2593
2629
|
async writeBytes(relativePath, bytes) {
|
|
@@ -2606,7 +2642,7 @@ var FsStore = class {
|
|
|
2606
2642
|
await syncDirectory(dirname(absolute));
|
|
2607
2643
|
} catch (error) {
|
|
2608
2644
|
await promises.rm(temp, { force: true }).catch(() => void 0);
|
|
2609
|
-
throw error;
|
|
2645
|
+
throw ioFailure(error);
|
|
2610
2646
|
}
|
|
2611
2647
|
}
|
|
2612
2648
|
/** Content hash of a file, or null when missing. */
|
|
@@ -2634,6 +2670,12 @@ async function syncDirectory(directory) {
|
|
|
2634
2670
|
function isMissing(error) {
|
|
2635
2671
|
return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
2636
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
|
+
}
|
|
2637
2679
|
/** Join project-relative segments with forward slashes. */
|
|
2638
2680
|
function joinRel(...parts) {
|
|
2639
2681
|
return parts.filter((part) => part !== "").join("/");
|
|
@@ -2757,21 +2799,18 @@ async function reconcileJournal(store) {
|
|
|
2757
2799
|
};
|
|
2758
2800
|
}
|
|
2759
2801
|
/**
|
|
2760
|
-
*
|
|
2761
|
-
*
|
|
2762
|
-
*
|
|
2763
|
-
* before touching a target". A fully matching pending transaction is promoted
|
|
2764
|
-
* to committed; a transaction whose targets all still match their pre-images
|
|
2765
|
-
* is closed as aborted; any mixture is refused as a partial commit.
|
|
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.
|
|
2766
2805
|
*/
|
|
2767
|
-
async function
|
|
2806
|
+
async function inspectPending(store) {
|
|
2768
2807
|
const { records } = await readJournal(store);
|
|
2769
2808
|
const last = [...records].reverse().find((record) => record.op !== "lock_reclaimed");
|
|
2770
|
-
if (last?.status !== "pending") return;
|
|
2809
|
+
if (last?.status !== "pending") return { state: "none" };
|
|
2771
2810
|
const matchesExpected = async () => {
|
|
2772
2811
|
if (last.hashes === void 0) return false;
|
|
2773
2812
|
for (const [path, expected] of Object.entries(last.hashes)) if (await store.hashBytes(path) !== expected) return false;
|
|
2774
|
-
return
|
|
2813
|
+
return Object.keys(last.hashes).length > 0;
|
|
2775
2814
|
};
|
|
2776
2815
|
const matchesBefore = async () => {
|
|
2777
2816
|
if (last.before === void 0) return false;
|
|
@@ -2779,9 +2818,45 @@ async function recoverPending(store) {
|
|
|
2779
2818
|
const actual = await store.hashBytes(path);
|
|
2780
2819
|
if (expected === null ? actual !== null : actual !== expected) return false;
|
|
2781
2820
|
}
|
|
2782
|
-
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)
|
|
2836
|
+
};
|
|
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)
|
|
2783
2843
|
};
|
|
2784
|
-
|
|
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") {
|
|
2785
2860
|
await appendJournal(store, {
|
|
2786
2861
|
...last,
|
|
2787
2862
|
status: "committed"
|
|
@@ -2789,7 +2864,7 @@ async function recoverPending(store) {
|
|
|
2789
2864
|
await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
|
|
2790
2865
|
return;
|
|
2791
2866
|
}
|
|
2792
|
-
if (
|
|
2867
|
+
if (inspection.state === "untouched") {
|
|
2793
2868
|
await appendJournal(store, {
|
|
2794
2869
|
...last,
|
|
2795
2870
|
status: "aborted",
|
|
@@ -2799,7 +2874,7 @@ async function recoverPending(store) {
|
|
|
2799
2874
|
await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
|
|
2800
2875
|
return;
|
|
2801
2876
|
}
|
|
2802
|
-
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`);
|
|
2803
2878
|
}
|
|
2804
2879
|
/**
|
|
2805
2880
|
* A crash during append can leave one JSONL fragment without a newline. Since
|
|
@@ -2991,22 +3066,43 @@ var FileLock = class {
|
|
|
2991
3066
|
if (Date.now() - mtimeMs <= this.staleMs) return false;
|
|
2992
3067
|
} else if (lockRecordIsLive(record)) return false;
|
|
2993
3068
|
const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
|
|
3069
|
+
let claimByRename = false;
|
|
2994
3070
|
try {
|
|
2995
3071
|
await promises.link(this.absolutePath, claimPath);
|
|
2996
3072
|
} catch (error) {
|
|
2997
3073
|
const code = error.code;
|
|
2998
3074
|
if (code === "ENOENT") return true;
|
|
2999
3075
|
if (code === "EPERM" || code === "EACCES") return false;
|
|
3000
|
-
|
|
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;
|
|
3001
3090
|
}
|
|
3002
3091
|
const moved = await this.readAt(claimPath);
|
|
3003
3092
|
const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
|
|
3004
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)) {
|
|
3005
|
-
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);
|
|
3006
3102
|
return false;
|
|
3007
3103
|
}
|
|
3008
|
-
const current = await this.readAt(this.absolutePath);
|
|
3009
|
-
if (!(record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce)) {
|
|
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))) {
|
|
3010
3106
|
await promises.rm(claimPath, { force: true }).catch(() => void 0);
|
|
3011
3107
|
return false;
|
|
3012
3108
|
}
|
|
@@ -3025,6 +3121,7 @@ const BACKUP_MANIFEST = "manifest.json";
|
|
|
3025
3121
|
async function listBackups(store) {
|
|
3026
3122
|
const entries = [];
|
|
3027
3123
|
for (const name of await store.list(BACKUP_DIR)) {
|
|
3124
|
+
if (name === "migrations") continue;
|
|
3028
3125
|
const info = await store.stat(joinRel(BACKUP_DIR, name));
|
|
3029
3126
|
if (info === null || !info.isDirectory) continue;
|
|
3030
3127
|
if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
|
|
@@ -4118,7 +4215,7 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4118
4215
|
notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
|
|
4119
4216
|
volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
|
|
4120
4217
|
status: "archived",
|
|
4121
|
-
archived_at: base.archived_at ??
|
|
4218
|
+
archived_at: base.archived_at ?? record.archived_at ?? null,
|
|
4122
4219
|
word_count: record.word_count,
|
|
4123
4220
|
score: record.score
|
|
4124
4221
|
});
|
|
@@ -4159,7 +4256,7 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4159
4256
|
...base,
|
|
4160
4257
|
volume: volumeForChapter(options.volumes, record.num, base.volume),
|
|
4161
4258
|
status: "archived",
|
|
4162
|
-
archived_at: base.archived_at ??
|
|
4259
|
+
archived_at: base.archived_at ?? record.archived_at ?? null,
|
|
4163
4260
|
word_count: record.word_count,
|
|
4164
4261
|
score: record.score
|
|
4165
4262
|
});
|
|
@@ -4876,6 +4973,7 @@ function renderWorldbookMarkdown(project, document) {
|
|
|
4876
4973
|
section("角色", document.characters.map((entry) => `- **${oneLine(entry.name)}**(${oneLine(entry.role)}):${oneLine(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine(entry.arc)}`}`));
|
|
4877
4974
|
section("术语", document.glossary.map((entry) => `- **${oneLine(entry.term)}**:${oneLine(entry.definition)}`));
|
|
4878
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)}`}`));
|
|
4879
4977
|
return parts.join("\n");
|
|
4880
4978
|
}
|
|
4881
4979
|
// src/engine/outline-merge.ts
|
|
@@ -5003,18 +5101,30 @@ const remapVolume = (card, oldVolumes, newVolumes) => {
|
|
|
5003
5101
|
volume: card.num >= next.start_chapter && card.num <= next.end_chapter ? nextIndex + 1 : null
|
|
5004
5102
|
};
|
|
5005
5103
|
};
|
|
5006
|
-
const
|
|
5007
|
-
const
|
|
5008
|
-
const
|
|
5009
|
-
const
|
|
5010
|
-
|
|
5011
|
-
const
|
|
5012
|
-
if (card === void 0 || card.volume === null) continue;
|
|
5013
|
-
const previous = options.existingVolumes[card.volume - 1];
|
|
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);
|
|
5014
5110
|
if (previous === void 0) continue;
|
|
5015
|
-
if (removed.has(previous.title) || !oldByTitle.has(previous.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${previous.title}": archived chapter ${record.num} belongs to it`, "archived cards are evidence; keep the volume or reopen the chapter first");
|
|
5016
5111
|
const next = newByTitle.get(previous.title);
|
|
5017
|
-
if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID,
|
|
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");
|
|
5018
5128
|
}
|
|
5019
5129
|
};
|
|
5020
5130
|
/**
|
|
@@ -5026,7 +5136,7 @@ const mergeOutline = (options) => {
|
|
|
5026
5136
|
const submitted = submittedNumbers(options.chapters);
|
|
5027
5137
|
const removed = parseChapterNumbers(options.remove, "remove");
|
|
5028
5138
|
const volumeChanges = mergeVolumes(options);
|
|
5029
|
-
|
|
5139
|
+
assertArchivedCardVolumes(options.archivedChapters, options.existing, options.existingVolumes, volumeChanges.volumes, "novel_outline");
|
|
5030
5140
|
const existingByNum = new Map(options.existing.map((card) => [card.num, card]));
|
|
5031
5141
|
const archivedByNum = new Map(options.archivedChapters.map((record) => [record.num, record]));
|
|
5032
5142
|
const archivedNums = new Set(archivedByNum.keys());
|
|
@@ -5036,9 +5146,10 @@ const mergeOutline = (options) => {
|
|
|
5036
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");
|
|
5037
5147
|
if (existingByNum.get(num)?.status !== "planned") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is not a planned card`);
|
|
5038
5148
|
}
|
|
5149
|
+
const existingForNormalize = options.existing.map((card) => archivedNums.has(card.num) ? remapVolume(card, options.existingVolumes, volumeChanges.volumes) : card);
|
|
5039
5150
|
const normalized = normalizeOutlineChapters(options.chapters, {
|
|
5040
5151
|
volumes: volumeChanges.volumes,
|
|
5041
|
-
existing:
|
|
5152
|
+
existing: existingForNormalize,
|
|
5042
5153
|
archived: options.archivedChapters,
|
|
5043
5154
|
titleRule: options.titleRule,
|
|
5044
5155
|
totalChapters: options.totalChapters,
|
|
@@ -5048,6 +5159,7 @@ const mergeOutline = (options) => {
|
|
|
5048
5159
|
const byNum = new Map(normalized.map((card) => [card.num, card]));
|
|
5049
5160
|
const updated = /* @__PURE__ */ new Set();
|
|
5050
5161
|
const added = /* @__PURE__ */ new Set();
|
|
5162
|
+
const reindexed = /* @__PURE__ */ new Set();
|
|
5051
5163
|
for (const card of normalized) {
|
|
5052
5164
|
if (!submitted.has(card.num)) continue;
|
|
5053
5165
|
const before = existingByNum.get(card.num);
|
|
@@ -5062,6 +5174,8 @@ const mergeOutline = (options) => {
|
|
|
5062
5174
|
...card,
|
|
5063
5175
|
status: "archived"
|
|
5064
5176
|
});
|
|
5177
|
+
const current = byNum.get(card.num);
|
|
5178
|
+
if (stableJson(card) !== stableJson(current)) reindexed.add(card.num);
|
|
5065
5179
|
continue;
|
|
5066
5180
|
}
|
|
5067
5181
|
const remapped = remapVolume(card, options.existingVolumes, volumeChanges.volumes);
|
|
@@ -5069,19 +5183,22 @@ const mergeOutline = (options) => {
|
|
|
5069
5183
|
const current = byNum.get(card.num);
|
|
5070
5184
|
if (stableJson(current) !== stableJson(remapped)) {
|
|
5071
5185
|
byNum.set(card.num, remapped);
|
|
5072
|
-
|
|
5186
|
+
reindexed.add(card.num);
|
|
5073
5187
|
}
|
|
5074
5188
|
} else {
|
|
5075
5189
|
byNum.set(card.num, remapped);
|
|
5076
|
-
if (
|
|
5190
|
+
if (stableJson(card) !== stableJson(remapped)) reindexed.add(card.num);
|
|
5077
5191
|
}
|
|
5078
5192
|
}
|
|
5079
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");
|
|
5080
5196
|
return {
|
|
5081
|
-
chapters
|
|
5197
|
+
chapters,
|
|
5082
5198
|
volumes: volumeChanges.volumes,
|
|
5083
5199
|
added: [...added].sort((left, right) => left - right),
|
|
5084
5200
|
updated: [...updated].sort((left, right) => left - right),
|
|
5201
|
+
reindexed: [...reindexed].sort((left, right) => left - right),
|
|
5085
5202
|
removed: [...removed].sort((left, right) => left - right),
|
|
5086
5203
|
volumes_added: volumeChanges.added,
|
|
5087
5204
|
volumes_updated: volumeChanges.updated,
|
|
@@ -5111,6 +5228,28 @@ const setOwn = (record, key, value) => {
|
|
|
5111
5228
|
writable: true
|
|
5112
5229
|
});
|
|
5113
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
|
+
};
|
|
5114
5253
|
function fail$1(detail) {
|
|
5115
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");
|
|
5116
5255
|
}
|
|
@@ -5204,6 +5343,20 @@ function applyStateDelta(input) {
|
|
|
5204
5343
|
const timeline = structuredClone(input.timeline);
|
|
5205
5344
|
const relationships = structuredClone(input.relationships);
|
|
5206
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
|
+
};
|
|
5207
5360
|
const appeared = [...new Set(summary.characters_appeared)];
|
|
5208
5361
|
const byName = new Map(characters.characters.map((character) => [character.name, character]));
|
|
5209
5362
|
const narrator = input.narrator ?? "";
|
|
@@ -5224,7 +5377,7 @@ function applyStateDelta(input) {
|
|
|
5224
5377
|
"profile",
|
|
5225
5378
|
"current_state",
|
|
5226
5379
|
"growth"
|
|
5227
|
-
]) 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\":\"...\"}"}`);
|
|
5228
5381
|
if (declared.growth !== void 0) {
|
|
5229
5382
|
const growth = declared.growth;
|
|
5230
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`);
|
|
@@ -5235,12 +5388,14 @@ function applyStateDelta(input) {
|
|
|
5235
5388
|
if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
|
|
5236
5389
|
const rawHint = input.roleHints?.[name];
|
|
5237
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}`);
|
|
5238
5393
|
const created = {
|
|
5239
5394
|
name,
|
|
5240
5395
|
role: normalizeRole(declared.role, hinted),
|
|
5241
5396
|
first_appearance: chapter,
|
|
5242
5397
|
profile: declared.profile ?? {},
|
|
5243
|
-
current_state:
|
|
5398
|
+
current_state: currentState,
|
|
5244
5399
|
growth: [],
|
|
5245
5400
|
updated_at: chapter
|
|
5246
5401
|
};
|
|
@@ -5261,10 +5416,11 @@ function applyStateDelta(input) {
|
|
|
5261
5416
|
...existing.profile,
|
|
5262
5417
|
...declared.profile
|
|
5263
5418
|
};
|
|
5264
|
-
if (declared.current_state !== void 0)
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
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
|
+
}
|
|
5268
5424
|
if (declared.growth !== void 0) existing.growth.push({
|
|
5269
5425
|
...declared.growth,
|
|
5270
5426
|
chapter
|
|
@@ -5288,6 +5444,7 @@ function applyStateDelta(input) {
|
|
|
5288
5444
|
...state,
|
|
5289
5445
|
resources
|
|
5290
5446
|
};
|
|
5447
|
+
validateResources(character.current_state, `characters.${change.character}`);
|
|
5291
5448
|
}
|
|
5292
5449
|
const findWorld = (name) => {
|
|
5293
5450
|
const location = world.locations.find((candidate) => candidate.name === name);
|
|
@@ -5323,8 +5480,11 @@ function applyStateDelta(input) {
|
|
|
5323
5480
|
if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
|
|
5324
5481
|
if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
|
|
5325
5482
|
const declared = delta.world[name];
|
|
5326
|
-
if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
|
|
5327
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
|
+
});
|
|
5328
5488
|
}
|
|
5329
5489
|
for (const [name, raw] of Object.entries(delta.world)) {
|
|
5330
5490
|
const declared = entry(raw, `world.${name}`);
|
|
@@ -5333,22 +5493,33 @@ function applyStateDelta(input) {
|
|
|
5333
5493
|
if (declared.layout !== void 0 && typeof declared.layout !== "string") fail$1(`world.${name}.layout must be a string`);
|
|
5334
5494
|
if (declared.capacity !== void 0 && (!Number.isInteger(declared.capacity) || declared.capacity < 0)) fail$1(`world.${name}.capacity must be a non-negative integer`);
|
|
5335
5495
|
if (declared.detail !== void 0 && !isRecord(declared.detail)) fail$1(`world.${name}.detail must be an object`);
|
|
5336
|
-
if (declared.detail !== void 0) {
|
|
5337
|
-
if (declared.action !== "created") {
|
|
5338
|
-
for (const reserved of [
|
|
5339
|
-
"name",
|
|
5340
|
-
"first_appearance",
|
|
5341
|
-
"description"
|
|
5342
|
-
]) if (Object.hasOwn(declared.detail, reserved)) fail$1(`world.${name}.detail cannot rewrite the reserved field "${reserved}"`);
|
|
5343
|
-
}
|
|
5344
|
-
}
|
|
5345
5496
|
const expected = expectedKind(name);
|
|
5346
|
-
if (expected !== null) {
|
|
5347
|
-
if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
|
|
5348
|
-
if (declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
|
|
5349
|
-
}
|
|
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}`);
|
|
5350
5498
|
const existing = findWorld(name);
|
|
5351
|
-
|
|
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") {
|
|
5352
5523
|
if (name.trim() === "") fail$1("a created world entity has an empty name");
|
|
5353
5524
|
if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
|
|
5354
5525
|
if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
|
|
@@ -5361,7 +5532,7 @@ function applyStateDelta(input) {
|
|
|
5361
5532
|
"faction",
|
|
5362
5533
|
"rule"
|
|
5363
5534
|
].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
|
|
5364
|
-
if (expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
|
|
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`);
|
|
5365
5536
|
if (kind === "location") world.locations.push({
|
|
5366
5537
|
name,
|
|
5367
5538
|
first_appearance: chapter,
|
|
@@ -5380,7 +5551,8 @@ function applyStateDelta(input) {
|
|
|
5380
5551
|
continue;
|
|
5381
5552
|
}
|
|
5382
5553
|
if (existing === null) fail$1(`world entity "${name}" does not exist; created must be used first`);
|
|
5383
|
-
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") {
|
|
5384
5556
|
if (existing.kind === "location") {
|
|
5385
5557
|
const location = existing.value;
|
|
5386
5558
|
if (declared.description !== void 0) location.description = declared.description;
|
|
@@ -5523,6 +5695,7 @@ function applyStateDelta(input) {
|
|
|
5523
5695
|
const declared = entry(delta.timeline[key], `timeline.${key}`);
|
|
5524
5696
|
if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
|
|
5525
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}"`);
|
|
5526
5699
|
const allocated = allocateIds("TE", timeline.next_seq, 1);
|
|
5527
5700
|
const id = allocated.ids[0];
|
|
5528
5701
|
if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
|
|
@@ -5589,7 +5762,9 @@ function applyStateDelta(input) {
|
|
|
5589
5762
|
foreshadows,
|
|
5590
5763
|
timeline,
|
|
5591
5764
|
relationships,
|
|
5592
|
-
assigned
|
|
5765
|
+
assigned,
|
|
5766
|
+
warnings,
|
|
5767
|
+
normalized_world: normalizedWorld
|
|
5593
5768
|
};
|
|
5594
5769
|
}
|
|
5595
5770
|
// src/engine/insight.ts
|
|
@@ -6925,6 +7100,26 @@ const SEMANTIC_CHECK_IDS = [
|
|
|
6925
7100
|
"L2-VOICE",
|
|
6926
7101
|
"L2-POV"
|
|
6927
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
|
+
};
|
|
6928
7123
|
/** Extract the quoted part of an evidence string, tolerating "第 N 段:" labels. */
|
|
6929
7124
|
function evidenceQuote(value) {
|
|
6930
7125
|
let candidate = value.trim();
|
|
@@ -6934,151 +7129,312 @@ function evidenceQuote(value) {
|
|
|
6934
7129
|
candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
|
|
6935
7130
|
return candidate;
|
|
6936
7131
|
}
|
|
6937
|
-
|
|
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
|
+
*/
|
|
6938
7150
|
function sentenceSpans(text) {
|
|
6939
7151
|
const spans = [];
|
|
6940
|
-
let
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
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
|
+
}
|
|
6947
7181
|
}
|
|
6948
|
-
if (
|
|
6949
|
-
start,
|
|
6950
|
-
end: text.length
|
|
7182
|
+
if (sentenceStart < text.length) spans.push({
|
|
7183
|
+
start: sentenceStart,
|
|
7184
|
+
end: text.length,
|
|
7185
|
+
paragraph
|
|
6951
7186
|
});
|
|
6952
7187
|
return spans;
|
|
6953
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
|
+
};
|
|
6954
7233
|
/**
|
|
6955
|
-
* Validate
|
|
6956
|
-
*
|
|
6957
|
-
*
|
|
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.
|
|
6958
7240
|
*/
|
|
6959
|
-
function evaluateSemantic(text,
|
|
6960
|
-
const
|
|
6961
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6962
|
-
const seenQuotes = /* @__PURE__ */ new Set();
|
|
7241
|
+
function evaluateSemantic(text, input) {
|
|
7242
|
+
const items = [];
|
|
6963
7243
|
const spans = [];
|
|
6964
7244
|
const sentences = sentenceSpans(text);
|
|
6965
|
-
const usedSentences = /* @__PURE__ */ new
|
|
6966
|
-
|
|
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()) {
|
|
6967
7255
|
const where = `semantic[${index}]`;
|
|
6968
|
-
if (typeof
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
7256
|
+
if (typeof raw !== "object" || raw === null) {
|
|
7257
|
+
items.push({
|
|
7258
|
+
id: where,
|
|
7259
|
+
state: "invalid",
|
|
6972
7260
|
detail: `${where} is not an object`
|
|
6973
7261
|
});
|
|
6974
7262
|
continue;
|
|
6975
7263
|
}
|
|
7264
|
+
const item = raw;
|
|
6976
7265
|
if (typeof item.id !== "string" || item.id.trim() === "") {
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
7266
|
+
items.push({
|
|
7267
|
+
id: where,
|
|
7268
|
+
state: "invalid",
|
|
6980
7269
|
detail: `${where} has no id`
|
|
6981
7270
|
});
|
|
6982
7271
|
continue;
|
|
6983
7272
|
}
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
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`
|
|
6989
7279
|
});
|
|
6990
7280
|
continue;
|
|
6991
7281
|
}
|
|
6992
|
-
|
|
7282
|
+
seenIds.add(id);
|
|
6993
7283
|
if (item.verdict !== "pass" && item.verdict !== "fail") {
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
detail: `${
|
|
7284
|
+
items.push({
|
|
7285
|
+
id,
|
|
7286
|
+
state: "invalid",
|
|
7287
|
+
detail: `${id} has verdict ${String(item.verdict)}; expected pass|fail`
|
|
6998
7288
|
});
|
|
6999
7289
|
continue;
|
|
7000
7290
|
}
|
|
7001
7291
|
if (typeof item.evidence !== "string" || item.evidence.trim() === "") {
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
detail: `${
|
|
7292
|
+
items.push({
|
|
7293
|
+
id,
|
|
7294
|
+
state: "invalid",
|
|
7295
|
+
detail: `${id} has no evidence quote`
|
|
7006
7296
|
});
|
|
7007
7297
|
continue;
|
|
7008
7298
|
}
|
|
7009
7299
|
const quote = evidenceQuote(item.evidence);
|
|
7010
7300
|
if (Array.from(quote).length < 8) {
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7301
|
+
items.push({
|
|
7302
|
+
id,
|
|
7303
|
+
state: "too_short",
|
|
7304
|
+
quote,
|
|
7305
|
+
detail: `${id} quotes fewer than 8 characters`
|
|
7015
7306
|
});
|
|
7016
7307
|
continue;
|
|
7017
7308
|
}
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
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`
|
|
7023
7317
|
});
|
|
7024
7318
|
continue;
|
|
7025
7319
|
}
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
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`
|
|
7031
7334
|
});
|
|
7032
7335
|
continue;
|
|
7033
7336
|
}
|
|
7034
|
-
let
|
|
7035
|
-
let
|
|
7036
|
-
|
|
7337
|
+
let accepted = null;
|
|
7338
|
+
let overlapWith;
|
|
7339
|
+
let sameSentenceWith;
|
|
7340
|
+
let boundary = false;
|
|
7341
|
+
for (const start of occurrences) {
|
|
7037
7342
|
const end = start + quote.length;
|
|
7038
|
-
const
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
break;
|
|
7343
|
+
const conflict = spans.find((span) => start < span.end && span.start < end);
|
|
7344
|
+
if (conflict !== void 0) {
|
|
7345
|
+
overlapWith ??= conflict.id;
|
|
7346
|
+
continue;
|
|
7043
7347
|
}
|
|
7044
|
-
|
|
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;
|
|
7355
|
+
}
|
|
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;
|
|
7045
7368
|
}
|
|
7046
|
-
if (
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
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`
|
|
7051
7390
|
});
|
|
7052
7391
|
continue;
|
|
7053
7392
|
}
|
|
7054
|
-
spans.push(
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
code: "SEMANTIC_CHECK_FAILED",
|
|
7062
|
-
scope: "check",
|
|
7063
|
-
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"}` } : {}
|
|
7064
7400
|
});
|
|
7065
7401
|
}
|
|
7066
|
-
const
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
scope: "check",
|
|
7402
|
+
for (const id of SEMANTIC_CHECK_IDS) if (!seenIds.has(id)) items.push({
|
|
7403
|
+
id,
|
|
7404
|
+
state: "missing",
|
|
7070
7405
|
detail: `${id} is required by the P6 task book`
|
|
7071
7406
|
});
|
|
7072
|
-
for (const id of
|
|
7073
|
-
|
|
7074
|
-
|
|
7407
|
+
for (const id of seenIds) if (!SEMANTIC_CHECK_IDS.includes(id)) items.push({
|
|
7408
|
+
id,
|
|
7409
|
+
state: "invalid",
|
|
7075
7410
|
detail: `${id} is not one of the required P6 semantic checks`
|
|
7076
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];
|
|
7077
7423
|
return {
|
|
7424
|
+
items,
|
|
7425
|
+
findings,
|
|
7078
7426
|
blocking,
|
|
7427
|
+
submission_failed: submissionItems.length > 0,
|
|
7428
|
+
content_failed: findings.length > 0,
|
|
7079
7429
|
failed: blocking.length > 0
|
|
7080
7430
|
};
|
|
7081
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
|
+
}
|
|
7082
7438
|
// src/engine/gates.ts
|
|
7083
7439
|
const block = (code, detail, hint, scope) => ({
|
|
7084
7440
|
code,
|
|
@@ -7088,6 +7444,7 @@ const block = (code, detail, hint, scope) => ({
|
|
|
7088
7444
|
});
|
|
7089
7445
|
/** PG-01: the model must have received the current brief before submitting. */
|
|
7090
7446
|
const briefRequired = (input) => {
|
|
7447
|
+
if (input.summaryOnlyRevision) return [];
|
|
7091
7448
|
if (input.briefStage === null) return [block("PG-01", "this operation needs a brief, but no brief stage is mapped")];
|
|
7092
7449
|
const record = input.pipeline.briefs[input.briefStage];
|
|
7093
7450
|
if (record === void 0) return [block("PG-01", `brief ${input.briefStage} has not been issued`, `call novel_brief with stage ${input.briefStage} first`)];
|
|
@@ -7184,7 +7541,29 @@ const summarySchema = (input) => {
|
|
|
7184
7541
|
"locations",
|
|
7185
7542
|
"abilities",
|
|
7186
7543
|
"artifacts"
|
|
7187
|
-
])
|
|
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
|
+
}
|
|
7188
7567
|
const resources = summary.resource_changes;
|
|
7189
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");
|
|
7190
7569
|
if (!isText(summary.opening)) problems.push("opening must be text");
|
|
@@ -7383,6 +7762,7 @@ const consistency = (input, options) => {
|
|
|
7383
7762
|
};
|
|
7384
7763
|
/** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
|
|
7385
7764
|
const rounds = (input) => {
|
|
7765
|
+
if (input.summaryOnly === true) return [];
|
|
7386
7766
|
if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
|
|
7387
7767
|
const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
|
|
7388
7768
|
if (!roundsExhausted(input.rounds, counter)) return [];
|
|
@@ -7448,7 +7828,13 @@ function applicableGates(input) {
|
|
|
7448
7828
|
"PG-06",
|
|
7449
7829
|
"PG-13"
|
|
7450
7830
|
];
|
|
7451
|
-
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
|
+
] : [
|
|
7452
7838
|
...always,
|
|
7453
7839
|
"PG-04",
|
|
7454
7840
|
"PG-03",
|
|
@@ -8016,6 +8402,8 @@ var EngineService = class {
|
|
|
8016
8402
|
stores = /* @__PURE__ */ new Map();
|
|
8017
8403
|
mutex = new KeyedMutex();
|
|
8018
8404
|
replay = /* @__PURE__ */ new Map();
|
|
8405
|
+
/** Bumped when an export artifact disappeared; makes the replay key miss. */
|
|
8406
|
+
exportArtifactEpoch = /* @__PURE__ */ new Map();
|
|
8019
8407
|
/** Status, panel and gates ask for the same stage context repeatedly. */
|
|
8020
8408
|
briefContextCache = /* @__PURE__ */ new WeakMap();
|
|
8021
8409
|
briefs;
|
|
@@ -8043,6 +8431,7 @@ var EngineService = class {
|
|
|
8043
8431
|
this.disposed = true;
|
|
8044
8432
|
this.stores.clear();
|
|
8045
8433
|
this.replay.clear();
|
|
8434
|
+
this.exportArtifactEpoch.clear();
|
|
8046
8435
|
this.compiled = null;
|
|
8047
8436
|
}
|
|
8048
8437
|
get rulepack() {
|
|
@@ -8467,6 +8856,7 @@ var EngineService = class {
|
|
|
8467
8856
|
const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
|
|
8468
8857
|
const removedVolumes = snapshot.outline.volumes.filter((volume) => !volumes.some((next) => next.title === volume.title)).map((volume) => volume.title);
|
|
8469
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");
|
|
8470
8860
|
let chapters = snapshot.outline.chapters.map((card) => ({
|
|
8471
8861
|
...card,
|
|
8472
8862
|
volume: volumeForChapter(volumes, card.num, card.volume)
|
|
@@ -8481,6 +8871,7 @@ var EngineService = class {
|
|
|
8481
8871
|
chapters
|
|
8482
8872
|
}, record, now, snapshot.project.targets.words_per_chapter).chapters;
|
|
8483
8873
|
}
|
|
8874
|
+
assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_blueprint");
|
|
8484
8875
|
const outline = {
|
|
8485
8876
|
...snapshot.outline,
|
|
8486
8877
|
updated_at: now,
|
|
@@ -8564,6 +8955,7 @@ var EngineService = class {
|
|
|
8564
8955
|
volumes: merged.volumes.length,
|
|
8565
8956
|
added: merged.added,
|
|
8566
8957
|
updated: merged.updated,
|
|
8958
|
+
reindexed: merged.reindexed,
|
|
8567
8959
|
removed: merged.removed,
|
|
8568
8960
|
volumes_added: merged.volumes_added,
|
|
8569
8961
|
volumes_updated: merged.volumes_updated,
|
|
@@ -8584,6 +8976,7 @@ var EngineService = class {
|
|
|
8584
8976
|
]);
|
|
8585
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(", ")}`);
|
|
8586
8978
|
const current = await this.load(input.root);
|
|
8979
|
+
this.assertPublicBookHasNoStrictExplicit(input.book, "novel_outline");
|
|
8587
8980
|
this.assertProjectReadable(current, "novel_outline");
|
|
8588
8981
|
this.assertPlanningDocumentReadable(current, "outline", "novel_outline");
|
|
8589
8982
|
this.assertArchiveIndexReadable(current, "novel_outline");
|
|
@@ -8596,6 +8989,7 @@ var EngineService = class {
|
|
|
8596
8989
|
volumes: current.outline.volumes.length,
|
|
8597
8990
|
added: [],
|
|
8598
8991
|
updated: [],
|
|
8992
|
+
reindexed: [],
|
|
8599
8993
|
removed: [],
|
|
8600
8994
|
volumes_added: [],
|
|
8601
8995
|
volumes_updated: [],
|
|
@@ -8612,10 +9006,12 @@ var EngineService = class {
|
|
|
8612
9006
|
...plan.data,
|
|
8613
9007
|
added: [],
|
|
8614
9008
|
updated: [],
|
|
9009
|
+
reindexed: [],
|
|
8615
9010
|
removed: [],
|
|
8616
9011
|
dry_run: true,
|
|
8617
9012
|
would_add: plan.data.added,
|
|
8618
9013
|
would_update: plan.data.updated,
|
|
9014
|
+
would_reindex: plan.data.reindexed,
|
|
8619
9015
|
would_remove: plan.data.removed
|
|
8620
9016
|
},
|
|
8621
9017
|
revision: current.pipeline.revision,
|
|
@@ -8673,6 +9069,7 @@ var EngineService = class {
|
|
|
8673
9069
|
]);
|
|
8674
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(", ")}`);
|
|
8675
9071
|
const parsed = parseOutlineInput(input.content, input.format);
|
|
9072
|
+
this.assertPublicBookHasNoStrictExplicit(parsed.book, "novel_outline_import");
|
|
8676
9073
|
const snapshot = await this.load(input.root);
|
|
8677
9074
|
this.assertProjectReadable(snapshot, "novel_outline_import");
|
|
8678
9075
|
this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline_import");
|
|
@@ -8698,6 +9095,7 @@ var EngineService = class {
|
|
|
8698
9095
|
defaultTargetWords: snapshot.project.targets.words_per_chapter,
|
|
8699
9096
|
now
|
|
8700
9097
|
});
|
|
9098
|
+
assertArchivedCardsInVolumes(snapshot.chapters.chapters, chapters, volumes, "novel_outline_import");
|
|
8701
9099
|
const candidate = {
|
|
8702
9100
|
schema_version: 1,
|
|
8703
9101
|
updated_at: now,
|
|
@@ -8705,6 +9103,9 @@ var EngineService = class {
|
|
|
8705
9103
|
volumes,
|
|
8706
9104
|
chapters
|
|
8707
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");
|
|
8708
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");
|
|
8709
9110
|
const summary = {
|
|
8710
9111
|
format: parsed.format,
|
|
@@ -8748,6 +9149,7 @@ var EngineService = class {
|
|
|
8748
9149
|
defaultTargetWords: fresh.project.targets.words_per_chapter,
|
|
8749
9150
|
now
|
|
8750
9151
|
});
|
|
9152
|
+
assertArchivedCardVolumes(fresh.chapters.chapters, fresh.outline.chapters, fresh.outline.volumes, nextVolumes, "novel_outline_import");
|
|
8751
9153
|
const outline = {
|
|
8752
9154
|
schema_version: 1,
|
|
8753
9155
|
updated_at: now,
|
|
@@ -8755,7 +9157,7 @@ var EngineService = class {
|
|
|
8755
9157
|
volumes: nextVolumes,
|
|
8756
9158
|
chapters: nextChapters
|
|
8757
9159
|
};
|
|
8758
|
-
this.assertActiveCardPreserved(fresh,
|
|
9160
|
+
this.assertActiveCardPreserved(fresh, nextChapters, "novel_outline_import");
|
|
8759
9161
|
this.assertOutlineWritable(fresh, outline, "novel_outline_import");
|
|
8760
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");
|
|
8761
9163
|
const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import_outline", this.now(), shortDigest(input.content)) });
|
|
@@ -9182,6 +9584,13 @@ var EngineService = class {
|
|
|
9182
9584
|
async journalBlocking(snapshot) {
|
|
9183
9585
|
const store = this.storeFor(snapshot.root);
|
|
9184
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
|
+
}];
|
|
9185
9594
|
if (await store.exists("novel.yaml")) {
|
|
9186
9595
|
const journalPresent = await store.exists(joinRel(STATE_DIR, STATE_FILES.journal));
|
|
9187
9596
|
const journal = journalPresent ? await readJournal(store) : null;
|
|
@@ -9279,6 +9688,11 @@ var EngineService = class {
|
|
|
9279
9688
|
async statusFor(snapshot, chapter) {
|
|
9280
9689
|
const metrics = await this.metricsFor(snapshot, chapter);
|
|
9281
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
|
+
}] : [];
|
|
9282
9696
|
const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
|
|
9283
9697
|
tool: "novel_audit",
|
|
9284
9698
|
args: {},
|
|
@@ -9298,6 +9712,7 @@ var EngineService = class {
|
|
|
9298
9712
|
stage: snapshot.pipeline.stage,
|
|
9299
9713
|
rounds: { ...snapshot.pipeline.rounds },
|
|
9300
9714
|
blocking: [...this.effectiveBlocking(snapshot), ...journal],
|
|
9715
|
+
...pendingWarnings.length === 0 ? {} : { warnings: pendingWarnings },
|
|
9301
9716
|
state_errors: this.stateErrors(snapshot),
|
|
9302
9717
|
selection: {
|
|
9303
9718
|
source: snapshot.selection.source,
|
|
@@ -9833,6 +10248,21 @@ var EngineService = class {
|
|
|
9833
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"),
|
|
9834
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" }
|
|
9835
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
|
+
}
|
|
9836
10266
|
add({
|
|
9837
10267
|
id: "worldbook",
|
|
9838
10268
|
severity: snapshot.worldbookError !== null ? "error" : snapshot.worldbookPresent ? "ok" : "info",
|
|
@@ -9852,6 +10282,14 @@ var EngineService = class {
|
|
|
9852
10282
|
const journal = await readJournal(store);
|
|
9853
10283
|
const journalCommitted = hasCommittedJournal(journal.records);
|
|
9854
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
|
+
});
|
|
9855
10293
|
add({
|
|
9856
10294
|
id: "journal",
|
|
9857
10295
|
severity: !journalPresent || !journalCommitted || journal.malformed > 0 || health.mismatches.length > 0 ? "error" : "ok",
|
|
@@ -10747,7 +11185,11 @@ var EngineService = class {
|
|
|
10747
11185
|
async submitRevision(input) {
|
|
10748
11186
|
const hasContent = input.content !== void 0;
|
|
10749
11187
|
const hasPatch = input.patch !== void 0;
|
|
10750
|
-
|
|
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");
|
|
10751
11193
|
if (hasPatch) {
|
|
10752
11194
|
const patch = input.patch;
|
|
10753
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");
|
|
@@ -10768,7 +11210,13 @@ var EngineService = class {
|
|
|
10768
11210
|
}))}`,
|
|
10769
11211
|
run: async (snapshot, context) => {
|
|
10770
11212
|
this.assertChapter(snapshot, input.chapter);
|
|
10771
|
-
|
|
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");
|
|
10772
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");
|
|
10773
11221
|
if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
|
|
10774
11222
|
let patchedReview = null;
|
|
@@ -10831,8 +11279,10 @@ var EngineService = class {
|
|
|
10831
11279
|
text: revisedContent,
|
|
10832
11280
|
summary: input.summary,
|
|
10833
11281
|
summaryProvided: input.summary !== void 0,
|
|
11282
|
+
summaryOnly,
|
|
11283
|
+
summaryOnlyRevision: summaryOnly,
|
|
10834
11284
|
review: patchedReview,
|
|
10835
|
-
proof: input.proof
|
|
11285
|
+
proof: input.proof ?? snapshot.review?.proof ?? null
|
|
10836
11286
|
});
|
|
10837
11287
|
const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
|
|
10838
11288
|
if (input.summary === void 0 && inheritedSummary !== void 0) {
|
|
@@ -10851,24 +11301,27 @@ var EngineService = class {
|
|
|
10851
11301
|
content_hash: sha256(snapshot.text ?? ""),
|
|
10852
11302
|
updated_at: this.now().toISOString()
|
|
10853
11303
|
};
|
|
10854
|
-
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);
|
|
10855
11305
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
10856
11306
|
stage: nextStage$1,
|
|
10857
11307
|
draft: {
|
|
10858
11308
|
...baseDraft,
|
|
10859
11309
|
summary: effectiveSummary,
|
|
10860
|
-
content_hash: sha256(revisedContent),
|
|
11310
|
+
content_hash: summaryOnly ? baseDraft.content_hash : sha256(revisedContent),
|
|
10861
11311
|
updated_at: this.now().toISOString()
|
|
10862
11312
|
},
|
|
10863
|
-
rounds: {
|
|
11313
|
+
rounds: summaryOnly ? { ...snapshot.pipeline.rounds } : {
|
|
10864
11314
|
...snapshot.pipeline.rounds,
|
|
10865
11315
|
revise: snapshot.pipeline.rounds.revise + 1
|
|
10866
11316
|
},
|
|
10867
|
-
blocking: [],
|
|
11317
|
+
blocking: summaryOnly ? snapshot.pipeline.blocking.filter((item) => item.code !== "PG-05") : [],
|
|
10868
11318
|
...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
|
|
10869
|
-
last_action: action("novel_revise", this.now(), sha256(revisedContent))
|
|
11319
|
+
last_action: action(summaryOnly ? "novel_revise_summary" : "novel_revise", this.now(), sha256(revisedContent))
|
|
10870
11320
|
});
|
|
10871
|
-
const files = [
|
|
11321
|
+
const files = summaryOnly ? [{
|
|
11322
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
11323
|
+
text: yaml(nextPipeline)
|
|
11324
|
+
}] : [
|
|
10872
11325
|
...snapshot.text === null ? [] : [{
|
|
10873
11326
|
path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
|
|
10874
11327
|
text: snapshot.text
|
|
@@ -10886,17 +11339,19 @@ var EngineService = class {
|
|
|
10886
11339
|
text: yaml(nextPipeline)
|
|
10887
11340
|
}
|
|
10888
11341
|
];
|
|
10889
|
-
if (reviewChanged && patchedReview !== null) files.push({
|
|
11342
|
+
if (!summaryOnly && reviewChanged && patchedReview !== null) files.push({
|
|
10890
11343
|
path: reviewFile(input.chapter),
|
|
10891
11344
|
text: yaml(patchedReview)
|
|
10892
11345
|
});
|
|
10893
11346
|
return {
|
|
10894
11347
|
files,
|
|
10895
|
-
backup:
|
|
11348
|
+
backup: !summaryOnly,
|
|
10896
11349
|
data: {
|
|
10897
11350
|
chapter: input.chapter,
|
|
10898
11351
|
stage: nextStage$1,
|
|
10899
|
-
metrics: machine.metrics
|
|
11352
|
+
metrics: machine.metrics,
|
|
11353
|
+
summary_only: summaryOnly,
|
|
11354
|
+
rounds_consumed: summaryOnly ? 0 : 1
|
|
10900
11355
|
},
|
|
10901
11356
|
warnings: machineWarnings(machine)
|
|
10902
11357
|
};
|
|
@@ -10917,7 +11372,9 @@ var EngineService = class {
|
|
|
10917
11372
|
run: async (snapshot, context) => {
|
|
10918
11373
|
this.assertChapter(snapshot, input.chapter);
|
|
10919
11374
|
const text = snapshot.text ?? "";
|
|
10920
|
-
|
|
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;
|
|
10921
11378
|
await this.assertGates(snapshot, "runCheck", {
|
|
10922
11379
|
briefStage: "P6",
|
|
10923
11380
|
text,
|
|
@@ -10938,8 +11395,8 @@ var EngineService = class {
|
|
|
10938
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`);
|
|
10939
11396
|
const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
|
|
10940
11397
|
const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
|
|
10941
|
-
const semanticResult =
|
|
10942
|
-
const verdict = machineBlocking.length === 0 && !semanticResult.
|
|
11398
|
+
const semanticResult = semanticEvaluation;
|
|
11399
|
+
const verdict = machineBlocking.length === 0 && !semanticResult.content_failed ? "pass" : "fail";
|
|
10943
11400
|
const check = {
|
|
10944
11401
|
schema_version: 1,
|
|
10945
11402
|
chapter: input.chapter,
|
|
@@ -10950,8 +11407,15 @@ var EngineService = class {
|
|
|
10950
11407
|
advisories: machineAdvisories,
|
|
10951
11408
|
metrics: scopedMachine.metrics
|
|
10952
11409
|
},
|
|
10953
|
-
semantic: {
|
|
11410
|
+
semantic: {
|
|
11411
|
+
items: semantic,
|
|
11412
|
+
...semanticResult.findings.length === 0 ? {} : { findings: semanticResult.findings }
|
|
11413
|
+
},
|
|
10954
11414
|
...acknowledged.length === 0 ? {} : { acknowledged },
|
|
11415
|
+
...verdict === "fail" ? {
|
|
11416
|
+
failure_class: "content",
|
|
11417
|
+
counted: true
|
|
11418
|
+
} : {},
|
|
10955
11419
|
verdict
|
|
10956
11420
|
};
|
|
10957
11421
|
try {
|
|
@@ -10965,12 +11429,12 @@ var EngineService = class {
|
|
|
10965
11429
|
code: violation.code,
|
|
10966
11430
|
scope: "check",
|
|
10967
11431
|
detail: violation.detail
|
|
10968
|
-
})), ...semanticResult.
|
|
11432
|
+
})), ...semanticResult.findings] : [];
|
|
10969
11433
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
10970
11434
|
stage: nextStage$2,
|
|
10971
11435
|
rounds: {
|
|
10972
11436
|
...snapshot.pipeline.rounds,
|
|
10973
|
-
check: snapshot.pipeline.rounds.check + (verdict === "fail"
|
|
11437
|
+
check: snapshot.pipeline.rounds.check + (verdict === "fail" ? 1 : 0)
|
|
10974
11438
|
},
|
|
10975
11439
|
blocking: blockingItems,
|
|
10976
11440
|
last_action: action("novel_check", this.now(), sha256(text))
|
|
@@ -10999,7 +11463,13 @@ var EngineService = class {
|
|
|
10999
11463
|
advisory_codes: advisoryCodes,
|
|
11000
11464
|
acknowledged,
|
|
11001
11465
|
metrics: scopedMachine.metrics,
|
|
11002
|
-
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"
|
|
11003
11473
|
},
|
|
11004
11474
|
warnings: [
|
|
11005
11475
|
...machineWarnings({
|
|
@@ -11047,7 +11517,9 @@ var EngineService = class {
|
|
|
11047
11517
|
chapter: input.chapter,
|
|
11048
11518
|
assigned_ids: already.assigned_ids ?? {},
|
|
11049
11519
|
metrics: countWordMetrics(archivedText),
|
|
11050
|
-
score: already.score
|
|
11520
|
+
score: already.score,
|
|
11521
|
+
delta_warnings: [],
|
|
11522
|
+
normalized_world: {}
|
|
11051
11523
|
},
|
|
11052
11524
|
warnings: [],
|
|
11053
11525
|
replay: true
|
|
@@ -11240,8 +11712,11 @@ var EngineService = class {
|
|
|
11240
11712
|
chapter,
|
|
11241
11713
|
assigned_ids: ledger.assigned,
|
|
11242
11714
|
metrics,
|
|
11243
|
-
score
|
|
11715
|
+
score,
|
|
11716
|
+
delta_warnings: ledger.warnings,
|
|
11717
|
+
normalized_world: ledger.normalized_world
|
|
11244
11718
|
},
|
|
11719
|
+
warnings: ledger.warnings,
|
|
11245
11720
|
event: {
|
|
11246
11721
|
name: "novel/archived",
|
|
11247
11722
|
payload: {
|
|
@@ -11524,6 +11999,15 @@ var EngineService = class {
|
|
|
11524
11999
|
code: "JOURNAL_HASH_MISMATCH",
|
|
11525
12000
|
message: `${health.mismatches.length} file(s) changed after the last commit; first: ${health.mismatches[0].path}`
|
|
11526
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
|
+
});
|
|
11527
12011
|
const requested = input.limit ?? 50;
|
|
11528
12012
|
const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
|
|
11529
12013
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
@@ -11566,10 +12050,22 @@ var EngineService = class {
|
|
|
11566
12050
|
const current = await this.load(input.root);
|
|
11567
12051
|
this.assertExportIndexReadable(current, input.kind);
|
|
11568
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;
|
|
11569
12065
|
return this.mutate({
|
|
11570
12066
|
root: input.root,
|
|
11571
12067
|
op: "export",
|
|
11572
|
-
key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
|
|
12068
|
+
key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${artifactEpoch}:${sha256(stableJson({
|
|
11573
12069
|
chapter: snapshot.pipeline.chapter,
|
|
11574
12070
|
text: sha256(snapshot.text ?? ""),
|
|
11575
12071
|
plan_title: stripTitlePrefix(snapshot.plan?.title ?? ""),
|
|
@@ -11791,14 +12287,14 @@ var EngineService = class {
|
|
|
11791
12287
|
root: active,
|
|
11792
12288
|
source: "active"
|
|
11793
12289
|
};
|
|
11794
|
-
else if (registry.projects.length === 1) selection = {
|
|
11795
|
-
root: registry.projects[0].root,
|
|
11796
|
-
source: "single"
|
|
11797
|
-
};
|
|
11798
12290
|
else if (rows.has(configured) && !registry.projects.some((entry) => entry.root === configured)) selection = {
|
|
11799
12291
|
root: configured,
|
|
11800
12292
|
source: "config"
|
|
11801
12293
|
};
|
|
12294
|
+
else if (registry.projects.length === 1) selection = {
|
|
12295
|
+
root: registry.projects[0].root,
|
|
12296
|
+
source: "single"
|
|
12297
|
+
};
|
|
11802
12298
|
else selection = {
|
|
11803
12299
|
root: null,
|
|
11804
12300
|
source: "none"
|
|
@@ -12297,15 +12793,23 @@ var EngineService = class {
|
|
|
12297
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}`);
|
|
12298
12794
|
}
|
|
12299
12795
|
/**
|
|
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
|
+
/**
|
|
12300
12805
|
* An import is a full replacement, unlike novel_outline's keyed upsert. It
|
|
12301
12806
|
* must not silently lose the active card while strict mode still requires it.
|
|
12302
12807
|
*/
|
|
12303
|
-
assertActiveCardPreserved(snapshot,
|
|
12304
|
-
if (!strict) return;
|
|
12808
|
+
assertActiveCardPreserved(snapshot, chapters, operation) {
|
|
12305
12809
|
const chapter = snapshot.pipeline.chapter;
|
|
12306
12810
|
if (!snapshot.outline.chapters.some((card) => card.num === chapter)) return;
|
|
12307
12811
|
if (chapters.some((card) => card.num === chapter)) return;
|
|
12308
|
-
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");
|
|
12309
12813
|
}
|
|
12310
12814
|
/**
|
|
12311
12815
|
* The outline is a derived progress view and the PG-13 input. Every write
|
|
@@ -12385,6 +12889,7 @@ var EngineService = class {
|
|
|
12385
12889
|
const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
|
|
12386
12890
|
const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
|
|
12387
12891
|
const journal = await readJournal(this.storeFor(snapshot.root));
|
|
12892
|
+
const pending = await inspectPending(this.storeFor(snapshot.root));
|
|
12388
12893
|
const journalBlocking = await this.journalBlocking(snapshot);
|
|
12389
12894
|
const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
|
|
12390
12895
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
@@ -12518,7 +13023,12 @@ var EngineService = class {
|
|
|
12518
13023
|
activity,
|
|
12519
13024
|
...advisories.length === 0 ? {} : { advisories },
|
|
12520
13025
|
...acknowledgedAdvisories === 0 ? {} : { acknowledgedAdvisories },
|
|
12521
|
-
...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
|
+
} }
|
|
12522
13032
|
};
|
|
12523
13033
|
}
|
|
12524
13034
|
/**
|
|
@@ -13196,6 +13706,13 @@ var EngineService = class {
|
|
|
13196
13706
|
rounds: snapshot.pipeline.rounds,
|
|
13197
13707
|
min_score: this.config.quality.minScore,
|
|
13198
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
|
+
})),
|
|
13199
13716
|
context
|
|
13200
13717
|
}));
|
|
13201
13718
|
}
|
|
@@ -13241,6 +13758,8 @@ var EngineService = class {
|
|
|
13241
13758
|
sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
|
|
13242
13759
|
summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
|
|
13243
13760
|
summaryProvided: extras.summaryProvided,
|
|
13761
|
+
summaryOnly: extras.summaryOnly,
|
|
13762
|
+
summaryOnlyRevision: extras.summaryOnlyRevision ?? snapshot.pipeline.last_action?.op === "novel_revise_summary",
|
|
13244
13763
|
review: extras.review ?? snapshot.review,
|
|
13245
13764
|
check: extras.check ?? snapshot.check,
|
|
13246
13765
|
semantic: extras.semantic ?? [],
|
|
@@ -13641,7 +14160,14 @@ var EngineService = class {
|
|
|
13641
14160
|
STATE_FILES.handoff
|
|
13642
14161
|
].map((file) => joinRel(STATE_DIR, file));
|
|
13643
14162
|
stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
|
|
13644
|
-
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);
|
|
13645
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");
|
|
13646
14172
|
await assertJournalConsistent(store);
|
|
13647
14173
|
const created = await options.run(this.emptySnapshot(root, selection), {
|
|
@@ -14156,7 +14682,9 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
14156
14682
|
if (kind !== "relationships") return problems;
|
|
14157
14683
|
}
|
|
14158
14684
|
if (kind === "characters") {
|
|
14159
|
-
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");
|
|
14160
14688
|
if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
|
|
14161
14689
|
for (const name of Object.keys(snapshot.locks.characters)) if (!names.has(name)) problems.push(`lock for removed character "${name}" still exists`);
|
|
14162
14690
|
}
|
|
@@ -14166,6 +14694,21 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
14166
14694
|
return problems;
|
|
14167
14695
|
}
|
|
14168
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");
|
|
14169
14712
|
const locations = new Set((Array.isArray(nextDocument.locations) ? nextDocument.locations : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
|
|
14170
14713
|
if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
|
|
14171
14714
|
for (const name of Object.keys(snapshot.locks.locations)) if (!locations.has(name)) problems.push(`lock for removed location "${name}" still exists`);
|
|
@@ -14255,6 +14798,19 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
|
|
|
14255
14798
|
const row = recordOf(raw);
|
|
14256
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`);
|
|
14257
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
|
+
}
|
|
14258
14814
|
for (const record of snapshot.chapters.chapters) {
|
|
14259
14815
|
const card = byNum.get(record.num);
|
|
14260
14816
|
if (card === void 0) {
|
|
@@ -14516,7 +15072,8 @@ const asJson = (value, field) => {
|
|
|
14516
15072
|
const fail = (error) => {
|
|
14517
15073
|
if (error instanceof EngineError) {
|
|
14518
15074
|
const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
|
|
14519
|
-
|
|
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}`);
|
|
14520
15077
|
}
|
|
14521
15078
|
throw error;
|
|
14522
15079
|
};
|
|
@@ -14905,15 +15462,17 @@ function createNovelTools(engine, options = {}) {
|
|
|
14905
15462
|
type: "json",
|
|
14906
15463
|
description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
|
|
14907
15464
|
},
|
|
14908
|
-
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
|
+
},
|
|
14909
15469
|
issues: {
|
|
14910
15470
|
type: "json",
|
|
14911
15471
|
description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
|
|
14912
15472
|
},
|
|
14913
15473
|
proof: {
|
|
14914
15474
|
type: "json",
|
|
14915
|
-
|
|
14916
|
-
description: arraySemanticsDescription("replace", "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.")
|
|
14917
15476
|
},
|
|
14918
15477
|
project_root: root
|
|
14919
15478
|
},
|
|
@@ -15615,7 +16174,12 @@ const bad = (text) => ({
|
|
|
15615
16174
|
kind: "error",
|
|
15616
16175
|
text
|
|
15617
16176
|
});
|
|
15618
|
-
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
|
+
};
|
|
15619
16183
|
const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
|
|
15620
16184
|
const statusText = async (engine) => {
|
|
15621
16185
|
const { data } = await engine.getStatus({});
|
|
@@ -16205,6 +16769,119 @@ function compileRulepack(raw) {
|
|
|
16205
16769
|
"exclusions",
|
|
16206
16770
|
"exemptions"
|
|
16207
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
|
+
}
|
|
16208
16885
|
const pack = normalize(raw);
|
|
16209
16886
|
const rawPlatform = object(root.platform, "platform");
|
|
16210
16887
|
for (const platform of PLATFORMS) if (rawPlatform[platform] === void 0) invalid(`platform.${platform} is missing`);
|