dsh-novel-write-engine 0.1.2 → 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 +18 -0
- package/README.md +182 -163
- package/briefs/P2.md +4 -1
- package/briefs/P3.md +4 -0
- package/briefs/P4.md +1 -1
- package/briefs/P6.md +3 -3
- package/lib/client.js +8 -3
- package/lib/index.d.ts +59 -3
- package/lib/index.js +1362 -225
- package/lib/types/client/dto.d.ts +6 -0
- package/lib/types/constants.d.ts +8 -0
- package/lib/types/types.d.ts +19 -2
- package/package.json +19 -17
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 = {
|
|
@@ -2120,16 +2099,25 @@ async function recoverPending(store) {
|
|
|
2120
2099
|
}
|
|
2121
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`);
|
|
2122
2101
|
}
|
|
2123
|
-
/**
|
|
2124
|
-
|
|
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 = []) {
|
|
2125
2111
|
await recoverPending(store);
|
|
2126
2112
|
const { records } = await readJournal(store);
|
|
2127
2113
|
const committed = new Set(records.filter((record) => record.status === "committed" && record.op !== "lock_reclaimed").map((record) => record.txid));
|
|
2128
2114
|
for (const entry of await store.list(STAGING_DIR)) if (committed.has(entry)) await store.removeTree(joinRel(STAGING_DIR, entry)).catch(() => void 0);
|
|
2129
2115
|
const { malformed, mismatches } = await reconcileJournal(store);
|
|
2130
2116
|
assertJournalHealthy(malformed);
|
|
2131
|
-
|
|
2132
|
-
|
|
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];
|
|
2133
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");
|
|
2134
2122
|
}
|
|
2135
2123
|
}
|
|
@@ -2493,7 +2481,7 @@ var ProjectRegistry = class {
|
|
|
2493
2481
|
rows.push(row);
|
|
2494
2482
|
const next = {
|
|
2495
2483
|
...document,
|
|
2496
|
-
projects: rows.sort((a, b) => a.root
|
|
2484
|
+
projects: rows.sort((a, b) => compareText(a.root, b.root))
|
|
2497
2485
|
};
|
|
2498
2486
|
await this.write(next);
|
|
2499
2487
|
return next;
|
|
@@ -2567,18 +2555,20 @@ const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").r
|
|
|
2567
2555
|
function chapterHeading(chapter) {
|
|
2568
2556
|
return `第${chapter.num}章 ${chapter.title}`;
|
|
2569
2557
|
}
|
|
2558
|
+
/** Remove blank lines at the document edges, never manuscript indentation. */
|
|
2559
|
+
const trimBlankEdges = (text) => text.replace(/^\n+|\n+$/g, "");
|
|
2570
2560
|
/** Markdown export: one heading per chapter, body unchanged (it is plain text). */
|
|
2571
2561
|
function renderMarkdown(project, chapters) {
|
|
2572
2562
|
const parts = [`# ${project.title}`, ""];
|
|
2573
2563
|
if (project.author !== "") parts.push(`作者:${project.author}`, "");
|
|
2574
2564
|
if (project.description !== "") parts.push(`> ${project.description}`, "");
|
|
2575
|
-
for (const chapter of chapters) parts.push(`## ${chapterHeading(chapter)}`, "", chapter.text
|
|
2565
|
+
for (const chapter of chapters) parts.push(`## ${chapterHeading(chapter)}`, "", trimBlankEdges(chapter.text), "");
|
|
2576
2566
|
return parts.join("\n");
|
|
2577
2567
|
}
|
|
2578
2568
|
/** Plain-text export: no markdown syntax, no BOM. */
|
|
2579
2569
|
function renderPlainText(project, chapters) {
|
|
2580
2570
|
const parts = [project.title, ""];
|
|
2581
|
-
for (const chapter of chapters) parts.push(chapterHeading(chapter), "", chapter.text
|
|
2571
|
+
for (const chapter of chapters) parts.push(chapterHeading(chapter), "", trimBlankEdges(chapter.text), "");
|
|
2582
2572
|
return parts.join("\n");
|
|
2583
2573
|
}
|
|
2584
2574
|
/** Blurb built from the project record; deterministic, no model call. */
|
|
@@ -2607,9 +2597,10 @@ function renderCover(project) {
|
|
|
2607
2597
|
}
|
|
2608
2598
|
/** XHTML chapter document for the EPUB package. */
|
|
2609
2599
|
function renderEpubChapter(chapter) {
|
|
2610
|
-
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");
|
|
2611
2601
|
return `<?xml version="1.0" encoding="utf-8"?>
|
|
2612
|
-
<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>
|
|
2613
2604
|
<body><h1>${escapeHtml(chapterHeading(chapter))}</h1>${paragraphs}</body></html>`;
|
|
2614
2605
|
}
|
|
2615
2606
|
// src/export/epub.ts
|
|
@@ -2843,6 +2834,162 @@ var BriefLibrary = class {
|
|
|
2843
2834
|
}
|
|
2844
2835
|
};
|
|
2845
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
|
+
};
|
|
2846
2993
|
// src/engine/context.ts
|
|
2847
2994
|
const compactSummary = (row) => ({
|
|
2848
2995
|
num: row.num,
|
|
@@ -2860,6 +3007,93 @@ const sceneDistribution = (chapters) => {
|
|
|
2860
3007
|
return counts;
|
|
2861
3008
|
};
|
|
2862
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
|
+
};
|
|
2863
3097
|
/**
|
|
2864
3098
|
* Build the stage-specific context pack. The task book explains the rules; this
|
|
2865
3099
|
* pack is the minimum project state the model needs to apply them without
|
|
@@ -2867,6 +3101,19 @@ const pendingForeshadows = (ledger) => ledger.items.filter((item) => item.status
|
|
|
2867
3101
|
*/
|
|
2868
3102
|
function unbudgetedBriefContext(input) {
|
|
2869
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);
|
|
2870
3117
|
const base = {
|
|
2871
3118
|
project: {
|
|
2872
3119
|
title: input.project.title,
|
|
@@ -2878,28 +3125,38 @@ function unbudgetedBriefContext(input) {
|
|
|
2878
3125
|
stage: input.pipeline.stage,
|
|
2879
3126
|
scene_distribution: sceneDistribution(input.chapters),
|
|
2880
3127
|
previous_ending: previousEnding,
|
|
3128
|
+
...input.world_plan_error === void 0 || input.world_plan_error === null ? {} : { world_plan_error: input.world_plan_error },
|
|
2881
3129
|
...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error },
|
|
2882
3130
|
...input.review_error === void 0 || input.review_error === null ? {} : { review_error: input.review_error },
|
|
2883
3131
|
...input.check_error === void 0 || input.check_error === null ? {} : { check_error: input.check_error }
|
|
2884
3132
|
};
|
|
2885
3133
|
switch (input.stage) {
|
|
2886
|
-
case "P1":
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
+
}
|
|
2894
3146
|
case "P2": return {
|
|
2895
3147
|
...base,
|
|
3148
|
+
world_plan: worldPlan,
|
|
3149
|
+
characters: [...input.worldPlan?.characters ?? []],
|
|
3150
|
+
other_characters: otherCharacters,
|
|
2896
3151
|
plan: input.plan,
|
|
2897
3152
|
open_foreshadows: pendingForeshadows(input.foreshadows)
|
|
2898
3153
|
};
|
|
2899
3154
|
case "P3": return {
|
|
2900
3155
|
...base,
|
|
2901
|
-
|
|
2902
|
-
|
|
3156
|
+
world_plan: worldPlan,
|
|
3157
|
+
characters: activeCast,
|
|
3158
|
+
other_characters: otherCharacters,
|
|
3159
|
+
locks: filterLocks(input.locks, activeNames, (input.worldPlan?.locations ?? []).map((location) => location.name)),
|
|
2903
3160
|
precheck: input.plan,
|
|
2904
3161
|
open_foreshadows: pendingForeshadows(input.foreshadows),
|
|
2905
3162
|
recent_summaries: input.chapters.chapters.slice(-5).map(compactSummary)
|
|
@@ -2910,7 +3167,9 @@ function unbudgetedBriefContext(input) {
|
|
|
2910
3167
|
summary: input.plan?.summary ?? null,
|
|
2911
3168
|
plan: input.plan,
|
|
2912
3169
|
machine: input.machine ?? null,
|
|
2913
|
-
|
|
3170
|
+
world_plan: worldPlan,
|
|
3171
|
+
characters: activeCast,
|
|
3172
|
+
other_characters: otherCharacters,
|
|
2914
3173
|
check: input.check,
|
|
2915
3174
|
score_history: input.chapters.chapters.slice(-5).map((row) => ({
|
|
2916
3175
|
num: row.num,
|
|
@@ -2923,14 +3182,20 @@ function unbudgetedBriefContext(input) {
|
|
|
2923
3182
|
plan: input.plan,
|
|
2924
3183
|
machine: input.machine ?? null,
|
|
2925
3184
|
review: input.review,
|
|
2926
|
-
issues: input.review?.issues ?? []
|
|
3185
|
+
issues: input.review?.issues ?? [],
|
|
3186
|
+
world_plan: worldPlan,
|
|
3187
|
+
characters: activeCast,
|
|
3188
|
+
other_characters: otherCharacters
|
|
2927
3189
|
};
|
|
2928
3190
|
case "P6": return {
|
|
2929
3191
|
...base,
|
|
2930
3192
|
text: input.text ?? "",
|
|
2931
3193
|
plan: input.plan,
|
|
2932
3194
|
machine: input.machine ?? null,
|
|
3195
|
+
world_plan: worldPlan,
|
|
2933
3196
|
characters: input.characters.characters,
|
|
3197
|
+
active_cast: activeCast,
|
|
3198
|
+
other_characters: otherCharacters,
|
|
2934
3199
|
world: input.world,
|
|
2935
3200
|
locks: input.locks,
|
|
2936
3201
|
foreshadows: input.foreshadows,
|
|
@@ -2968,7 +3233,7 @@ const CONTEXT_BUDGETS = {
|
|
|
2968
3233
|
};
|
|
2969
3234
|
const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
2970
3235
|
const asArray = (value) => Array.isArray(value) ? value : null;
|
|
2971
|
-
const jsonSize = (value) =>
|
|
3236
|
+
const jsonSize = (value) => JSON.stringify(value).length;
|
|
2972
3237
|
const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(value) ? 1 : 0;
|
|
2973
3238
|
/**
|
|
2974
3239
|
* Apply the stage budget after the stage-specific pack exists. Protected keys
|
|
@@ -2978,6 +3243,7 @@ const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(va
|
|
|
2978
3243
|
function withContextBudget(stage, raw) {
|
|
2979
3244
|
const context = structuredClone(raw);
|
|
2980
3245
|
const budget = CONTEXT_BUDGETS[stage] ?? 8e3;
|
|
3246
|
+
const smallP2Candidates = stage === "P2" && Array.isArray(context.characters) && context.characters.length <= 32;
|
|
2981
3247
|
const protectedKeys = [
|
|
2982
3248
|
"project",
|
|
2983
3249
|
"chapter",
|
|
@@ -2989,21 +3255,25 @@ function withContextBudget(stage, raw) {
|
|
|
2989
3255
|
"review",
|
|
2990
3256
|
"check",
|
|
2991
3257
|
"machine",
|
|
2992
|
-
"summary"
|
|
3258
|
+
"summary",
|
|
3259
|
+
"world_plan",
|
|
3260
|
+
"active_cast",
|
|
3261
|
+
...stage === "P3" || stage === "P4" || stage === "P5" || smallP2Candidates ? ["characters"] : []
|
|
2993
3262
|
];
|
|
2994
3263
|
const mentioned = [
|
|
2995
3264
|
typeof context.text === "string" ? context.text : "",
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3265
|
+
stableJson(context.plan ?? ""),
|
|
3266
|
+
stableJson(context.review ?? ""),
|
|
3267
|
+
stableJson(context.check ?? ""),
|
|
3268
|
+
stableJson(context.summary ?? ""),
|
|
3269
|
+
stableJson(context.world_plan ?? "")
|
|
3000
3270
|
].join("\n");
|
|
3001
3271
|
const omitted = {};
|
|
3002
3272
|
const truncated = /* @__PURE__ */ new Set();
|
|
3003
3273
|
const candidates = [];
|
|
3004
3274
|
const stableKey = (value) => {
|
|
3005
3275
|
const row = asRecord(value);
|
|
3006
|
-
if (row === null) return
|
|
3276
|
+
if (row === null) return stableJson(value);
|
|
3007
3277
|
for (const key of [
|
|
3008
3278
|
"id",
|
|
3009
3279
|
"name",
|
|
@@ -3012,10 +3282,10 @@ function withContextBudget(stage, raw) {
|
|
|
3012
3282
|
const item = row[key];
|
|
3013
3283
|
if (typeof item === "string" && item !== "") return item;
|
|
3014
3284
|
}
|
|
3015
|
-
return
|
|
3285
|
+
return stableJson(row);
|
|
3016
3286
|
};
|
|
3017
|
-
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
3018
3287
|
const addArray = (key, array, rank, tie = stableKey) => {
|
|
3288
|
+
if (protectedKeys.includes(key)) return;
|
|
3019
3289
|
let prepared = false;
|
|
3020
3290
|
const sort = () => {
|
|
3021
3291
|
if (prepared) return;
|
|
@@ -3039,6 +3309,7 @@ function withContextBudget(stage, raw) {
|
|
|
3039
3309
|
});
|
|
3040
3310
|
};
|
|
3041
3311
|
const addObject = (key, map, rank) => {
|
|
3312
|
+
if (protectedKeys.includes(key)) return;
|
|
3042
3313
|
let keys = null;
|
|
3043
3314
|
const ensure = () => {
|
|
3044
3315
|
const target = map();
|
|
@@ -3064,10 +3335,36 @@ function withContextBudget(stage, raw) {
|
|
|
3064
3335
|
});
|
|
3065
3336
|
};
|
|
3066
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));
|
|
3067
3340
|
addArray("characters", () => asArray(context.characters), (value) => {
|
|
3068
3341
|
const row = asRecord(value);
|
|
3069
3342
|
const name = typeof row?.name === "string" ? row.name : "";
|
|
3070
|
-
return mentionScore(mentioned, 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;
|
|
3071
3368
|
}, (value) => {
|
|
3072
3369
|
const row = asRecord(value);
|
|
3073
3370
|
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
@@ -3088,7 +3385,7 @@ function withContextBudget(stage, raw) {
|
|
|
3088
3385
|
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3089
3386
|
});
|
|
3090
3387
|
const locks = asRecord(context.locks);
|
|
3091
|
-
for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => mentionScore(mentioned, name) * 1e3);
|
|
3388
|
+
for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => (activeNames.has(name) ? 1e6 : 0) + mentionScore(mentioned, name) * 1e3);
|
|
3092
3389
|
addArray("open_foreshadows", () => asArray(context.open_foreshadows), (value) => {
|
|
3093
3390
|
const row = asRecord(value);
|
|
3094
3391
|
const title = typeof row?.title === "string" ? row.title : "";
|
|
@@ -3127,11 +3424,7 @@ function withContextBudget(stage, raw) {
|
|
|
3127
3424
|
addArray("recent_summaries", () => asArray(context.recent_summaries), (value) => chapter(value), (value) => String(chapter(value)).padStart(8, "0"));
|
|
3128
3425
|
addArray("score_history", () => asArray(context.score_history), (value) => Number(asRecord(value)?.num ?? 0), (value) => String(Number(asRecord(value)?.num ?? 0)).padStart(8, "0"));
|
|
3129
3426
|
for (const candidate of candidates) candidate.prepare();
|
|
3130
|
-
const used = () =>
|
|
3131
|
-
const measure = { ...context };
|
|
3132
|
-
delete measure.context_meta;
|
|
3133
|
-
return jsonSize(measure);
|
|
3134
|
-
};
|
|
3427
|
+
const used = () => jsonSize(context);
|
|
3135
3428
|
const buildMeta = (finalUsed) => ({
|
|
3136
3429
|
version: 1,
|
|
3137
3430
|
stage,
|
|
@@ -3139,24 +3432,22 @@ function withContextBudget(stage, raw) {
|
|
|
3139
3432
|
used: finalUsed,
|
|
3140
3433
|
over_budget: false,
|
|
3141
3434
|
protected: protectedKeys,
|
|
3142
|
-
truncated: [...truncated].sort(),
|
|
3143
|
-
omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => left
|
|
3144
|
-
strategy: "stage-
|
|
3435
|
+
truncated: [...truncated].sort(compareText),
|
|
3436
|
+
omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => compareText(left, right))),
|
|
3437
|
+
strategy: "stage-v2"
|
|
3145
3438
|
});
|
|
3146
|
-
const totalWith = (finalUsed, meta) =>
|
|
3147
|
-
|
|
3148
|
-
context_meta: {
|
|
3439
|
+
const totalWith = (finalUsed, meta) => {
|
|
3440
|
+
const metaSize = jsonSize({
|
|
3149
3441
|
...meta,
|
|
3150
3442
|
used: finalUsed
|
|
3151
|
-
}
|
|
3152
|
-
|
|
3443
|
+
});
|
|
3444
|
+
return finalUsed + 1 + 15 + metaSize;
|
|
3445
|
+
};
|
|
3153
3446
|
let finalUsed = used();
|
|
3154
3447
|
let metaBase = buildMeta(finalUsed);
|
|
3155
3448
|
while (totalWith(finalUsed, metaBase) > budget) {
|
|
3156
3449
|
let changed = false;
|
|
3157
3450
|
for (const candidate of candidates) {
|
|
3158
|
-
finalUsed = used();
|
|
3159
|
-
metaBase = buildMeta(finalUsed);
|
|
3160
3451
|
if (totalWith(finalUsed, metaBase) <= budget) break;
|
|
3161
3452
|
if (candidate.count() === 0) continue;
|
|
3162
3453
|
candidate.prepare();
|
|
@@ -3164,10 +3455,10 @@ function withContextBudget(stage, raw) {
|
|
|
3164
3455
|
omitted[candidate.key] = (omitted[candidate.key] ?? 0) + 1;
|
|
3165
3456
|
truncated.add(candidate.key);
|
|
3166
3457
|
changed = true;
|
|
3458
|
+
finalUsed = used();
|
|
3459
|
+
metaBase = buildMeta(finalUsed);
|
|
3167
3460
|
}
|
|
3168
3461
|
}
|
|
3169
|
-
finalUsed = used();
|
|
3170
|
-
metaBase = buildMeta(finalUsed);
|
|
3171
3462
|
if (!changed) break;
|
|
3172
3463
|
}
|
|
3173
3464
|
const overBudget = totalWith(finalUsed, metaBase) > budget;
|
|
@@ -3884,7 +4175,7 @@ const summarySchema = (input) => {
|
|
|
3884
4175
|
else {
|
|
3885
4176
|
const opening = summary.opening;
|
|
3886
4177
|
if (Array.from(opening).length < LIMITS.minQuoteChars) problems.push(`opening must be at least ${LIMITS.minQuoteChars} characters`);
|
|
3887
|
-
if (!
|
|
4178
|
+
if (!openingInFirstTwoParagraphs(input.text, opening)) problems.push("opening must be quoted from the first two paragraphs");
|
|
3888
4179
|
}
|
|
3889
4180
|
const continuity = summary.continuity;
|
|
3890
4181
|
if (typeof continuity !== "object" || continuity === null) problems.push("continuity must be an object");
|
|
@@ -3975,8 +4266,8 @@ function verifyReference(text, reference) {
|
|
|
3975
4266
|
if (typeof reference !== "object" || reference === null) return "reading proof reference is not an object";
|
|
3976
4267
|
if (typeof reference.quote !== "string") return "reading proof quote is not text";
|
|
3977
4268
|
if (!Number.isInteger(reference.paragraph) || reference.paragraph < 1) return "reading proof paragraph must be a positive integer";
|
|
3978
|
-
const quote = reference.quote;
|
|
3979
|
-
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`;
|
|
3980
4271
|
const actual = splitParagraphs(text).findIndex((paragraph) => paragraph.includes(quote));
|
|
3981
4272
|
if (actual === -1) return `quote "${quote}" does not appear in the text`;
|
|
3982
4273
|
if (Math.abs(actual + 1 - reference.paragraph) > 1) return `quote "${quote}" is at paragraph ${actual + 1}, not ${reference.paragraph}`;
|
|
@@ -4062,7 +4353,8 @@ const archiveAtomic = (input) => {
|
|
|
4062
4353
|
if (input.archive.chapterExists) problems.push("this chapter is already in the index");
|
|
4063
4354
|
if (!input.archive.checkPassed) problems.push("the check document has not passed");
|
|
4064
4355
|
if (!input.archive.hashMatches) problems.push("the manuscript changed since the check was written");
|
|
4065
|
-
|
|
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")];
|
|
4066
4358
|
};
|
|
4067
4359
|
/** Fixed order; evaluation collects every failure instead of stopping at the first. */
|
|
4068
4360
|
const GATE_FUNCTIONS = {
|
|
@@ -4116,7 +4408,8 @@ function applicableGates(input) {
|
|
|
4116
4408
|
"PG-03",
|
|
4117
4409
|
"PG-05",
|
|
4118
4410
|
"PG-09",
|
|
4119
|
-
"PG-08"
|
|
4411
|
+
"PG-08",
|
|
4412
|
+
"PG-11"
|
|
4120
4413
|
];
|
|
4121
4414
|
case "submitReview": return [
|
|
4122
4415
|
...always,
|
|
@@ -4127,7 +4420,10 @@ function applicableGates(input) {
|
|
|
4127
4420
|
case "runCheck": return [
|
|
4128
4421
|
...always,
|
|
4129
4422
|
"PG-04",
|
|
4423
|
+
"PG-03",
|
|
4130
4424
|
"PG-05",
|
|
4425
|
+
"PG-06",
|
|
4426
|
+
"PG-07",
|
|
4131
4427
|
"PG-08",
|
|
4132
4428
|
"PG-09",
|
|
4133
4429
|
"PG-10",
|
|
@@ -4220,6 +4516,11 @@ function projectSummary(snapshot) {
|
|
|
4220
4516
|
revision: snapshot.revision,
|
|
4221
4517
|
totalWords: snapshot.totalWords,
|
|
4222
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 },
|
|
4223
4524
|
phases: snapshot.phases,
|
|
4224
4525
|
blocking: snapshot.blocking,
|
|
4225
4526
|
finished: snapshot.finished,
|
|
@@ -4230,9 +4531,11 @@ function projectSummary(snapshot) {
|
|
|
4230
4531
|
};
|
|
4231
4532
|
}
|
|
4232
4533
|
function projectChapters(snapshot) {
|
|
4233
|
-
const rows = snapshot.chapters.map(projectChapter);
|
|
4234
4534
|
const draft = snapshot.draft;
|
|
4235
|
-
|
|
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({
|
|
4236
4539
|
num: draft.num,
|
|
4237
4540
|
title: draft.title,
|
|
4238
4541
|
wordCount: draft.wordCount,
|
|
@@ -4363,7 +4666,20 @@ const STAGE_RANK = {
|
|
|
4363
4666
|
ready_to_archive: 6,
|
|
4364
4667
|
done: 7
|
|
4365
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
|
+
};
|
|
4366
4681
|
const MUTABLE_STATE = {
|
|
4682
|
+
[PROJECT_FILE]: "project",
|
|
4367
4683
|
[joinRel(STATE_DIR, STATE_FILES.pipeline)]: "pipeline",
|
|
4368
4684
|
[joinRel(STATE_DIR, STATE_FILES.chapters)]: "chapters",
|
|
4369
4685
|
[joinRel(STATE_DIR, STATE_FILES.characters)]: "characters",
|
|
@@ -4383,6 +4699,8 @@ var EngineService = class {
|
|
|
4383
4699
|
stores = /* @__PURE__ */ new Map();
|
|
4384
4700
|
mutex = new KeyedMutex();
|
|
4385
4701
|
replay = /* @__PURE__ */ new Map();
|
|
4702
|
+
/** Status, panel and gates ask for the same stage context repeatedly. */
|
|
4703
|
+
briefContextCache = /* @__PURE__ */ new WeakMap();
|
|
4386
4704
|
briefs;
|
|
4387
4705
|
now;
|
|
4388
4706
|
/** The project this process last touched; the panel follows it without a workspace hint. */
|
|
@@ -4518,12 +4836,50 @@ var EngineService = class {
|
|
|
4518
4836
|
emptyAllowed: true
|
|
4519
4837
|
});
|
|
4520
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
|
+
}
|
|
4521
4872
|
async getStatus(input = {}) {
|
|
4522
4873
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
4523
4874
|
const snapshot = await this.load(input.root);
|
|
4524
4875
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4525
4876
|
const metrics = await this.metricsFor(snapshot, chapter);
|
|
4526
|
-
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);
|
|
4527
4883
|
return {
|
|
4528
4884
|
data: {
|
|
4529
4885
|
project: {
|
|
@@ -4536,9 +4892,18 @@ var EngineService = class {
|
|
|
4536
4892
|
active_chapter: snapshot.pipeline.chapter,
|
|
4537
4893
|
stage: snapshot.pipeline.stage,
|
|
4538
4894
|
rounds: { ...snapshot.pipeline.rounds },
|
|
4539
|
-
blocking: this.effectiveBlocking(snapshot),
|
|
4895
|
+
blocking: [...this.effectiveBlocking(snapshot), ...journal],
|
|
4540
4896
|
state_errors: this.stateErrors(snapshot),
|
|
4541
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
|
+
})(),
|
|
4542
4907
|
next
|
|
4543
4908
|
},
|
|
4544
4909
|
revision: snapshot.pipeline.revision,
|
|
@@ -4564,17 +4929,50 @@ var EngineService = class {
|
|
|
4564
4929
|
const snapshot = await this.load(input.root);
|
|
4565
4930
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
4566
4931
|
const store = this.storeFor(snapshot.root);
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
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
|
+
},
|
|
4570
4942
|
revision: snapshot.pipeline.revision,
|
|
4571
4943
|
warnings: []
|
|
4572
4944
|
};
|
|
4573
|
-
|
|
4574
|
-
|
|
4945
|
+
}
|
|
4946
|
+
switch (input.kind) {
|
|
4947
|
+
case "pipeline": return {
|
|
4948
|
+
data: snapshot.pipeline,
|
|
4575
4949
|
revision: snapshot.pipeline.revision,
|
|
4576
4950
|
warnings: []
|
|
4577
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
|
+
}
|
|
4578
4976
|
case "characters": return {
|
|
4579
4977
|
data: snapshot.characters,
|
|
4580
4978
|
revision: snapshot.pipeline.revision,
|
|
@@ -4610,30 +5008,79 @@ var EngineService = class {
|
|
|
4610
5008
|
revision: snapshot.pipeline.revision,
|
|
4611
5009
|
warnings: []
|
|
4612
5010
|
};
|
|
4613
|
-
case "review":
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
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
|
+
};
|
|
4628
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
|
+
}
|
|
4629
5068
|
const raw = await store.readText(planWorldFile(chapter));
|
|
4630
5069
|
return {
|
|
4631
|
-
data: raw === null ? null : parsePlanYaml(raw, planWorldFile(chapter)),
|
|
5070
|
+
data: raw === null ? null : narrow("worldPlan", parsePlanYaml(raw, planWorldFile(chapter))),
|
|
4632
5071
|
revision: snapshot.pipeline.revision,
|
|
4633
5072
|
warnings: []
|
|
4634
5073
|
};
|
|
4635
5074
|
}
|
|
4636
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
|
+
}
|
|
4637
5084
|
const raw = await store.readText(planChapterFile(chapter));
|
|
4638
5085
|
return {
|
|
4639
5086
|
data: raw === null ? null : parsePlanYaml(raw, planChapterFile(chapter)),
|
|
@@ -4646,7 +5093,12 @@ var EngineService = class {
|
|
|
4646
5093
|
}
|
|
4647
5094
|
async advise(input = {}) {
|
|
4648
5095
|
const snapshot = await this.load(input.root);
|
|
4649
|
-
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);
|
|
4650
5102
|
return {
|
|
4651
5103
|
data: {
|
|
4652
5104
|
stage: snapshot.pipeline.stage,
|
|
@@ -4654,7 +5106,7 @@ var EngineService = class {
|
|
|
4654
5106
|
tool: next.tool,
|
|
4655
5107
|
args: next.args,
|
|
4656
5108
|
reason: next.reason,
|
|
4657
|
-
blocking: this.effectiveBlocking(snapshot),
|
|
5109
|
+
blocking: [...this.effectiveBlocking(snapshot), ...journal],
|
|
4658
5110
|
state_errors: this.stateErrors(snapshot)
|
|
4659
5111
|
},
|
|
4660
5112
|
revision: snapshot.pipeline.revision,
|
|
@@ -4690,6 +5142,7 @@ var EngineService = class {
|
|
|
4690
5142
|
/** P4 must apply exclusions before its arithmetic closes. */
|
|
4691
5143
|
score_exclusions: this.rulepack.exclusions,
|
|
4692
5144
|
next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
|
|
5145
|
+
...snapshot.worldPlanError === null ? {} : { world_plan_error: snapshot.worldPlanError },
|
|
4693
5146
|
...snapshot.planError === null ? {} : { plan_error: snapshot.planError },
|
|
4694
5147
|
...snapshot.reviewError === null ? {} : { review_error: snapshot.reviewError },
|
|
4695
5148
|
...snapshot.checkError === null ? {} : { check_error: snapshot.checkError }
|
|
@@ -4735,6 +5188,13 @@ var EngineService = class {
|
|
|
4735
5188
|
if (typeof input.objective !== "string" || input.objective.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "world plan objective must not be empty");
|
|
4736
5189
|
const constraints = stringList(input.constraints, "constraints");
|
|
4737
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
|
+
}
|
|
4738
5198
|
const characters = objectList(input.characters, "characters");
|
|
4739
5199
|
for (const [index, character] of characters.entries()) {
|
|
4740
5200
|
requireText(character, "name", `characters[${index}].name`);
|
|
@@ -4795,9 +5255,11 @@ var EngineService = class {
|
|
|
4795
5255
|
}
|
|
4796
5256
|
async submitChapterPlan(input) {
|
|
4797
5257
|
if (typeof input.title !== "string" || input.title.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan title must not be empty");
|
|
4798
|
-
const
|
|
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();
|
|
4799
5261
|
const titleLength = Array.from(titleBody).length;
|
|
4800
|
-
if (titleLength <
|
|
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");
|
|
4801
5263
|
if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan narrator must not be empty");
|
|
4802
5264
|
const beats = stringList(input.beats, "beats");
|
|
4803
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");
|
|
@@ -4812,12 +5274,14 @@ var EngineService = class {
|
|
|
4812
5274
|
key: (snapshot) => `plan:${snapshot.pipeline.chapter}:${sha256(stableJson(input))}`,
|
|
4813
5275
|
run: async (snapshot, context) => {
|
|
4814
5276
|
await this.assertGates(snapshot, "submitChapterPlan", { briefStage: "P2" });
|
|
5277
|
+
const cast = normalizeCast(input.cast, snapshot);
|
|
4815
5278
|
const chapter = snapshot.pipeline.chapter;
|
|
4816
5279
|
const path = planChapterFile(chapter);
|
|
4817
5280
|
const plan = {
|
|
4818
5281
|
...input,
|
|
4819
5282
|
chapter,
|
|
4820
5283
|
beats,
|
|
5284
|
+
...cast === void 0 ? {} : { cast },
|
|
4821
5285
|
schema_version: 1
|
|
4822
5286
|
};
|
|
4823
5287
|
delete plan.root;
|
|
@@ -4846,6 +5310,7 @@ var EngineService = class {
|
|
|
4846
5310
|
async submitDraft(input) {
|
|
4847
5311
|
if (typeof input.content !== "string" || input.content.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "draft content must not be empty");
|
|
4848
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");
|
|
4849
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(", ")}`);
|
|
4850
5315
|
return this.mutate({
|
|
4851
5316
|
root: input.root,
|
|
@@ -4872,18 +5337,16 @@ var EngineService = class {
|
|
|
4872
5337
|
summary: input.summary
|
|
4873
5338
|
});
|
|
4874
5339
|
const chapter = input.chapter;
|
|
4875
|
-
const
|
|
4876
|
-
|
|
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");
|
|
4877
5342
|
const mergedPlan = {
|
|
4878
|
-
...
|
|
4879
|
-
schema_version: 1,
|
|
4880
|
-
chapter
|
|
4881
|
-
},
|
|
5343
|
+
...plan,
|
|
4882
5344
|
title: input.chapter_title,
|
|
4883
5345
|
scene_type: input.scene_type,
|
|
4884
5346
|
narrator: input.narrator,
|
|
4885
5347
|
summary: input.summary
|
|
4886
5348
|
};
|
|
5349
|
+
const planPath = planChapterFile(chapter);
|
|
4887
5350
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
4888
5351
|
stage: "P4",
|
|
4889
5352
|
blocking: [],
|
|
@@ -5097,7 +5560,7 @@ var EngineService = class {
|
|
|
5097
5560
|
});
|
|
5098
5561
|
if (input.summary === void 0 && snapshot.plan?.summary !== void 0) {
|
|
5099
5562
|
const opening = snapshot.plan.summary.opening;
|
|
5100
|
-
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");
|
|
5101
5564
|
}
|
|
5102
5565
|
const machine = this.machine(snapshot, input.content, { summary: input.summary ?? snapshot.plan?.summary ?? null });
|
|
5103
5566
|
const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
|
|
@@ -5185,17 +5648,18 @@ var EngineService = class {
|
|
|
5185
5648
|
semantic: { items: semantic },
|
|
5186
5649
|
verdict
|
|
5187
5650
|
};
|
|
5188
|
-
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";
|
|
5189
5653
|
const blockingItems = verdict === "fail" ? [...machineBlocking.map((violation) => ({
|
|
5190
5654
|
code: violation.code,
|
|
5191
5655
|
scope: "check",
|
|
5192
5656
|
detail: violation.detail
|
|
5193
5657
|
})), ...semanticResult.blocking] : [];
|
|
5194
5658
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
5195
|
-
stage: nextStage,
|
|
5659
|
+
stage: nextStage$2,
|
|
5196
5660
|
rounds: {
|
|
5197
5661
|
...snapshot.pipeline.rounds,
|
|
5198
|
-
check: snapshot.pipeline.rounds.check + 1
|
|
5662
|
+
check: snapshot.pipeline.rounds.check + (verdict === "fail" || fullScope ? 1 : 0)
|
|
5199
5663
|
},
|
|
5200
5664
|
blocking: blockingItems,
|
|
5201
5665
|
last_action: action("novel_check", this.now(), sha256(text))
|
|
@@ -5213,19 +5677,23 @@ var EngineService = class {
|
|
|
5213
5677
|
verdict,
|
|
5214
5678
|
violations: scopedMachine.violations.length,
|
|
5215
5679
|
metrics: scopedMachine.metrics,
|
|
5216
|
-
stage: nextStage
|
|
5680
|
+
stage: nextStage$2
|
|
5217
5681
|
},
|
|
5218
|
-
warnings: machineWarnings(scopedMachine)
|
|
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
|
+
}] : []]
|
|
5219
5686
|
};
|
|
5220
5687
|
}
|
|
5221
5688
|
});
|
|
5222
5689
|
}
|
|
5223
5690
|
async archiveChapter(input) {
|
|
5224
|
-
const
|
|
5691
|
+
const handoffOverride = normalizeHandoffOverride(input.handoff);
|
|
5692
|
+
const archiveDigest = `v2:${sha256(stableJson({
|
|
5225
5693
|
chapter: input.chapter,
|
|
5226
5694
|
state_delta: input.state_delta,
|
|
5227
|
-
handoff:
|
|
5228
|
-
}))
|
|
5695
|
+
handoff: handoffOverride
|
|
5696
|
+
}))}`;
|
|
5229
5697
|
return this.mutate({
|
|
5230
5698
|
root: input.root,
|
|
5231
5699
|
op: "archiveChapter",
|
|
@@ -5237,7 +5705,7 @@ var EngineService = class {
|
|
|
5237
5705
|
const already = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
|
|
5238
5706
|
if (already !== void 0) {
|
|
5239
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");
|
|
5240
|
-
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");
|
|
5241
5709
|
const archivedText = await this.readChapter(snapshot, input.chapter);
|
|
5242
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");
|
|
5243
5711
|
return {
|
|
@@ -5280,7 +5748,7 @@ var EngineService = class {
|
|
|
5280
5748
|
const metrics = countWordMetrics(text);
|
|
5281
5749
|
const score = snapshot.review?.total ?? 0;
|
|
5282
5750
|
const chapter = input.chapter;
|
|
5283
|
-
const storedTitle = (snapshot.plan?.title ?? "")
|
|
5751
|
+
const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
|
|
5284
5752
|
const record = {
|
|
5285
5753
|
num: chapter,
|
|
5286
5754
|
title: storedTitle === "" ? `第${chapter}章` : storedTitle,
|
|
@@ -5315,18 +5783,22 @@ var EngineService = class {
|
|
|
5315
5783
|
for (const item of foreshadows.items) if (item.status !== "resolved" && chapter - item.last_touched >= this.rulepack.crosschapter.foreshadow_dormant_after) item.status = "dormant";
|
|
5316
5784
|
const chapters = structuredClone(snapshot.chapters);
|
|
5317
5785
|
chapters.chapters.push(record);
|
|
5318
|
-
const
|
|
5319
|
-
const derivedVolume = Math.max(1, Math.ceil(chapter / 20));
|
|
5786
|
+
const position = advanceHandoff(snapshot.handoff, chapter, handoffOverride);
|
|
5320
5787
|
const handoff = {
|
|
5321
5788
|
schema_version: 1,
|
|
5322
5789
|
current_chapter: chapter,
|
|
5323
|
-
volume:
|
|
5324
|
-
volume_name:
|
|
5325
|
-
act:
|
|
5326
|
-
act_progress:
|
|
5790
|
+
volume: position.volume,
|
|
5791
|
+
volume_name: position.volume_name,
|
|
5792
|
+
act: position.act,
|
|
5793
|
+
act_progress: position.act_progress,
|
|
5327
5794
|
next_chapter: chapter + 1,
|
|
5328
5795
|
last_summary: summary.events.join(";").slice(0, 200),
|
|
5329
|
-
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) => ({
|
|
5330
5802
|
id: item.id,
|
|
5331
5803
|
title: item.title
|
|
5332
5804
|
})),
|
|
@@ -5354,6 +5826,20 @@ var EngineService = class {
|
|
|
5354
5826
|
["relationships", ledger.relationships],
|
|
5355
5827
|
["handoff", handoff]
|
|
5356
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;
|
|
5357
5843
|
return {
|
|
5358
5844
|
files: [
|
|
5359
5845
|
{
|
|
@@ -5395,6 +5881,10 @@ var EngineService = class {
|
|
|
5395
5881
|
{
|
|
5396
5882
|
path: joinRel(STATE_DIR, STATE_FILES.relationships),
|
|
5397
5883
|
text: yaml(ledger.relationships)
|
|
5884
|
+
},
|
|
5885
|
+
{
|
|
5886
|
+
path: ARCHIVE_DIGESTS,
|
|
5887
|
+
text: `${JSON.stringify(archiveDigests, null, 2)}\n`
|
|
5398
5888
|
}
|
|
5399
5889
|
],
|
|
5400
5890
|
backup: true,
|
|
@@ -5423,20 +5913,72 @@ var EngineService = class {
|
|
|
5423
5913
|
if (typeof input.reason !== "string" || input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason");
|
|
5424
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");
|
|
5425
5915
|
const resolvedRoot = await this.resolveRoot(input.root);
|
|
5426
|
-
const
|
|
5916
|
+
const store = this.storeFor(resolvedRoot);
|
|
5917
|
+
const absolute = store.path(input.path);
|
|
5427
5918
|
const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
|
|
5428
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
|
+
}
|
|
5429
5925
|
return this.mutate({
|
|
5430
5926
|
root: resolvedRoot,
|
|
5431
5927
|
op: "amend",
|
|
5928
|
+
...repairPaths.length === 0 ? {} : { repairPaths },
|
|
5432
5929
|
key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
|
|
5433
5930
|
run: async (snapshot, context) => {
|
|
5434
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");
|
|
5435
5932
|
if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
|
|
5436
|
-
|
|
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, {
|
|
5437
5946
|
...input.content,
|
|
5438
5947
|
schema_version: input.content.schema_version ?? 1
|
|
5439
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");
|
|
5440
5982
|
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_amend", this.now(), shortDigest(input.reason)) });
|
|
5441
5983
|
return {
|
|
5442
5984
|
files: [{
|
|
@@ -5555,8 +6097,14 @@ var EngineService = class {
|
|
|
5555
6097
|
root: input.root,
|
|
5556
6098
|
op: "export",
|
|
5557
6099
|
key: (snapshot) => `export:${input.kind}:${input.chapter ?? "all"}:${sha256(stableJson({
|
|
6100
|
+
chapter: snapshot.pipeline.chapter,
|
|
5558
6101
|
text: sha256(snapshot.text ?? ""),
|
|
5559
|
-
|
|
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
|
+
})),
|
|
5560
6108
|
title: snapshot.project.title
|
|
5561
6109
|
}))}`,
|
|
5562
6110
|
run: async (snapshot, context) => {
|
|
@@ -5699,6 +6247,9 @@ var EngineService = class {
|
|
|
5699
6247
|
op: "amend",
|
|
5700
6248
|
key: () => `import:${input.kind}:${sha256(stableJson(input.content))}`,
|
|
5701
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");
|
|
5702
6253
|
const nextPipeline = pipelineWith(fresh, context.revision, { last_action: action("novel_import", this.now(), shortDigest(input.kind)) });
|
|
5703
6254
|
return {
|
|
5704
6255
|
files: [{
|
|
@@ -5727,7 +6278,7 @@ var EngineService = class {
|
|
|
5727
6278
|
const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
|
|
5728
6279
|
const draft = archivedCurrent || snapshot.pipeline.stage === "done" ? null : {
|
|
5729
6280
|
num: snapshot.pipeline.chapter,
|
|
5730
|
-
title: (snapshot.plan?.title ?? "")
|
|
6281
|
+
title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
|
|
5731
6282
|
wordCount: draftWords,
|
|
5732
6283
|
score: snapshot.review?.total ?? 0,
|
|
5733
6284
|
status: this.effectiveBlocking(snapshot).length > 0 ? "blocked" : "draft",
|
|
@@ -5743,19 +6294,85 @@ var EngineService = class {
|
|
|
5743
6294
|
revision: snapshot.pipeline.revision,
|
|
5744
6295
|
rounds: snapshot.pipeline.rounds,
|
|
5745
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
|
+
})(),
|
|
5746
6306
|
next: finished ? void 0 : this.nextStep(snapshot),
|
|
5747
6307
|
finished,
|
|
5748
6308
|
chapters: archived,
|
|
5749
6309
|
draft,
|
|
5750
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,
|
|
5751
6312
|
phases: phaseStrip(snapshot.pipeline.stage, finished)
|
|
5752
6313
|
};
|
|
5753
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
|
+
}
|
|
5754
6365
|
nextStep(snapshot) {
|
|
5755
6366
|
const stage = snapshot.pipeline.stage;
|
|
5756
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
|
+
};
|
|
5757
6373
|
if (this.stateErrors(snapshot).length > 0) {
|
|
5758
6374
|
const recovery = this.recoveryFor(snapshot);
|
|
6375
|
+
if (recovery.tool === "novel_amend") return recovery;
|
|
5759
6376
|
const target = recovery.args.to_stage;
|
|
5760
6377
|
if (STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[stage]) return recovery;
|
|
5761
6378
|
}
|
|
@@ -5796,11 +6413,25 @@ var EngineService = class {
|
|
|
5796
6413
|
},
|
|
5797
6414
|
reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
|
|
5798
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
|
+
};
|
|
5799
6421
|
if (stage === "P6" && snapshot.checkError !== null) return {
|
|
5800
6422
|
tool: "novel_check",
|
|
5801
6423
|
args: this.nextArgs("P6", chapter),
|
|
5802
6424
|
reason: `CHECK_UNREADABLE@${checkFile(chapter)}; repair the check with novel_check before any stale revision blocker`
|
|
5803
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
|
+
};
|
|
5804
6435
|
if (snapshot.pipeline.blocking.length > 0) {
|
|
5805
6436
|
if (stage === "P5" || stage === "P6") return {
|
|
5806
6437
|
tool: "novel_revise",
|
|
@@ -5827,14 +6458,17 @@ var EngineService = class {
|
|
|
5827
6458
|
"P5",
|
|
5828
6459
|
"P6"
|
|
5829
6460
|
].includes(stage)) return null;
|
|
5830
|
-
const
|
|
5831
|
-
if (!roundsExhausted(snapshot.pipeline.rounds, counter)) return null;
|
|
5832
|
-
return {
|
|
6461
|
+
const block = (counter) => ({
|
|
5833
6462
|
code: "PG-11",
|
|
5834
6463
|
scope: "rounds",
|
|
5835
6464
|
detail: `round limit reached for ${counter} (review ${snapshot.pipeline.rounds.review}, revise ${snapshot.pipeline.rounds.revise}, check ${snapshot.pipeline.rounds.check})`,
|
|
5836
6465
|
hint: "call novel_rollback with to_stage P4; the manuscript is kept for reference"
|
|
5837
|
-
};
|
|
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;
|
|
5838
6472
|
}
|
|
5839
6473
|
/** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
|
|
5840
6474
|
effectiveBlocking(snapshot) {
|
|
@@ -5844,10 +6478,19 @@ var EngineService = class {
|
|
|
5844
6478
|
detail: `${error.code}: ${error.message}`,
|
|
5845
6479
|
hint: this.recoveryFor(snapshot).reason
|
|
5846
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
|
+
}] : [];
|
|
5847
6487
|
const extra = this.roundsLimitBlock(snapshot);
|
|
5848
6488
|
const reviewDigest = this.reviewDigestProblem(snapshot);
|
|
6489
|
+
const archive = this.archivePreflight(snapshot);
|
|
5849
6490
|
const base = [
|
|
5850
6491
|
...state,
|
|
6492
|
+
...active,
|
|
6493
|
+
...archive === null ? [] : [archive.item],
|
|
5851
6494
|
...reviewDigest === null ? [] : [reviewDigest],
|
|
5852
6495
|
...snapshot.pipeline.blocking
|
|
5853
6496
|
];
|
|
@@ -5879,8 +6522,18 @@ var EngineService = class {
|
|
|
5879
6522
|
return null;
|
|
5880
6523
|
}
|
|
5881
6524
|
stateErrors(snapshot) {
|
|
5882
|
-
const errors = [];
|
|
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
|
+
});
|
|
5883
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
|
+
});
|
|
5884
6537
|
if (snapshot.planError !== null) errors.push({
|
|
5885
6538
|
path: planChapterFile(chapter),
|
|
5886
6539
|
code: "PLAN_UNREADABLE",
|
|
@@ -5902,7 +6555,26 @@ var EngineService = class {
|
|
|
5902
6555
|
recoveryFor(snapshot) {
|
|
5903
6556
|
const chapter = snapshot.pipeline.chapter;
|
|
5904
6557
|
const errors = this.stateErrors(snapshot);
|
|
5905
|
-
|
|
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";
|
|
5906
6578
|
const target = this.clampRollbackTarget(snapshot.pipeline.stage, desired);
|
|
5907
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`;
|
|
5908
6580
|
return {
|
|
@@ -5937,13 +6609,16 @@ var EngineService = class {
|
|
|
5937
6609
|
"runCheck",
|
|
5938
6610
|
"archiveChapter"
|
|
5939
6611
|
];
|
|
6612
|
+
const worldPlanSensitive = ["submitChapterPlan", ...planSensitive];
|
|
5940
6613
|
const reviewSensitive = [
|
|
5941
6614
|
"submitRevision",
|
|
5942
6615
|
"runCheck",
|
|
5943
6616
|
"archiveChapter"
|
|
5944
6617
|
];
|
|
5945
6618
|
const checkSensitive = ["archiveChapter"];
|
|
5946
|
-
const relevant = [];
|
|
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"));
|
|
5947
6622
|
if (snapshot.planError !== null && planSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PLAN_UNREADABLE"));
|
|
5948
6623
|
if (snapshot.reviewError !== null && reviewSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "REVIEW_UNREADABLE"));
|
|
5949
6624
|
if (snapshot.checkError !== null && checkSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "CHECK_UNREADABLE"));
|
|
@@ -6002,7 +6677,7 @@ var EngineService = class {
|
|
|
6002
6677
|
}
|
|
6003
6678
|
if (chapters.length === 0 && snapshot.text !== null && (chapter === void 0 || chapter === snapshot.pipeline.chapter)) chapters.push({
|
|
6004
6679
|
num: snapshot.pipeline.chapter,
|
|
6005
|
-
title: snapshot.plan?.title ?? "未命名",
|
|
6680
|
+
title: stripTitlePrefix(snapshot.plan?.title ?? "") || "未命名",
|
|
6006
6681
|
text: snapshot.text
|
|
6007
6682
|
});
|
|
6008
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");
|
|
@@ -6011,7 +6686,7 @@ var EngineService = class {
|
|
|
6011
6686
|
machine(snapshot, text, extra) {
|
|
6012
6687
|
return runMachineChecks(text, {
|
|
6013
6688
|
chapter: snapshot.pipeline.chapter,
|
|
6014
|
-
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),
|
|
6015
6690
|
genre: snapshot.project.genre,
|
|
6016
6691
|
sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
|
|
6017
6692
|
rulepack: this.rulepack,
|
|
@@ -6029,12 +6704,20 @@ var EngineService = class {
|
|
|
6029
6704
|
});
|
|
6030
6705
|
}
|
|
6031
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;
|
|
6032
6715
|
const machine = [
|
|
6033
6716
|
"P4",
|
|
6034
6717
|
"P5",
|
|
6035
6718
|
"P6"
|
|
6036
6719
|
].includes(stage) && snapshot.text !== null ? this.machine(snapshot, snapshot.text, { summary: snapshot.plan?.summary ?? null }) : void 0;
|
|
6037
|
-
|
|
6720
|
+
const context = briefContext({
|
|
6038
6721
|
stage,
|
|
6039
6722
|
chapter,
|
|
6040
6723
|
project: snapshot.project,
|
|
@@ -6046,6 +6729,8 @@ var EngineService = class {
|
|
|
6046
6729
|
foreshadows: snapshot.foreshadows,
|
|
6047
6730
|
timeline: snapshot.timeline,
|
|
6048
6731
|
relationships: snapshot.relationships,
|
|
6732
|
+
worldPlan: snapshot.worldPlan,
|
|
6733
|
+
world_plan_error: snapshot.worldPlanError,
|
|
6049
6734
|
plan: snapshot.plan,
|
|
6050
6735
|
plan_error: snapshot.planError,
|
|
6051
6736
|
review: snapshot.review,
|
|
@@ -6057,6 +6742,8 @@ var EngineService = class {
|
|
|
6057
6742
|
previousText: snapshot.previousText,
|
|
6058
6743
|
handoff: snapshot.handoff
|
|
6059
6744
|
});
|
|
6745
|
+
cache.set(cacheKey, context);
|
|
6746
|
+
return context;
|
|
6060
6747
|
}
|
|
6061
6748
|
briefDigest(stage, snapshot, context) {
|
|
6062
6749
|
const briefHash = this.briefs.render(stage, this.briefVariables(snapshot, snapshot.pipeline.chapter)).contentHash;
|
|
@@ -6084,17 +6771,19 @@ var EngineService = class {
|
|
|
6084
6771
|
round: snapshot.pipeline.stage === "P5" ? snapshot.pipeline.rounds.revise + 1 : snapshot.pipeline.rounds.review + 1
|
|
6085
6772
|
};
|
|
6086
6773
|
}
|
|
6087
|
-
|
|
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 = {}) {
|
|
6088
6780
|
const text = extras.text ?? snapshot.text ?? "";
|
|
6089
|
-
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");
|
|
6090
|
-
const stateBlocking = this.stateGateBlock(snapshot, operation);
|
|
6091
|
-
if (stateBlocking.length > 0) throw new EngineBlockedError(ERROR_CODES.GATE_BLOCKED, `${operation} blocked by unreadable state: ${stateBlocking[0].detail}`, stateBlocking, this.recoveryFor(snapshot).reason);
|
|
6092
6781
|
const archive = operation === "archiveChapter" ? {
|
|
6093
6782
|
chapterExists: snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter),
|
|
6094
6783
|
checkPassed: snapshot.check?.verdict === "pass",
|
|
6095
6784
|
hashMatches: snapshot.check !== null && snapshot.text !== null && snapshot.check.content_hash === sha256(snapshot.text)
|
|
6096
6785
|
} : null;
|
|
6097
|
-
|
|
6786
|
+
return {
|
|
6098
6787
|
operation,
|
|
6099
6788
|
stage: snapshot.pipeline.stage,
|
|
6100
6789
|
text,
|
|
@@ -6104,7 +6793,7 @@ var EngineService = class {
|
|
|
6104
6793
|
chapters: snapshot.chapters,
|
|
6105
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))),
|
|
6106
6795
|
briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
|
|
6107
|
-
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),
|
|
6108
6797
|
sceneType: extras.sceneType ?? snapshot.plan?.scene_type ?? null,
|
|
6109
6798
|
summary: extras.summary ?? snapshot.plan?.summary ?? null,
|
|
6110
6799
|
summaryProvided: extras.summaryProvided,
|
|
@@ -6126,7 +6815,13 @@ var EngineService = class {
|
|
|
6126
6815
|
previousText: snapshot.previousText,
|
|
6127
6816
|
previousTexts: snapshot.previousTexts,
|
|
6128
6817
|
priorSceneTypes: snapshot.chapters.chapters.map((record) => record.scene_type)
|
|
6129
|
-
}
|
|
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), {
|
|
6130
6825
|
rulepack: this.rulepack,
|
|
6131
6826
|
minScore: this.config.quality.minScore
|
|
6132
6827
|
});
|
|
@@ -6153,36 +6848,76 @@ var EngineService = class {
|
|
|
6153
6848
|
}
|
|
6154
6849
|
async load(root, retries = 1) {
|
|
6155
6850
|
const store = this.storeFor(await this.resolveRoot(root));
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
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
|
+
}
|
|
6159
6879
|
const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
6160
6880
|
if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
|
|
6161
|
-
const
|
|
6162
|
-
const
|
|
6163
|
-
|
|
6164
|
-
|
|
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", {
|
|
6165
6900
|
schema_version: 1,
|
|
6166
6901
|
locked_at_chapter: 0,
|
|
6167
6902
|
characters: {},
|
|
6168
6903
|
locations: {}
|
|
6169
|
-
};
|
|
6170
|
-
const timeline = await
|
|
6904
|
+
});
|
|
6905
|
+
const timeline = await ledger(joinRel(STATE_DIR, STATE_FILES.timeline), "timeline", "TIMELINE_UNREADABLE", {
|
|
6171
6906
|
schema_version: 1,
|
|
6172
6907
|
next_seq: 1,
|
|
6173
6908
|
items: []
|
|
6174
|
-
};
|
|
6175
|
-
const foreshadows = await
|
|
6909
|
+
});
|
|
6910
|
+
const foreshadows = await ledger(joinRel(STATE_DIR, STATE_FILES.foreshadows), "foreshadows", "FORESHADOWS_UNREADABLE", {
|
|
6176
6911
|
schema_version: 1,
|
|
6177
6912
|
next_seq: 1,
|
|
6178
6913
|
items: []
|
|
6179
|
-
};
|
|
6180
|
-
const relationships = await
|
|
6914
|
+
});
|
|
6915
|
+
const relationships = await ledger(joinRel(STATE_DIR, STATE_FILES.relationships), "relationships", "RELATIONSHIPS_UNREADABLE", {
|
|
6181
6916
|
schema_version: 1,
|
|
6182
6917
|
next_seq: 1,
|
|
6183
6918
|
items: []
|
|
6184
|
-
};
|
|
6185
|
-
const handoff = await
|
|
6919
|
+
});
|
|
6920
|
+
const handoff = await ledger(joinRel(STATE_DIR, STATE_FILES.handoff), "handoff", "HANDOFF_UNREADABLE", null);
|
|
6186
6921
|
const chapter = pipeline.chapter;
|
|
6187
6922
|
const text = await store.readText(chapterFile(chapter));
|
|
6188
6923
|
const previousText = chapter > 1 ? await store.readText(chapterFile(chapter - 1)) : null;
|
|
@@ -6202,12 +6937,29 @@ var EngineService = class {
|
|
|
6202
6937
|
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
6203
6938
|
planError = errorMessage(error);
|
|
6204
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;
|
|
6948
|
+
} catch (error) {
|
|
6949
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
6950
|
+
worldPlanError = errorMessage(error);
|
|
6951
|
+
}
|
|
6205
6952
|
const reviewRead = await this.recoverableDoc(store, reviewFile(chapter), "review");
|
|
6206
6953
|
const checkRead = await this.recoverableDoc(store, checkFile(chapter), "check");
|
|
6207
6954
|
const review = reviewRead.value;
|
|
6208
|
-
|
|
6955
|
+
let reviewError = reviewRead.error;
|
|
6209
6956
|
const check = checkRead.value;
|
|
6210
|
-
|
|
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`;
|
|
6211
6963
|
const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
6212
6964
|
if (latest === null || latest.revision !== pipeline.revision) {
|
|
6213
6965
|
if (retries > 0) return this.load(store.root, retries - 1);
|
|
@@ -6217,6 +6969,7 @@ var EngineService = class {
|
|
|
6217
6969
|
return {
|
|
6218
6970
|
root: store.root,
|
|
6219
6971
|
project,
|
|
6972
|
+
projectError,
|
|
6220
6973
|
pipeline,
|
|
6221
6974
|
chapters,
|
|
6222
6975
|
characters,
|
|
@@ -6226,17 +6979,31 @@ var EngineService = class {
|
|
|
6226
6979
|
foreshadows,
|
|
6227
6980
|
relationships,
|
|
6228
6981
|
handoff,
|
|
6982
|
+
worldPlan,
|
|
6983
|
+
ledgerErrors,
|
|
6229
6984
|
text,
|
|
6230
6985
|
previousText,
|
|
6231
6986
|
previousTexts,
|
|
6232
6987
|
plan,
|
|
6233
6988
|
planError,
|
|
6989
|
+
worldPlanError,
|
|
6234
6990
|
review,
|
|
6235
6991
|
reviewError,
|
|
6236
6992
|
check,
|
|
6237
6993
|
checkError
|
|
6238
6994
|
};
|
|
6239
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
|
+
}
|
|
6240
7007
|
/** Read a review/check without letting one corrupt file wedge every read. */
|
|
6241
7008
|
async recoverableDoc(store, path, kind) {
|
|
6242
7009
|
try {
|
|
@@ -6313,7 +7080,7 @@ var EngineService = class {
|
|
|
6313
7080
|
};
|
|
6314
7081
|
}
|
|
6315
7082
|
}
|
|
6316
|
-
await assertJournalConsistent(store);
|
|
7083
|
+
await assertJournalConsistent(store, options.repairPaths ?? []);
|
|
6317
7084
|
const snapshot = await this.load(root);
|
|
6318
7085
|
const key = options.key?.(snapshot);
|
|
6319
7086
|
if (key !== void 0) {
|
|
@@ -6374,6 +7141,7 @@ var EngineService = class {
|
|
|
6374
7141
|
return {
|
|
6375
7142
|
root,
|
|
6376
7143
|
project: { ...ENGINE_PROJECT_INITIAL },
|
|
7144
|
+
projectError: null,
|
|
6377
7145
|
pipeline: emptyPipeline({
|
|
6378
7146
|
revision: 1,
|
|
6379
7147
|
chapter: 1,
|
|
@@ -6404,11 +7172,14 @@ var EngineService = class {
|
|
|
6404
7172
|
items: []
|
|
6405
7173
|
},
|
|
6406
7174
|
handoff: null,
|
|
7175
|
+
worldPlan: null,
|
|
7176
|
+
ledgerErrors: [],
|
|
6407
7177
|
text: null,
|
|
6408
7178
|
previousText: null,
|
|
6409
7179
|
previousTexts: [],
|
|
6410
7180
|
plan: null,
|
|
6411
7181
|
planError: null,
|
|
7182
|
+
worldPlanError: null,
|
|
6412
7183
|
review: null,
|
|
6413
7184
|
reviewError: null,
|
|
6414
7185
|
check: null,
|
|
@@ -6488,20 +7259,362 @@ const machineWarnings = (machine) => machine.violations.filter((violation) => vi
|
|
|
6488
7259
|
code: violation.code,
|
|
6489
7260
|
message: `${violation.detail} (paragraph ${violation.location.paragraph})`
|
|
6490
7261
|
}));
|
|
6491
|
-
/**
|
|
6492
|
-
const
|
|
6493
|
-
if (title === null) return null;
|
|
6494
|
-
return /^第\s*\d+\s*章/.test(title) ? title : `第${chapter}章 ${title}`;
|
|
6495
|
-
};
|
|
6496
|
-
const stableJson = (value) => {
|
|
7262
|
+
/** 0.1.2 archive digests used localeCompare; keep that comparison only for upgrades. */
|
|
7263
|
+
const legacyStableJson = (value) => {
|
|
6497
7264
|
const normalize = (input) => {
|
|
6498
7265
|
if (Array.isArray(input)) return input.map(normalize);
|
|
6499
|
-
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)]));
|
|
6500
7267
|
return input;
|
|
6501
7268
|
};
|
|
6502
|
-
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}`;
|
|
6503
7303
|
};
|
|
6504
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
|
+
};
|
|
6505
7618
|
/** Validate the shape a stored chapter plan must have before any stage uses it. */
|
|
6506
7619
|
const validateStoredChapterPlan = (plan, path) => {
|
|
6507
7620
|
const fail = (detail) => {
|
|
@@ -6526,7 +7639,14 @@ const validateStoredChapterPlan = (plan, path) => {
|
|
|
6526
7639
|
if (!SCENE_TYPES.includes(plan.scene_type)) fail(`unknown scene_type "${String(plan.scene_type)}"`);
|
|
6527
7640
|
if (!Array.isArray(plan.beats) || plan.beats.some((beat) => typeof beat !== "string")) fail("beats must be a string array");
|
|
6528
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");
|
|
6529
|
-
if (plan.summary !== void 0
|
|
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
|
+
}
|
|
6530
7650
|
};
|
|
6531
7651
|
const CHECK_SCOPES = ["content", "integrity"];
|
|
6532
7652
|
const normalizeCheckScope = (value) => {
|
|
@@ -6823,6 +7943,10 @@ function createNovelTools(engine) {
|
|
|
6823
7943
|
type: "array",
|
|
6824
7944
|
items: { type: "integer" }
|
|
6825
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
|
+
},
|
|
6826
7950
|
project_root: root
|
|
6827
7951
|
},
|
|
6828
7952
|
output: jsonOutput,
|
|
@@ -6835,6 +7959,7 @@ function createNovelTools(engine) {
|
|
|
6835
7959
|
opening: args.opening ?? "",
|
|
6836
7960
|
ending: args.ending ?? "",
|
|
6837
7961
|
target_words: [args.target_words?.[0] ?? 2e3, args.target_words?.[1] ?? 3e3],
|
|
7962
|
+
...args.cast === void 0 ? {} : { cast: asJson(args.cast, "cast") },
|
|
6838
7963
|
root: args.project_root
|
|
6839
7964
|
}))
|
|
6840
7965
|
}),
|
|
@@ -7046,12 +8171,23 @@ function createNovelTools(engine) {
|
|
|
7046
8171
|
]
|
|
7047
8172
|
},
|
|
7048
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
|
+
},
|
|
7049
8183
|
project_root: root
|
|
7050
8184
|
},
|
|
7051
8185
|
output: jsonOutput,
|
|
7052
8186
|
execute: guarded(async (args) => engine.queryState({
|
|
7053
8187
|
kind: args.kind,
|
|
7054
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 },
|
|
7055
8191
|
root: args.project_root
|
|
7056
8192
|
}))
|
|
7057
8193
|
}),
|
|
@@ -7063,6 +8199,7 @@ function createNovelTools(engine) {
|
|
|
7063
8199
|
type: "string",
|
|
7064
8200
|
required: true,
|
|
7065
8201
|
enum: [
|
|
8202
|
+
"novel.yaml",
|
|
7066
8203
|
"state/chapters.yaml",
|
|
7067
8204
|
"state/characters.yaml",
|
|
7068
8205
|
"state/world.yaml",
|