dsh-novel-write-engine 0.1.1 → 0.1.3
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 +35 -0
- package/README.md +192 -160
- package/briefs/P1.md +6 -4
- package/briefs/P2.md +4 -1
- package/briefs/P3.md +4 -0
- package/briefs/P4.md +3 -0
- package/briefs/P5.md +4 -3
- package/briefs/P6.md +7 -1
- package/lib/client.js +8 -3
- package/lib/index.d.ts +85 -1
- package/lib/index.js +1885 -219
- package/lib/types/client/dto.d.ts +6 -0
- package/lib/types/constants.d.ts +8 -0
- package/lib/types/types.d.ts +28 -0
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -54,7 +54,13 @@ const LIMITS = {
|
|
|
54
54
|
/** How long a writer waits for the keyed mutex before failing. */
|
|
55
55
|
lockWaitMs: 1e4,
|
|
56
56
|
/** Reading proofs shorter than this cannot be verified. */
|
|
57
|
-
minQuoteChars: 12
|
|
57
|
+
minQuoteChars: 12,
|
|
58
|
+
/** World-plan text fields are protected context, so they need a hard bound. */
|
|
59
|
+
maxWorldPlanItems: 64,
|
|
60
|
+
maxWorldPlanTextChars: 4e3,
|
|
61
|
+
maxWorldPlanObjectiveChars: 2e3,
|
|
62
|
+
/** Cast plus summary may exceed eight, but not without a bound. */
|
|
63
|
+
maxActiveCast: 12
|
|
58
64
|
};
|
|
59
65
|
/** The order used to render the nine-phase progress strip. */
|
|
60
66
|
const PHASE_STRIP = [
|
|
@@ -455,6 +461,16 @@ function dialogueCharacterRatio(paragraph) {
|
|
|
455
461
|
for (const match of paragraph.matchAll(/["“]([^"“”]*)["”]/g)) quoted += Array.from(match[1]).length;
|
|
456
462
|
return quoted / total;
|
|
457
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* The opening quote rule is shared by PG-05 (draft/revise) and the P5 fast
|
|
466
|
+
* check. Keeping one implementation prevents the two paths from disagreeing
|
|
467
|
+
* about what counts as the first two paragraphs.
|
|
468
|
+
*/
|
|
469
|
+
function openingInFirstTwoParagraphs(text, opening) {
|
|
470
|
+
const needle = opening.trim();
|
|
471
|
+
if (needle === "") return false;
|
|
472
|
+
return splitParagraphs(text).slice(0, 2).some((paragraph) => paragraph.includes(needle));
|
|
473
|
+
}
|
|
458
474
|
// src/domain/simhash.ts
|
|
459
475
|
const MASK64 = (1n << 64n) - 1n;
|
|
460
476
|
/** FNV-1a over UTF-16 code units; deterministic across platforms and Node versions. */
|
|
@@ -529,105 +545,47 @@ const TRANSITIONS = [
|
|
|
529
545
|
{
|
|
530
546
|
from: "idle",
|
|
531
547
|
op: "submitWorldPlan",
|
|
532
|
-
to: () => "P2"
|
|
533
|
-
guards: ["PG-01", "PG-02"]
|
|
548
|
+
to: () => "P2"
|
|
534
549
|
},
|
|
535
550
|
{
|
|
536
551
|
from: "done",
|
|
537
552
|
op: "submitWorldPlan",
|
|
538
|
-
to: () => "P2"
|
|
539
|
-
guards: ["PG-01", "PG-02"]
|
|
553
|
+
to: () => "P2"
|
|
540
554
|
},
|
|
541
555
|
{
|
|
542
556
|
from: "P2",
|
|
543
557
|
op: "submitChapterPlan",
|
|
544
|
-
to: () => "P3"
|
|
545
|
-
guards: ["PG-01", "PG-02"]
|
|
558
|
+
to: () => "P3"
|
|
546
559
|
},
|
|
547
560
|
{
|
|
548
561
|
from: "P3",
|
|
549
562
|
op: "submitDraft",
|
|
550
|
-
to: () => "P4"
|
|
551
|
-
guards: [
|
|
552
|
-
"PG-01",
|
|
553
|
-
"PG-02",
|
|
554
|
-
"PG-03",
|
|
555
|
-
"PG-04",
|
|
556
|
-
"PG-05",
|
|
557
|
-
"PG-06"
|
|
558
|
-
]
|
|
563
|
+
to: () => "P4"
|
|
559
564
|
},
|
|
560
565
|
{
|
|
561
566
|
from: "P4",
|
|
562
567
|
op: "submitReview",
|
|
563
|
-
to: (context) => context.openBlockers > 0 ? "P5" : "P6"
|
|
564
|
-
guards: [
|
|
565
|
-
"PG-01",
|
|
566
|
-
"PG-02",
|
|
567
|
-
"PG-07",
|
|
568
|
-
"PG-09",
|
|
569
|
-
"PG-11"
|
|
570
|
-
]
|
|
568
|
+
to: (context) => context.openBlockers > 0 ? "P5" : "P6"
|
|
571
569
|
},
|
|
572
570
|
{
|
|
573
571
|
from: "P5",
|
|
574
572
|
op: "submitRevision",
|
|
575
|
-
to: () => "P4"
|
|
576
|
-
guards: [
|
|
577
|
-
"PG-01",
|
|
578
|
-
"PG-02",
|
|
579
|
-
"PG-03",
|
|
580
|
-
"PG-04",
|
|
581
|
-
"PG-05",
|
|
582
|
-
"PG-08",
|
|
583
|
-
"PG-09"
|
|
584
|
-
]
|
|
573
|
+
to: () => "P4"
|
|
585
574
|
},
|
|
586
575
|
{
|
|
587
576
|
from: "P6",
|
|
588
577
|
op: "submitRevision",
|
|
589
|
-
to: () => "P4"
|
|
590
|
-
guards: [
|
|
591
|
-
"PG-01",
|
|
592
|
-
"PG-02",
|
|
593
|
-
"PG-03",
|
|
594
|
-
"PG-04",
|
|
595
|
-
"PG-05",
|
|
596
|
-
"PG-08",
|
|
597
|
-
"PG-09"
|
|
598
|
-
]
|
|
578
|
+
to: () => "P4"
|
|
599
579
|
},
|
|
600
580
|
{
|
|
601
581
|
from: "P6",
|
|
602
582
|
op: "runCheck",
|
|
603
|
-
to: () => "ready_to_archive"
|
|
604
|
-
guards: [
|
|
605
|
-
"PG-01",
|
|
606
|
-
"PG-02",
|
|
607
|
-
"PG-04",
|
|
608
|
-
"PG-05",
|
|
609
|
-
"PG-08",
|
|
610
|
-
"PG-09",
|
|
611
|
-
"PG-10",
|
|
612
|
-
"PG-11"
|
|
613
|
-
]
|
|
583
|
+
to: (context) => context.completeCheck === false ? "P6" : "ready_to_archive"
|
|
614
584
|
},
|
|
615
585
|
{
|
|
616
586
|
from: "ready_to_archive",
|
|
617
587
|
op: "archiveChapter",
|
|
618
|
-
to: () => "done"
|
|
619
|
-
guards: [
|
|
620
|
-
"PG-01",
|
|
621
|
-
"PG-02",
|
|
622
|
-
"PG-03",
|
|
623
|
-
"PG-04",
|
|
624
|
-
"PG-05",
|
|
625
|
-
"PG-06",
|
|
626
|
-
"PG-07",
|
|
627
|
-
"PG-08",
|
|
628
|
-
"PG-10",
|
|
629
|
-
"PG-12"
|
|
630
|
-
]
|
|
588
|
+
to: () => "done"
|
|
631
589
|
}
|
|
632
590
|
];
|
|
633
591
|
/** Find the row for this (stage, op), if any. */
|
|
@@ -639,10 +597,13 @@ function canRun(stage, op) {
|
|
|
639
597
|
return findTransition(stage, op) !== void 0;
|
|
640
598
|
}
|
|
641
599
|
/** Compute the next stage, or throw `ENGINE_STAGE_ORDER` with an actionable message. */
|
|
642
|
-
function nextStage(stage, op, openBlockers) {
|
|
600
|
+
function nextStage(stage, op, openBlockers, context = {}) {
|
|
643
601
|
const transition = findTransition(stage, op);
|
|
644
602
|
if (transition === void 0) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `operation ${op} is not allowed at stage ${stage}`, `allowed here: ${availableOps(stage).join(", ")}; call novel_status for the full picture`);
|
|
645
|
-
return transition.to({
|
|
603
|
+
return transition.to({
|
|
604
|
+
openBlockers,
|
|
605
|
+
...context
|
|
606
|
+
});
|
|
646
607
|
}
|
|
647
608
|
/** Operations legal at a stage, in table order. */
|
|
648
609
|
function availableOps(stage) {
|
|
@@ -732,6 +693,24 @@ function countWordMetrics(text) {
|
|
|
732
693
|
function countWordMetricsOfParagraph(paragraph) {
|
|
733
694
|
return matchCount(paragraph, CJK) + matchCount(paragraph, ENGLISH_WORD) + matchCount(paragraph, NUMBER) + matchCount(paragraph, CHINESE_PUNCT) + matchCount(paragraph, ENGLISH_PUNCT);
|
|
734
695
|
}
|
|
696
|
+
// src/domain/stable.ts
|
|
697
|
+
/**
|
|
698
|
+
* Locale-independent ordering and hashing primitives.
|
|
699
|
+
*
|
|
700
|
+
* A digest must not depend on the process locale. `localeCompare` can reorder
|
|
701
|
+
* CJK keys differently under different ICU collations, so every persisted sort
|
|
702
|
+
* and every digest uses UTF-16 code-unit order instead.
|
|
703
|
+
*/
|
|
704
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
705
|
+
const normalize$1 = (input) => {
|
|
706
|
+
if (Array.isArray(input)) return input.map(normalize$1);
|
|
707
|
+
if (input instanceof Date) return input;
|
|
708
|
+
if (typeof input === "object" && input !== null && typeof input.toJSON === "function") return input;
|
|
709
|
+
if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== void 0).sort(([left], [right]) => compareText(left, right)).map(([key, value]) => [key, normalize$1(value)]));
|
|
710
|
+
return input;
|
|
711
|
+
};
|
|
712
|
+
/** Deterministic JSON used by every replay key and digest. */
|
|
713
|
+
const stableJson = (value) => JSON.stringify(normalize$1(value)) ?? "null";
|
|
735
714
|
// src/codes.ts
|
|
736
715
|
/** Stable rule codes. One owner per code: the rules registry refuses duplicates. */
|
|
737
716
|
const FORMAT_RULES = {
|
|
@@ -1023,10 +1002,16 @@ const adjectiveDensity = (ctx) => {
|
|
|
1023
1002
|
return [v(SEMANTIC_RULES.ADJECTIVE_DENSITY, "advisory", 1, `adjectives every ${(100 / density).toFixed(1)} chars; limit is ${max}%`)];
|
|
1024
1003
|
};
|
|
1025
1004
|
const slidingWindows = (text, size) => {
|
|
1026
|
-
|
|
1005
|
+
const positions = [];
|
|
1006
|
+
for (let index = 0; index < text.length; index += 1) if (/\S/.test(text[index])) positions.push(index);
|
|
1007
|
+
if (positions.length <= size) return [text];
|
|
1027
1008
|
const windows = [];
|
|
1028
|
-
for (let
|
|
1029
|
-
|
|
1009
|
+
for (let startIndex = 0; startIndex + size <= positions.length; startIndex += 1) {
|
|
1010
|
+
const start = positions[startIndex];
|
|
1011
|
+
const end = startIndex + size < positions.length ? positions[startIndex + size] : text.length;
|
|
1012
|
+
windows.push(text.slice(start, end));
|
|
1013
|
+
}
|
|
1014
|
+
const tail = text.slice(positions[positions.length - size]);
|
|
1030
1015
|
if (windows[windows.length - 1] !== tail) windows.push(tail);
|
|
1031
1016
|
return windows;
|
|
1032
1017
|
};
|
|
@@ -1104,10 +1089,18 @@ const revisitedTooSoon = (ctx) => {
|
|
|
1104
1089
|
}).map((item) => v(CROSS_RULES.FS_REVISITED_TOO_SOON, "advisory", 1, `foreshadow ${item.id} reappears after ${ctx.chapter - item.last_touched} chapters (minimum ${min})`));
|
|
1105
1090
|
};
|
|
1106
1091
|
const resourceMismatch = (ctx) => {
|
|
1107
|
-
|
|
1092
|
+
const changes = ctx.summary?.resource_changes ?? [];
|
|
1093
|
+
const positive = /(增加|多了|得到|获得|赚|加|上升|增|\+)/;
|
|
1094
|
+
const negative = /(减少|少了|失去|消耗|花|损失|下降|减|-|−)/;
|
|
1095
|
+
return changes.flatMap((change) => {
|
|
1108
1096
|
if (change.delta === 0) return [];
|
|
1109
|
-
const magnitude =
|
|
1110
|
-
return ctx.text.
|
|
1097
|
+
const magnitude = Math.abs(change.delta);
|
|
1098
|
+
return [...ctx.text.matchAll(/\d+/g)].some((match) => {
|
|
1099
|
+
if (Number(match[0]) !== magnitude) return false;
|
|
1100
|
+
const index = match.index ?? 0;
|
|
1101
|
+
const window = ctx.text.slice(Math.max(0, index - 80), Math.min(ctx.text.length, index + match[0].length + 80));
|
|
1102
|
+
return window.includes(change.character) && window.includes(change.resource) && (change.delta > 0 ? positive : negative).test(window);
|
|
1103
|
+
}) ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", 1, `${change.character}'s ${change.resource} change (${change.delta}) is not stated with the right sign in the text`)];
|
|
1111
1104
|
});
|
|
1112
1105
|
};
|
|
1113
1106
|
const timeRegression = (ctx) => {
|
|
@@ -1263,6 +1256,11 @@ const lexiconCodes = /* @__PURE__ */ new Set([
|
|
|
1263
1256
|
"GOD_VIEW",
|
|
1264
1257
|
"CONCLUSION_FIRST"
|
|
1265
1258
|
]);
|
|
1259
|
+
const integrityCodes = new Set(Object.values(CROSS_RULES));
|
|
1260
|
+
/** Which runCheck scope owns one machine finding. */
|
|
1261
|
+
function scopeOfRule(code) {
|
|
1262
|
+
return integrityCodes.has(code) ? "integrity" : "content";
|
|
1263
|
+
}
|
|
1266
1264
|
/**
|
|
1267
1265
|
* Run the deterministic pass. Format and punctuation run before the lexicon so
|
|
1268
1266
|
* the report reads in the order an author fixes things; the lexicon runs last
|
|
@@ -2101,16 +2099,25 @@ async function recoverPending(store) {
|
|
|
2101
2099
|
}
|
|
2102
2100
|
throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `pending transaction ${last.txid} partially changed the project; automatic recovery is unsafe`, `restore from backups/ or ${STAGING_DIR}/${last.txid}/before/ before retrying`);
|
|
2103
2101
|
}
|
|
2104
|
-
/**
|
|
2105
|
-
|
|
2102
|
+
/**
|
|
2103
|
+
* Throw before a write when the journal or the last commit is inconsistent.
|
|
2104
|
+
*
|
|
2105
|
+
* `repairPaths` is used only by an explicit amend: a damaged ledger is by
|
|
2106
|
+
* definition a file whose bytes no longer match the last committed hash, and
|
|
2107
|
+
* refusing every write would make the documented repair path unreachable. All
|
|
2108
|
+
* other mismatches still fail closed.
|
|
2109
|
+
*/
|
|
2110
|
+
async function assertJournalConsistent(store, repairPaths = []) {
|
|
2106
2111
|
await recoverPending(store);
|
|
2107
2112
|
const { records } = await readJournal(store);
|
|
2108
2113
|
const committed = new Set(records.filter((record) => record.status === "committed" && record.op !== "lock_reclaimed").map((record) => record.txid));
|
|
2109
2114
|
for (const entry of await store.list(STAGING_DIR)) if (committed.has(entry)) await store.removeTree(joinRel(STAGING_DIR, entry)).catch(() => void 0);
|
|
2110
2115
|
const { malformed, mismatches } = await reconcileJournal(store);
|
|
2111
2116
|
assertJournalHealthy(malformed);
|
|
2112
|
-
|
|
2113
|
-
|
|
2117
|
+
const allowed = new Set(repairPaths);
|
|
2118
|
+
const fatal = mismatches.filter((mismatch) => !allowed.has(mismatch.path));
|
|
2119
|
+
if (fatal.length > 0) {
|
|
2120
|
+
const first = fatal[0];
|
|
2114
2121
|
throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `journal hash mismatch for ${first.path} (txid ${first.txid}); expected ${first.expected}, found ${first.actual ?? "missing"}`, "restore the file from backups/ or fix the external change before retrying");
|
|
2115
2122
|
}
|
|
2116
2123
|
}
|
|
@@ -2474,7 +2481,7 @@ var ProjectRegistry = class {
|
|
|
2474
2481
|
rows.push(row);
|
|
2475
2482
|
const next = {
|
|
2476
2483
|
...document,
|
|
2477
|
-
projects: rows.sort((a, b) => a.root
|
|
2484
|
+
projects: rows.sort((a, b) => compareText(a.root, b.root))
|
|
2478
2485
|
};
|
|
2479
2486
|
await this.write(next);
|
|
2480
2487
|
return next;
|
|
@@ -2548,18 +2555,20 @@ const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").r
|
|
|
2548
2555
|
function chapterHeading(chapter) {
|
|
2549
2556
|
return `第${chapter.num}章 ${chapter.title}`;
|
|
2550
2557
|
}
|
|
2558
|
+
/** Remove blank lines at the document edges, never manuscript indentation. */
|
|
2559
|
+
const trimBlankEdges = (text) => text.replace(/^\n+|\n+$/g, "");
|
|
2551
2560
|
/** Markdown export: one heading per chapter, body unchanged (it is plain text). */
|
|
2552
2561
|
function renderMarkdown(project, chapters) {
|
|
2553
2562
|
const parts = [`# ${project.title}`, ""];
|
|
2554
2563
|
if (project.author !== "") parts.push(`作者:${project.author}`, "");
|
|
2555
2564
|
if (project.description !== "") parts.push(`> ${project.description}`, "");
|
|
2556
|
-
for (const chapter of chapters) parts.push(`## ${chapterHeading(chapter)}`, "", chapter.text
|
|
2565
|
+
for (const chapter of chapters) parts.push(`## ${chapterHeading(chapter)}`, "", trimBlankEdges(chapter.text), "");
|
|
2557
2566
|
return parts.join("\n");
|
|
2558
2567
|
}
|
|
2559
2568
|
/** Plain-text export: no markdown syntax, no BOM. */
|
|
2560
2569
|
function renderPlainText(project, chapters) {
|
|
2561
2570
|
const parts = [project.title, ""];
|
|
2562
|
-
for (const chapter of chapters) parts.push(chapterHeading(chapter), "", chapter.text
|
|
2571
|
+
for (const chapter of chapters) parts.push(chapterHeading(chapter), "", trimBlankEdges(chapter.text), "");
|
|
2563
2572
|
return parts.join("\n");
|
|
2564
2573
|
}
|
|
2565
2574
|
/** Blurb built from the project record; deterministic, no model call. */
|
|
@@ -2588,9 +2597,10 @@ function renderCover(project) {
|
|
|
2588
2597
|
}
|
|
2589
2598
|
/** XHTML chapter document for the EPUB package. */
|
|
2590
2599
|
function renderEpubChapter(chapter) {
|
|
2591
|
-
const paragraphs = chapter.text.split(/\n+/).map((line) => line.
|
|
2600
|
+
const paragraphs = chapter.text.split(/\n+/).map((line) => line.replace(/\r$/, "")).filter((line) => line.trim() !== "").map((line) => `<p class="body">${escapeHtml(line)}</p>`).join("\n");
|
|
2592
2601
|
return `<?xml version="1.0" encoding="utf-8"?>
|
|
2593
|
-
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeHtml(chapterHeading(chapter))}</title
|
|
2602
|
+
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeHtml(chapterHeading(chapter))}</title>
|
|
2603
|
+
<style>.body { white-space: pre-wrap; margin: 0 0 0.6em; }</style></head>
|
|
2594
2604
|
<body><h1>${escapeHtml(chapterHeading(chapter))}</h1>${paragraphs}</body></html>`;
|
|
2595
2605
|
}
|
|
2596
2606
|
// src/export/epub.ts
|
|
@@ -2824,6 +2834,162 @@ var BriefLibrary = class {
|
|
|
2824
2834
|
}
|
|
2825
2835
|
};
|
|
2826
2836
|
const hash = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
2837
|
+
// src/engine/handoff.ts
|
|
2838
|
+
const ACT_DIGITS = [
|
|
2839
|
+
"零",
|
|
2840
|
+
"一",
|
|
2841
|
+
"二",
|
|
2842
|
+
"三",
|
|
2843
|
+
"四",
|
|
2844
|
+
"五",
|
|
2845
|
+
"六",
|
|
2846
|
+
"七",
|
|
2847
|
+
"八",
|
|
2848
|
+
"九"
|
|
2849
|
+
];
|
|
2850
|
+
/** Chinese numerals for the act labels the task books use. */
|
|
2851
|
+
const chineseNumber = (value) => {
|
|
2852
|
+
if (!Number.isInteger(value) || value < 0) return String(value);
|
|
2853
|
+
if (value < 10) return ACT_DIGITS[value];
|
|
2854
|
+
if (value === 10) return "十";
|
|
2855
|
+
if (value < 20) return `十${ACT_DIGITS[value - 10]}`;
|
|
2856
|
+
if (value < 100) {
|
|
2857
|
+
const tens = Math.floor(value / 10);
|
|
2858
|
+
const ones = value % 10;
|
|
2859
|
+
return `${ACT_DIGITS[tens]}十${ones === 0 ? "" : ACT_DIGITS[ones]}`;
|
|
2860
|
+
}
|
|
2861
|
+
return String(value);
|
|
2862
|
+
};
|
|
2863
|
+
const parseActNumber = (act) => {
|
|
2864
|
+
const match = /^第\s*([零一二三四五六七八九十百\d]+)\s*幕$/.exec(act.trim());
|
|
2865
|
+
if (match === null) return null;
|
|
2866
|
+
const raw = match[1];
|
|
2867
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
2868
|
+
if (raw === "十") return 10;
|
|
2869
|
+
const ten = raw.indexOf("十");
|
|
2870
|
+
if (ten !== -1) {
|
|
2871
|
+
const left = raw.slice(0, ten);
|
|
2872
|
+
const right = raw.slice(ten + 1);
|
|
2873
|
+
const tens = left === "" ? 1 : ACT_DIGITS.indexOf(left);
|
|
2874
|
+
const ones = right === "" ? 0 : ACT_DIGITS.indexOf(right);
|
|
2875
|
+
if (tens < 0 || ones < 0) return null;
|
|
2876
|
+
return tens * 10 + ones;
|
|
2877
|
+
}
|
|
2878
|
+
const index = ACT_DIGITS.indexOf(raw);
|
|
2879
|
+
return index < 0 ? null : index;
|
|
2880
|
+
};
|
|
2881
|
+
const nextAct = (act) => {
|
|
2882
|
+
const value = parseActNumber(act);
|
|
2883
|
+
return value === null ? act : `第${chineseNumber(value + 1)}幕`;
|
|
2884
|
+
};
|
|
2885
|
+
/** `n/d`, with a non-zero denominator; `0/0` is the initialized handoff. */
|
|
2886
|
+
const parseActProgress = (value) => {
|
|
2887
|
+
if (typeof value !== "string") return null;
|
|
2888
|
+
const match = /^(\d+)\/(\d+)$/.exec(value.trim());
|
|
2889
|
+
if (match === null) return null;
|
|
2890
|
+
const done = Number(match[1]);
|
|
2891
|
+
const total = Number(match[2]);
|
|
2892
|
+
if (!Number.isSafeInteger(done) || !Number.isSafeInteger(total)) return null;
|
|
2893
|
+
if (done < 0 || total < 0 || done > total) return null;
|
|
2894
|
+
return {
|
|
2895
|
+
done,
|
|
2896
|
+
total
|
|
2897
|
+
};
|
|
2898
|
+
};
|
|
2899
|
+
const firstPosition = (chapter) => {
|
|
2900
|
+
const block = Math.max(0, chapter - 1);
|
|
2901
|
+
return {
|
|
2902
|
+
volume: 1,
|
|
2903
|
+
volume_name: "第一卷",
|
|
2904
|
+
act: `第${chineseNumber(Math.floor(block / 5) + 1)}幕`,
|
|
2905
|
+
act_progress: `${block % 5 + 1}/5`
|
|
2906
|
+
};
|
|
2907
|
+
};
|
|
2908
|
+
const advanceProgress = (previous) => {
|
|
2909
|
+
const act = typeof previous.act === "string" && previous.act.trim() !== "" ? previous.act : "第一幕";
|
|
2910
|
+
const progress = parseActProgress(previous.act_progress);
|
|
2911
|
+
if (progress === null || progress.total === 0) return {
|
|
2912
|
+
act,
|
|
2913
|
+
act_progress: "1/5"
|
|
2914
|
+
};
|
|
2915
|
+
if (progress.done < progress.total) return {
|
|
2916
|
+
act,
|
|
2917
|
+
act_progress: `${progress.done + 1}/${progress.total}`
|
|
2918
|
+
};
|
|
2919
|
+
return {
|
|
2920
|
+
act: nextAct(act),
|
|
2921
|
+
act_progress: `1/${progress.total}`
|
|
2922
|
+
};
|
|
2923
|
+
};
|
|
2924
|
+
const volumeOf = (previous) => previous !== null && Number.isInteger(previous.volume) && previous.volume >= 1 ? previous.volume : 1;
|
|
2925
|
+
const volumeNameOf = (previous, volume) => previous !== null && typeof previous.volume_name === "string" && previous.volume_name.trim() !== "" ? previous.volume_name : `第${volume}卷`;
|
|
2926
|
+
/**
|
|
2927
|
+
* Advance from the stored handoff of the previous chapter to the next chapter.
|
|
2928
|
+
* The archive override is applied as a starting point: changing volume resets
|
|
2929
|
+
* the act, while changing only the act starts its first beat.
|
|
2930
|
+
*/
|
|
2931
|
+
const advanceHandoff = (previous, chapter, override) => {
|
|
2932
|
+
if (previous === null) {
|
|
2933
|
+
const position = firstPosition(chapter);
|
|
2934
|
+
const volume = override?.volume ?? position.volume;
|
|
2935
|
+
return {
|
|
2936
|
+
volume,
|
|
2937
|
+
volume_name: override?.volume_name ?? (volume === position.volume ? position.volume_name : `第${volume}卷`),
|
|
2938
|
+
act: override?.act ?? position.act,
|
|
2939
|
+
act_progress: override?.act_progress ?? (volume === position.volume ? position.act_progress : "1/5")
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
const previousVolume = volumeOf(previous);
|
|
2943
|
+
const volume = override?.volume ?? previousVolume;
|
|
2944
|
+
const volumeChanged = volume !== previousVolume;
|
|
2945
|
+
const base = volumeChanged ? {
|
|
2946
|
+
act: "第一幕",
|
|
2947
|
+
act_progress: "1/5"
|
|
2948
|
+
} : advanceProgress(previous);
|
|
2949
|
+
const act = override?.act ?? base.act;
|
|
2950
|
+
const actProgress = override?.act_progress ?? (override?.act !== void 0 && override.act !== previous.act ? "1/5" : base.act_progress);
|
|
2951
|
+
return {
|
|
2952
|
+
volume,
|
|
2953
|
+
volume_name: override?.volume_name ?? (volumeChanged ? `第${volume}卷` : volumeNameOf(previous, volume)),
|
|
2954
|
+
act,
|
|
2955
|
+
act_progress: actProgress
|
|
2956
|
+
};
|
|
2957
|
+
};
|
|
2958
|
+
/** Project the stored handoff to the active chapter without changing the file. */
|
|
2959
|
+
const projectHandoff = (previous, chapter) => {
|
|
2960
|
+
if (previous === null) return firstPosition(chapter);
|
|
2961
|
+
const current = Number.isInteger(previous.current_chapter) ? previous.current_chapter : chapter - 1;
|
|
2962
|
+
if (current >= chapter) {
|
|
2963
|
+
const volume = volumeOf(previous);
|
|
2964
|
+
return {
|
|
2965
|
+
volume,
|
|
2966
|
+
volume_name: volumeNameOf(previous, volume),
|
|
2967
|
+
act: previous.act,
|
|
2968
|
+
act_progress: previous.act_progress
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
let position = {
|
|
2972
|
+
volume: volumeOf(previous),
|
|
2973
|
+
volume_name: volumeNameOf(previous, volumeOf(previous)),
|
|
2974
|
+
act: previous.act,
|
|
2975
|
+
act_progress: previous.act_progress
|
|
2976
|
+
};
|
|
2977
|
+
for (let step = current; step < chapter; step += 1) {
|
|
2978
|
+
const fake = {
|
|
2979
|
+
...previous,
|
|
2980
|
+
volume: position.volume,
|
|
2981
|
+
volume_name: position.volume_name,
|
|
2982
|
+
act: position.act,
|
|
2983
|
+
act_progress: position.act_progress
|
|
2984
|
+
};
|
|
2985
|
+
position = {
|
|
2986
|
+
volume: position.volume,
|
|
2987
|
+
volume_name: position.volume_name,
|
|
2988
|
+
...advanceProgress(fake)
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
return position;
|
|
2992
|
+
};
|
|
2827
2993
|
// src/engine/context.ts
|
|
2828
2994
|
const compactSummary = (row) => ({
|
|
2829
2995
|
num: row.num,
|
|
@@ -2841,13 +3007,113 @@ const sceneDistribution = (chapters) => {
|
|
|
2841
3007
|
return counts;
|
|
2842
3008
|
};
|
|
2843
3009
|
const pendingForeshadows = (ledger) => ledger.items.filter((item) => item.status !== "resolved");
|
|
3010
|
+
const unique = (values) => {
|
|
3011
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3012
|
+
const result = [];
|
|
3013
|
+
for (const value of values) {
|
|
3014
|
+
if (value === "" || seen.has(value)) continue;
|
|
3015
|
+
seen.add(value);
|
|
3016
|
+
result.push(value);
|
|
3017
|
+
}
|
|
3018
|
+
return result;
|
|
3019
|
+
};
|
|
3020
|
+
/** Match known names at occurrence level, longest first, without substring theft. */
|
|
3021
|
+
const mentionedNames = (text, characters) => {
|
|
3022
|
+
let remaining = text;
|
|
3023
|
+
const names = [...new Set(characters.characters.map((character) => character.name))].filter((name) => name !== "").sort((left, right) => {
|
|
3024
|
+
const length = Array.from(right).length - Array.from(left).length;
|
|
3025
|
+
return length !== 0 ? length : compareText(left, right);
|
|
3026
|
+
});
|
|
3027
|
+
const matched = [];
|
|
3028
|
+
for (const name of names) {
|
|
3029
|
+
if (!remaining.includes(name)) continue;
|
|
3030
|
+
matched.push(name);
|
|
3031
|
+
remaining = remaining.split(name).join("\0");
|
|
3032
|
+
}
|
|
3033
|
+
return matched;
|
|
3034
|
+
};
|
|
3035
|
+
/** The names the current chapter is actually about. */
|
|
3036
|
+
const activeCastNames = (plan, characters, text, appearances) => {
|
|
3037
|
+
const explicit = unique([...(plan?.cast ?? []).map((member) => member.name), ...plan?.summary?.characters_appeared ?? []]);
|
|
3038
|
+
const narrator = (plan?.narrator ?? "").split(/[((]/, 1)[0].trim();
|
|
3039
|
+
const narratorKnown = narrator !== "" && characters.characters.some((character) => character.name === narrator);
|
|
3040
|
+
const planText = [
|
|
3041
|
+
plan?.title ?? "",
|
|
3042
|
+
plan?.narrator ?? "",
|
|
3043
|
+
plan?.opening ?? "",
|
|
3044
|
+
plan?.ending ?? "",
|
|
3045
|
+
...plan?.beats ?? [],
|
|
3046
|
+
text ?? ""
|
|
3047
|
+
].join("\n");
|
|
3048
|
+
const byName = new Map(characters.characters.map((character) => [character.name, character]));
|
|
3049
|
+
const orderedMentioned = mentionedNames(planText, characters).map((name) => byName.get(name)).sort((left, right) => {
|
|
3050
|
+
const byRecency = recencyOf(right, appearances) - recencyOf(left, appearances);
|
|
3051
|
+
return byRecency !== 0 ? byRecency : compareText(left.name, right.name);
|
|
3052
|
+
}).map((character) => character.name);
|
|
3053
|
+
if (explicit.length > 0) return unique([...explicit, ...orderedMentioned]).slice(0, LIMITS.maxActiveCast);
|
|
3054
|
+
return unique([...narratorKnown ? [narrator] : [], ...orderedMentioned]).slice(0, 8);
|
|
3055
|
+
};
|
|
3056
|
+
const recencyOf = (character, appearances) => Math.max(Number(character.updated_at ?? 0), appearances.get(character.name) ?? 0);
|
|
3057
|
+
const appearancesByChapter = (chapters) => {
|
|
3058
|
+
const result = /* @__PURE__ */ new Map();
|
|
3059
|
+
for (const row of chapters.chapters) for (const name of row.summary.characters_appeared) result.set(name, Math.max(result.get(name) ?? 0, row.num));
|
|
3060
|
+
return result;
|
|
3061
|
+
};
|
|
3062
|
+
const compactCharacter = (character, appearances) => ({
|
|
3063
|
+
name: character.name,
|
|
3064
|
+
role: character.role,
|
|
3065
|
+
first_appearance: character.first_appearance,
|
|
3066
|
+
last_seen: recencyOf(character, appearances)
|
|
3067
|
+
});
|
|
3068
|
+
const castCard = (name, character, member, declared) => {
|
|
3069
|
+
return {
|
|
3070
|
+
...character ?? declared ?? { name },
|
|
3071
|
+
name,
|
|
3072
|
+
...member === void 0 ? {} : {
|
|
3073
|
+
entry_state: member.entry_state,
|
|
3074
|
+
goal: member.goal,
|
|
3075
|
+
knowledge: member.knowledge,
|
|
3076
|
+
exit_change: member.exit_change
|
|
3077
|
+
}
|
|
3078
|
+
};
|
|
3079
|
+
};
|
|
3080
|
+
const worldPlanBrief = (plan) => plan === null ? null : {
|
|
3081
|
+
chapter: plan.chapter,
|
|
3082
|
+
objective: plan.objective,
|
|
3083
|
+
constraints: plan.constraints,
|
|
3084
|
+
characters: plan.characters,
|
|
3085
|
+
locations: plan.locations,
|
|
3086
|
+
continuity_notes: plan.continuity_notes
|
|
3087
|
+
};
|
|
3088
|
+
const filterLocks = (locks, cast, locations) => {
|
|
3089
|
+
const characterNames = new Set(cast);
|
|
3090
|
+
const locationNames = new Set(locations);
|
|
3091
|
+
return {
|
|
3092
|
+
...locks,
|
|
3093
|
+
characters: characterNames.size === 0 ? locks.characters : Object.fromEntries(Object.entries(locks.characters).filter(([name]) => characterNames.has(name))),
|
|
3094
|
+
locations: locationNames.size === 0 ? locks.locations : Object.fromEntries(Object.entries(locks.locations).filter(([name]) => locationNames.has(name)))
|
|
3095
|
+
};
|
|
3096
|
+
};
|
|
2844
3097
|
/**
|
|
2845
3098
|
* Build the stage-specific context pack. The task book explains the rules; this
|
|
2846
3099
|
* pack is the minimum project state the model needs to apply them without
|
|
2847
3100
|
* guessing who exists, what was locked, or what the previous chapter ended on.
|
|
2848
3101
|
*/
|
|
2849
|
-
function
|
|
3102
|
+
function unbudgetedBriefContext(input) {
|
|
2850
3103
|
const previousEnding = input.previousText === null ? "" : input.previousText.trim().slice(-400);
|
|
3104
|
+
const appearances = appearancesByChapter(input.chapters);
|
|
3105
|
+
const activeNames = activeCastNames(input.plan, input.characters, input.text, appearances);
|
|
3106
|
+
const activeSet = new Set(activeNames);
|
|
3107
|
+
const planCast = new Map((input.plan?.cast ?? []).map((member) => [member.name, member]));
|
|
3108
|
+
const byName = new Map(input.characters.characters.map((character) => [character.name, character]));
|
|
3109
|
+
const worldPlanByName = new Map((input.worldPlan?.characters ?? []).map((character) => [character.name, character]));
|
|
3110
|
+
const activeCast = activeNames.map((name) => castCard(name, byName.get(name), planCast.get(name), worldPlanByName.get(name)));
|
|
3111
|
+
const candidateNames = new Set(input.stage === "P2" ? (input.worldPlan?.characters ?? []).map((character) => character.name) : []);
|
|
3112
|
+
const otherCharacters = input.characters.characters.filter((character) => !activeSet.has(character.name) && !candidateNames.has(character.name)).sort((left, right) => {
|
|
3113
|
+
const byRecency = recencyOf(right, appearances) - recencyOf(left, appearances);
|
|
3114
|
+
return byRecency !== 0 ? byRecency : compareText(left.name, right.name);
|
|
3115
|
+
}).map((character) => compactCharacter(character, appearances));
|
|
3116
|
+
const worldPlan = worldPlanBrief(input.worldPlan ?? null);
|
|
2851
3117
|
const base = {
|
|
2852
3118
|
project: {
|
|
2853
3119
|
title: input.project.title,
|
|
@@ -2859,26 +3125,38 @@ function briefContext(input) {
|
|
|
2859
3125
|
stage: input.pipeline.stage,
|
|
2860
3126
|
scene_distribution: sceneDistribution(input.chapters),
|
|
2861
3127
|
previous_ending: previousEnding,
|
|
2862
|
-
...input.
|
|
3128
|
+
...input.world_plan_error === void 0 || input.world_plan_error === null ? {} : { world_plan_error: input.world_plan_error },
|
|
3129
|
+
...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error },
|
|
3130
|
+
...input.review_error === void 0 || input.review_error === null ? {} : { review_error: input.review_error },
|
|
3131
|
+
...input.check_error === void 0 || input.check_error === null ? {} : { check_error: input.check_error }
|
|
2863
3132
|
};
|
|
2864
3133
|
switch (input.stage) {
|
|
2865
|
-
case "P1":
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
3134
|
+
case "P1": {
|
|
3135
|
+
const position = input.handoff === null ? null : projectHandoff(input.handoff, input.chapter);
|
|
3136
|
+
return {
|
|
3137
|
+
...base,
|
|
3138
|
+
recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary),
|
|
3139
|
+
open_foreshadows: pendingForeshadows(input.foreshadows),
|
|
3140
|
+
volume: position?.volume ?? 1,
|
|
3141
|
+
volume_name: position?.volume_name ?? "",
|
|
3142
|
+
act: position?.act ?? "",
|
|
3143
|
+
act_progress: position?.act_progress ?? ""
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
2873
3146
|
case "P2": return {
|
|
2874
3147
|
...base,
|
|
3148
|
+
world_plan: worldPlan,
|
|
3149
|
+
characters: [...input.worldPlan?.characters ?? []],
|
|
3150
|
+
other_characters: otherCharacters,
|
|
2875
3151
|
plan: input.plan,
|
|
2876
3152
|
open_foreshadows: pendingForeshadows(input.foreshadows)
|
|
2877
3153
|
};
|
|
2878
3154
|
case "P3": return {
|
|
2879
3155
|
...base,
|
|
2880
|
-
|
|
2881
|
-
|
|
3156
|
+
world_plan: worldPlan,
|
|
3157
|
+
characters: activeCast,
|
|
3158
|
+
other_characters: otherCharacters,
|
|
3159
|
+
locks: filterLocks(input.locks, activeNames, (input.worldPlan?.locations ?? []).map((location) => location.name)),
|
|
2882
3160
|
precheck: input.plan,
|
|
2883
3161
|
open_foreshadows: pendingForeshadows(input.foreshadows),
|
|
2884
3162
|
recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary)
|
|
@@ -2887,8 +3165,11 @@ function briefContext(input) {
|
|
|
2887
3165
|
...base,
|
|
2888
3166
|
text: input.text ?? "",
|
|
2889
3167
|
summary: input.plan?.summary ?? null,
|
|
3168
|
+
plan: input.plan,
|
|
2890
3169
|
machine: input.machine ?? null,
|
|
2891
|
-
|
|
3170
|
+
world_plan: worldPlan,
|
|
3171
|
+
characters: activeCast,
|
|
3172
|
+
other_characters: otherCharacters,
|
|
2892
3173
|
check: input.check,
|
|
2893
3174
|
score_history: input.chapters.chapters.slice(-5).map((row) => ({
|
|
2894
3175
|
num: row.num,
|
|
@@ -2898,15 +3179,23 @@ function briefContext(input) {
|
|
|
2898
3179
|
case "P5": return {
|
|
2899
3180
|
...base,
|
|
2900
3181
|
text: input.text ?? "",
|
|
3182
|
+
plan: input.plan,
|
|
2901
3183
|
machine: input.machine ?? null,
|
|
2902
3184
|
review: input.review,
|
|
2903
|
-
issues: input.review?.issues ?? []
|
|
3185
|
+
issues: input.review?.issues ?? [],
|
|
3186
|
+
world_plan: worldPlan,
|
|
3187
|
+
characters: activeCast,
|
|
3188
|
+
other_characters: otherCharacters
|
|
2904
3189
|
};
|
|
2905
3190
|
case "P6": return {
|
|
2906
3191
|
...base,
|
|
2907
3192
|
text: input.text ?? "",
|
|
3193
|
+
plan: input.plan,
|
|
2908
3194
|
machine: input.machine ?? null,
|
|
3195
|
+
world_plan: worldPlan,
|
|
2909
3196
|
characters: input.characters.characters,
|
|
3197
|
+
active_cast: activeCast,
|
|
3198
|
+
other_characters: otherCharacters,
|
|
2910
3199
|
world: input.world,
|
|
2911
3200
|
locks: input.locks,
|
|
2912
3201
|
foreshadows: input.foreshadows,
|
|
@@ -2922,6 +3211,7 @@ function briefContext(input) {
|
|
|
2922
3211
|
};
|
|
2923
3212
|
case "P8": return {
|
|
2924
3213
|
...base,
|
|
3214
|
+
plan: input.plan,
|
|
2925
3215
|
check: input.check,
|
|
2926
3216
|
review: input.review,
|
|
2927
3217
|
summary: input.plan?.summary ?? null,
|
|
@@ -2931,6 +3221,264 @@ function briefContext(input) {
|
|
|
2931
3221
|
default: return base;
|
|
2932
3222
|
}
|
|
2933
3223
|
}
|
|
3224
|
+
/** Per-stage context budgets, measured in JSON characters (metadata excluded). */
|
|
3225
|
+
const CONTEXT_BUDGETS = {
|
|
3226
|
+
P1: 6e3,
|
|
3227
|
+
P2: 8e3,
|
|
3228
|
+
P3: 16e3,
|
|
3229
|
+
P4: 24e3,
|
|
3230
|
+
P5: 24e3,
|
|
3231
|
+
P6: 32e3,
|
|
3232
|
+
P8: 12e3
|
|
3233
|
+
};
|
|
3234
|
+
const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
3235
|
+
const asArray = (value) => Array.isArray(value) ? value : null;
|
|
3236
|
+
const jsonSize = (value) => JSON.stringify(value).length;
|
|
3237
|
+
const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(value) ? 1 : 0;
|
|
3238
|
+
/**
|
|
3239
|
+
* Apply the stage budget after the stage-specific pack exists. Protected keys
|
|
3240
|
+
* are never candidates; optional ledgers are trimmed round-robin so one large
|
|
3241
|
+
* list cannot starve every other kind of context.
|
|
3242
|
+
*/
|
|
3243
|
+
function withContextBudget(stage, raw) {
|
|
3244
|
+
const context = structuredClone(raw);
|
|
3245
|
+
const budget = CONTEXT_BUDGETS[stage] ?? 8e3;
|
|
3246
|
+
const smallP2Candidates = stage === "P2" && Array.isArray(context.characters) && context.characters.length <= 32;
|
|
3247
|
+
const protectedKeys = [
|
|
3248
|
+
"project",
|
|
3249
|
+
"chapter",
|
|
3250
|
+
"stage",
|
|
3251
|
+
"previous_ending",
|
|
3252
|
+
"text",
|
|
3253
|
+
"plan",
|
|
3254
|
+
"precheck",
|
|
3255
|
+
"review",
|
|
3256
|
+
"check",
|
|
3257
|
+
"machine",
|
|
3258
|
+
"summary",
|
|
3259
|
+
"world_plan",
|
|
3260
|
+
"active_cast",
|
|
3261
|
+
...stage === "P3" || stage === "P4" || stage === "P5" || smallP2Candidates ? ["characters"] : []
|
|
3262
|
+
];
|
|
3263
|
+
const mentioned = [
|
|
3264
|
+
typeof context.text === "string" ? context.text : "",
|
|
3265
|
+
stableJson(context.plan ?? ""),
|
|
3266
|
+
stableJson(context.review ?? ""),
|
|
3267
|
+
stableJson(context.check ?? ""),
|
|
3268
|
+
stableJson(context.summary ?? ""),
|
|
3269
|
+
stableJson(context.world_plan ?? "")
|
|
3270
|
+
].join("\n");
|
|
3271
|
+
const omitted = {};
|
|
3272
|
+
const truncated = /* @__PURE__ */ new Set();
|
|
3273
|
+
const candidates = [];
|
|
3274
|
+
const stableKey = (value) => {
|
|
3275
|
+
const row = asRecord(value);
|
|
3276
|
+
if (row === null) return stableJson(value);
|
|
3277
|
+
for (const key of [
|
|
3278
|
+
"id",
|
|
3279
|
+
"name",
|
|
3280
|
+
"title"
|
|
3281
|
+
]) {
|
|
3282
|
+
const item = row[key];
|
|
3283
|
+
if (typeof item === "string" && item !== "") return item;
|
|
3284
|
+
}
|
|
3285
|
+
return stableJson(row);
|
|
3286
|
+
};
|
|
3287
|
+
const addArray = (key, array, rank, tie = stableKey) => {
|
|
3288
|
+
if (protectedKeys.includes(key)) return;
|
|
3289
|
+
let prepared = false;
|
|
3290
|
+
const sort = () => {
|
|
3291
|
+
if (prepared) return;
|
|
3292
|
+
prepared = true;
|
|
3293
|
+
array()?.sort((left, right) => {
|
|
3294
|
+
const byRank = rank(right) - rank(left);
|
|
3295
|
+
return byRank !== 0 ? byRank : compareText(tie(left), tie(right));
|
|
3296
|
+
});
|
|
3297
|
+
};
|
|
3298
|
+
candidates.push({
|
|
3299
|
+
key,
|
|
3300
|
+
count: () => array()?.length ?? 0,
|
|
3301
|
+
trim: () => {
|
|
3302
|
+
sort();
|
|
3303
|
+
const list = array();
|
|
3304
|
+
if (list === null || list.length === 0) return false;
|
|
3305
|
+
list.pop();
|
|
3306
|
+
return true;
|
|
3307
|
+
},
|
|
3308
|
+
prepare: sort
|
|
3309
|
+
});
|
|
3310
|
+
};
|
|
3311
|
+
const addObject = (key, map, rank) => {
|
|
3312
|
+
if (protectedKeys.includes(key)) return;
|
|
3313
|
+
let keys = null;
|
|
3314
|
+
const ensure = () => {
|
|
3315
|
+
const target = map();
|
|
3316
|
+
keys ??= target === null ? [] : Object.keys(target);
|
|
3317
|
+
keys.sort((left, right) => {
|
|
3318
|
+
const byRank = rank(right, map()?.[right]) - rank(left, map()?.[left]);
|
|
3319
|
+
return byRank !== 0 ? byRank : compareText(left, right);
|
|
3320
|
+
});
|
|
3321
|
+
return keys;
|
|
3322
|
+
};
|
|
3323
|
+
candidates.push({
|
|
3324
|
+
key,
|
|
3325
|
+
count: () => Object.keys(map() ?? {}).length,
|
|
3326
|
+
trim: () => {
|
|
3327
|
+
const target = map();
|
|
3328
|
+
if (target === null) return false;
|
|
3329
|
+
const keyToDelete = ensure().pop();
|
|
3330
|
+
if (keyToDelete === void 0 || !Object.hasOwn(target, keyToDelete)) return false;
|
|
3331
|
+
delete target[keyToDelete];
|
|
3332
|
+
return true;
|
|
3333
|
+
},
|
|
3334
|
+
prepare: ensure
|
|
3335
|
+
});
|
|
3336
|
+
};
|
|
3337
|
+
const chapter = (value) => asRecord(value)?.num ?? 0;
|
|
3338
|
+
const activeNames = new Set(Array.isArray(context.active_cast) ? context.active_cast.map((value) => asRecord(value)?.name ?? "").filter((name) => name !== "") : []);
|
|
3339
|
+
const recency = (row) => Math.max(Number(row.updated_at ?? 0), Number(row.last_seen ?? 0));
|
|
3340
|
+
addArray("characters", () => asArray(context.characters), (value) => {
|
|
3341
|
+
const row = asRecord(value);
|
|
3342
|
+
const name = typeof row?.name === "string" ? row.name : "";
|
|
3343
|
+
return (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e5 + recency(row ?? {});
|
|
3344
|
+
}, (value) => {
|
|
3345
|
+
const row = asRecord(value);
|
|
3346
|
+
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3347
|
+
});
|
|
3348
|
+
addArray("other_characters", () => asArray(context.other_characters), (value) => {
|
|
3349
|
+
const row = asRecord(value);
|
|
3350
|
+
return Number(row?.last_seen ?? 0);
|
|
3351
|
+
}, (value) => {
|
|
3352
|
+
const row = asRecord(value);
|
|
3353
|
+
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3354
|
+
});
|
|
3355
|
+
const worldPlan = asRecord(context.world_plan);
|
|
3356
|
+
addArray("world_plan.characters", () => asArray(worldPlan?.characters), (value) => {
|
|
3357
|
+
const row = asRecord(value);
|
|
3358
|
+
const name = typeof row?.name === "string" ? row.name : "";
|
|
3359
|
+
return (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e5;
|
|
3360
|
+
}, (value) => {
|
|
3361
|
+
const row = asRecord(value);
|
|
3362
|
+
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3363
|
+
});
|
|
3364
|
+
addArray("world_plan.locations", () => asArray(worldPlan?.locations), (value) => {
|
|
3365
|
+
const row = asRecord(value);
|
|
3366
|
+
const name = typeof row?.name === "string" ? row.name : "";
|
|
3367
|
+
return mentionScore(mentioned, name) * 1e3;
|
|
3368
|
+
}, (value) => {
|
|
3369
|
+
const row = asRecord(value);
|
|
3370
|
+
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3371
|
+
});
|
|
3372
|
+
const world = asRecord(context.world);
|
|
3373
|
+
for (const key of [
|
|
3374
|
+
"locations",
|
|
3375
|
+
"abilities",
|
|
3376
|
+
"artifacts",
|
|
3377
|
+
"factions",
|
|
3378
|
+
"rules"
|
|
3379
|
+
]) addArray(`world.${key}`, () => asArray(world?.[key]), (value) => {
|
|
3380
|
+
const row = asRecord(value);
|
|
3381
|
+
const name = typeof row?.name === "string" ? row.name : "";
|
|
3382
|
+
return mentionScore(mentioned, name) * 1e3 + Number(row?.first_appearance ?? 0);
|
|
3383
|
+
}, (value) => {
|
|
3384
|
+
const row = asRecord(value);
|
|
3385
|
+
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3386
|
+
});
|
|
3387
|
+
const locks = asRecord(context.locks);
|
|
3388
|
+
for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e3);
|
|
3389
|
+
addArray("open_foreshadows", () => asArray(context.open_foreshadows), (value) => {
|
|
3390
|
+
const row = asRecord(value);
|
|
3391
|
+
const title = typeof row?.title === "string" ? row.title : "";
|
|
3392
|
+
return mentionScore(mentioned, title) * 1e3 - Number(row?.last_touched ?? 0);
|
|
3393
|
+
}, (value) => {
|
|
3394
|
+
const row = asRecord(value);
|
|
3395
|
+
return `${typeof row?.title === "string" ? row.title : ""}\u0000${typeof row?.id === "string" ? row.id : ""}`;
|
|
3396
|
+
});
|
|
3397
|
+
const foreshadows = asRecord(context.foreshadows);
|
|
3398
|
+
addArray("foreshadows", () => asArray(foreshadows?.items), (value) => {
|
|
3399
|
+
const row = asRecord(value);
|
|
3400
|
+
const title = typeof row?.title === "string" ? row.title : "";
|
|
3401
|
+
const id = typeof row?.id === "string" ? row.id : "";
|
|
3402
|
+
return Math.max(mentionScore(mentioned, title), mentionScore(mentioned, id)) * 1e3 - Number(row?.last_touched ?? 0);
|
|
3403
|
+
}, (value) => {
|
|
3404
|
+
const row = asRecord(value);
|
|
3405
|
+
return `${typeof row?.id === "string" ? row.id : ""}\u0000${typeof row?.title === "string" ? row.title : ""}`;
|
|
3406
|
+
});
|
|
3407
|
+
addArray("timeline", () => asArray(context.timeline), (value) => {
|
|
3408
|
+
const row = asRecord(value);
|
|
3409
|
+
const text = `${String(row?.description ?? "")}${String(row?.location ?? "")}`;
|
|
3410
|
+
return mentionScore(mentioned, text) * 1e3 + Number(row?.chapter ?? 0);
|
|
3411
|
+
}, (value) => {
|
|
3412
|
+
const row = asRecord(value);
|
|
3413
|
+
return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.chapter ?? "")}`;
|
|
3414
|
+
});
|
|
3415
|
+
const relationships = asRecord(context.relationships);
|
|
3416
|
+
addArray("relationships", () => asArray(relationships?.items), (value) => {
|
|
3417
|
+
const row = asRecord(value);
|
|
3418
|
+
const text = `${String(row?.a ?? "")}${String(row?.b ?? "")}`;
|
|
3419
|
+
return mentionScore(mentioned, text) * 1e3 + Number(row?.since ?? 0);
|
|
3420
|
+
}, (value) => {
|
|
3421
|
+
const row = asRecord(value);
|
|
3422
|
+
return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.a ?? "")}\u0000${String(row?.b ?? "")}`;
|
|
3423
|
+
});
|
|
3424
|
+
addArray("recent_summaries", () => asArray(context.recent_summaries), (value) => chapter(value), (value) => String(chapter(value)).padStart(8, "0"));
|
|
3425
|
+
addArray("score_history", () => asArray(context.score_history), (value) => Number(asRecord(value)?.num ?? 0), (value) => String(Number(asRecord(value)?.num ?? 0)).padStart(8, "0"));
|
|
3426
|
+
for (const candidate of candidates) candidate.prepare();
|
|
3427
|
+
const used = () => jsonSize(context);
|
|
3428
|
+
const buildMeta = (finalUsed) => ({
|
|
3429
|
+
version: 1,
|
|
3430
|
+
stage,
|
|
3431
|
+
budget,
|
|
3432
|
+
used: finalUsed,
|
|
3433
|
+
over_budget: false,
|
|
3434
|
+
protected: protectedKeys,
|
|
3435
|
+
truncated: [...truncated].sort(compareText),
|
|
3436
|
+
omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => compareText(left, right))),
|
|
3437
|
+
strategy: "stage-v2"
|
|
3438
|
+
});
|
|
3439
|
+
const totalWith = (finalUsed, meta) => {
|
|
3440
|
+
const metaSize = jsonSize({
|
|
3441
|
+
...meta,
|
|
3442
|
+
used: finalUsed
|
|
3443
|
+
});
|
|
3444
|
+
return finalUsed + 1 + 15 + metaSize;
|
|
3445
|
+
};
|
|
3446
|
+
let finalUsed = used();
|
|
3447
|
+
let metaBase = buildMeta(finalUsed);
|
|
3448
|
+
while (totalWith(finalUsed, metaBase) > budget) {
|
|
3449
|
+
let changed = false;
|
|
3450
|
+
for (const candidate of candidates) {
|
|
3451
|
+
if (totalWith(finalUsed, metaBase) <= budget) break;
|
|
3452
|
+
if (candidate.count() === 0) continue;
|
|
3453
|
+
candidate.prepare();
|
|
3454
|
+
if (candidate.trim()) {
|
|
3455
|
+
omitted[candidate.key] = (omitted[candidate.key] ?? 0) + 1;
|
|
3456
|
+
truncated.add(candidate.key);
|
|
3457
|
+
changed = true;
|
|
3458
|
+
finalUsed = used();
|
|
3459
|
+
metaBase = buildMeta(finalUsed);
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
if (!changed) break;
|
|
3463
|
+
}
|
|
3464
|
+
const overBudget = totalWith(finalUsed, metaBase) > budget;
|
|
3465
|
+
const meta = {
|
|
3466
|
+
...metaBase,
|
|
3467
|
+
over_budget: overBudget
|
|
3468
|
+
};
|
|
3469
|
+
return {
|
|
3470
|
+
...context,
|
|
3471
|
+
context_meta: meta
|
|
3472
|
+
};
|
|
3473
|
+
}
|
|
3474
|
+
/**
|
|
3475
|
+
* Build the stage-specific context pack plus the deterministic budget report.
|
|
3476
|
+
* The budget report is part of the brief digest; a truncated pack cannot be
|
|
3477
|
+
* silently mistaken for the full ledger set.
|
|
3478
|
+
*/
|
|
3479
|
+
function briefContext(input) {
|
|
3480
|
+
return withContextBudget(input.stage, unbudgetedBriefContext(input));
|
|
3481
|
+
}
|
|
2934
3482
|
// src/engine/state-delta.ts
|
|
2935
3483
|
const SECTIONS = [
|
|
2936
3484
|
"characters",
|
|
@@ -3545,6 +4093,7 @@ const wordCount = (input) => {
|
|
|
3545
4093
|
};
|
|
3546
4094
|
/** PG-04: format blockers must be zero before length is judged. */
|
|
3547
4095
|
const format = (input, options) => {
|
|
4096
|
+
if (input.scope !== void 0 && !input.scope.includes("content")) return [];
|
|
3548
4097
|
const violations = runRules([
|
|
3549
4098
|
"MD_CONTAMINATION",
|
|
3550
4099
|
"ILLEGAL_QUOTE",
|
|
@@ -3626,7 +4175,7 @@ const summarySchema = (input) => {
|
|
|
3626
4175
|
else {
|
|
3627
4176
|
const opening = summary.opening;
|
|
3628
4177
|
if (Array.from(opening).length < LIMITS.minQuoteChars) problems.push(`opening must be at least ${LIMITS.minQuoteChars} characters`);
|
|
3629
|
-
if (!
|
|
4178
|
+
if (!openingInFirstTwoParagraphs(input.text, opening)) problems.push("opening must be quoted from the first two paragraphs");
|
|
3630
4179
|
}
|
|
3631
4180
|
const continuity = summary.continuity;
|
|
3632
4181
|
if (typeof continuity !== "object" || continuity === null) problems.push("continuity must be an object");
|
|
@@ -3717,8 +4266,8 @@ function verifyReference(text, reference) {
|
|
|
3717
4266
|
if (typeof reference !== "object" || reference === null) return "reading proof reference is not an object";
|
|
3718
4267
|
if (typeof reference.quote !== "string") return "reading proof quote is not text";
|
|
3719
4268
|
if (!Number.isInteger(reference.paragraph) || reference.paragraph < 1) return "reading proof paragraph must be a positive integer";
|
|
3720
|
-
const quote = reference.quote;
|
|
3721
|
-
if (Array.from(quote
|
|
4269
|
+
const quote = reference.quote.trim();
|
|
4270
|
+
if (Array.from(quote).length < LIMITS.minQuoteChars) return `quote "${reference.quote}" is shorter than ${LIMITS.minQuoteChars} characters`;
|
|
3722
4271
|
const actual = splitParagraphs(text).findIndex((paragraph) => paragraph.includes(quote));
|
|
3723
4272
|
if (actual === -1) return `quote "${quote}" does not appear in the text`;
|
|
3724
4273
|
if (Math.abs(actual + 1 - reference.paragraph) > 1) return `quote "${quote}" is at paragraph ${actual + 1}, not ${reference.paragraph}`;
|
|
@@ -3757,6 +4306,7 @@ const readingProof = (input) => {
|
|
|
3757
4306
|
};
|
|
3758
4307
|
/** PG-10: cross-chapter reconciliation must be clean. */
|
|
3759
4308
|
const consistency = (input, options) => {
|
|
4309
|
+
if (input.scope !== void 0 && !input.scope.includes("integrity")) return [];
|
|
3760
4310
|
const violations = runRules([
|
|
3761
4311
|
"DUPLICATE_PARAGRAPH",
|
|
3762
4312
|
"FORESHADOW_DORMANT",
|
|
@@ -3803,7 +4353,8 @@ const archiveAtomic = (input) => {
|
|
|
3803
4353
|
if (input.archive.chapterExists) problems.push("this chapter is already in the index");
|
|
3804
4354
|
if (!input.archive.checkPassed) problems.push("the check document has not passed");
|
|
3805
4355
|
if (!input.archive.hashMatches) problems.push("the manuscript changed since the check was written");
|
|
3806
|
-
|
|
4356
|
+
if (input.check === null || !Array.isArray(input.check.scope) || !input.check.scope.includes("content") || !input.check.scope.includes("integrity")) problems.push("the check document did not cover content+integrity");
|
|
4357
|
+
return problems.length === 0 ? [] : [block("PG-12", problems.join("; "), "call novel_rollback to P6, then re-run novel_check on the current manuscript")];
|
|
3807
4358
|
};
|
|
3808
4359
|
/** Fixed order; evaluation collects every failure instead of stopping at the first. */
|
|
3809
4360
|
const GATE_FUNCTIONS = {
|
|
@@ -3857,7 +4408,8 @@ function applicableGates(input) {
|
|
|
3857
4408
|
"PG-03",
|
|
3858
4409
|
"PG-05",
|
|
3859
4410
|
"PG-09",
|
|
3860
|
-
"PG-08"
|
|
4411
|
+
"PG-08",
|
|
4412
|
+
"PG-11"
|
|
3861
4413
|
];
|
|
3862
4414
|
case "submitReview": return [
|
|
3863
4415
|
...always,
|
|
@@ -3868,7 +4420,10 @@ function applicableGates(input) {
|
|
|
3868
4420
|
case "runCheck": return [
|
|
3869
4421
|
...always,
|
|
3870
4422
|
"PG-04",
|
|
4423
|
+
"PG-03",
|
|
3871
4424
|
"PG-05",
|
|
4425
|
+
"PG-06",
|
|
4426
|
+
"PG-07",
|
|
3872
4427
|
"PG-08",
|
|
3873
4428
|
"PG-09",
|
|
3874
4429
|
"PG-10",
|
|
@@ -3915,7 +4470,7 @@ const STAGE_TO_PHASE = {
|
|
|
3915
4470
|
P4: "P4",
|
|
3916
4471
|
P5: "P5",
|
|
3917
4472
|
P6: "P6",
|
|
3918
|
-
ready_to_archive: "
|
|
4473
|
+
ready_to_archive: "P7",
|
|
3919
4474
|
done: "P9"
|
|
3920
4475
|
};
|
|
3921
4476
|
/** The nine-phase progress strip: phases before the active one are done. */
|
|
@@ -3961,6 +4516,11 @@ function projectSummary(snapshot) {
|
|
|
3961
4516
|
revision: snapshot.revision,
|
|
3962
4517
|
totalWords: snapshot.totalWords,
|
|
3963
4518
|
totalChapters: snapshot.chapters.length,
|
|
4519
|
+
averageScore: snapshot.averageScore ?? (snapshot.chapters.length === 0 ? 0 : snapshot.chapters.reduce((sum, chapter) => sum + chapter.score, 0) / snapshot.chapters.length),
|
|
4520
|
+
...snapshot.volume === void 0 ? {} : { volume: snapshot.volume },
|
|
4521
|
+
...snapshot.volume_name === void 0 ? {} : { volume_name: snapshot.volume_name },
|
|
4522
|
+
...snapshot.act === void 0 ? {} : { act: snapshot.act },
|
|
4523
|
+
...snapshot.act_progress === void 0 ? {} : { act_progress: snapshot.act_progress },
|
|
3964
4524
|
phases: snapshot.phases,
|
|
3965
4525
|
blocking: snapshot.blocking,
|
|
3966
4526
|
finished: snapshot.finished,
|
|
@@ -3971,9 +4531,11 @@ function projectSummary(snapshot) {
|
|
|
3971
4531
|
};
|
|
3972
4532
|
}
|
|
3973
4533
|
function projectChapters(snapshot) {
|
|
3974
|
-
const rows = snapshot.chapters.map(projectChapter);
|
|
3975
4534
|
const draft = snapshot.draft;
|
|
3976
|
-
|
|
4535
|
+
const draftNeedsRow = draft !== void 0 && draft !== null && !snapshot.chapters.some((row) => row.num === draft.num);
|
|
4536
|
+
const archivedLimit = draftNeedsRow ? 29 : 30;
|
|
4537
|
+
const rows = snapshot.chapters.slice(-archivedLimit).map(projectChapter);
|
|
4538
|
+
if (draftNeedsRow) rows.push({
|
|
3977
4539
|
num: draft.num,
|
|
3978
4540
|
title: draft.title,
|
|
3979
4541
|
wordCount: draft.wordCount,
|
|
@@ -4093,7 +4655,31 @@ const ROUND_TRANSITIONS = {
|
|
|
4093
4655
|
P5: "P5",
|
|
4094
4656
|
P6: "P6"
|
|
4095
4657
|
};
|
|
4658
|
+
/** Forward order of stages; rollback may only move to an equal or earlier rank. */
|
|
4659
|
+
const STAGE_RANK = {
|
|
4660
|
+
idle: 0,
|
|
4661
|
+
P2: 1,
|
|
4662
|
+
P3: 2,
|
|
4663
|
+
P4: 3,
|
|
4664
|
+
P5: 4,
|
|
4665
|
+
P6: 5,
|
|
4666
|
+
ready_to_archive: 6,
|
|
4667
|
+
done: 7
|
|
4668
|
+
};
|
|
4669
|
+
/** Internal, journal-tracked archive idempotency ledger; not a user state doc. */
|
|
4670
|
+
const ARCHIVE_DIGESTS = joinRel(STATE_DIR, "archive-digests.json");
|
|
4671
|
+
const LEDGER_PATHS = {
|
|
4672
|
+
chapters: joinRel(STATE_DIR, STATE_FILES.chapters),
|
|
4673
|
+
characters: joinRel(STATE_DIR, STATE_FILES.characters),
|
|
4674
|
+
world: joinRel(STATE_DIR, STATE_FILES.world),
|
|
4675
|
+
locks: joinRel(STATE_DIR, STATE_FILES.locks),
|
|
4676
|
+
timeline: joinRel(STATE_DIR, STATE_FILES.timeline),
|
|
4677
|
+
foreshadows: joinRel(STATE_DIR, STATE_FILES.foreshadows),
|
|
4678
|
+
relationships: joinRel(STATE_DIR, STATE_FILES.relationships),
|
|
4679
|
+
handoff: joinRel(STATE_DIR, STATE_FILES.handoff)
|
|
4680
|
+
};
|
|
4096
4681
|
const MUTABLE_STATE = {
|
|
4682
|
+
[PROJECT_FILE]: "project",
|
|
4097
4683
|
[joinRel(STATE_DIR, STATE_FILES.pipeline)]: "pipeline",
|
|
4098
4684
|
[joinRel(STATE_DIR, STATE_FILES.chapters)]: "chapters",
|
|
4099
4685
|
[joinRel(STATE_DIR, STATE_FILES.characters)]: "characters",
|
|
@@ -4113,6 +4699,8 @@ var EngineService = class {
|
|
|
4113
4699
|
stores = /* @__PURE__ */ new Map();
|
|
4114
4700
|
mutex = new KeyedMutex();
|
|
4115
4701
|
replay = /* @__PURE__ */ new Map();
|
|
4702
|
+
/** Status, panel and gates ask for the same stage context repeatedly. */
|
|
4703
|
+
briefContextCache = /* @__PURE__ */ new WeakMap();
|
|
4116
4704
|
briefs;
|
|
4117
4705
|
now;
|
|
4118
4706
|
/** The project this process last touched; the panel follows it without a workspace hint. */
|
|
@@ -4180,6 +4768,8 @@ var EngineService = class {
|
|
|
4180
4768
|
async initProject(input) {
|
|
4181
4769
|
const title = typeof input.title === "string" ? input.title.trim() : "";
|
|
4182
4770
|
if (title === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "title must not be empty");
|
|
4771
|
+
if (input.author !== void 0 && typeof input.author !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "author must be a string");
|
|
4772
|
+
if (input.description !== void 0 && typeof input.description !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "description must be a string");
|
|
4183
4773
|
if (!GENRES.includes(input.genre)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown genre "${String(input.genre)}"`, `allowed: ${GENRES.join(", ")}`);
|
|
4184
4774
|
if (!PLATFORMS.includes(input.platform)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown platform "${String(input.platform)}"`, `allowed: ${PLATFORMS.join(", ")}`);
|
|
4185
4775
|
if (input.words_per_chapter !== void 0) {
|
|
@@ -4246,12 +4836,50 @@ var EngineService = class {
|
|
|
4246
4836
|
emptyAllowed: true
|
|
4247
4837
|
});
|
|
4248
4838
|
}
|
|
4839
|
+
/**
|
|
4840
|
+
* Read-only paths must surface a committed-journal mismatch. The write path
|
|
4841
|
+
* already fails closed; without this, status recommends the next write and
|
|
4842
|
+
* the model only learns about the mismatch when that write aborts.
|
|
4843
|
+
*/
|
|
4844
|
+
async journalBlocking(snapshot) {
|
|
4845
|
+
const store = this.storeFor(snapshot.root);
|
|
4846
|
+
if (await store.exists(".engine.lock")) return [];
|
|
4847
|
+
try {
|
|
4848
|
+
const { malformed, mismatches } = await reconcileJournal(store);
|
|
4849
|
+
const items = [];
|
|
4850
|
+
if (malformed > 0) items.push({
|
|
4851
|
+
code: "JOURNAL_MALFORMED",
|
|
4852
|
+
scope: "journal",
|
|
4853
|
+
detail: `${malformed} malformed journal line(s)`,
|
|
4854
|
+
hint: "inspect the journal with novel_audit; restore a known-good journal before writing"
|
|
4855
|
+
});
|
|
4856
|
+
for (const mismatch of mismatches) items.push({
|
|
4857
|
+
code: "JOURNAL_HASH_MISMATCH",
|
|
4858
|
+
scope: "journal",
|
|
4859
|
+
detail: `${mismatch.path} changed after commit ${mismatch.txid}`,
|
|
4860
|
+
hint: "restore the file from backups/ or inspect with novel_audit; writes remain fail-closed"
|
|
4861
|
+
});
|
|
4862
|
+
return items;
|
|
4863
|
+
} catch (error) {
|
|
4864
|
+
return [{
|
|
4865
|
+
code: "JOURNAL_UNREADABLE",
|
|
4866
|
+
scope: "journal",
|
|
4867
|
+
detail: errorMessage(error),
|
|
4868
|
+
hint: "inspect state/journal.jsonl and restore a known-good copy"
|
|
4869
|
+
}];
|
|
4870
|
+
}
|
|
4871
|
+
}
|
|
4249
4872
|
async getStatus(input = {}) {
|
|
4250
4873
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
4251
4874
|
const snapshot = await this.load(input.root);
|
|
4252
4875
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4253
4876
|
const metrics = await this.metricsFor(snapshot, chapter);
|
|
4254
|
-
const
|
|
4877
|
+
const journal = await this.journalBlocking(snapshot);
|
|
4878
|
+
const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
|
|
4879
|
+
tool: "novel_audit",
|
|
4880
|
+
args: {},
|
|
4881
|
+
reason: `${journal[0].code}: ${journal[0].detail}; inspect and restore before the next write`
|
|
4882
|
+
} : this.nextStep(snapshot);
|
|
4255
4883
|
return {
|
|
4256
4884
|
data: {
|
|
4257
4885
|
project: {
|
|
@@ -4264,8 +4892,18 @@ var EngineService = class {
|
|
|
4264
4892
|
active_chapter: snapshot.pipeline.chapter,
|
|
4265
4893
|
stage: snapshot.pipeline.stage,
|
|
4266
4894
|
rounds: { ...snapshot.pipeline.rounds },
|
|
4267
|
-
blocking: this.effectiveBlocking(snapshot),
|
|
4895
|
+
blocking: [...this.effectiveBlocking(snapshot), ...journal],
|
|
4896
|
+
state_errors: this.stateErrors(snapshot),
|
|
4268
4897
|
metrics,
|
|
4898
|
+
...snapshot.handoff === null ? {} : (() => {
|
|
4899
|
+
const position = projectHandoff(snapshot.handoff, snapshot.pipeline.chapter);
|
|
4900
|
+
return {
|
|
4901
|
+
volume: position.volume,
|
|
4902
|
+
volume_name: position.volume_name,
|
|
4903
|
+
act: position.act,
|
|
4904
|
+
act_progress: position.act_progress
|
|
4905
|
+
};
|
|
4906
|
+
})(),
|
|
4269
4907
|
next
|
|
4270
4908
|
},
|
|
4271
4909
|
revision: snapshot.pipeline.revision,
|
|
@@ -4291,17 +4929,50 @@ var EngineService = class {
|
|
|
4291
4929
|
const snapshot = await this.load(input.root);
|
|
4292
4930
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4293
4931
|
const store = this.storeFor(snapshot.root);
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4932
|
+
const ledgerPath = LEDGER_PATHS[input.kind];
|
|
4933
|
+
if (ledgerPath !== void 0) {
|
|
4934
|
+
const damage = snapshot.ledgerErrors.find((error) => error.path === ledgerPath);
|
|
4935
|
+
if (damage !== void 0) return {
|
|
4936
|
+
data: {
|
|
4937
|
+
error: damage.code,
|
|
4938
|
+
message: damage.message,
|
|
4939
|
+
path: damage.path,
|
|
4940
|
+
raw: await store.readText(damage.path)
|
|
4941
|
+
},
|
|
4297
4942
|
revision: snapshot.pipeline.revision,
|
|
4298
4943
|
warnings: []
|
|
4299
4944
|
};
|
|
4300
|
-
|
|
4301
|
-
|
|
4945
|
+
}
|
|
4946
|
+
switch (input.kind) {
|
|
4947
|
+
case "pipeline": return {
|
|
4948
|
+
data: snapshot.pipeline,
|
|
4302
4949
|
revision: snapshot.pipeline.revision,
|
|
4303
4950
|
warnings: []
|
|
4304
4951
|
};
|
|
4952
|
+
case "chapters": {
|
|
4953
|
+
const range = input.chapter_range;
|
|
4954
|
+
if (range !== void 0) {
|
|
4955
|
+
if (!Array.isArray(range) || range.length !== 2 || !Number.isInteger(range[0]) || !Number.isInteger(range[1]) || range[0] < 1 || range[1] < range[0]) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter_range must be [start, end] positive integers with start <= end");
|
|
4956
|
+
}
|
|
4957
|
+
const rows = range === void 0 ? snapshot.chapters.chapters : snapshot.chapters.chapters.filter((row) => row.num >= range[0] && row.num <= range[1]);
|
|
4958
|
+
return {
|
|
4959
|
+
data: input.include_summary === true ? {
|
|
4960
|
+
schema_version: 1,
|
|
4961
|
+
chapters: rows
|
|
4962
|
+
} : {
|
|
4963
|
+
schema_version: 1,
|
|
4964
|
+
chapters: rows.map((row) => ({
|
|
4965
|
+
num: row.num,
|
|
4966
|
+
title: row.title,
|
|
4967
|
+
word_count: row.word_count,
|
|
4968
|
+
score: row.score,
|
|
4969
|
+
status: row.status
|
|
4970
|
+
}))
|
|
4971
|
+
},
|
|
4972
|
+
revision: snapshot.pipeline.revision,
|
|
4973
|
+
warnings: []
|
|
4974
|
+
};
|
|
4975
|
+
}
|
|
4305
4976
|
case "characters": return {
|
|
4306
4977
|
data: snapshot.characters,
|
|
4307
4978
|
revision: snapshot.pipeline.revision,
|
|
@@ -4337,30 +5008,79 @@ var EngineService = class {
|
|
|
4337
5008
|
revision: snapshot.pipeline.revision,
|
|
4338
5009
|
warnings: []
|
|
4339
5010
|
};
|
|
4340
|
-
case "review":
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
5011
|
+
case "review":
|
|
5012
|
+
if (chapter === snapshot.pipeline.chapter && snapshot.reviewError !== null) return {
|
|
5013
|
+
data: {
|
|
5014
|
+
error: "REVIEW_UNREADABLE",
|
|
5015
|
+
message: snapshot.reviewError,
|
|
5016
|
+
path: reviewFile(chapter),
|
|
5017
|
+
raw: await store.readText(reviewFile(chapter))
|
|
5018
|
+
},
|
|
5019
|
+
revision: snapshot.pipeline.revision,
|
|
5020
|
+
warnings: []
|
|
5021
|
+
};
|
|
5022
|
+
return {
|
|
5023
|
+
data: chapter === snapshot.pipeline.chapter ? snapshot.review : await store.readDoc(reviewFile(chapter), "review"),
|
|
5024
|
+
revision: snapshot.pipeline.revision,
|
|
5025
|
+
warnings: []
|
|
5026
|
+
};
|
|
5027
|
+
case "check":
|
|
5028
|
+
if (chapter === snapshot.pipeline.chapter && snapshot.checkError !== null) return {
|
|
5029
|
+
data: {
|
|
5030
|
+
error: "CHECK_UNREADABLE",
|
|
5031
|
+
message: snapshot.checkError,
|
|
5032
|
+
path: checkFile(chapter),
|
|
5033
|
+
raw: await store.readText(checkFile(chapter))
|
|
5034
|
+
},
|
|
5035
|
+
revision: snapshot.pipeline.revision,
|
|
5036
|
+
warnings: []
|
|
5037
|
+
};
|
|
5038
|
+
return {
|
|
5039
|
+
data: chapter === snapshot.pipeline.chapter ? snapshot.check : await store.readDoc(checkFile(chapter), "check"),
|
|
5040
|
+
revision: snapshot.pipeline.revision,
|
|
5041
|
+
warnings: []
|
|
5042
|
+
};
|
|
5043
|
+
case "novel":
|
|
5044
|
+
if (snapshot.projectError !== null) return {
|
|
5045
|
+
data: {
|
|
5046
|
+
error: "PROJECT_UNREADABLE",
|
|
5047
|
+
message: snapshot.projectError,
|
|
5048
|
+
path: PROJECT_FILE,
|
|
5049
|
+
raw: await store.readText(PROJECT_FILE)
|
|
5050
|
+
},
|
|
5051
|
+
revision: snapshot.pipeline.revision,
|
|
5052
|
+
warnings: []
|
|
5053
|
+
};
|
|
5054
|
+
return {
|
|
5055
|
+
data: snapshot.project,
|
|
5056
|
+
revision: snapshot.pipeline.revision,
|
|
5057
|
+
warnings: []
|
|
5058
|
+
};
|
|
4355
5059
|
case "planWorld": {
|
|
5060
|
+
if (chapter === snapshot.pipeline.chapter) {
|
|
5061
|
+
if (snapshot.worldPlanError !== null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${planWorldFile(chapter)}: ${snapshot.worldPlanError}`, "run novel_status and repair or re-plan the world plan");
|
|
5062
|
+
return {
|
|
5063
|
+
data: snapshot.worldPlan,
|
|
5064
|
+
revision: snapshot.pipeline.revision,
|
|
5065
|
+
warnings: []
|
|
5066
|
+
};
|
|
5067
|
+
}
|
|
4356
5068
|
const raw = await store.readText(planWorldFile(chapter));
|
|
4357
5069
|
return {
|
|
4358
|
-
data: raw === null ? null : parsePlanYaml(raw, planWorldFile(chapter)),
|
|
5070
|
+
data: raw === null ? null : narrow("worldPlan", parsePlanYaml(raw, planWorldFile(chapter))),
|
|
4359
5071
|
revision: snapshot.pipeline.revision,
|
|
4360
5072
|
warnings: []
|
|
4361
5073
|
};
|
|
4362
5074
|
}
|
|
4363
5075
|
case "planChapter": {
|
|
5076
|
+
if (chapter === snapshot.pipeline.chapter) {
|
|
5077
|
+
if (snapshot.planError !== null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${planChapterFile(chapter)}: ${snapshot.planError}`, "run novel_status and repair or re-plan the chapter");
|
|
5078
|
+
return {
|
|
5079
|
+
data: snapshot.plan,
|
|
5080
|
+
revision: snapshot.pipeline.revision,
|
|
5081
|
+
warnings: []
|
|
5082
|
+
};
|
|
5083
|
+
}
|
|
4364
5084
|
const raw = await store.readText(planChapterFile(chapter));
|
|
4365
5085
|
return {
|
|
4366
5086
|
data: raw === null ? null : parsePlanYaml(raw, planChapterFile(chapter)),
|
|
@@ -4373,7 +5093,12 @@ var EngineService = class {
|
|
|
4373
5093
|
}
|
|
4374
5094
|
async advise(input = {}) {
|
|
4375
5095
|
const snapshot = await this.load(input.root);
|
|
4376
|
-
const
|
|
5096
|
+
const journal = await this.journalBlocking(snapshot);
|
|
5097
|
+
const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
|
|
5098
|
+
tool: "novel_audit",
|
|
5099
|
+
args: {},
|
|
5100
|
+
reason: `${journal[0].code}: ${journal[0].detail}; inspect and restore before the next write`
|
|
5101
|
+
} : this.nextStep(snapshot);
|
|
4377
5102
|
return {
|
|
4378
5103
|
data: {
|
|
4379
5104
|
stage: snapshot.pipeline.stage,
|
|
@@ -4381,7 +5106,8 @@ var EngineService = class {
|
|
|
4381
5106
|
tool: next.tool,
|
|
4382
5107
|
args: next.args,
|
|
4383
5108
|
reason: next.reason,
|
|
4384
|
-
blocking: this.effectiveBlocking(snapshot)
|
|
5109
|
+
blocking: [...this.effectiveBlocking(snapshot), ...journal],
|
|
5110
|
+
state_errors: this.stateErrors(snapshot)
|
|
4385
5111
|
},
|
|
4386
5112
|
revision: snapshot.pipeline.revision,
|
|
4387
5113
|
warnings: []
|
|
@@ -4416,7 +5142,10 @@ var EngineService = class {
|
|
|
4416
5142
|
/** P4 must apply exclusions before its arithmetic closes. */
|
|
4417
5143
|
score_exclusions: this.rulepack.exclusions,
|
|
4418
5144
|
next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
|
|
4419
|
-
...snapshot.
|
|
5145
|
+
...snapshot.worldPlanError === null ? {} : { world_plan_error: snapshot.worldPlanError },
|
|
5146
|
+
...snapshot.planError === null ? {} : { plan_error: snapshot.planError },
|
|
5147
|
+
...snapshot.reviewError === null ? {} : { review_error: snapshot.reviewError },
|
|
5148
|
+
...snapshot.checkError === null ? {} : { check_error: snapshot.checkError }
|
|
4420
5149
|
};
|
|
4421
5150
|
const data = {
|
|
4422
5151
|
stage: input.stage,
|
|
@@ -4457,6 +5186,27 @@ var EngineService = class {
|
|
|
4457
5186
|
}
|
|
4458
5187
|
async submitWorldPlan(input) {
|
|
4459
5188
|
if (typeof input.objective !== "string" || input.objective.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "world plan objective must not be empty");
|
|
5189
|
+
const constraints = stringList(input.constraints, "constraints");
|
|
5190
|
+
const continuity_notes = stringList(input.continuity_notes, "continuity_notes");
|
|
5191
|
+
const objectiveChars = Array.from(input.objective).length;
|
|
5192
|
+
if (objectiveChars > LIMITS.maxWorldPlanObjectiveChars) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `world plan objective is ${objectiveChars} characters; limit is ${LIMITS.maxWorldPlanObjectiveChars}`, "keep the protected objective within the P2 context budget");
|
|
5193
|
+
for (const [field, values] of [["constraints", constraints], ["continuity_notes", continuity_notes]]) {
|
|
5194
|
+
if (values.length > LIMITS.maxWorldPlanItems) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `world plan ${field} has ${values.length} items; limit is ${LIMITS.maxWorldPlanItems}`, "merge related constraints/notes so the protected context stays bounded");
|
|
5195
|
+
const chars = values.reduce((total, value) => total + Array.from(value).length, 0);
|
|
5196
|
+
if (chars > LIMITS.maxWorldPlanTextChars) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `world plan ${field} has ${chars} characters; limit is ${LIMITS.maxWorldPlanTextChars}`, "keep constraints/continuity notes concise; the protected context has a fixed budget");
|
|
5197
|
+
}
|
|
5198
|
+
const characters = objectList(input.characters, "characters");
|
|
5199
|
+
for (const [index, character] of characters.entries()) {
|
|
5200
|
+
requireText(character, "name", `characters[${index}].name`);
|
|
5201
|
+
requireText(character, "role", `characters[${index}].role`);
|
|
5202
|
+
requireText(character, "current_state", `characters[${index}].current_state`);
|
|
5203
|
+
}
|
|
5204
|
+
const locations = objectList(input.locations, "locations");
|
|
5205
|
+
for (const [index, location] of locations.entries()) {
|
|
5206
|
+
requireText(location, "name", `locations[${index}].name`);
|
|
5207
|
+
requireText(location, "layout", `locations[${index}].layout`);
|
|
5208
|
+
if (typeof location.capacity !== "number" || !Number.isFinite(location.capacity)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a finite number`);
|
|
5209
|
+
}
|
|
4460
5210
|
return this.mutate({
|
|
4461
5211
|
root: input.root,
|
|
4462
5212
|
op: "submitWorldPlan",
|
|
@@ -4467,10 +5217,10 @@ var EngineService = class {
|
|
|
4467
5217
|
const plan = {
|
|
4468
5218
|
chapter,
|
|
4469
5219
|
objective: input.objective,
|
|
4470
|
-
constraints
|
|
4471
|
-
characters
|
|
4472
|
-
locations
|
|
4473
|
-
continuity_notes
|
|
5220
|
+
constraints,
|
|
5221
|
+
characters,
|
|
5222
|
+
locations,
|
|
5223
|
+
continuity_notes
|
|
4474
5224
|
};
|
|
4475
5225
|
const path = planWorldFile(chapter);
|
|
4476
5226
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
@@ -4505,6 +5255,15 @@ var EngineService = class {
|
|
|
4505
5255
|
}
|
|
4506
5256
|
async submitChapterPlan(input) {
|
|
4507
5257
|
if (typeof input.title !== "string" || input.title.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan title must not be empty");
|
|
5258
|
+
const titleTemplate = this.rulepack.format.title;
|
|
5259
|
+
if (LENIENT_TITLE_PREFIX.test(input.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan title must not include the 第N章 prefix", "submit the title body only; the engine adds the canonical prefix");
|
|
5260
|
+
const titleBody = input.title.trim();
|
|
5261
|
+
const titleLength = Array.from(titleBody).length;
|
|
5262
|
+
if (titleLength < titleTemplate.min_chars || titleLength > titleTemplate.max_chars) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan title body must be ${titleTemplate.min_chars}..${titleTemplate.max_chars} characters, got ${titleLength}`, "use the same title body the P3 title rule will judge");
|
|
5263
|
+
if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan narrator must not be empty");
|
|
5264
|
+
const beats = stringList(input.beats, "beats");
|
|
5265
|
+
if (beats.length < 5 || beats.length > 8) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan beats must contain 5..8 non-empty items, got ${beats.length}`, "write the five to eight causal beats the P2 task book requires");
|
|
5266
|
+
for (const [field, value] of [["opening", input.opening], ["ending", input.ending]]) if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan ${field} must not be empty`);
|
|
4508
5267
|
if (!SCENE_TYPES.includes(input.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown scene_type "${String(input.scene_type)}"`, `allowed: ${SCENE_TYPES.join(", ")}`);
|
|
4509
5268
|
if (!Array.isArray(input.target_words) || input.target_words.length !== 2) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "target_words must be [min, max] positive integers with min <= max");
|
|
4510
5269
|
const [min, max] = input.target_words;
|
|
@@ -4515,11 +5274,14 @@ var EngineService = class {
|
|
|
4515
5274
|
key: (snapshot) => `plan:${snapshot.pipeline.chapter}:${sha256(stableJson(input))}`,
|
|
4516
5275
|
run: async (snapshot, context) => {
|
|
4517
5276
|
await this.assertGates(snapshot, "submitChapterPlan", { briefStage: "P2" });
|
|
5277
|
+
const cast = normalizeCast(input.cast, snapshot);
|
|
4518
5278
|
const chapter = snapshot.pipeline.chapter;
|
|
4519
5279
|
const path = planChapterFile(chapter);
|
|
4520
5280
|
const plan = {
|
|
4521
5281
|
...input,
|
|
4522
5282
|
chapter,
|
|
5283
|
+
beats,
|
|
5284
|
+
...cast === void 0 ? {} : { cast },
|
|
4523
5285
|
schema_version: 1
|
|
4524
5286
|
};
|
|
4525
5287
|
delete plan.root;
|
|
@@ -4548,6 +5310,7 @@ var EngineService = class {
|
|
|
4548
5310
|
async submitDraft(input) {
|
|
4549
5311
|
if (typeof input.content !== "string" || input.content.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "draft content must not be empty");
|
|
4550
5312
|
if (typeof input.chapter_title !== "string" || input.chapter_title.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter_title must not be empty");
|
|
5313
|
+
if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "narrator must not be empty", "use the narration person/name validated at P2; a blank narrator would make the stored plan unreadable");
|
|
4551
5314
|
if (!SCENE_TYPES.includes(input.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown scene_type "${String(input.scene_type)}"`, `allowed: ${SCENE_TYPES.join(", ")}`);
|
|
4552
5315
|
return this.mutate({
|
|
4553
5316
|
root: input.root,
|
|
@@ -4574,18 +5337,16 @@ var EngineService = class {
|
|
|
4574
5337
|
summary: input.summary
|
|
4575
5338
|
});
|
|
4576
5339
|
const chapter = input.chapter;
|
|
4577
|
-
const
|
|
4578
|
-
|
|
5340
|
+
const plan = snapshot.plan;
|
|
5341
|
+
if (plan === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no usable plan`, "rollback to P2 and submit novel_plan_chapter; the draft was not written");
|
|
4579
5342
|
const mergedPlan = {
|
|
4580
|
-
...
|
|
4581
|
-
schema_version: 1,
|
|
4582
|
-
chapter
|
|
4583
|
-
},
|
|
5343
|
+
...plan,
|
|
4584
5344
|
title: input.chapter_title,
|
|
4585
5345
|
scene_type: input.scene_type,
|
|
4586
5346
|
narrator: input.narrator,
|
|
4587
5347
|
summary: input.summary
|
|
4588
5348
|
};
|
|
5349
|
+
const planPath = planChapterFile(chapter);
|
|
4589
5350
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
4590
5351
|
stage: "P4",
|
|
4591
5352
|
blocking: [],
|
|
@@ -4653,6 +5414,11 @@ var EngineService = class {
|
|
|
4653
5414
|
"fix"
|
|
4654
5415
|
]) if (typeof issue[field] !== "string" || issue[field].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} has no ${field}`, "every issue needs id, severity, status, dimension, location, problem and fix");
|
|
4655
5416
|
if (issue.evidence !== void 0 && typeof issue.evidence !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} evidence must be a string`, "quote the revised text as evidence, or omit the field");
|
|
5417
|
+
if (issue.severity === "blocker" && issue.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} is a blocker and cannot be waived`, "fix the issue and quote the revised text, or keep it open");
|
|
5418
|
+
if (this.rulepack.quality.dimensions.find((item) => item.id === issue.dimension) === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} references unknown dimension "${issue.dimension}"`, `use one of: ${this.rulepack.quality.dimensions.map((item) => item.id).join(", ")}`);
|
|
5419
|
+
const paragraph = parseParagraphLocation(issue.location);
|
|
5420
|
+
const paragraphCount = splitParagraphs(text).length;
|
|
5421
|
+
if (paragraph === null || paragraph < 1 || paragraph > paragraphCount) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} location "${issue.location}" is outside the chapter`, `use 第 1 段 .. 第 ${paragraphCount} 段`);
|
|
4656
5422
|
}
|
|
4657
5423
|
const review = {
|
|
4658
5424
|
schema_version: 1,
|
|
@@ -4757,7 +5523,15 @@ var EngineService = class {
|
|
|
4757
5523
|
seen.add(submitted.id);
|
|
4758
5524
|
const before = byId.get(submitted.id);
|
|
4759
5525
|
if (before === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `revision references unknown issue ${submitted.id}`, "only update issues from the current review; new issues belong in the next review");
|
|
4760
|
-
|
|
5526
|
+
for (const field of [
|
|
5527
|
+
"dimension",
|
|
5528
|
+
"location",
|
|
5529
|
+
"problem",
|
|
5530
|
+
"fix"
|
|
5531
|
+
]) if (submitted[field] !== void 0 && submitted[field] !== before[field]) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change its ${field} during revision`, "only status and evidence may change; new findings belong in the next review");
|
|
5532
|
+
if (submitted.severity !== void 0 && submitted.severity !== before.severity) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change severity during revision`, "only status and evidence may change; a blocker stays a blocker until re-reviewed");
|
|
5533
|
+
if (before.severity === "blocker" && submitted.severity !== void 0 && submitted.severity !== "blocker") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be downgraded`, "keep severity blocker and mark it fixed with evidence");
|
|
5534
|
+
if (before.severity === "blocker" && submitted.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be waived`, "keep severity blocker and mark it fixed with evidence");
|
|
4761
5535
|
const index = issues.findIndex((issue) => issue.id === submitted.id);
|
|
4762
5536
|
issues[index] = {
|
|
4763
5537
|
...before,
|
|
@@ -4786,7 +5560,7 @@ var EngineService = class {
|
|
|
4786
5560
|
});
|
|
4787
5561
|
if (input.summary === void 0 && snapshot.plan?.summary !== void 0) {
|
|
4788
5562
|
const opening = snapshot.plan.summary.opening;
|
|
4789
|
-
if (!
|
|
5563
|
+
if (!openingInFirstTwoParagraphs(input.content, opening)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
|
|
4790
5564
|
}
|
|
4791
5565
|
const machine = this.machine(snapshot, input.content, { summary: input.summary ?? snapshot.plan?.summary ?? null });
|
|
4792
5566
|
const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
|
|
@@ -4835,12 +5609,13 @@ var EngineService = class {
|
|
|
4835
5609
|
});
|
|
4836
5610
|
}
|
|
4837
5611
|
async runCheck(input) {
|
|
5612
|
+
const scope = normalizeCheckScope(input.scope);
|
|
4838
5613
|
return this.mutate({
|
|
4839
5614
|
root: input.root,
|
|
4840
5615
|
op: "runCheck",
|
|
4841
5616
|
key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
|
|
4842
5617
|
semantic: input.semantic ?? [],
|
|
4843
|
-
scope
|
|
5618
|
+
scope
|
|
4844
5619
|
}))}`,
|
|
4845
5620
|
run: async (snapshot, context) => {
|
|
4846
5621
|
this.assertChapter(snapshot, input.chapter);
|
|
@@ -4850,35 +5625,41 @@ var EngineService = class {
|
|
|
4850
5625
|
briefStage: "P6",
|
|
4851
5626
|
text,
|
|
4852
5627
|
proof: snapshot.review?.proof ?? null,
|
|
4853
|
-
semantic
|
|
5628
|
+
semantic,
|
|
5629
|
+
scope
|
|
4854
5630
|
});
|
|
4855
5631
|
const machine = this.machine(snapshot, text, { summary: snapshot.plan?.summary ?? null });
|
|
4856
|
-
const
|
|
5632
|
+
const scopedMachine = {
|
|
5633
|
+
...machine,
|
|
5634
|
+
violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
|
|
5635
|
+
};
|
|
5636
|
+
const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
|
|
4857
5637
|
const semanticResult = evaluateSemantic(text, semantic);
|
|
4858
5638
|
const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
|
|
4859
5639
|
const check = {
|
|
4860
5640
|
schema_version: 1,
|
|
4861
5641
|
chapter: input.chapter,
|
|
4862
|
-
scope
|
|
5642
|
+
scope,
|
|
4863
5643
|
content_hash: sha256(text),
|
|
4864
5644
|
machine: {
|
|
4865
|
-
violations:
|
|
4866
|
-
metrics:
|
|
5645
|
+
violations: scopedMachine.violations,
|
|
5646
|
+
metrics: scopedMachine.metrics
|
|
4867
5647
|
},
|
|
4868
5648
|
semantic: { items: semantic },
|
|
4869
5649
|
verdict
|
|
4870
5650
|
};
|
|
4871
|
-
const
|
|
5651
|
+
const fullScope = scope.includes("content") && scope.includes("integrity");
|
|
5652
|
+
const nextStage$2 = verdict === "pass" ? nextStage(snapshot.pipeline.stage, "runCheck", 0, { completeCheck: fullScope }) : "P6";
|
|
4872
5653
|
const blockingItems = verdict === "fail" ? [...machineBlocking.map((violation) => ({
|
|
4873
5654
|
code: violation.code,
|
|
4874
5655
|
scope: "check",
|
|
4875
5656
|
detail: violation.detail
|
|
4876
5657
|
})), ...semanticResult.blocking] : [];
|
|
4877
5658
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
4878
|
-
stage: nextStage,
|
|
5659
|
+
stage: nextStage$2,
|
|
4879
5660
|
rounds: {
|
|
4880
5661
|
...snapshot.pipeline.rounds,
|
|
4881
|
-
check: snapshot.pipeline.rounds.check + 1
|
|
5662
|
+
check: snapshot.pipeline.rounds.check + (verdict === "fail" || fullScope ? 1 : 0)
|
|
4882
5663
|
},
|
|
4883
5664
|
blocking: blockingItems,
|
|
4884
5665
|
last_action: action("novel_check", this.now(), sha256(text))
|
|
@@ -4894,21 +5675,25 @@ var EngineService = class {
|
|
|
4894
5675
|
data: {
|
|
4895
5676
|
chapter: input.chapter,
|
|
4896
5677
|
verdict,
|
|
4897
|
-
violations:
|
|
4898
|
-
metrics:
|
|
4899
|
-
stage: nextStage
|
|
5678
|
+
violations: scopedMachine.violations.length,
|
|
5679
|
+
metrics: scopedMachine.metrics,
|
|
5680
|
+
stage: nextStage$2
|
|
4900
5681
|
},
|
|
4901
|
-
warnings: machineWarnings(
|
|
5682
|
+
warnings: [...machineWarnings(scopedMachine), ...verdict === "pass" && !fullScope ? [{
|
|
5683
|
+
code: "CHECK_SCOPE_PARTIAL",
|
|
5684
|
+
message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
|
|
5685
|
+
}] : []]
|
|
4902
5686
|
};
|
|
4903
5687
|
}
|
|
4904
5688
|
});
|
|
4905
5689
|
}
|
|
4906
5690
|
async archiveChapter(input) {
|
|
4907
|
-
const
|
|
5691
|
+
const handoffOverride = normalizeHandoffOverride(input.handoff);
|
|
5692
|
+
const archiveDigest = `v2:${sha256(stableJson({
|
|
4908
5693
|
chapter: input.chapter,
|
|
4909
5694
|
state_delta: input.state_delta,
|
|
4910
|
-
handoff:
|
|
4911
|
-
}))
|
|
5695
|
+
handoff: handoffOverride
|
|
5696
|
+
}))}`;
|
|
4912
5697
|
return this.mutate({
|
|
4913
5698
|
root: input.root,
|
|
4914
5699
|
op: "archiveChapter",
|
|
@@ -4920,7 +5705,7 @@ var EngineService = class {
|
|
|
4920
5705
|
const already = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
|
|
4921
5706
|
if (already !== void 0) {
|
|
4922
5707
|
if (already.archive_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} has no archive_digest; the index entry is not trustworthy`, "restore the chapter index from backups/ or re-archive from the original manuscript");
|
|
4923
|
-
if (already.archive_digest !== archiveDigest) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} is already archived with a different state_delta`, "archive is idempotent by chapter; start the next chapter instead of replaying old data");
|
|
5708
|
+
if (already.archive_digest !== archiveDigest && already.archive_digest !== legacyArchiveDigest(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} is already archived with a different state_delta`, "archive is idempotent by chapter; start the next chapter instead of replaying old data");
|
|
4924
5709
|
const archivedText = await this.readChapter(snapshot, input.chapter);
|
|
4925
5710
|
if (archivedText === null || sha256(archivedText) !== already.content_hash) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `archived chapter ${input.chapter} no longer matches its index`, "restore the chapter from backups/ before retrying");
|
|
4926
5711
|
return {
|
|
@@ -4939,6 +5724,7 @@ var EngineService = class {
|
|
|
4939
5724
|
const text = snapshot.text ?? "";
|
|
4940
5725
|
const summary = snapshot.plan?.summary;
|
|
4941
5726
|
if (summary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the draft has no summary; resubmit the draft with a summary", "call novel_write_draft with the full summary block");
|
|
5727
|
+
if (snapshot.review !== null && snapshot.pipeline.review_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review has no review_digest; it predates digest-bound reviews and cannot be archived blindly", "re-run novel_review for this chapter, then re-run the archive");
|
|
4942
5728
|
if (snapshot.review !== null && snapshot.pipeline.review_digest !== void 0 && snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review changed after submission; its digest no longer matches", "re-run novel_review instead of editing reviews/ directly");
|
|
4943
5729
|
await this.assertGates(snapshot, "archiveChapter", {
|
|
4944
5730
|
briefStage: "P8",
|
|
@@ -4962,7 +5748,7 @@ var EngineService = class {
|
|
|
4962
5748
|
const metrics = countWordMetrics(text);
|
|
4963
5749
|
const score = snapshot.review?.total ?? 0;
|
|
4964
5750
|
const chapter = input.chapter;
|
|
4965
|
-
const storedTitle = (snapshot.plan?.title ?? "")
|
|
5751
|
+
const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
|
|
4966
5752
|
const record = {
|
|
4967
5753
|
num: chapter,
|
|
4968
5754
|
title: storedTitle === "" ? `第${chapter}章` : storedTitle,
|
|
@@ -4997,18 +5783,22 @@ var EngineService = class {
|
|
|
4997
5783
|
for (const item of foreshadows.items) if (item.status !== "resolved" && chapter - item.last_touched >= this.rulepack.crosschapter.foreshadow_dormant_after) item.status = "dormant";
|
|
4998
5784
|
const chapters = structuredClone(snapshot.chapters);
|
|
4999
5785
|
chapters.chapters.push(record);
|
|
5000
|
-
const
|
|
5001
|
-
const derivedVolume = Math.max(1, Math.ceil(chapter / 20));
|
|
5786
|
+
const position = advanceHandoff(snapshot.handoff, chapter, handoffOverride);
|
|
5002
5787
|
const handoff = {
|
|
5003
5788
|
schema_version: 1,
|
|
5004
5789
|
current_chapter: chapter,
|
|
5005
|
-
volume:
|
|
5006
|
-
volume_name:
|
|
5007
|
-
act:
|
|
5008
|
-
act_progress:
|
|
5790
|
+
volume: position.volume,
|
|
5791
|
+
volume_name: position.volume_name,
|
|
5792
|
+
act: position.act,
|
|
5793
|
+
act_progress: position.act_progress,
|
|
5009
5794
|
next_chapter: chapter + 1,
|
|
5010
5795
|
last_summary: summary.events.join(";").slice(0, 200),
|
|
5011
|
-
open_hooks: foreshadows.items.filter((item) => item.status !== "resolved").
|
|
5796
|
+
open_hooks: foreshadows.items.filter((item) => item.status !== "resolved").sort((left, right) => {
|
|
5797
|
+
const byAge = left.last_touched - right.last_touched;
|
|
5798
|
+
if (byAge !== 0) return byAge;
|
|
5799
|
+
const byId = compareText(left.id, right.id);
|
|
5800
|
+
return byId !== 0 ? byId : compareText(left.title, right.title);
|
|
5801
|
+
}).slice(0, 5).map((item) => ({
|
|
5012
5802
|
id: item.id,
|
|
5013
5803
|
title: item.title
|
|
5014
5804
|
})),
|
|
@@ -5036,6 +5826,20 @@ var EngineService = class {
|
|
|
5036
5826
|
["relationships", ledger.relationships],
|
|
5037
5827
|
["handoff", handoff]
|
|
5038
5828
|
]);
|
|
5829
|
+
const digestRaw = await context.store.readText(ARCHIVE_DIGESTS);
|
|
5830
|
+
let archiveDigests = {};
|
|
5831
|
+
if (digestRaw !== null) {
|
|
5832
|
+
let parsed;
|
|
5833
|
+
try {
|
|
5834
|
+
parsed = JSON.parse(digestRaw);
|
|
5835
|
+
} catch (error) {
|
|
5836
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} is not valid JSON`, "restore the internal archive ledger before archiving; it protects idempotent replays", { cause: error });
|
|
5837
|
+
}
|
|
5838
|
+
const record = recordOf(parsed);
|
|
5839
|
+
if (record === null || Object.values(record).some((value) => typeof value !== "string")) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} must be a JSON object of chapter -> digest`, "restore the internal archive ledger before archiving");
|
|
5840
|
+
archiveDigests = record;
|
|
5841
|
+
}
|
|
5842
|
+
archiveDigests[String(chapter)] = archiveDigest;
|
|
5039
5843
|
return {
|
|
5040
5844
|
files: [
|
|
5041
5845
|
{
|
|
@@ -5077,6 +5881,10 @@ var EngineService = class {
|
|
|
5077
5881
|
{
|
|
5078
5882
|
path: joinRel(STATE_DIR, STATE_FILES.relationships),
|
|
5079
5883
|
text: yaml(ledger.relationships)
|
|
5884
|
+
},
|
|
5885
|
+
{
|
|
5886
|
+
path: ARCHIVE_DIGESTS,
|
|
5887
|
+
text: `${JSON.stringify(archiveDigests, null, 2)}\n`
|
|
5080
5888
|
}
|
|
5081
5889
|
],
|
|
5082
5890
|
backup: true,
|
|
@@ -5105,20 +5913,72 @@ var EngineService = class {
|
|
|
5105
5913
|
if (typeof input.reason !== "string" || input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason");
|
|
5106
5914
|
if (typeof input.content !== "object" || input.content === null || Array.isArray(input.content)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend content must be an object");
|
|
5107
5915
|
const resolvedRoot = await this.resolveRoot(input.root);
|
|
5108
|
-
const
|
|
5916
|
+
const store = this.storeFor(resolvedRoot);
|
|
5917
|
+
const absolute = store.path(input.path);
|
|
5109
5918
|
const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
|
|
5110
5919
|
if (kind === void 0 || absolute.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `amend can only target state documents: ${Object.keys(MUTABLE_STATE).join(", ")}`, "edit novel content through the draft/revision tools, not amend");
|
|
5920
|
+
const repairPaths = [];
|
|
5921
|
+
for (const [path, docKind] of Object.entries(MUTABLE_STATE)) {
|
|
5922
|
+
if (docKind === "pipeline") continue;
|
|
5923
|
+
if (await this.stateDocIsUnreadable(store, path, docKind)) repairPaths.push(path);
|
|
5924
|
+
}
|
|
5111
5925
|
return this.mutate({
|
|
5112
5926
|
root: resolvedRoot,
|
|
5113
5927
|
op: "amend",
|
|
5928
|
+
...repairPaths.length === 0 ? {} : { repairPaths },
|
|
5114
5929
|
key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
|
|
5115
5930
|
run: async (snapshot, context) => {
|
|
5116
5931
|
if (input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason", "state the intent so the journal explains the change");
|
|
5117
5932
|
if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
|
|
5118
|
-
|
|
5933
|
+
let projectBaseline = snapshot.project;
|
|
5934
|
+
let projectDamaged = snapshot.projectError !== null;
|
|
5935
|
+
if (kind === "project" && snapshot.projectError !== null) {
|
|
5936
|
+
const backup = await this.latestBackup(context.store, PROJECT_FILE);
|
|
5937
|
+
if (backup !== null) try {
|
|
5938
|
+
projectBaseline = narrow("project", parse(backup));
|
|
5939
|
+
projectDamaged = false;
|
|
5940
|
+
} catch {}
|
|
5941
|
+
}
|
|
5942
|
+
const document = kind === "project" ? snapshot.projectError !== null ? narrow("project", {
|
|
5943
|
+
...input.content,
|
|
5944
|
+
schema_version: input.content.schema_version ?? 1
|
|
5945
|
+
}) : mergeProjectAmendment(input.content, snapshot.project) : narrow(kind, {
|
|
5119
5946
|
...input.content,
|
|
5120
5947
|
schema_version: input.content.schema_version ?? 1
|
|
5121
5948
|
});
|
|
5949
|
+
let oldDocument = snapshot[kind];
|
|
5950
|
+
if (kind === "project") oldDocument = projectBaseline;
|
|
5951
|
+
else if (snapshot.ledgerErrors.some((error) => error.path === input.path)) {
|
|
5952
|
+
const backup = await this.latestBackup(context.store, input.path);
|
|
5953
|
+
if (backup !== null) try {
|
|
5954
|
+
oldDocument = narrow(kind, parse(backup));
|
|
5955
|
+
} catch {}
|
|
5956
|
+
}
|
|
5957
|
+
const archivedFiles = kind === "chapters" || kind === "project" ? (await context.store.list(CHAPTERS_DIR)).map((name) => /^chapter-(\d{3})\.md$/.exec(name)).filter((match) => match !== null).map((match) => Number(match[1])).filter((num) => num !== snapshot.pipeline.chapter).length : 0;
|
|
5958
|
+
let archiveDigests = {};
|
|
5959
|
+
if (kind === "chapters") {
|
|
5960
|
+
const digestRaw = await context.store.readText(ARCHIVE_DIGESTS);
|
|
5961
|
+
if (digestRaw !== null) {
|
|
5962
|
+
let parsed;
|
|
5963
|
+
try {
|
|
5964
|
+
parsed = JSON.parse(digestRaw);
|
|
5965
|
+
} catch (error) {
|
|
5966
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} is not valid JSON`, "restore the internal archive ledger before repairing chapters", { cause: error });
|
|
5967
|
+
}
|
|
5968
|
+
const record = recordOf(parsed);
|
|
5969
|
+
if (record === null || Object.values(record).some((value) => typeof value !== "string")) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${ARCHIVE_DIGESTS} must be a JSON object of chapter -> digest`, "restore the internal archive ledger before repairing chapters");
|
|
5970
|
+
archiveDigests = record;
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5973
|
+
const problems = amendProblems(kind, oldDocument, document, snapshot, input.path, archivedFiles, projectBaseline, projectDamaged, archiveDigests);
|
|
5974
|
+
if (kind === "chapters") for (const raw of Array.isArray(document.chapters) ? document.chapters : []) {
|
|
5975
|
+
const row = recordOf(raw);
|
|
5976
|
+
if (row === null || typeof row.file !== "string" || typeof row.content_hash !== "string") continue;
|
|
5977
|
+
const text = await context.store.readText(row.file);
|
|
5978
|
+
if (text === null) problems.push(`archived chapter ${String(row.num)} has no manuscript at ${row.file}`);
|
|
5979
|
+
else if (sha256(text) !== row.content_hash) problems.push(`archived chapter ${String(row.num)} content_hash does not match ${row.file}`);
|
|
5980
|
+
}
|
|
5981
|
+
if (problems.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `amend would break a state invariant: ${problems.join("; ")}`, "keep archived chapters, entity references and next_seq monotonic; nothing was written");
|
|
5122
5982
|
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_amend", this.now(), shortDigest(input.reason)) });
|
|
5123
5983
|
return {
|
|
5124
5984
|
files: [{
|
|
@@ -5155,6 +6015,7 @@ var EngineService = class {
|
|
|
5155
6015
|
this.assertChapter(snapshot, input.chapter);
|
|
5156
6016
|
if (!Object.prototype.hasOwnProperty.call(ROUND_TRANSITIONS, input.toStage)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown rollback target "${String(input.toStage)}"`, `allowed targets: ${Object.keys(ROUND_TRANSITIONS).join(", ")}`);
|
|
5157
6017
|
const stage = ROUND_TRANSITIONS[input.toStage];
|
|
6018
|
+
if (STAGE_RANK[stage] > STAGE_RANK[snapshot.pipeline.stage]) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `cannot roll back from ${snapshot.pipeline.stage} to later stage ${stage}`, "rollback only moves to the current stage or an earlier one; use the normal stage tools to advance");
|
|
5158
6019
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
5159
6020
|
chapter: input.chapter,
|
|
5160
6021
|
stage,
|
|
@@ -5236,8 +6097,14 @@ var EngineService = class {
|
|
|
5236
6097
|
root: input.root,
|
|
5237
6098
|
op: "export",
|
|
5238
6099
|
key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
|
|
6100
|
+
chapter: snapshot.pipeline.chapter,
|
|
5239
6101
|
text: sha256(snapshot.text ?? ""),
|
|
5240
|
-
|
|
6102
|
+
plan_title: stripTitlePrefix(snapshot.plan?.title ?? ""),
|
|
6103
|
+
project: sha256(stableJson(snapshot.project)),
|
|
6104
|
+
chapters: snapshot.chapters.chapters.map((chapter) => ({
|
|
6105
|
+
content_hash: chapter.content_hash,
|
|
6106
|
+
title: chapter.title
|
|
6107
|
+
})),
|
|
5241
6108
|
title: snapshot.project.title
|
|
5242
6109
|
}))}`,
|
|
5243
6110
|
run: async (snapshot, context) => {
|
|
@@ -5380,6 +6247,9 @@ var EngineService = class {
|
|
|
5380
6247
|
op: "amend",
|
|
5381
6248
|
key: () => `import:${input.kind}:${sha256(stableJson(input.content))}`,
|
|
5382
6249
|
run: async (fresh, context) => {
|
|
6250
|
+
if (fresh.pipeline.revision !== snapshot.pipeline.revision) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `${input.kind} import was prepared against revision ${snapshot.pipeline.revision}, but the project is now at ${fresh.pipeline.revision}`, "re-run the import against the current project; nothing was written");
|
|
6251
|
+
const problems = amendProblems(input.kind, fresh[input.kind], document, fresh, path);
|
|
6252
|
+
if (problems.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `import would break a state invariant: ${problems.join("; ")}`, "fix the pack or the referencing ledgers; nothing was written");
|
|
5383
6253
|
const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import", this.now(), shortDigest(input.kind)) });
|
|
5384
6254
|
return {
|
|
5385
6255
|
files: [{
|
|
@@ -5408,10 +6278,10 @@ var EngineService = class {
|
|
|
5408
6278
|
const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
|
|
5409
6279
|
const draft = archivedCurrent || snapshot.pipeline.stage === "done" ? null : {
|
|
5410
6280
|
num: snapshot.pipeline.chapter,
|
|
5411
|
-
title: (snapshot.plan?.title ?? "")
|
|
6281
|
+
title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
|
|
5412
6282
|
wordCount: draftWords,
|
|
5413
6283
|
score: snapshot.review?.total ?? 0,
|
|
5414
|
-
status: snapshot.
|
|
6284
|
+
status: this.effectiveBlocking(snapshot).length > 0 ? "blocked" : "draft",
|
|
5415
6285
|
details: (snapshot.plan?.summary?.events ?? []).join(";").slice(0, 240)
|
|
5416
6286
|
};
|
|
5417
6287
|
return {
|
|
@@ -5424,17 +6294,97 @@ var EngineService = class {
|
|
|
5424
6294
|
revision: snapshot.pipeline.revision,
|
|
5425
6295
|
rounds: snapshot.pipeline.rounds,
|
|
5426
6296
|
blocking: this.effectiveBlocking(snapshot),
|
|
6297
|
+
...snapshot.handoff === null ? {} : (() => {
|
|
6298
|
+
const position = projectHandoff(snapshot.handoff, snapshot.pipeline.chapter);
|
|
6299
|
+
return {
|
|
6300
|
+
volume: position.volume,
|
|
6301
|
+
volume_name: position.volume_name,
|
|
6302
|
+
act: position.act,
|
|
6303
|
+
act_progress: position.act_progress
|
|
6304
|
+
};
|
|
6305
|
+
})(),
|
|
5427
6306
|
next: finished ? void 0 : this.nextStep(snapshot),
|
|
5428
6307
|
finished,
|
|
5429
6308
|
chapters: archived,
|
|
5430
6309
|
draft,
|
|
5431
6310
|
totalWords: archived.reduce((total, chapter) => total + chapter.word_count, 0) + (draft === null ? 0 : draft.wordCount),
|
|
6311
|
+
averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
|
|
5432
6312
|
phases: phaseStrip(snapshot.pipeline.stage, finished)
|
|
5433
6313
|
};
|
|
5434
6314
|
}
|
|
6315
|
+
/**
|
|
6316
|
+
* The archive stage has only one legal operation, so a status/panel that
|
|
6317
|
+
* recommends it without checking the same gates as the archive creates a
|
|
6318
|
+
* loop. Evaluate the real archive gates through `gateInput`, then add the
|
|
6319
|
+
* machine pass and semantic evidence that archive re-checks separately.
|
|
6320
|
+
*/
|
|
6321
|
+
archivePreflight(snapshot) {
|
|
6322
|
+
if (snapshot.pipeline.stage !== "ready_to_archive") return null;
|
|
6323
|
+
const text = snapshot.text ?? "";
|
|
6324
|
+
const gateResult = evaluateGates(this.gateInput(snapshot, "archiveChapter", {
|
|
6325
|
+
briefStage: "P8",
|
|
6326
|
+
proof: snapshot.review?.proof ?? null
|
|
6327
|
+
}), {
|
|
6328
|
+
rulepack: this.rulepack,
|
|
6329
|
+
minScore: this.config.quality.minScore
|
|
6330
|
+
});
|
|
6331
|
+
if (!gateResult.passed) {
|
|
6332
|
+
const first = gateResult.blocking[0];
|
|
6333
|
+
const target = first.code === "PG-03" || first.code === "PG-04" || first.code === "PG-05" || first.code === "PG-06" ? "P3" : first.code === "PG-07" || first.code === "PG-08" || first.code === "PG-09" ? "P4" : first.code === "PG-11" ? "P4" : "P6";
|
|
6334
|
+
return {
|
|
6335
|
+
item: {
|
|
6336
|
+
...first,
|
|
6337
|
+
scope: first.scope ?? "archive"
|
|
6338
|
+
},
|
|
6339
|
+
to_stage: target
|
|
6340
|
+
};
|
|
6341
|
+
}
|
|
6342
|
+
const summary = snapshot.plan?.summary ?? null;
|
|
6343
|
+
const blockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
|
|
6344
|
+
if (blockers.length > 0) return {
|
|
6345
|
+
item: {
|
|
6346
|
+
code: blockers[0].code,
|
|
6347
|
+
scope: "archive",
|
|
6348
|
+
detail: `${blockers.length} machine blocker(s) remain; first: ${blockers[0].detail}`,
|
|
6349
|
+
hint: "call novel_rollback with to_stage P3 and re-submit the draft and plan fields"
|
|
6350
|
+
},
|
|
6351
|
+
to_stage: "P3"
|
|
6352
|
+
};
|
|
6353
|
+
const semantic = evaluateSemantic(text, Array.isArray(snapshot.check?.semantic.items) ? snapshot.check.semantic.items : []);
|
|
6354
|
+
if (semantic.failed) return {
|
|
6355
|
+
item: {
|
|
6356
|
+
code: semantic.blocking[0]?.code ?? "SEMANTIC_CHECK_FAILED",
|
|
6357
|
+
scope: "archive",
|
|
6358
|
+
detail: `${semantic.blocking.length} semantic check item(s) fail`,
|
|
6359
|
+
hint: "call novel_rollback with to_stage P6, then re-run novel_check"
|
|
6360
|
+
},
|
|
6361
|
+
to_stage: "P6"
|
|
6362
|
+
};
|
|
6363
|
+
return null;
|
|
6364
|
+
}
|
|
5435
6365
|
nextStep(snapshot) {
|
|
5436
6366
|
const stage = snapshot.pipeline.stage;
|
|
5437
6367
|
const chapter = snapshot.pipeline.chapter;
|
|
6368
|
+
if (snapshot.chapters.chapters.some((record) => record.num === chapter)) return {
|
|
6369
|
+
tool: "novel_amend",
|
|
6370
|
+
args: { path: joinRel(STATE_DIR, STATE_FILES.chapters) },
|
|
6371
|
+
reason: `chapter ${chapter} is the active chapter but is already in the index; read novel_state {kind:"chapters", include_summary:true}, remove it with novel_amend, then continue`
|
|
6372
|
+
};
|
|
6373
|
+
if (this.stateErrors(snapshot).length > 0) {
|
|
6374
|
+
const recovery = this.recoveryFor(snapshot);
|
|
6375
|
+
if (recovery.tool === "novel_amend") return recovery;
|
|
6376
|
+
const target = recovery.args.to_stage;
|
|
6377
|
+
if (STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[stage]) return recovery;
|
|
6378
|
+
}
|
|
6379
|
+
const reviewDigest = this.reviewDigestProblem(snapshot);
|
|
6380
|
+
if (reviewDigest !== null) return {
|
|
6381
|
+
tool: "novel_rollback",
|
|
6382
|
+
args: {
|
|
6383
|
+
chapter,
|
|
6384
|
+
to_stage: "P4"
|
|
6385
|
+
},
|
|
6386
|
+
reason: `${reviewDigest.code}: ${reviewDigest.detail}; ${reviewDigest.hint}`
|
|
6387
|
+
};
|
|
5438
6388
|
const step = STAGE_NEXT[stage];
|
|
5439
6389
|
const target = snapshot.project.targets.total_chapters;
|
|
5440
6390
|
if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
|
|
@@ -5463,6 +6413,25 @@ var EngineService = class {
|
|
|
5463
6413
|
},
|
|
5464
6414
|
reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
|
|
5465
6415
|
};
|
|
6416
|
+
if (stage === "P6" && snapshot.check !== null && snapshot.check.verdict === "pass" && (!snapshot.check.scope.includes("content") || !snapshot.check.scope.includes("integrity"))) return {
|
|
6417
|
+
tool: "novel_check",
|
|
6418
|
+
args: this.nextArgs("P6", chapter),
|
|
6419
|
+
reason: "the last check covered only one scope; run novel_check without scope (content+integrity) to reach archive"
|
|
6420
|
+
};
|
|
6421
|
+
if (stage === "P6" && snapshot.checkError !== null) return {
|
|
6422
|
+
tool: "novel_check",
|
|
6423
|
+
args: this.nextArgs("P6", chapter),
|
|
6424
|
+
reason: `CHECK_UNREADABLE@${checkFile(chapter)}; repair the check with novel_check before any stale revision blocker`
|
|
6425
|
+
};
|
|
6426
|
+
const archive = this.archivePreflight(snapshot);
|
|
6427
|
+
if (archive !== null) return {
|
|
6428
|
+
tool: "novel_rollback",
|
|
6429
|
+
args: {
|
|
6430
|
+
chapter,
|
|
6431
|
+
to_stage: archive.to_stage
|
|
6432
|
+
},
|
|
6433
|
+
reason: `${archive.item.code}: ${archive.item.detail}; ${archive.item.hint ?? ""}`.trim()
|
|
6434
|
+
};
|
|
5466
6435
|
if (snapshot.pipeline.blocking.length > 0) {
|
|
5467
6436
|
if (stage === "P5" || stage === "P6") return {
|
|
5468
6437
|
tool: "novel_revise",
|
|
@@ -5489,20 +6458,176 @@ var EngineService = class {
|
|
|
5489
6458
|
"P5",
|
|
5490
6459
|
"P6"
|
|
5491
6460
|
].includes(stage)) return null;
|
|
5492
|
-
const
|
|
5493
|
-
if (!roundsExhausted(snapshot.pipeline.rounds, counter)) return null;
|
|
5494
|
-
return {
|
|
6461
|
+
const block = (counter) => ({
|
|
5495
6462
|
code: "PG-11",
|
|
5496
6463
|
scope: "rounds",
|
|
5497
6464
|
detail: `round limit reached for ${counter} (review ${snapshot.pipeline.rounds.review}, revise ${snapshot.pipeline.rounds.revise}, check ${snapshot.pipeline.rounds.check})`,
|
|
5498
6465
|
hint: "call novel_rollback with to_stage P4; the manuscript is kept for reference"
|
|
5499
|
-
};
|
|
6466
|
+
});
|
|
6467
|
+
if (stage === "P4") return roundsExhausted(snapshot.pipeline.rounds, "review") ? block("review") : null;
|
|
6468
|
+
if (stage === "P5") return roundsExhausted(snapshot.pipeline.rounds, "revise") ? block("revise") : null;
|
|
6469
|
+
if (roundsExhausted(snapshot.pipeline.rounds, "check")) return block("check");
|
|
6470
|
+
if (snapshot.pipeline.blocking.length > 0 && roundsExhausted(snapshot.pipeline.rounds, "revise")) return block("revise");
|
|
6471
|
+
return null;
|
|
5500
6472
|
}
|
|
5501
6473
|
/** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
|
|
5502
6474
|
effectiveBlocking(snapshot) {
|
|
6475
|
+
const state = this.stateErrors(snapshot).map((error) => ({
|
|
6476
|
+
code: "STATE_UNREADABLE",
|
|
6477
|
+
scope: "state",
|
|
6478
|
+
detail: `${error.code}: ${error.message}`,
|
|
6479
|
+
hint: this.recoveryFor(snapshot).reason
|
|
6480
|
+
}));
|
|
6481
|
+
const active = snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter) ? [{
|
|
6482
|
+
code: "ACTIVE_CHAPTER_INDEXED",
|
|
6483
|
+
scope: "state",
|
|
6484
|
+
detail: `chapter ${snapshot.pipeline.chapter} is active but already exists in state/chapters.yaml`,
|
|
6485
|
+
hint: "read the index with novel_state {kind:\"chapters\", include_summary:true}, remove the active row with novel_amend, then continue"
|
|
6486
|
+
}] : [];
|
|
5503
6487
|
const extra = this.roundsLimitBlock(snapshot);
|
|
5504
|
-
|
|
5505
|
-
|
|
6488
|
+
const reviewDigest = this.reviewDigestProblem(snapshot);
|
|
6489
|
+
const archive = this.archivePreflight(snapshot);
|
|
6490
|
+
const base = [
|
|
6491
|
+
...state,
|
|
6492
|
+
...active,
|
|
6493
|
+
...archive === null ? [] : [archive.item],
|
|
6494
|
+
...reviewDigest === null ? [] : [reviewDigest],
|
|
6495
|
+
...snapshot.pipeline.blocking
|
|
6496
|
+
];
|
|
6497
|
+
if (extra === null) return base;
|
|
6498
|
+
return base.some((item) => item.code === extra.code) ? base : [...base, extra];
|
|
6499
|
+
}
|
|
6500
|
+
/**
|
|
6501
|
+
* A review that exists but cannot be tied to the pipeline digest would let a
|
|
6502
|
+
* hand-edited review pass archive. Archive already fails closed; status and
|
|
6503
|
+
* next must surface the same repair path instead of pointing at the archive
|
|
6504
|
+
* command forever.
|
|
6505
|
+
*/
|
|
6506
|
+
reviewDigestProblem(snapshot) {
|
|
6507
|
+
if (snapshot.pipeline.stage !== "ready_to_archive" || snapshot.review === null) return null;
|
|
6508
|
+
const chapter = snapshot.pipeline.chapter;
|
|
6509
|
+
const hint = "call novel_rollback with to_stage P4, then re-run novel_review before archiving";
|
|
6510
|
+
if (snapshot.pipeline.review_digest === void 0) return {
|
|
6511
|
+
code: "REVIEW_DIGEST_MISSING",
|
|
6512
|
+
scope: "state",
|
|
6513
|
+
detail: `review ${reviewFile(chapter)} has no pipeline.review_digest; it predates digest-bound reviews`,
|
|
6514
|
+
hint
|
|
6515
|
+
};
|
|
6516
|
+
if (snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) return {
|
|
6517
|
+
code: "REVIEW_DIGEST_MISMATCH",
|
|
6518
|
+
scope: "state",
|
|
6519
|
+
detail: `review ${reviewFile(chapter)} changed after submission; its digest no longer matches`,
|
|
6520
|
+
hint
|
|
6521
|
+
};
|
|
6522
|
+
return null;
|
|
6523
|
+
}
|
|
6524
|
+
stateErrors(snapshot) {
|
|
6525
|
+
const errors = [...snapshot.ledgerErrors];
|
|
6526
|
+
if (snapshot.projectError !== null) errors.push({
|
|
6527
|
+
path: PROJECT_FILE,
|
|
6528
|
+
code: "PROJECT_UNREADABLE",
|
|
6529
|
+
message: snapshot.projectError
|
|
6530
|
+
});
|
|
6531
|
+
const chapter = snapshot.pipeline.chapter;
|
|
6532
|
+
if (snapshot.worldPlanError !== null) errors.push({
|
|
6533
|
+
path: planWorldFile(chapter),
|
|
6534
|
+
code: "WORLD_PLAN_UNREADABLE",
|
|
6535
|
+
message: snapshot.worldPlanError
|
|
6536
|
+
});
|
|
6537
|
+
if (snapshot.planError !== null) errors.push({
|
|
6538
|
+
path: planChapterFile(chapter),
|
|
6539
|
+
code: "PLAN_UNREADABLE",
|
|
6540
|
+
message: snapshot.planError
|
|
6541
|
+
});
|
|
6542
|
+
if (snapshot.reviewError !== null) errors.push({
|
|
6543
|
+
path: reviewFile(chapter),
|
|
6544
|
+
code: "REVIEW_UNREADABLE",
|
|
6545
|
+
message: snapshot.reviewError
|
|
6546
|
+
});
|
|
6547
|
+
if (snapshot.checkError !== null) errors.push({
|
|
6548
|
+
path: checkFile(chapter),
|
|
6549
|
+
code: "CHECK_UNREADABLE",
|
|
6550
|
+
message: snapshot.checkError
|
|
6551
|
+
});
|
|
6552
|
+
return errors;
|
|
6553
|
+
}
|
|
6554
|
+
/** The earliest rebuild point for the current state damage. */
|
|
6555
|
+
recoveryFor(snapshot) {
|
|
6556
|
+
const chapter = snapshot.pipeline.chapter;
|
|
6557
|
+
const errors = this.stateErrors(snapshot);
|
|
6558
|
+
if (snapshot.projectError !== null) return {
|
|
6559
|
+
tool: "novel_amend",
|
|
6560
|
+
args: {
|
|
6561
|
+
path: PROJECT_FILE,
|
|
6562
|
+
reason: `repair PROJECT_UNREADABLE: ${snapshot.projectError}`
|
|
6563
|
+
},
|
|
6564
|
+
reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; read novel_state novel for the raw document, then amend with a complete project document`
|
|
6565
|
+
};
|
|
6566
|
+
if (snapshot.ledgerErrors.length > 0) {
|
|
6567
|
+
const first = snapshot.ledgerErrors[0];
|
|
6568
|
+
return {
|
|
6569
|
+
tool: "novel_amend",
|
|
6570
|
+
args: {
|
|
6571
|
+
path: first.path,
|
|
6572
|
+
reason: `repair ${first.code}: ${first.message}`
|
|
6573
|
+
},
|
|
6574
|
+
reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; read novel_state for the raw document, then amend it with a complete replacement`
|
|
6575
|
+
};
|
|
6576
|
+
}
|
|
6577
|
+
const desired = snapshot.worldPlanError !== null ? "P1" : snapshot.planError !== null ? "P2" : snapshot.reviewError !== null ? "P4" : "P6";
|
|
6578
|
+
const target = this.clampRollbackTarget(snapshot.pipeline.stage, desired);
|
|
6579
|
+
const action = STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[snapshot.pipeline.stage] ? `rollback to ${target} and rebuild through the normal stage tools` : `repair the file at ${snapshot.pipeline.stage} with its normal stage tool`;
|
|
6580
|
+
return {
|
|
6581
|
+
tool: "novel_rollback",
|
|
6582
|
+
args: {
|
|
6583
|
+
chapter,
|
|
6584
|
+
to_stage: target
|
|
6585
|
+
},
|
|
6586
|
+
reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; ${action}`
|
|
6587
|
+
};
|
|
6588
|
+
}
|
|
6589
|
+
/** Never point at a stage later than the current one; rollback only moves backward. */
|
|
6590
|
+
clampRollbackTarget(stage, desired) {
|
|
6591
|
+
if (STAGE_RANK[ROUND_TRANSITIONS[desired]] <= STAGE_RANK[stage]) return desired;
|
|
6592
|
+
return {
|
|
6593
|
+
idle: "P1",
|
|
6594
|
+
P2: "P2",
|
|
6595
|
+
P3: "P3",
|
|
6596
|
+
P4: "P4",
|
|
6597
|
+
P5: "P5",
|
|
6598
|
+
P6: "P6",
|
|
6599
|
+
ready_to_archive: "P6",
|
|
6600
|
+
done: "P6"
|
|
6601
|
+
}[stage];
|
|
6602
|
+
}
|
|
6603
|
+
/** State damage that makes this operation unsafe to start. */
|
|
6604
|
+
stateGateBlock(snapshot, operation) {
|
|
6605
|
+
const planSensitive = [
|
|
6606
|
+
"submitDraft",
|
|
6607
|
+
"submitReview",
|
|
6608
|
+
"submitRevision",
|
|
6609
|
+
"runCheck",
|
|
6610
|
+
"archiveChapter"
|
|
6611
|
+
];
|
|
6612
|
+
const worldPlanSensitive = ["submitChapterPlan", ...planSensitive];
|
|
6613
|
+
const reviewSensitive = [
|
|
6614
|
+
"submitRevision",
|
|
6615
|
+
"runCheck",
|
|
6616
|
+
"archiveChapter"
|
|
6617
|
+
];
|
|
6618
|
+
const checkSensitive = ["archiveChapter"];
|
|
6619
|
+
const relevant = [...snapshot.ledgerErrors];
|
|
6620
|
+
if (snapshot.projectError !== null) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PROJECT_UNREADABLE"));
|
|
6621
|
+
if (snapshot.worldPlanError !== null && worldPlanSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "WORLD_PLAN_UNREADABLE"));
|
|
6622
|
+
if (snapshot.planError !== null && planSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PLAN_UNREADABLE"));
|
|
6623
|
+
if (snapshot.reviewError !== null && reviewSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "REVIEW_UNREADABLE"));
|
|
6624
|
+
if (snapshot.checkError !== null && checkSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "CHECK_UNREADABLE"));
|
|
6625
|
+
return relevant.map((error) => ({
|
|
6626
|
+
code: "STATE_UNREADABLE",
|
|
6627
|
+
scope: "state",
|
|
6628
|
+
detail: `${error.code}: ${error.message}`,
|
|
6629
|
+
hint: this.recoveryFor(snapshot).reason
|
|
6630
|
+
}));
|
|
5506
6631
|
}
|
|
5507
6632
|
/** Whether the stored brief still signs the current snapshot. */
|
|
5508
6633
|
briefIsCurrent(snapshot, stage) {
|
|
@@ -5552,7 +6677,7 @@ var EngineService = class {
|
|
|
5552
6677
|
}
|
|
5553
6678
|
if (chapters.length === 0 && snapshot.text !== null && (chapter === void 0 || chapter === snapshot.pipeline.chapter)) chapters.push({
|
|
5554
6679
|
num: snapshot.pipeline.chapter,
|
|
5555
|
-
title: snapshot.plan?.title ?? "未命名",
|
|
6680
|
+
title: stripTitlePrefix(snapshot.plan?.title ?? "") || "未命名",
|
|
5556
6681
|
text: snapshot.text
|
|
5557
6682
|
});
|
|
5558
6683
|
if (chapters.length === 0) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, "no chapter is available to export", "write or archive at least one chapter first");
|
|
@@ -5561,7 +6686,7 @@ var EngineService = class {
|
|
|
5561
6686
|
machine(snapshot, text, extra) {
|
|
5562
6687
|
return runMachineChecks(text, {
|
|
5563
6688
|
chapter: snapshot.pipeline.chapter,
|
|
5564
|
-
title: composeTitle(snapshot.pipeline.chapter, extra.title ?? snapshot.plan?.title ?? null),
|
|
6689
|
+
title: composeTitle(snapshot.pipeline.chapter, extra.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
|
|
5565
6690
|
genre: snapshot.project.genre,
|
|
5566
6691
|
sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
|
|
5567
6692
|
rulepack: this.rulepack,
|
|
@@ -5579,12 +6704,20 @@ var EngineService = class {
|
|
|
5579
6704
|
});
|
|
5580
6705
|
}
|
|
5581
6706
|
briefContextFor(stage, snapshot, chapter) {
|
|
6707
|
+
let cache = this.briefContextCache.get(snapshot);
|
|
6708
|
+
if (cache === void 0) {
|
|
6709
|
+
cache = /* @__PURE__ */ new Map();
|
|
6710
|
+
this.briefContextCache.set(snapshot, cache);
|
|
6711
|
+
}
|
|
6712
|
+
const cacheKey = `${stage}:${chapter}`;
|
|
6713
|
+
const cached = cache.get(cacheKey);
|
|
6714
|
+
if (cached !== void 0) return cached;
|
|
5582
6715
|
const machine = [
|
|
5583
6716
|
"P4",
|
|
5584
6717
|
"P5",
|
|
5585
6718
|
"P6"
|
|
5586
6719
|
].includes(stage) && snapshot.text !== null ? this.machine(snapshot, snapshot.text, { summary: snapshot.plan?.summary ?? null }) : void 0;
|
|
5587
|
-
|
|
6720
|
+
const context = briefContext({
|
|
5588
6721
|
stage,
|
|
5589
6722
|
chapter,
|
|
5590
6723
|
project: snapshot.project,
|
|
@@ -5596,15 +6729,21 @@ var EngineService = class {
|
|
|
5596
6729
|
foreshadows: snapshot.foreshadows,
|
|
5597
6730
|
timeline: snapshot.timeline,
|
|
5598
6731
|
relationships: snapshot.relationships,
|
|
6732
|
+
worldPlan: snapshot.worldPlan,
|
|
6733
|
+
world_plan_error: snapshot.worldPlanError,
|
|
5599
6734
|
plan: snapshot.plan,
|
|
5600
6735
|
plan_error: snapshot.planError,
|
|
5601
6736
|
review: snapshot.review,
|
|
6737
|
+
review_error: snapshot.reviewError,
|
|
5602
6738
|
check: snapshot.check,
|
|
6739
|
+
check_error: snapshot.checkError,
|
|
5603
6740
|
text: snapshot.text,
|
|
5604
6741
|
machine,
|
|
5605
6742
|
previousText: snapshot.previousText,
|
|
5606
6743
|
handoff: snapshot.handoff
|
|
5607
6744
|
});
|
|
6745
|
+
cache.set(cacheKey, context);
|
|
6746
|
+
return context;
|
|
5608
6747
|
}
|
|
5609
6748
|
briefDigest(stage, snapshot, context) {
|
|
5610
6749
|
const briefHash = this.briefs.render(stage, this.briefVariables(snapshot, snapshot.pipeline.chapter)).contentHash;
|
|
@@ -5632,15 +6771,19 @@ var EngineService = class {
|
|
|
5632
6771
|
round: snapshot.pipeline.stage === "P5" ? snapshot.pipeline.rounds.revise + 1 : snapshot.pipeline.rounds.review + 1
|
|
5633
6772
|
};
|
|
5634
6773
|
}
|
|
5635
|
-
|
|
6774
|
+
/**
|
|
6775
|
+
* The one place a gate input is assembled. Archive preflight evaluates the
|
|
6776
|
+
* same object as the real archive, so a gate cannot pass preflight and then
|
|
6777
|
+
* fail the write (the old PG-06/scope holes did exactly that).
|
|
6778
|
+
*/
|
|
6779
|
+
gateInput(snapshot, operation, extras = {}) {
|
|
5636
6780
|
const text = extras.text ?? snapshot.text ?? "";
|
|
5637
|
-
if (!canRun(snapshot.pipeline.stage, operation)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `${operation} is not allowed at stage ${snapshot.pipeline.stage}`, "call novel_status to see the next legal step");
|
|
5638
6781
|
const archive = operation === "archiveChapter" ? {
|
|
5639
6782
|
chapterExists: snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter),
|
|
5640
6783
|
checkPassed: snapshot.check?.verdict === "pass",
|
|
5641
6784
|
hashMatches: snapshot.check !== null && snapshot.text !== null && snapshot.check.content_hash === sha256(snapshot.text)
|
|
5642
6785
|
} : null;
|
|
5643
|
-
|
|
6786
|
+
return {
|
|
5644
6787
|
operation,
|
|
5645
6788
|
stage: snapshot.pipeline.stage,
|
|
5646
6789
|
text,
|
|
@@ -5650,7 +6793,7 @@ var EngineService = class {
|
|
|
5650
6793
|
chapters: snapshot.chapters,
|
|
5651
6794
|
briefDigest: extras.briefDigest ?? (extras.briefStage === void 0 || extras.briefStage === null ? null : this.briefDigest(extras.briefStage, snapshot, this.briefContextFor(extras.briefStage, snapshot, snapshot.pipeline.chapter))),
|
|
5652
6795
|
briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
|
|
5653
|
-
draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.plan?.title ?? null),
|
|
6796
|
+
draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
|
|
5654
6797
|
sceneType: extras.sceneType ?? snapshot.plan?.scene_type ?? null,
|
|
5655
6798
|
summary: extras.summary ?? snapshot.plan?.summary ?? null,
|
|
5656
6799
|
summaryProvided: extras.summaryProvided,
|
|
@@ -5658,6 +6801,7 @@ var EngineService = class {
|
|
|
5658
6801
|
check: extras.check ?? snapshot.check,
|
|
5659
6802
|
semantic: extras.semantic ?? [],
|
|
5660
6803
|
proof: extras.proof ?? null,
|
|
6804
|
+
scope: extras.scope,
|
|
5661
6805
|
rounds: snapshot.pipeline.rounds,
|
|
5662
6806
|
archive,
|
|
5663
6807
|
evidence: {
|
|
@@ -5671,7 +6815,13 @@ var EngineService = class {
|
|
|
5671
6815
|
previousText: snapshot.previousText,
|
|
5672
6816
|
previousTexts: snapshot.previousTexts,
|
|
5673
6817
|
priorSceneTypes: snapshot.chapters.chapters.map((record) => record.scene_type)
|
|
5674
|
-
}
|
|
6818
|
+
};
|
|
6819
|
+
}
|
|
6820
|
+
async assertGates(snapshot, operation, extras) {
|
|
6821
|
+
if (!canRun(snapshot.pipeline.stage, operation)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `${operation} is not allowed at stage ${snapshot.pipeline.stage}`, "call novel_status to see the next legal step");
|
|
6822
|
+
const stateBlocking = this.stateGateBlock(snapshot, operation);
|
|
6823
|
+
if (stateBlocking.length > 0) throw new EngineBlockedError(ERROR_CODES.GATE_BLOCKED, `${operation} blocked by unreadable state: ${stateBlocking[0].detail}`, stateBlocking, this.recoveryFor(snapshot).reason);
|
|
6824
|
+
const result = evaluateGates(this.gateInput(snapshot, operation, extras), {
|
|
5675
6825
|
rulepack: this.rulepack,
|
|
5676
6826
|
minScore: this.config.quality.minScore
|
|
5677
6827
|
});
|
|
@@ -5698,36 +6848,76 @@ var EngineService = class {
|
|
|
5698
6848
|
}
|
|
5699
6849
|
async load(root, retries = 1) {
|
|
5700
6850
|
const store = this.storeFor(await this.resolveRoot(root));
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
6851
|
+
const projectExists = await store.exists(PROJECT_FILE);
|
|
6852
|
+
if (!projectExists) {
|
|
6853
|
+
const stateFiles = [
|
|
6854
|
+
STATE_FILES.pipeline,
|
|
6855
|
+
STATE_FILES.chapters,
|
|
6856
|
+
STATE_FILES.characters,
|
|
6857
|
+
STATE_FILES.world,
|
|
6858
|
+
STATE_FILES.locks,
|
|
6859
|
+
STATE_FILES.timeline,
|
|
6860
|
+
STATE_FILES.foreshadows,
|
|
6861
|
+
STATE_FILES.relationships,
|
|
6862
|
+
STATE_FILES.handoff
|
|
6863
|
+
].map((file) => joinRel(STATE_DIR, file));
|
|
6864
|
+
if (!((await Promise.all(stateFiles.map((path) => store.exists(path)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0)) throw new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no novel project at ${store.root}`, "call novel_init to create one, or pass the correct project root");
|
|
6865
|
+
}
|
|
6866
|
+
let project = {
|
|
6867
|
+
...ENGINE_PROJECT_INITIAL,
|
|
6868
|
+
title: "(项目文件不可读)"
|
|
6869
|
+
};
|
|
6870
|
+
let projectError = projectExists ? null : `${PROJECT_FILE} is missing`;
|
|
6871
|
+
if (projectExists) try {
|
|
6872
|
+
const read = await store.readDoc(PROJECT_FILE, "project");
|
|
6873
|
+
if (read === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${PROJECT_FILE} disappeared mid-read`);
|
|
6874
|
+
project = read;
|
|
6875
|
+
} catch (error) {
|
|
6876
|
+
if (error instanceof EngineFaultError && (error.code === ERROR_CODES.SCHEMA_TOO_NEW || error.code === ERROR_CODES.PATH_ESCAPE)) throw error;
|
|
6877
|
+
projectError = errorMessage(error);
|
|
6878
|
+
}
|
|
5704
6879
|
const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
5705
6880
|
if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
|
|
5706
|
-
const
|
|
5707
|
-
const
|
|
5708
|
-
|
|
5709
|
-
|
|
6881
|
+
const ledgerErrors = [];
|
|
6882
|
+
const ledger = async (path, kind, code, fallback) => {
|
|
6883
|
+
const read = await this.recoverableDoc(store, path, kind);
|
|
6884
|
+
if (read.error !== null) ledgerErrors.push({
|
|
6885
|
+
path,
|
|
6886
|
+
code,
|
|
6887
|
+
message: read.error
|
|
6888
|
+
});
|
|
6889
|
+
else if (read.value === null) ledgerErrors.push({
|
|
6890
|
+
path,
|
|
6891
|
+
code,
|
|
6892
|
+
message: `${path} is missing`
|
|
6893
|
+
});
|
|
6894
|
+
return read.value ?? fallback;
|
|
6895
|
+
};
|
|
6896
|
+
const chapters = await ledger(joinRel(STATE_DIR, STATE_FILES.chapters), "chapters", "CHAPTERS_UNREADABLE", emptyChapters());
|
|
6897
|
+
const characters = await ledger(joinRel(STATE_DIR, STATE_FILES.characters), "characters", "CHARACTERS_UNREADABLE", emptyCharacters());
|
|
6898
|
+
const world = await ledger(joinRel(STATE_DIR, STATE_FILES.world), "world", "WORLD_UNREADABLE", emptyWorld());
|
|
6899
|
+
const locks = await ledger(joinRel(STATE_DIR, STATE_FILES.locks), "locks", "LOCKS_UNREADABLE", {
|
|
5710
6900
|
schema_version: 1,
|
|
5711
6901
|
locked_at_chapter: 0,
|
|
5712
6902
|
characters: {},
|
|
5713
6903
|
locations: {}
|
|
5714
|
-
};
|
|
5715
|
-
const timeline = await
|
|
6904
|
+
});
|
|
6905
|
+
const timeline = await ledger(joinRel(STATE_DIR, STATE_FILES.timeline), "timeline", "TIMELINE_UNREADABLE", {
|
|
5716
6906
|
schema_version: 1,
|
|
5717
6907
|
next_seq: 1,
|
|
5718
6908
|
items: []
|
|
5719
|
-
};
|
|
5720
|
-
const foreshadows = await
|
|
6909
|
+
});
|
|
6910
|
+
const foreshadows = await ledger(joinRel(STATE_DIR, STATE_FILES.foreshadows), "foreshadows", "FORESHADOWS_UNREADABLE", {
|
|
5721
6911
|
schema_version: 1,
|
|
5722
6912
|
next_seq: 1,
|
|
5723
6913
|
items: []
|
|
5724
|
-
};
|
|
5725
|
-
const relationships = await
|
|
6914
|
+
});
|
|
6915
|
+
const relationships = await ledger(joinRel(STATE_DIR, STATE_FILES.relationships), "relationships", "RELATIONSHIPS_UNREADABLE", {
|
|
5726
6916
|
schema_version: 1,
|
|
5727
6917
|
next_seq: 1,
|
|
5728
6918
|
items: []
|
|
5729
|
-
};
|
|
5730
|
-
const handoff = await
|
|
6919
|
+
});
|
|
6920
|
+
const handoff = await ledger(joinRel(STATE_DIR, STATE_FILES.handoff), "handoff", "HANDOFF_UNREADABLE", null);
|
|
5731
6921
|
const chapter = pipeline.chapter;
|
|
5732
6922
|
const text = await store.readText(chapterFile(chapter));
|
|
5733
6923
|
const previousText = chapter > 1 ? await store.readText(chapterFile(chapter - 1)) : null;
|
|
@@ -5740,12 +6930,36 @@ var EngineService = class {
|
|
|
5740
6930
|
let plan = null;
|
|
5741
6931
|
let planError = null;
|
|
5742
6932
|
if (planRaw !== null) try {
|
|
5743
|
-
|
|
6933
|
+
const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
|
|
6934
|
+
validateStoredChapterPlan(parsed, planChapterFile(chapter));
|
|
6935
|
+
plan = parsed;
|
|
6936
|
+
} catch (error) {
|
|
6937
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
6938
|
+
planError = errorMessage(error);
|
|
6939
|
+
}
|
|
6940
|
+
const worldPlanPath = planWorldFile(chapter);
|
|
6941
|
+
const worldPlanRaw = await store.readText(worldPlanPath);
|
|
6942
|
+
let worldPlan = null;
|
|
6943
|
+
let worldPlanError = null;
|
|
6944
|
+
if (worldPlanRaw !== null) try {
|
|
6945
|
+
const narrowed = narrow("worldPlan", parsePlanYaml(worldPlanRaw, worldPlanPath));
|
|
6946
|
+
validateWorldPlanShape(narrowed, worldPlanPath);
|
|
6947
|
+
worldPlan = narrowed;
|
|
5744
6948
|
} catch (error) {
|
|
5745
|
-
|
|
6949
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
6950
|
+
worldPlanError = errorMessage(error);
|
|
5746
6951
|
}
|
|
5747
|
-
const
|
|
5748
|
-
const
|
|
6952
|
+
const reviewRead = await this.recoverableDoc(store, reviewFile(chapter), "review");
|
|
6953
|
+
const checkRead = await this.recoverableDoc(store, checkFile(chapter), "check");
|
|
6954
|
+
const review = reviewRead.value;
|
|
6955
|
+
let reviewError = reviewRead.error;
|
|
6956
|
+
const check = checkRead.value;
|
|
6957
|
+
let checkError = checkRead.error;
|
|
6958
|
+
const cycleRank = pipeline.stage === "done" ? STAGE_RANK.idle : STAGE_RANK[pipeline.stage];
|
|
6959
|
+
if (worldPlanRaw === null && worldPlanError === null && cycleRank >= STAGE_RANK.P2) worldPlanError = `${planWorldFile(chapter)} is missing`;
|
|
6960
|
+
if (planRaw === null && planError === null && cycleRank >= STAGE_RANK.P3) planError = `${planChapterFile(chapter)} is missing`;
|
|
6961
|
+
if (review === null && reviewError === null && cycleRank >= STAGE_RANK.P5) reviewError = `${reviewFile(chapter)} is missing`;
|
|
6962
|
+
if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
|
|
5749
6963
|
const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
5750
6964
|
if (latest === null || latest.revision !== pipeline.revision) {
|
|
5751
6965
|
if (retries > 0) return this.load(store.root, retries - 1);
|
|
@@ -5755,6 +6969,7 @@ var EngineService = class {
|
|
|
5755
6969
|
return {
|
|
5756
6970
|
root: store.root,
|
|
5757
6971
|
project,
|
|
6972
|
+
projectError,
|
|
5758
6973
|
pipeline,
|
|
5759
6974
|
chapters,
|
|
5760
6975
|
characters,
|
|
@@ -5764,15 +6979,46 @@ var EngineService = class {
|
|
|
5764
6979
|
foreshadows,
|
|
5765
6980
|
relationships,
|
|
5766
6981
|
handoff,
|
|
6982
|
+
worldPlan,
|
|
6983
|
+
ledgerErrors,
|
|
5767
6984
|
text,
|
|
5768
6985
|
previousText,
|
|
5769
6986
|
previousTexts,
|
|
5770
6987
|
plan,
|
|
5771
6988
|
planError,
|
|
6989
|
+
worldPlanError,
|
|
5772
6990
|
review,
|
|
5773
|
-
|
|
6991
|
+
reviewError,
|
|
6992
|
+
check,
|
|
6993
|
+
checkError
|
|
5774
6994
|
};
|
|
5775
6995
|
}
|
|
6996
|
+
/** A repair may bypass the journal hash only when the document is unreadable. */
|
|
6997
|
+
async stateDocIsUnreadable(store, path, kind) {
|
|
6998
|
+
if (await store.readText(path) === null) return true;
|
|
6999
|
+
try {
|
|
7000
|
+
await store.readDoc(path, kind);
|
|
7001
|
+
return false;
|
|
7002
|
+
} catch (error) {
|
|
7003
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) return false;
|
|
7004
|
+
return true;
|
|
7005
|
+
}
|
|
7006
|
+
}
|
|
7007
|
+
/** Read a review/check without letting one corrupt file wedge every read. */
|
|
7008
|
+
async recoverableDoc(store, path, kind) {
|
|
7009
|
+
try {
|
|
7010
|
+
return {
|
|
7011
|
+
value: await store.readDoc(path, kind),
|
|
7012
|
+
error: null
|
|
7013
|
+
};
|
|
7014
|
+
} catch (error) {
|
|
7015
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
7016
|
+
return {
|
|
7017
|
+
value: null,
|
|
7018
|
+
error: errorMessage(error)
|
|
7019
|
+
};
|
|
7020
|
+
}
|
|
7021
|
+
}
|
|
5776
7022
|
async mutate(options) {
|
|
5777
7023
|
const root = await this.resolveRoot(options.root);
|
|
5778
7024
|
const signal = this.signalContext.getStore();
|
|
@@ -5834,7 +7080,7 @@ var EngineService = class {
|
|
|
5834
7080
|
};
|
|
5835
7081
|
}
|
|
5836
7082
|
}
|
|
5837
|
-
await assertJournalConsistent(store);
|
|
7083
|
+
await assertJournalConsistent(store, options.repairPaths ?? []);
|
|
5838
7084
|
const snapshot = await this.load(root);
|
|
5839
7085
|
const key = options.key?.(snapshot);
|
|
5840
7086
|
if (key !== void 0) {
|
|
@@ -5895,6 +7141,7 @@ var EngineService = class {
|
|
|
5895
7141
|
return {
|
|
5896
7142
|
root,
|
|
5897
7143
|
project: { ...ENGINE_PROJECT_INITIAL },
|
|
7144
|
+
projectError: null,
|
|
5898
7145
|
pipeline: emptyPipeline({
|
|
5899
7146
|
revision: 1,
|
|
5900
7147
|
chapter: 1,
|
|
@@ -5925,13 +7172,18 @@ var EngineService = class {
|
|
|
5925
7172
|
items: []
|
|
5926
7173
|
},
|
|
5927
7174
|
handoff: null,
|
|
7175
|
+
worldPlan: null,
|
|
7176
|
+
ledgerErrors: [],
|
|
5928
7177
|
text: null,
|
|
5929
7178
|
previousText: null,
|
|
5930
7179
|
previousTexts: [],
|
|
5931
7180
|
plan: null,
|
|
5932
7181
|
planError: null,
|
|
7182
|
+
worldPlanError: null,
|
|
5933
7183
|
review: null,
|
|
5934
|
-
|
|
7184
|
+
reviewError: null,
|
|
7185
|
+
check: null,
|
|
7186
|
+
checkError: null
|
|
5935
7187
|
};
|
|
5936
7188
|
}
|
|
5937
7189
|
};
|
|
@@ -6007,18 +7259,415 @@ const machineWarnings = (machine) => machine.violations.filter((violation) => vi
|
|
|
6007
7259
|
code: violation.code,
|
|
6008
7260
|
message: `${violation.detail} (paragraph ${violation.location.paragraph})`
|
|
6009
7261
|
}));
|
|
6010
|
-
/**
|
|
6011
|
-
const
|
|
6012
|
-
if (title === null) return null;
|
|
6013
|
-
return /^第\s*\d+\s*章/.test(title) ? title : `第${chapter}章 ${title}`;
|
|
6014
|
-
};
|
|
6015
|
-
const stableJson = (value) => {
|
|
7262
|
+
/** 0.1.2 archive digests used localeCompare; keep that comparison only for upgrades. */
|
|
7263
|
+
const legacyStableJson = (value) => {
|
|
6016
7264
|
const normalize = (input) => {
|
|
6017
7265
|
if (Array.isArray(input)) return input.map(normalize);
|
|
6018
|
-
if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).filter(([,
|
|
7266
|
+
if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, normalize(item)]));
|
|
6019
7267
|
return input;
|
|
6020
7268
|
};
|
|
6021
|
-
return JSON.stringify(normalize(value));
|
|
7269
|
+
return JSON.stringify(normalize(value)) ?? "null";
|
|
7270
|
+
};
|
|
7271
|
+
const normalizeHandoffOverride = (value) => {
|
|
7272
|
+
if (value === void 0 || value === null) return void 0;
|
|
7273
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff must be an object");
|
|
7274
|
+
const volume = value.volume;
|
|
7275
|
+
if (volume !== void 0 && (!Number.isInteger(volume) || volume < 1)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.volume must be a positive integer");
|
|
7276
|
+
const volumeName = value.volume_name;
|
|
7277
|
+
if (volumeName !== void 0 && (typeof volumeName !== "string" || volumeName.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.volume_name must be non-empty");
|
|
7278
|
+
const act = value.act;
|
|
7279
|
+
if (act !== void 0 && (typeof act !== "string" || act.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.act must be non-empty");
|
|
7280
|
+
const actProgress = value.act_progress;
|
|
7281
|
+
if (actProgress !== void 0 && parseActProgress(actProgress) === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "handoff.act_progress must be n/d with a positive denominator and n <= d");
|
|
7282
|
+
return {
|
|
7283
|
+
...volume === void 0 ? {} : { volume },
|
|
7284
|
+
...volumeName === void 0 ? {} : { volume_name: volumeName.trim() },
|
|
7285
|
+
...act === void 0 ? {} : { act: act.trim() },
|
|
7286
|
+
...actProgress === void 0 ? {} : { act_progress: actProgress.trim() }
|
|
7287
|
+
};
|
|
7288
|
+
};
|
|
7289
|
+
const legacyArchiveDigest = (input) => sha256(legacyStableJson({
|
|
7290
|
+
chapter: input.chapter,
|
|
7291
|
+
state_delta: input.state_delta,
|
|
7292
|
+
handoff: input.handoff
|
|
7293
|
+
}));
|
|
7294
|
+
const LENIENT_TITLE_PREFIX = /^第\s*\d+\s*章\s*/;
|
|
7295
|
+
/** Titles are stored as bodies; the format rule judges the rendered heading. */
|
|
7296
|
+
const stripTitlePrefix = (title) => title.replace(LENIENT_TITLE_PREFIX, "").trim();
|
|
7297
|
+
const composeTitle = (chapter, title, pattern) => {
|
|
7298
|
+
if (title === null) return null;
|
|
7299
|
+
const text = title.trim();
|
|
7300
|
+
if (pattern !== void 0 && new RegExp(pattern).test(text)) return text;
|
|
7301
|
+
const body = stripTitlePrefix(text);
|
|
7302
|
+
return `第${chapter}章 ${body === "" ? text : body}`;
|
|
7303
|
+
};
|
|
7304
|
+
const errorMessage = (error) => error instanceof Error ? `${error.message}${error.cause instanceof Error ? `: ${error.cause.message}` : ""}` : String(error);
|
|
7305
|
+
const recordOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
7306
|
+
const mergeProjectAmendment = (content, old) => {
|
|
7307
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
7308
|
+
"schema_version",
|
|
7309
|
+
"title",
|
|
7310
|
+
"author",
|
|
7311
|
+
"description",
|
|
7312
|
+
"targets",
|
|
7313
|
+
"total_chapters"
|
|
7314
|
+
]);
|
|
7315
|
+
for (const key of Object.keys(content)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel.yaml amend cannot change "${key}"`, "only title, author, description and targets.total_chapters are mutable");
|
|
7316
|
+
const merged = {
|
|
7317
|
+
...old,
|
|
7318
|
+
...content
|
|
7319
|
+
};
|
|
7320
|
+
if (content.targets !== void 0) {
|
|
7321
|
+
const targets = recordOf(content.targets);
|
|
7322
|
+
if (targets === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "targets must be an object");
|
|
7323
|
+
const allowedTargets = /* @__PURE__ */ new Set([
|
|
7324
|
+
"words_per_chapter",
|
|
7325
|
+
"dialogue_ratio",
|
|
7326
|
+
"total_chapters"
|
|
7327
|
+
]);
|
|
7328
|
+
for (const key of Object.keys(targets)) if (!allowedTargets.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel.yaml targets cannot change "${key}"`, "only targets.total_chapters is mutable; words_per_chapter and dialogue_ratio are immutable");
|
|
7329
|
+
merged.targets = {
|
|
7330
|
+
...old.targets,
|
|
7331
|
+
...targets
|
|
7332
|
+
};
|
|
7333
|
+
}
|
|
7334
|
+
if (Object.hasOwn(content, "total_chapters")) merged.targets = {
|
|
7335
|
+
...recordOf(merged.targets) ?? {},
|
|
7336
|
+
total_chapters: content.total_chapters
|
|
7337
|
+
};
|
|
7338
|
+
delete merged.total_chapters;
|
|
7339
|
+
return narrow("project", merged);
|
|
7340
|
+
};
|
|
7341
|
+
const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archivedFiles = 0, projectBaseline = snapshot.project, projectDamaged = false, archiveDigests = {}) => {
|
|
7342
|
+
const problems = [];
|
|
7343
|
+
const old = recordOf(oldDocument);
|
|
7344
|
+
if (kind === "project") {
|
|
7345
|
+
const next = nextDocument;
|
|
7346
|
+
const targets = recordOf(next.targets);
|
|
7347
|
+
if (typeof next.title !== "string" || next.title.trim() === "") problems.push("title must not be empty");
|
|
7348
|
+
if (!projectDamaged) {
|
|
7349
|
+
if (next.genre !== projectBaseline.genre) problems.push("genre is immutable");
|
|
7350
|
+
if (next.platform !== projectBaseline.platform) problems.push("platform is immutable");
|
|
7351
|
+
if (next.created_at !== projectBaseline.created_at) problems.push("created_at is immutable");
|
|
7352
|
+
}
|
|
7353
|
+
const oldTargets = projectBaseline.targets;
|
|
7354
|
+
if (targets === null) problems.push("targets must be an object");
|
|
7355
|
+
else {
|
|
7356
|
+
if (!projectDamaged && (JSON.stringify(targets.words_per_chapter) !== JSON.stringify(oldTargets.words_per_chapter) || JSON.stringify(targets.dialogue_ratio) !== JSON.stringify(oldTargets.dialogue_ratio))) problems.push("words_per_chapter and dialogue_ratio are immutable; only total_chapters may change");
|
|
7357
|
+
const total = targets.total_chapters;
|
|
7358
|
+
if (total !== null && (!Number.isInteger(total) || total < 1)) problems.push("targets.total_chapters must be a positive integer or null");
|
|
7359
|
+
const archivedCount = Math.max(snapshot.chapters.chapters.length, archivedFiles);
|
|
7360
|
+
if (typeof total === "number" && Number.isInteger(total) && total < archivedCount) problems.push(`targets.total_chapters ${total} is below the ${archivedCount} archived chapter(s)`);
|
|
7361
|
+
}
|
|
7362
|
+
return problems;
|
|
7363
|
+
}
|
|
7364
|
+
if (kind === "chapters") {
|
|
7365
|
+
const active = snapshot.pipeline.chapter;
|
|
7366
|
+
const oldRows = Array.isArray(old?.chapters) ? old.chapters : [];
|
|
7367
|
+
const nextRows = Array.isArray(nextDocument.chapters) ? nextDocument.chapters : [];
|
|
7368
|
+
const rowNum = (raw) => {
|
|
7369
|
+
const row = recordOf(raw);
|
|
7370
|
+
return row !== null && Number.isInteger(row.num) ? row.num : null;
|
|
7371
|
+
};
|
|
7372
|
+
const oldArchived = oldRows.filter((raw) => {
|
|
7373
|
+
const num = rowNum(raw);
|
|
7374
|
+
return num !== null && num < active;
|
|
7375
|
+
});
|
|
7376
|
+
const nextArchived = nextRows.filter((raw) => {
|
|
7377
|
+
const num = rowNum(raw);
|
|
7378
|
+
return num !== null && num < active;
|
|
7379
|
+
});
|
|
7380
|
+
if (nextArchived.length < oldArchived.length) problems.push("archived chapters may not be deleted");
|
|
7381
|
+
const archivedCount = Math.max(0, active - 1, archivedFiles);
|
|
7382
|
+
if (nextArchived.length < archivedCount) problems.push(`chapters index has ${nextArchived.length} archived rows but ${archivedCount} chapter(s) are archived`);
|
|
7383
|
+
const byNum = /* @__PURE__ */ new Map();
|
|
7384
|
+
for (const [index, raw] of nextRows.entries()) {
|
|
7385
|
+
const row = recordOf(raw);
|
|
7386
|
+
if (row === null || !Number.isInteger(row.num)) {
|
|
7387
|
+
problems.push(`chapters[${index}] is not a valid record`);
|
|
7388
|
+
continue;
|
|
7389
|
+
}
|
|
7390
|
+
const num = row.num;
|
|
7391
|
+
if (num !== index + 1) problems.push(`chapters[${index}].num must be ${index + 1}`);
|
|
7392
|
+
if (num >= active) problems.push(`chapter ${num} is active or in the future; the index may contain only archived chapters`);
|
|
7393
|
+
if (byNum.has(num)) problems.push(`chapter ${num} appears more than once`);
|
|
7394
|
+
byNum.set(num, row);
|
|
7395
|
+
const knownDigest = archiveDigests[String(num)];
|
|
7396
|
+
if (knownDigest !== void 0 && row.archive_digest !== knownDigest) problems.push(`archived chapter ${num} cannot change its archive_digest`);
|
|
7397
|
+
}
|
|
7398
|
+
for (const raw of oldArchived) {
|
|
7399
|
+
const before = recordOf(raw);
|
|
7400
|
+
if (before === null || !Number.isInteger(before.num)) continue;
|
|
7401
|
+
const after = byNum.get(before.num);
|
|
7402
|
+
if (after === void 0) {
|
|
7403
|
+
problems.push(`archived chapter ${String(before.num)} is missing`);
|
|
7404
|
+
continue;
|
|
7405
|
+
}
|
|
7406
|
+
if (before.content_hash !== after.content_hash) problems.push(`archived chapter ${String(before.num)} cannot change its content_hash`);
|
|
7407
|
+
if (before.archive_digest !== after.archive_digest) problems.push(`archived chapter ${String(before.num)} cannot change its archive_digest`);
|
|
7408
|
+
if (stableJson(before.assigned_ids ?? {}) !== stableJson(after.assigned_ids ?? {})) problems.push(`archived chapter ${String(before.num)} cannot change its assigned_ids`);
|
|
7409
|
+
}
|
|
7410
|
+
return problems;
|
|
7411
|
+
}
|
|
7412
|
+
if (kind === "timeline" || kind === "foreshadows" || kind === "relationships") {
|
|
7413
|
+
if (snapshot.ledgerErrors.some((error) => error.code === "CHAPTERS_UNREADABLE")) {
|
|
7414
|
+
problems.push("cannot validate next_seq while chapters.yaml is unreadable");
|
|
7415
|
+
return problems;
|
|
7416
|
+
}
|
|
7417
|
+
if (kind === "foreshadows" && snapshot.ledgerErrors.some((error) => error.code === "HANDOFF_UNREADABLE")) {
|
|
7418
|
+
problems.push("cannot validate next_seq while handoff.yaml is unreadable");
|
|
7419
|
+
return problems;
|
|
7420
|
+
}
|
|
7421
|
+
const items = Array.isArray(nextDocument.items) ? nextDocument.items : [];
|
|
7422
|
+
const ids = /* @__PURE__ */ new Set();
|
|
7423
|
+
let maxSuffix = 0;
|
|
7424
|
+
for (const [index, raw] of items.entries()) {
|
|
7425
|
+
const row = recordOf(raw);
|
|
7426
|
+
if (row === null || typeof row.id !== "string" || row.id === "") {
|
|
7427
|
+
problems.push(`items[${index}].id must be a non-empty string`);
|
|
7428
|
+
continue;
|
|
7429
|
+
}
|
|
7430
|
+
if (ids.has(row.id)) problems.push(`item id "${row.id}" appears more than once`);
|
|
7431
|
+
ids.add(row.id);
|
|
7432
|
+
const match = /-(\d+)$/.exec(row.id);
|
|
7433
|
+
if (match !== null) maxSuffix = Math.max(maxSuffix, Number(match[1]));
|
|
7434
|
+
}
|
|
7435
|
+
const nextSeq = nextDocument.next_seq;
|
|
7436
|
+
const prefix = kind === "foreshadows" ? "FS-" : kind === "timeline" ? "TE-" : "RL-";
|
|
7437
|
+
let engineFloor = 0;
|
|
7438
|
+
for (const chapter of snapshot.chapters.chapters) for (const value of Object.values(chapter.assigned_ids ?? {})) {
|
|
7439
|
+
if (typeof value !== "string" || !value.startsWith(prefix)) continue;
|
|
7440
|
+
const match = /-(\d+)$/.exec(value);
|
|
7441
|
+
if (match !== null) engineFloor = Math.max(engineFloor, Number(match[1]));
|
|
7442
|
+
}
|
|
7443
|
+
if (kind === "foreshadows" && snapshot.handoff !== null) for (const hook of snapshot.handoff.open_hooks) {
|
|
7444
|
+
if (!hook.id.startsWith(prefix)) continue;
|
|
7445
|
+
const match = /-(\d+)$/.exec(hook.id);
|
|
7446
|
+
if (match !== null) engineFloor = Math.max(engineFloor, Number(match[1]));
|
|
7447
|
+
}
|
|
7448
|
+
if (!Number.isInteger(nextSeq) || nextSeq < 1) problems.push("next_seq must be a positive integer");
|
|
7449
|
+
else {
|
|
7450
|
+
if (nextSeq < maxSuffix + 1) problems.push(`next_seq ${String(nextSeq)} would collide with existing id suffix ${maxSuffix}`);
|
|
7451
|
+
if (nextSeq < items.length + 1) problems.push(`next_seq ${String(nextSeq)} is below items.length + 1`);
|
|
7452
|
+
if (nextSeq < engineFloor + 1) problems.push(`next_seq ${String(nextSeq)} would reuse an id already assigned by an archived chapter (${prefix}${String(engineFloor).padStart(3, "0")})`);
|
|
7453
|
+
const oldSeq = old?.next_seq;
|
|
7454
|
+
if (typeof oldSeq === "number" && nextSeq < oldSeq) problems.push(`next_seq cannot decrease from ${oldSeq} to ${String(nextSeq)}`);
|
|
7455
|
+
}
|
|
7456
|
+
if (kind !== "relationships") return problems;
|
|
7457
|
+
}
|
|
7458
|
+
if (kind === "characters") {
|
|
7459
|
+
const names = new Set((Array.isArray(nextDocument.characters) ? nextDocument.characters : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
|
|
7460
|
+
if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
|
|
7461
|
+
for (const name of Object.keys(snapshot.locks.characters)) if (!names.has(name)) problems.push(`lock for removed character "${name}" still exists`);
|
|
7462
|
+
}
|
|
7463
|
+
if (!snapshot.ledgerErrors.some((error) => error.code === "RELATIONSHIPS_UNREADABLE")) {
|
|
7464
|
+
for (const relationship of snapshot.relationships.items) if (!names.has(relationship.a) || !names.has(relationship.b)) problems.push(`relationship ${relationship.id} would reference a removed character`);
|
|
7465
|
+
}
|
|
7466
|
+
return problems;
|
|
7467
|
+
}
|
|
7468
|
+
if (kind === "world") {
|
|
7469
|
+
const locations = new Set((Array.isArray(nextDocument.locations) ? nextDocument.locations : []).map((raw) => recordOf(raw)?.name).filter((name) => typeof name === "string"));
|
|
7470
|
+
if (!snapshot.ledgerErrors.some((error) => error.code === "LOCKS_UNREADABLE")) {
|
|
7471
|
+
for (const name of Object.keys(snapshot.locks.locations)) if (!locations.has(name)) problems.push(`lock for removed location "${name}" still exists`);
|
|
7472
|
+
}
|
|
7473
|
+
return problems;
|
|
7474
|
+
}
|
|
7475
|
+
if (kind === "locks") {
|
|
7476
|
+
const characters = new Set(snapshot.characters.characters.map((character) => character.name));
|
|
7477
|
+
const locations = new Set(snapshot.world.locations.map((location) => location.name));
|
|
7478
|
+
if (snapshot.ledgerErrors.some((error) => error.code === "CHARACTERS_UNREADABLE")) problems.push("cannot validate locks while characters.yaml is unreadable");
|
|
7479
|
+
if (snapshot.ledgerErrors.some((error) => error.code === "WORLD_UNREADABLE")) problems.push("cannot validate locks while world.yaml is unreadable");
|
|
7480
|
+
for (const name of Object.keys(recordOf(nextDocument.characters) ?? {})) if (!characters.has(name)) problems.push(`lock references unknown character "${name}"`);
|
|
7481
|
+
for (const name of Object.keys(recordOf(nextDocument.locations) ?? {})) if (!locations.has(name)) problems.push(`lock references unknown location "${name}"`);
|
|
7482
|
+
return problems;
|
|
7483
|
+
}
|
|
7484
|
+
if (kind === "relationships") {
|
|
7485
|
+
if (snapshot.ledgerErrors.some((error) => error.code === "CHARACTERS_UNREADABLE")) {
|
|
7486
|
+
problems.push("cannot validate relationships while characters.yaml is unreadable");
|
|
7487
|
+
return problems;
|
|
7488
|
+
}
|
|
7489
|
+
const names = new Set(snapshot.characters.characters.map((character) => character.name));
|
|
7490
|
+
for (const raw of Array.isArray(nextDocument.items) ? nextDocument.items : []) {
|
|
7491
|
+
const row = recordOf(raw);
|
|
7492
|
+
if (row !== null && (typeof row.a !== "string" || typeof row.b !== "string")) problems.push("relationship endpoints must be strings");
|
|
7493
|
+
else if (row !== null && (!names.has(row.a) || !names.has(row.b))) problems.push(`relationship ${String(row.id)} references an unknown character`);
|
|
7494
|
+
}
|
|
7495
|
+
return problems;
|
|
7496
|
+
}
|
|
7497
|
+
if (kind === "handoff") {
|
|
7498
|
+
const current = nextDocument.current_chapter;
|
|
7499
|
+
const next = nextDocument.next_chapter;
|
|
7500
|
+
if (!Number.isInteger(current) || current < 0) problems.push("handoff.current_chapter must be a non-negative integer");
|
|
7501
|
+
if (!Number.isInteger(next) || next < 1) problems.push("handoff.next_chapter must be a positive integer");
|
|
7502
|
+
else if (typeof current === "number" && Number.isInteger(current) && next !== current + 1) problems.push("handoff.next_chapter must equal current_chapter + 1");
|
|
7503
|
+
if (!Number.isInteger(nextDocument.volume) || nextDocument.volume < 1) problems.push("handoff.volume must be a positive integer");
|
|
7504
|
+
for (const field of ["volume_name", "act"]) if (typeof nextDocument[field] !== "string" || nextDocument[field].trim() === "") problems.push(`handoff.${field} must be non-empty`);
|
|
7505
|
+
if (parseActProgress(nextDocument.act_progress) === null) problems.push("handoff.act_progress must be n/d with a positive denominator and n <= d");
|
|
7506
|
+
if (!snapshot.ledgerErrors.some((error) => error.code === "CHAPTERS_UNREADABLE")) {
|
|
7507
|
+
const archivedMax = snapshot.chapters.chapters.reduce((max, record) => Math.max(max, record.num), 0);
|
|
7508
|
+
if (typeof current === "number" && Number.isInteger(current) && current !== archivedMax) problems.push(`handoff.current_chapter ${String(current)} must match archived chapter ${archivedMax}`);
|
|
7509
|
+
}
|
|
7510
|
+
const hooks = Array.isArray(nextDocument.open_hooks) ? nextDocument.open_hooks : [];
|
|
7511
|
+
const seenHooks = /* @__PURE__ */ new Set();
|
|
7512
|
+
if (!snapshot.ledgerErrors.some((error) => error.code === "FORESHADOWS_UNREADABLE")) {
|
|
7513
|
+
const openById = new Map(snapshot.foreshadows.items.map((item) => [item.id, item]));
|
|
7514
|
+
for (const raw of hooks) {
|
|
7515
|
+
const hook = recordOf(raw);
|
|
7516
|
+
if (hook === null || typeof hook.id !== "string" || typeof hook.title !== "string") {
|
|
7517
|
+
problems.push("handoff.open_hooks must contain id/title strings");
|
|
7518
|
+
continue;
|
|
7519
|
+
}
|
|
7520
|
+
if (seenHooks.has(hook.id)) problems.push(`handoff.open_hooks duplicate ${hook.id}`);
|
|
7521
|
+
seenHooks.add(hook.id);
|
|
7522
|
+
const known = openById.get(hook.id);
|
|
7523
|
+
if (known === void 0) problems.push(`handoff.open_hooks references unknown foreshadow ${hook.id}`);
|
|
7524
|
+
else if (known.title !== hook.title) problems.push(`handoff.open_hooks title for ${hook.id} does not match foreshadows`);
|
|
7525
|
+
}
|
|
7526
|
+
}
|
|
7527
|
+
return problems;
|
|
7528
|
+
}
|
|
7529
|
+
return problems;
|
|
7530
|
+
};
|
|
7531
|
+
const castShapeProblems = (value) => {
|
|
7532
|
+
if (!Array.isArray(value)) return ["cast must be an array"];
|
|
7533
|
+
if (value.length < 1 || value.length > 8) return ["cast must contain 1..8 members"];
|
|
7534
|
+
const problems = [];
|
|
7535
|
+
const names = /* @__PURE__ */ new Set();
|
|
7536
|
+
for (const [index, raw] of value.entries()) {
|
|
7537
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
7538
|
+
problems.push(`cast[${index}] must be an object`);
|
|
7539
|
+
continue;
|
|
7540
|
+
}
|
|
7541
|
+
const member = raw;
|
|
7542
|
+
if (typeof member.name !== "string" || member.name.trim() === "") {
|
|
7543
|
+
problems.push(`cast[${index}].name must be non-empty`);
|
|
7544
|
+
continue;
|
|
7545
|
+
}
|
|
7546
|
+
if (names.has(member.name)) problems.push(`cast contains duplicate name "${member.name}"`);
|
|
7547
|
+
names.add(member.name);
|
|
7548
|
+
for (const field of [
|
|
7549
|
+
"entry_state",
|
|
7550
|
+
"goal",
|
|
7551
|
+
"exit_change"
|
|
7552
|
+
]) if (member[field] !== void 0 && typeof member[field] !== "string") problems.push(`cast[${index}].${field} must be a string`);
|
|
7553
|
+
if (member.knowledge !== void 0 && (!Array.isArray(member.knowledge) || member.knowledge.some((item) => typeof item !== "string"))) problems.push(`cast[${index}].knowledge must be a string array`);
|
|
7554
|
+
}
|
|
7555
|
+
return problems;
|
|
7556
|
+
};
|
|
7557
|
+
const normalizeCast = (value, snapshot) => {
|
|
7558
|
+
if (value === void 0) return void 0;
|
|
7559
|
+
const problems = castShapeProblems(value);
|
|
7560
|
+
if (problems.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, problems.join("; "));
|
|
7561
|
+
const members = value;
|
|
7562
|
+
const known = /* @__PURE__ */ new Set([...(snapshot.worldPlan?.characters ?? []).map((character) => character.name), ...snapshot.characters.characters.map((character) => character.name)]);
|
|
7563
|
+
for (const member of members) if (!known.has(member.name)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cast member "${member.name}" is not declared in the P1 world plan or the character ledger`, "add the character to novel_plan_world first; cast cannot invent an actor");
|
|
7564
|
+
return members;
|
|
7565
|
+
};
|
|
7566
|
+
/** The summary fields the read model dereferences at P3–P6 and in the panel. */
|
|
7567
|
+
const summaryContextProblems = (value) => {
|
|
7568
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return ["summary must be an object when present"];
|
|
7569
|
+
const summary = value;
|
|
7570
|
+
const problems = [];
|
|
7571
|
+
const strings = (key) => {
|
|
7572
|
+
if (!Array.isArray(summary[key]) || summary[key].some((item) => typeof item !== "string")) problems.push(`summary.${key} must be a string array`);
|
|
7573
|
+
};
|
|
7574
|
+
strings("events");
|
|
7575
|
+
strings("characters_appeared");
|
|
7576
|
+
strings("pleasure_points");
|
|
7577
|
+
const foreshadows = recordOf(summary.foreshadows);
|
|
7578
|
+
if (foreshadows === null) problems.push("summary.foreshadows must be an object");
|
|
7579
|
+
else for (const key of [
|
|
7580
|
+
"planted",
|
|
7581
|
+
"advanced",
|
|
7582
|
+
"resolved"
|
|
7583
|
+
]) if (!Array.isArray(foreshadows[key])) problems.push(`summary.foreshadows.${key} must be an array`);
|
|
7584
|
+
const elements = recordOf(summary.new_elements);
|
|
7585
|
+
if (elements === null) problems.push("summary.new_elements must be an object");
|
|
7586
|
+
else for (const key of [
|
|
7587
|
+
"locations",
|
|
7588
|
+
"abilities",
|
|
7589
|
+
"artifacts"
|
|
7590
|
+
]) if (!Array.isArray(elements[key]) || elements[key].some((item) => typeof item !== "string")) problems.push(`summary.new_elements.${key} must be a string array`);
|
|
7591
|
+
if (!Array.isArray(summary.resource_changes)) problems.push("summary.resource_changes must be an array");
|
|
7592
|
+
if (typeof summary.opening !== "string") problems.push("summary.opening must be a string");
|
|
7593
|
+
const continuity = recordOf(summary.continuity);
|
|
7594
|
+
if (continuity === null) problems.push("summary.continuity must be an object");
|
|
7595
|
+
else for (const key of ["opens_from", "ends_with"]) if (typeof continuity[key] !== "string") problems.push(`summary.continuity.${key} must be a string`);
|
|
7596
|
+
if (!Number.isInteger(summary.hook_strength)) problems.push("summary.hook_strength must be an integer");
|
|
7597
|
+
if (![
|
|
7598
|
+
"high",
|
|
7599
|
+
"mid",
|
|
7600
|
+
"low"
|
|
7601
|
+
].includes(summary.plot_advancement)) problems.push("summary.plot_advancement must be high|mid|low");
|
|
7602
|
+
return problems;
|
|
7603
|
+
};
|
|
7604
|
+
/** Validate the nested roster fields the context builder dereferences. */
|
|
7605
|
+
const validateWorldPlanShape = (plan, path) => {
|
|
7606
|
+
const fail = (detail) => {
|
|
7607
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not a usable world plan: ${detail}`);
|
|
7608
|
+
};
|
|
7609
|
+
for (const key of ["constraints", "continuity_notes"]) if (!Array.isArray(plan[key]) || plan[key].some((item) => typeof item !== "string")) fail(`${key} must be a string array`);
|
|
7610
|
+
for (const key of ["characters", "locations"]) {
|
|
7611
|
+
if (!Array.isArray(plan[key])) fail(`${key} must be an array`);
|
|
7612
|
+
for (const [index, raw] of plan[key].entries()) {
|
|
7613
|
+
const row = recordOf(raw);
|
|
7614
|
+
if (row === null || typeof row.name !== "string" || row.name.trim() === "") fail(`${key}[${index}].name must be a non-empty string`);
|
|
7615
|
+
}
|
|
7616
|
+
}
|
|
7617
|
+
};
|
|
7618
|
+
/** Validate the shape a stored chapter plan must have before any stage uses it. */
|
|
7619
|
+
const validateStoredChapterPlan = (plan, path) => {
|
|
7620
|
+
const fail = (detail) => {
|
|
7621
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not a usable chapter plan: ${detail}`);
|
|
7622
|
+
};
|
|
7623
|
+
const rawVersion = plan.schema_version;
|
|
7624
|
+
if (rawVersion !== void 0) {
|
|
7625
|
+
const version = typeof rawVersion === "number" ? rawVersion : null;
|
|
7626
|
+
if (version === null) fail("schema_version must be a positive integer");
|
|
7627
|
+
const checkedVersion = version;
|
|
7628
|
+
if (!Number.isInteger(checkedVersion) || checkedVersion < 1) fail("schema_version must be a positive integer");
|
|
7629
|
+
if (checkedVersion > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `${path} uses schema_version ${checkedVersion}, newer than supported 1`, "upgrade the plugin; the plan was left untouched");
|
|
7630
|
+
if (checkedVersion !== 1) fail(`schema_version must be 1`);
|
|
7631
|
+
}
|
|
7632
|
+
if (!Number.isInteger(plan.chapter) || plan.chapter < 1) fail("chapter must be a positive integer");
|
|
7633
|
+
for (const key of [
|
|
7634
|
+
"title",
|
|
7635
|
+
"narrator",
|
|
7636
|
+
"opening",
|
|
7637
|
+
"ending"
|
|
7638
|
+
]) if (typeof plan[key] !== "string" || plan[key].trim() === "") fail(`${key} must be non-empty`);
|
|
7639
|
+
if (!SCENE_TYPES.includes(plan.scene_type)) fail(`unknown scene_type "${String(plan.scene_type)}"`);
|
|
7640
|
+
if (!Array.isArray(plan.beats) || plan.beats.some((beat) => typeof beat !== "string")) fail("beats must be a string array");
|
|
7641
|
+
if (!Array.isArray(plan.target_words) || plan.target_words.length !== 2 || plan.target_words.some((value) => !Number.isInteger(value) || value <= 0)) fail("target_words must be [min, max] positive integers");
|
|
7642
|
+
if (plan.summary !== void 0) {
|
|
7643
|
+
const problems = summaryContextProblems(plan.summary);
|
|
7644
|
+
if (problems.length > 0) fail(problems.join("; "));
|
|
7645
|
+
}
|
|
7646
|
+
if (plan.cast !== void 0) {
|
|
7647
|
+
const problems = castShapeProblems(plan.cast);
|
|
7648
|
+
if (problems.length > 0) fail(problems.join("; "));
|
|
7649
|
+
}
|
|
7650
|
+
};
|
|
7651
|
+
const CHECK_SCOPES = ["content", "integrity"];
|
|
7652
|
+
const normalizeCheckScope = (value) => {
|
|
7653
|
+
if (value === void 0) return [...CHECK_SCOPES];
|
|
7654
|
+
if (!Array.isArray(value) || value.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "scope must contain at least one of content, integrity");
|
|
7655
|
+
const unique = [...new Set(value)];
|
|
7656
|
+
for (const scope of unique) if (scope !== "content" && scope !== "integrity") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown check scope "${String(scope)}"`, `allowed: ${CHECK_SCOPES.join(", ")}`);
|
|
7657
|
+
return CHECK_SCOPES.filter((scope) => unique.includes(scope));
|
|
7658
|
+
};
|
|
7659
|
+
const stringList = (value, field) => {
|
|
7660
|
+
if (value === void 0) return [];
|
|
7661
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of non-empty strings`);
|
|
7662
|
+
return value;
|
|
7663
|
+
};
|
|
7664
|
+
const objectList = (value, field) => {
|
|
7665
|
+
if (value === void 0) return [];
|
|
7666
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "object" || item === null || Array.isArray(item))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of objects`);
|
|
7667
|
+
return value;
|
|
7668
|
+
};
|
|
7669
|
+
const requireText = (object, key, path) => {
|
|
7670
|
+
if (typeof object[key] !== "string" || object[key].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${path} must be a non-empty string`);
|
|
6022
7671
|
};
|
|
6023
7672
|
/** Parse a plan file with a machine code instead of leaking a YAML parser error. */
|
|
6024
7673
|
const parsePlanYaml = (text, path) => {
|
|
@@ -6294,6 +7943,10 @@ function createNovelTools(engine) {
|
|
|
6294
7943
|
type: "array",
|
|
6295
7944
|
items: { type: "integer" }
|
|
6296
7945
|
},
|
|
7946
|
+
cast: {
|
|
7947
|
+
type: "json",
|
|
7948
|
+
description: "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger."
|
|
7949
|
+
},
|
|
6297
7950
|
project_root: root
|
|
6298
7951
|
},
|
|
6299
7952
|
output: jsonOutput,
|
|
@@ -6306,6 +7959,7 @@ function createNovelTools(engine) {
|
|
|
6306
7959
|
opening: args.opening ?? "",
|
|
6307
7960
|
ending: args.ending ?? "",
|
|
6308
7961
|
target_words: [args.target_words?.[0] ?? 2e3, args.target_words?.[1] ?? 3e3],
|
|
7962
|
+
...args.cast === void 0 ? {} : { cast: asJson(args.cast, "cast") },
|
|
6309
7963
|
root: args.project_root
|
|
6310
7964
|
}))
|
|
6311
7965
|
}),
|
|
@@ -6517,12 +8171,23 @@ function createNovelTools(engine) {
|
|
|
6517
8171
|
]
|
|
6518
8172
|
},
|
|
6519
8173
|
chapter: { type: "integer" },
|
|
8174
|
+
include_summary: {
|
|
8175
|
+
type: "boolean",
|
|
8176
|
+
description: "For kind=chapters: include the full ten-field summary (default compact)."
|
|
8177
|
+
},
|
|
8178
|
+
chapter_range: {
|
|
8179
|
+
type: "array",
|
|
8180
|
+
items: { type: "integer" },
|
|
8181
|
+
description: "For kind=chapters: inclusive [start, end] range."
|
|
8182
|
+
},
|
|
6520
8183
|
project_root: root
|
|
6521
8184
|
},
|
|
6522
8185
|
output: jsonOutput,
|
|
6523
8186
|
execute: guarded(async (args) => engine.queryState({
|
|
6524
8187
|
kind: args.kind,
|
|
6525
8188
|
chapter: args.chapter,
|
|
8189
|
+
...args.include_summary === void 0 ? {} : { include_summary: args.include_summary },
|
|
8190
|
+
...args.chapter_range === void 0 ? {} : { chapter_range: args.chapter_range },
|
|
6526
8191
|
root: args.project_root
|
|
6527
8192
|
}))
|
|
6528
8193
|
}),
|
|
@@ -6534,6 +8199,7 @@ function createNovelTools(engine) {
|
|
|
6534
8199
|
type: "string",
|
|
6535
8200
|
required: true,
|
|
6536
8201
|
enum: [
|
|
8202
|
+
"novel.yaml",
|
|
6537
8203
|
"state/chapters.yaml",
|
|
6538
8204
|
"state/characters.yaml",
|
|
6539
8205
|
"state/world.yaml",
|