dsh-novel-write-engine 0.1.1 → 0.1.2
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 +17 -0
- package/README.md +42 -29
- package/briefs/P1.md +6 -4
- package/briefs/P4.md +3 -0
- package/briefs/P5.md +4 -3
- package/briefs/P6.md +6 -0
- package/lib/index.d.ts +29 -1
- package/lib/index.js +563 -34
- package/lib/types/types.d.ts +11 -0
- package/package.json +17 -18
package/lib/index.js
CHANGED
|
@@ -1023,10 +1023,16 @@ const adjectiveDensity = (ctx) => {
|
|
|
1023
1023
|
return [v(SEMANTIC_RULES.ADJECTIVE_DENSITY, "advisory", 1, `adjectives every ${(100 / density).toFixed(1)} chars; limit is ${max}%`)];
|
|
1024
1024
|
};
|
|
1025
1025
|
const slidingWindows = (text, size) => {
|
|
1026
|
-
|
|
1026
|
+
const positions = [];
|
|
1027
|
+
for (let index = 0; index < text.length; index += 1) if (/\S/.test(text[index])) positions.push(index);
|
|
1028
|
+
if (positions.length <= size) return [text];
|
|
1027
1029
|
const windows = [];
|
|
1028
|
-
for (let
|
|
1029
|
-
|
|
1030
|
+
for (let startIndex = 0; startIndex + size <= positions.length; startIndex += 1) {
|
|
1031
|
+
const start = positions[startIndex];
|
|
1032
|
+
const end = startIndex + size < positions.length ? positions[startIndex + size] : text.length;
|
|
1033
|
+
windows.push(text.slice(start, end));
|
|
1034
|
+
}
|
|
1035
|
+
const tail = text.slice(positions[positions.length - size]);
|
|
1030
1036
|
if (windows[windows.length - 1] !== tail) windows.push(tail);
|
|
1031
1037
|
return windows;
|
|
1032
1038
|
};
|
|
@@ -1104,10 +1110,18 @@ const revisitedTooSoon = (ctx) => {
|
|
|
1104
1110
|
}).map((item) => v(CROSS_RULES.FS_REVISITED_TOO_SOON, "advisory", 1, `foreshadow ${item.id} reappears after ${ctx.chapter - item.last_touched} chapters (minimum ${min})`));
|
|
1105
1111
|
};
|
|
1106
1112
|
const resourceMismatch = (ctx) => {
|
|
1107
|
-
|
|
1113
|
+
const changes = ctx.summary?.resource_changes ?? [];
|
|
1114
|
+
const positive = /(增加|多了|得到|获得|赚|加|上升|增|\+)/;
|
|
1115
|
+
const negative = /(减少|少了|失去|消耗|花|损失|下降|减|-|−)/;
|
|
1116
|
+
return changes.flatMap((change) => {
|
|
1108
1117
|
if (change.delta === 0) return [];
|
|
1109
|
-
const magnitude =
|
|
1110
|
-
return ctx.text.
|
|
1118
|
+
const magnitude = Math.abs(change.delta);
|
|
1119
|
+
return [...ctx.text.matchAll(/\d+/g)].some((match) => {
|
|
1120
|
+
if (Number(match[0]) !== magnitude) return false;
|
|
1121
|
+
const index = match.index ?? 0;
|
|
1122
|
+
const window = ctx.text.slice(Math.max(0, index - 80), Math.min(ctx.text.length, index + match[0].length + 80));
|
|
1123
|
+
return window.includes(change.character) && window.includes(change.resource) && (change.delta > 0 ? positive : negative).test(window);
|
|
1124
|
+
}) ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", 1, `${change.character}'s ${change.resource} change (${change.delta}) is not stated with the right sign in the text`)];
|
|
1111
1125
|
});
|
|
1112
1126
|
};
|
|
1113
1127
|
const timeRegression = (ctx) => {
|
|
@@ -1263,6 +1277,11 @@ const lexiconCodes = /* @__PURE__ */ new Set([
|
|
|
1263
1277
|
"GOD_VIEW",
|
|
1264
1278
|
"CONCLUSION_FIRST"
|
|
1265
1279
|
]);
|
|
1280
|
+
const integrityCodes = new Set(Object.values(CROSS_RULES));
|
|
1281
|
+
/** Which runCheck scope owns one machine finding. */
|
|
1282
|
+
function scopeOfRule(code) {
|
|
1283
|
+
return integrityCodes.has(code) ? "integrity" : "content";
|
|
1284
|
+
}
|
|
1266
1285
|
/**
|
|
1267
1286
|
* Run the deterministic pass. Format and punctuation run before the lexicon so
|
|
1268
1287
|
* the report reads in the order an author fixes things; the lexicon runs last
|
|
@@ -2846,7 +2865,7 @@ const pendingForeshadows = (ledger) => ledger.items.filter((item) => item.status
|
|
|
2846
2865
|
* pack is the minimum project state the model needs to apply them without
|
|
2847
2866
|
* guessing who exists, what was locked, or what the previous chapter ended on.
|
|
2848
2867
|
*/
|
|
2849
|
-
function
|
|
2868
|
+
function unbudgetedBriefContext(input) {
|
|
2850
2869
|
const previousEnding = input.previousText === null ? "" : input.previousText.trim().slice(-400);
|
|
2851
2870
|
const base = {
|
|
2852
2871
|
project: {
|
|
@@ -2859,7 +2878,9 @@ function briefContext(input) {
|
|
|
2859
2878
|
stage: input.pipeline.stage,
|
|
2860
2879
|
scene_distribution: sceneDistribution(input.chapters),
|
|
2861
2880
|
previous_ending: previousEnding,
|
|
2862
|
-
...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error }
|
|
2881
|
+
...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error },
|
|
2882
|
+
...input.review_error === void 0 || input.review_error === null ? {} : { review_error: input.review_error },
|
|
2883
|
+
...input.check_error === void 0 || input.check_error === null ? {} : { check_error: input.check_error }
|
|
2863
2884
|
};
|
|
2864
2885
|
switch (input.stage) {
|
|
2865
2886
|
case "P1": return {
|
|
@@ -2887,6 +2908,7 @@ function briefContext(input) {
|
|
|
2887
2908
|
...base,
|
|
2888
2909
|
text: input.text ?? "",
|
|
2889
2910
|
summary: input.plan?.summary ?? null,
|
|
2911
|
+
plan: input.plan,
|
|
2890
2912
|
machine: input.machine ?? null,
|
|
2891
2913
|
characters: input.characters.characters,
|
|
2892
2914
|
check: input.check,
|
|
@@ -2898,6 +2920,7 @@ function briefContext(input) {
|
|
|
2898
2920
|
case "P5": return {
|
|
2899
2921
|
...base,
|
|
2900
2922
|
text: input.text ?? "",
|
|
2923
|
+
plan: input.plan,
|
|
2901
2924
|
machine: input.machine ?? null,
|
|
2902
2925
|
review: input.review,
|
|
2903
2926
|
issues: input.review?.issues ?? []
|
|
@@ -2905,6 +2928,7 @@ function briefContext(input) {
|
|
|
2905
2928
|
case "P6": return {
|
|
2906
2929
|
...base,
|
|
2907
2930
|
text: input.text ?? "",
|
|
2931
|
+
plan: input.plan,
|
|
2908
2932
|
machine: input.machine ?? null,
|
|
2909
2933
|
characters: input.characters.characters,
|
|
2910
2934
|
world: input.world,
|
|
@@ -2922,6 +2946,7 @@ function briefContext(input) {
|
|
|
2922
2946
|
};
|
|
2923
2947
|
case "P8": return {
|
|
2924
2948
|
...base,
|
|
2949
|
+
plan: input.plan,
|
|
2925
2950
|
check: input.check,
|
|
2926
2951
|
review: input.review,
|
|
2927
2952
|
summary: input.plan?.summary ?? null,
|
|
@@ -2931,6 +2956,238 @@ function briefContext(input) {
|
|
|
2931
2956
|
default: return base;
|
|
2932
2957
|
}
|
|
2933
2958
|
}
|
|
2959
|
+
/** Per-stage context budgets, measured in JSON characters (metadata excluded). */
|
|
2960
|
+
const CONTEXT_BUDGETS = {
|
|
2961
|
+
P1: 6e3,
|
|
2962
|
+
P2: 8e3,
|
|
2963
|
+
P3: 16e3,
|
|
2964
|
+
P4: 24e3,
|
|
2965
|
+
P5: 24e3,
|
|
2966
|
+
P6: 32e3,
|
|
2967
|
+
P8: 12e3
|
|
2968
|
+
};
|
|
2969
|
+
const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
2970
|
+
const asArray = (value) => Array.isArray(value) ? value : null;
|
|
2971
|
+
const jsonSize = (value) => Array.from(JSON.stringify(value)).length;
|
|
2972
|
+
const mentionScore = (mentioned, value) => value !== "" && mentioned.includes(value) ? 1 : 0;
|
|
2973
|
+
/**
|
|
2974
|
+
* Apply the stage budget after the stage-specific pack exists. Protected keys
|
|
2975
|
+
* are never candidates; optional ledgers are trimmed round-robin so one large
|
|
2976
|
+
* list cannot starve every other kind of context.
|
|
2977
|
+
*/
|
|
2978
|
+
function withContextBudget(stage, raw) {
|
|
2979
|
+
const context = structuredClone(raw);
|
|
2980
|
+
const budget = CONTEXT_BUDGETS[stage] ?? 8e3;
|
|
2981
|
+
const protectedKeys = [
|
|
2982
|
+
"project",
|
|
2983
|
+
"chapter",
|
|
2984
|
+
"stage",
|
|
2985
|
+
"previous_ending",
|
|
2986
|
+
"text",
|
|
2987
|
+
"plan",
|
|
2988
|
+
"precheck",
|
|
2989
|
+
"review",
|
|
2990
|
+
"check",
|
|
2991
|
+
"machine",
|
|
2992
|
+
"summary"
|
|
2993
|
+
];
|
|
2994
|
+
const mentioned = [
|
|
2995
|
+
typeof context.text === "string" ? context.text : "",
|
|
2996
|
+
JSON.stringify(context.plan ?? ""),
|
|
2997
|
+
JSON.stringify(context.review ?? ""),
|
|
2998
|
+
JSON.stringify(context.check ?? ""),
|
|
2999
|
+
JSON.stringify(context.summary ?? "")
|
|
3000
|
+
].join("\n");
|
|
3001
|
+
const omitted = {};
|
|
3002
|
+
const truncated = /* @__PURE__ */ new Set();
|
|
3003
|
+
const candidates = [];
|
|
3004
|
+
const stableKey = (value) => {
|
|
3005
|
+
const row = asRecord(value);
|
|
3006
|
+
if (row === null) return JSON.stringify(value) ?? "";
|
|
3007
|
+
for (const key of [
|
|
3008
|
+
"id",
|
|
3009
|
+
"name",
|
|
3010
|
+
"title"
|
|
3011
|
+
]) {
|
|
3012
|
+
const item = row[key];
|
|
3013
|
+
if (typeof item === "string" && item !== "") return item;
|
|
3014
|
+
}
|
|
3015
|
+
return JSON.stringify(row);
|
|
3016
|
+
};
|
|
3017
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
3018
|
+
const addArray = (key, array, rank, tie = stableKey) => {
|
|
3019
|
+
let prepared = false;
|
|
3020
|
+
const sort = () => {
|
|
3021
|
+
if (prepared) return;
|
|
3022
|
+
prepared = true;
|
|
3023
|
+
array()?.sort((left, right) => {
|
|
3024
|
+
const byRank = rank(right) - rank(left);
|
|
3025
|
+
return byRank !== 0 ? byRank : compareText(tie(left), tie(right));
|
|
3026
|
+
});
|
|
3027
|
+
};
|
|
3028
|
+
candidates.push({
|
|
3029
|
+
key,
|
|
3030
|
+
count: () => array()?.length ?? 0,
|
|
3031
|
+
trim: () => {
|
|
3032
|
+
sort();
|
|
3033
|
+
const list = array();
|
|
3034
|
+
if (list === null || list.length === 0) return false;
|
|
3035
|
+
list.pop();
|
|
3036
|
+
return true;
|
|
3037
|
+
},
|
|
3038
|
+
prepare: sort
|
|
3039
|
+
});
|
|
3040
|
+
};
|
|
3041
|
+
const addObject = (key, map, rank) => {
|
|
3042
|
+
let keys = null;
|
|
3043
|
+
const ensure = () => {
|
|
3044
|
+
const target = map();
|
|
3045
|
+
keys ??= target === null ? [] : Object.keys(target);
|
|
3046
|
+
keys.sort((left, right) => {
|
|
3047
|
+
const byRank = rank(right, map()?.[right]) - rank(left, map()?.[left]);
|
|
3048
|
+
return byRank !== 0 ? byRank : compareText(left, right);
|
|
3049
|
+
});
|
|
3050
|
+
return keys;
|
|
3051
|
+
};
|
|
3052
|
+
candidates.push({
|
|
3053
|
+
key,
|
|
3054
|
+
count: () => Object.keys(map() ?? {}).length,
|
|
3055
|
+
trim: () => {
|
|
3056
|
+
const target = map();
|
|
3057
|
+
if (target === null) return false;
|
|
3058
|
+
const keyToDelete = ensure().pop();
|
|
3059
|
+
if (keyToDelete === void 0 || !Object.hasOwn(target, keyToDelete)) return false;
|
|
3060
|
+
delete target[keyToDelete];
|
|
3061
|
+
return true;
|
|
3062
|
+
},
|
|
3063
|
+
prepare: ensure
|
|
3064
|
+
});
|
|
3065
|
+
};
|
|
3066
|
+
const chapter = (value) => asRecord(value)?.num ?? 0;
|
|
3067
|
+
addArray("characters", () => asArray(context.characters), (value) => {
|
|
3068
|
+
const row = asRecord(value);
|
|
3069
|
+
const name = typeof row?.name === "string" ? row.name : "";
|
|
3070
|
+
return mentionScore(mentioned, name) * 1e3 + Number(row?.first_appearance ?? 0);
|
|
3071
|
+
}, (value) => {
|
|
3072
|
+
const row = asRecord(value);
|
|
3073
|
+
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3074
|
+
});
|
|
3075
|
+
const world = asRecord(context.world);
|
|
3076
|
+
for (const key of [
|
|
3077
|
+
"locations",
|
|
3078
|
+
"abilities",
|
|
3079
|
+
"artifacts",
|
|
3080
|
+
"factions",
|
|
3081
|
+
"rules"
|
|
3082
|
+
]) addArray(`world.${key}`, () => asArray(world?.[key]), (value) => {
|
|
3083
|
+
const row = asRecord(value);
|
|
3084
|
+
const name = typeof row?.name === "string" ? row.name : "";
|
|
3085
|
+
return mentionScore(mentioned, name) * 1e3 + Number(row?.first_appearance ?? 0);
|
|
3086
|
+
}, (value) => {
|
|
3087
|
+
const row = asRecord(value);
|
|
3088
|
+
return typeof row?.name === "string" ? row.name : stableKey(value);
|
|
3089
|
+
});
|
|
3090
|
+
const locks = asRecord(context.locks);
|
|
3091
|
+
for (const key of ["characters", "locations"]) addObject(`locks.${key}`, () => asRecord(locks?.[key]), (name) => mentionScore(mentioned, name) * 1e3);
|
|
3092
|
+
addArray("open_foreshadows", () => asArray(context.open_foreshadows), (value) => {
|
|
3093
|
+
const row = asRecord(value);
|
|
3094
|
+
const title = typeof row?.title === "string" ? row.title : "";
|
|
3095
|
+
return mentionScore(mentioned, title) * 1e3 - Number(row?.last_touched ?? 0);
|
|
3096
|
+
}, (value) => {
|
|
3097
|
+
const row = asRecord(value);
|
|
3098
|
+
return `${typeof row?.title === "string" ? row.title : ""}\u0000${typeof row?.id === "string" ? row.id : ""}`;
|
|
3099
|
+
});
|
|
3100
|
+
const foreshadows = asRecord(context.foreshadows);
|
|
3101
|
+
addArray("foreshadows", () => asArray(foreshadows?.items), (value) => {
|
|
3102
|
+
const row = asRecord(value);
|
|
3103
|
+
const title = typeof row?.title === "string" ? row.title : "";
|
|
3104
|
+
const id = typeof row?.id === "string" ? row.id : "";
|
|
3105
|
+
return Math.max(mentionScore(mentioned, title), mentionScore(mentioned, id)) * 1e3 - Number(row?.last_touched ?? 0);
|
|
3106
|
+
}, (value) => {
|
|
3107
|
+
const row = asRecord(value);
|
|
3108
|
+
return `${typeof row?.id === "string" ? row.id : ""}\u0000${typeof row?.title === "string" ? row.title : ""}`;
|
|
3109
|
+
});
|
|
3110
|
+
addArray("timeline", () => asArray(context.timeline), (value) => {
|
|
3111
|
+
const row = asRecord(value);
|
|
3112
|
+
const text = `${String(row?.description ?? "")}${String(row?.location ?? "")}`;
|
|
3113
|
+
return mentionScore(mentioned, text) * 1e3 + Number(row?.chapter ?? 0);
|
|
3114
|
+
}, (value) => {
|
|
3115
|
+
const row = asRecord(value);
|
|
3116
|
+
return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.chapter ?? "")}`;
|
|
3117
|
+
});
|
|
3118
|
+
const relationships = asRecord(context.relationships);
|
|
3119
|
+
addArray("relationships", () => asArray(relationships?.items), (value) => {
|
|
3120
|
+
const row = asRecord(value);
|
|
3121
|
+
const text = `${String(row?.a ?? "")}${String(row?.b ?? "")}`;
|
|
3122
|
+
return mentionScore(mentioned, text) * 1e3 + Number(row?.since ?? 0);
|
|
3123
|
+
}, (value) => {
|
|
3124
|
+
const row = asRecord(value);
|
|
3125
|
+
return `${typeof row?.id === "string" ? row.id : ""}\u0000${String(row?.a ?? "")}\u0000${String(row?.b ?? "")}`;
|
|
3126
|
+
});
|
|
3127
|
+
addArray("recent_summaries", () => asArray(context.recent_summaries), (value) => chapter(value), (value) => String(chapter(value)).padStart(8, "0"));
|
|
3128
|
+
addArray("score_history", () => asArray(context.score_history), (value) => Number(asRecord(value)?.num ?? 0), (value) => String(Number(asRecord(value)?.num ?? 0)).padStart(8, "0"));
|
|
3129
|
+
for (const candidate of candidates) candidate.prepare();
|
|
3130
|
+
const used = () => {
|
|
3131
|
+
const measure = { ...context };
|
|
3132
|
+
delete measure.context_meta;
|
|
3133
|
+
return jsonSize(measure);
|
|
3134
|
+
};
|
|
3135
|
+
const buildMeta = (finalUsed) => ({
|
|
3136
|
+
version: 1,
|
|
3137
|
+
stage,
|
|
3138
|
+
budget,
|
|
3139
|
+
used: finalUsed,
|
|
3140
|
+
over_budget: false,
|
|
3141
|
+
protected: protectedKeys,
|
|
3142
|
+
truncated: [...truncated].sort(),
|
|
3143
|
+
omitted: Object.fromEntries(Object.entries(omitted).sort(([left], [right]) => left.localeCompare(right))),
|
|
3144
|
+
strategy: "stage-v1"
|
|
3145
|
+
});
|
|
3146
|
+
const totalWith = (finalUsed, meta) => jsonSize({
|
|
3147
|
+
...context,
|
|
3148
|
+
context_meta: {
|
|
3149
|
+
...meta,
|
|
3150
|
+
used: finalUsed
|
|
3151
|
+
}
|
|
3152
|
+
});
|
|
3153
|
+
let finalUsed = used();
|
|
3154
|
+
let metaBase = buildMeta(finalUsed);
|
|
3155
|
+
while (totalWith(finalUsed, metaBase) > budget) {
|
|
3156
|
+
let changed = false;
|
|
3157
|
+
for (const candidate of candidates) {
|
|
3158
|
+
finalUsed = used();
|
|
3159
|
+
metaBase = buildMeta(finalUsed);
|
|
3160
|
+
if (totalWith(finalUsed, metaBase) <= budget) break;
|
|
3161
|
+
if (candidate.count() === 0) continue;
|
|
3162
|
+
candidate.prepare();
|
|
3163
|
+
if (candidate.trim()) {
|
|
3164
|
+
omitted[candidate.key] = (omitted[candidate.key] ?? 0) + 1;
|
|
3165
|
+
truncated.add(candidate.key);
|
|
3166
|
+
changed = true;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
finalUsed = used();
|
|
3170
|
+
metaBase = buildMeta(finalUsed);
|
|
3171
|
+
if (!changed) break;
|
|
3172
|
+
}
|
|
3173
|
+
const overBudget = totalWith(finalUsed, metaBase) > budget;
|
|
3174
|
+
const meta = {
|
|
3175
|
+
...metaBase,
|
|
3176
|
+
over_budget: overBudget
|
|
3177
|
+
};
|
|
3178
|
+
return {
|
|
3179
|
+
...context,
|
|
3180
|
+
context_meta: meta
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Build the stage-specific context pack plus the deterministic budget report.
|
|
3185
|
+
* The budget report is part of the brief digest; a truncated pack cannot be
|
|
3186
|
+
* silently mistaken for the full ledger set.
|
|
3187
|
+
*/
|
|
3188
|
+
function briefContext(input) {
|
|
3189
|
+
return withContextBudget(input.stage, unbudgetedBriefContext(input));
|
|
3190
|
+
}
|
|
2934
3191
|
// src/engine/state-delta.ts
|
|
2935
3192
|
const SECTIONS = [
|
|
2936
3193
|
"characters",
|
|
@@ -3545,6 +3802,7 @@ const wordCount = (input) => {
|
|
|
3545
3802
|
};
|
|
3546
3803
|
/** PG-04: format blockers must be zero before length is judged. */
|
|
3547
3804
|
const format = (input, options) => {
|
|
3805
|
+
if (input.scope !== void 0 && !input.scope.includes("content")) return [];
|
|
3548
3806
|
const violations = runRules([
|
|
3549
3807
|
"MD_CONTAMINATION",
|
|
3550
3808
|
"ILLEGAL_QUOTE",
|
|
@@ -3757,6 +4015,7 @@ const readingProof = (input) => {
|
|
|
3757
4015
|
};
|
|
3758
4016
|
/** PG-10: cross-chapter reconciliation must be clean. */
|
|
3759
4017
|
const consistency = (input, options) => {
|
|
4018
|
+
if (input.scope !== void 0 && !input.scope.includes("integrity")) return [];
|
|
3760
4019
|
const violations = runRules([
|
|
3761
4020
|
"DUPLICATE_PARAGRAPH",
|
|
3762
4021
|
"FORESHADOW_DORMANT",
|
|
@@ -3915,7 +4174,7 @@ const STAGE_TO_PHASE = {
|
|
|
3915
4174
|
P4: "P4",
|
|
3916
4175
|
P5: "P5",
|
|
3917
4176
|
P6: "P6",
|
|
3918
|
-
ready_to_archive: "
|
|
4177
|
+
ready_to_archive: "P7",
|
|
3919
4178
|
done: "P9"
|
|
3920
4179
|
};
|
|
3921
4180
|
/** The nine-phase progress strip: phases before the active one are done. */
|
|
@@ -4093,6 +4352,17 @@ const ROUND_TRANSITIONS = {
|
|
|
4093
4352
|
P5: "P5",
|
|
4094
4353
|
P6: "P6"
|
|
4095
4354
|
};
|
|
4355
|
+
/** Forward order of stages; rollback may only move to an equal or earlier rank. */
|
|
4356
|
+
const STAGE_RANK = {
|
|
4357
|
+
idle: 0,
|
|
4358
|
+
P2: 1,
|
|
4359
|
+
P3: 2,
|
|
4360
|
+
P4: 3,
|
|
4361
|
+
P5: 4,
|
|
4362
|
+
P6: 5,
|
|
4363
|
+
ready_to_archive: 6,
|
|
4364
|
+
done: 7
|
|
4365
|
+
};
|
|
4096
4366
|
const MUTABLE_STATE = {
|
|
4097
4367
|
[joinRel(STATE_DIR, STATE_FILES.pipeline)]: "pipeline",
|
|
4098
4368
|
[joinRel(STATE_DIR, STATE_FILES.chapters)]: "chapters",
|
|
@@ -4180,6 +4450,8 @@ var EngineService = class {
|
|
|
4180
4450
|
async initProject(input) {
|
|
4181
4451
|
const title = typeof input.title === "string" ? input.title.trim() : "";
|
|
4182
4452
|
if (title === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "title must not be empty");
|
|
4453
|
+
if (input.author !== void 0 && typeof input.author !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "author must be a string");
|
|
4454
|
+
if (input.description !== void 0 && typeof input.description !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "description must be a string");
|
|
4183
4455
|
if (!GENRES.includes(input.genre)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown genre "${String(input.genre)}"`, `allowed: ${GENRES.join(", ")}`);
|
|
4184
4456
|
if (!PLATFORMS.includes(input.platform)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown platform "${String(input.platform)}"`, `allowed: ${PLATFORMS.join(", ")}`);
|
|
4185
4457
|
if (input.words_per_chapter !== void 0) {
|
|
@@ -4265,6 +4537,7 @@ var EngineService = class {
|
|
|
4265
4537
|
stage: snapshot.pipeline.stage,
|
|
4266
4538
|
rounds: { ...snapshot.pipeline.rounds },
|
|
4267
4539
|
blocking: this.effectiveBlocking(snapshot),
|
|
4540
|
+
state_errors: this.stateErrors(snapshot),
|
|
4268
4541
|
metrics,
|
|
4269
4542
|
next
|
|
4270
4543
|
},
|
|
@@ -4381,7 +4654,8 @@ var EngineService = class {
|
|
|
4381
4654
|
tool: next.tool,
|
|
4382
4655
|
args: next.args,
|
|
4383
4656
|
reason: next.reason,
|
|
4384
|
-
blocking: this.effectiveBlocking(snapshot)
|
|
4657
|
+
blocking: this.effectiveBlocking(snapshot),
|
|
4658
|
+
state_errors: this.stateErrors(snapshot)
|
|
4385
4659
|
},
|
|
4386
4660
|
revision: snapshot.pipeline.revision,
|
|
4387
4661
|
warnings: []
|
|
@@ -4416,7 +4690,9 @@ var EngineService = class {
|
|
|
4416
4690
|
/** P4 must apply exclusions before its arithmetic closes. */
|
|
4417
4691
|
score_exclusions: this.rulepack.exclusions,
|
|
4418
4692
|
next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
|
|
4419
|
-
...snapshot.planError === null ? {} : { plan_error: snapshot.planError }
|
|
4693
|
+
...snapshot.planError === null ? {} : { plan_error: snapshot.planError },
|
|
4694
|
+
...snapshot.reviewError === null ? {} : { review_error: snapshot.reviewError },
|
|
4695
|
+
...snapshot.checkError === null ? {} : { check_error: snapshot.checkError }
|
|
4420
4696
|
};
|
|
4421
4697
|
const data = {
|
|
4422
4698
|
stage: input.stage,
|
|
@@ -4457,6 +4733,20 @@ var EngineService = class {
|
|
|
4457
4733
|
}
|
|
4458
4734
|
async submitWorldPlan(input) {
|
|
4459
4735
|
if (typeof input.objective !== "string" || input.objective.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "world plan objective must not be empty");
|
|
4736
|
+
const constraints = stringList(input.constraints, "constraints");
|
|
4737
|
+
const continuity_notes = stringList(input.continuity_notes, "continuity_notes");
|
|
4738
|
+
const characters = objectList(input.characters, "characters");
|
|
4739
|
+
for (const [index, character] of characters.entries()) {
|
|
4740
|
+
requireText(character, "name", `characters[${index}].name`);
|
|
4741
|
+
requireText(character, "role", `characters[${index}].role`);
|
|
4742
|
+
requireText(character, "current_state", `characters[${index}].current_state`);
|
|
4743
|
+
}
|
|
4744
|
+
const locations = objectList(input.locations, "locations");
|
|
4745
|
+
for (const [index, location] of locations.entries()) {
|
|
4746
|
+
requireText(location, "name", `locations[${index}].name`);
|
|
4747
|
+
requireText(location, "layout", `locations[${index}].layout`);
|
|
4748
|
+
if (typeof location.capacity !== "number" || !Number.isFinite(location.capacity)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a finite number`);
|
|
4749
|
+
}
|
|
4460
4750
|
return this.mutate({
|
|
4461
4751
|
root: input.root,
|
|
4462
4752
|
op: "submitWorldPlan",
|
|
@@ -4467,10 +4757,10 @@ var EngineService = class {
|
|
|
4467
4757
|
const plan = {
|
|
4468
4758
|
chapter,
|
|
4469
4759
|
objective: input.objective,
|
|
4470
|
-
constraints
|
|
4471
|
-
characters
|
|
4472
|
-
locations
|
|
4473
|
-
continuity_notes
|
|
4760
|
+
constraints,
|
|
4761
|
+
characters,
|
|
4762
|
+
locations,
|
|
4763
|
+
continuity_notes
|
|
4474
4764
|
};
|
|
4475
4765
|
const path = planWorldFile(chapter);
|
|
4476
4766
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
@@ -4505,6 +4795,13 @@ var EngineService = class {
|
|
|
4505
4795
|
}
|
|
4506
4796
|
async submitChapterPlan(input) {
|
|
4507
4797
|
if (typeof input.title !== "string" || input.title.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan title must not be empty");
|
|
4798
|
+
const titleBody = input.title.replace(/^第\s*\d+\s*章\s*/, "").trim();
|
|
4799
|
+
const titleLength = Array.from(titleBody).length;
|
|
4800
|
+
if (titleLength < 4 || titleLength > 8) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan title body must be 4..8 characters, got ${titleLength}`, "use the same 4..8 character body the P3 title rule will judge");
|
|
4801
|
+
if (typeof input.narrator !== "string" || input.narrator.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter plan narrator must not be empty");
|
|
4802
|
+
const beats = stringList(input.beats, "beats");
|
|
4803
|
+
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");
|
|
4804
|
+
for (const [field, value] of [["opening", input.opening], ["ending", input.ending]]) if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter plan ${field} must not be empty`);
|
|
4508
4805
|
if (!SCENE_TYPES.includes(input.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown scene_type "${String(input.scene_type)}"`, `allowed: ${SCENE_TYPES.join(", ")}`);
|
|
4509
4806
|
if (!Array.isArray(input.target_words) || input.target_words.length !== 2) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "target_words must be [min, max] positive integers with min <= max");
|
|
4510
4807
|
const [min, max] = input.target_words;
|
|
@@ -4520,6 +4817,7 @@ var EngineService = class {
|
|
|
4520
4817
|
const plan = {
|
|
4521
4818
|
...input,
|
|
4522
4819
|
chapter,
|
|
4820
|
+
beats,
|
|
4523
4821
|
schema_version: 1
|
|
4524
4822
|
};
|
|
4525
4823
|
delete plan.root;
|
|
@@ -4653,6 +4951,11 @@ var EngineService = class {
|
|
|
4653
4951
|
"fix"
|
|
4654
4952
|
]) if (typeof issue[field] !== "string" || issue[field].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} has no ${field}`, "every issue needs id, severity, status, dimension, location, problem and fix");
|
|
4655
4953
|
if (issue.evidence !== void 0 && typeof issue.evidence !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} evidence must be a string`, "quote the revised text as evidence, or omit the field");
|
|
4954
|
+
if (issue.severity === "blocker" && issue.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} is a blocker and cannot be waived`, "fix the issue and quote the revised text, or keep it open");
|
|
4955
|
+
if (this.rulepack.quality.dimensions.find((item) => item.id === issue.dimension) === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} references unknown dimension "${issue.dimension}"`, `use one of: ${this.rulepack.quality.dimensions.map((item) => item.id).join(", ")}`);
|
|
4956
|
+
const paragraph = parseParagraphLocation(issue.location);
|
|
4957
|
+
const paragraphCount = splitParagraphs(text).length;
|
|
4958
|
+
if (paragraph === null || paragraph < 1 || paragraph > paragraphCount) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${issue.id} location "${issue.location}" is outside the chapter`, `use 第 1 段 .. 第 ${paragraphCount} 段`);
|
|
4656
4959
|
}
|
|
4657
4960
|
const review = {
|
|
4658
4961
|
schema_version: 1,
|
|
@@ -4757,7 +5060,15 @@ var EngineService = class {
|
|
|
4757
5060
|
seen.add(submitted.id);
|
|
4758
5061
|
const before = byId.get(submitted.id);
|
|
4759
5062
|
if (before === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `revision references unknown issue ${submitted.id}`, "only update issues from the current review; new issues belong in the next review");
|
|
4760
|
-
|
|
5063
|
+
for (const field of [
|
|
5064
|
+
"dimension",
|
|
5065
|
+
"location",
|
|
5066
|
+
"problem",
|
|
5067
|
+
"fix"
|
|
5068
|
+
]) if (submitted[field] !== void 0 && submitted[field] !== before[field]) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change its ${field} during revision`, "only status and evidence may change; new findings belong in the next review");
|
|
5069
|
+
if (submitted.severity !== void 0 && submitted.severity !== before.severity) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} cannot change severity during revision`, "only status and evidence may change; a blocker stays a blocker until re-reviewed");
|
|
5070
|
+
if (before.severity === "blocker" && submitted.severity !== void 0 && submitted.severity !== "blocker") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be downgraded`, "keep severity blocker and mark it fixed with evidence");
|
|
5071
|
+
if (before.severity === "blocker" && submitted.status === "waived") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `issue ${submitted.id} is a blocker and cannot be waived`, "keep severity blocker and mark it fixed with evidence");
|
|
4761
5072
|
const index = issues.findIndex((issue) => issue.id === submitted.id);
|
|
4762
5073
|
issues[index] = {
|
|
4763
5074
|
...before,
|
|
@@ -4835,12 +5146,13 @@ var EngineService = class {
|
|
|
4835
5146
|
});
|
|
4836
5147
|
}
|
|
4837
5148
|
async runCheck(input) {
|
|
5149
|
+
const scope = normalizeCheckScope(input.scope);
|
|
4838
5150
|
return this.mutate({
|
|
4839
5151
|
root: input.root,
|
|
4840
5152
|
op: "runCheck",
|
|
4841
5153
|
key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
|
|
4842
5154
|
semantic: input.semantic ?? [],
|
|
4843
|
-
scope
|
|
5155
|
+
scope
|
|
4844
5156
|
}))}`,
|
|
4845
5157
|
run: async (snapshot, context) => {
|
|
4846
5158
|
this.assertChapter(snapshot, input.chapter);
|
|
@@ -4850,20 +5162,25 @@ var EngineService = class {
|
|
|
4850
5162
|
briefStage: "P6",
|
|
4851
5163
|
text,
|
|
4852
5164
|
proof: snapshot.review?.proof ?? null,
|
|
4853
|
-
semantic
|
|
5165
|
+
semantic,
|
|
5166
|
+
scope
|
|
4854
5167
|
});
|
|
4855
5168
|
const machine = this.machine(snapshot, text, { summary: snapshot.plan?.summary ?? null });
|
|
4856
|
-
const
|
|
5169
|
+
const scopedMachine = {
|
|
5170
|
+
...machine,
|
|
5171
|
+
violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
|
|
5172
|
+
};
|
|
5173
|
+
const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
|
|
4857
5174
|
const semanticResult = evaluateSemantic(text, semantic);
|
|
4858
5175
|
const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
|
|
4859
5176
|
const check = {
|
|
4860
5177
|
schema_version: 1,
|
|
4861
5178
|
chapter: input.chapter,
|
|
4862
|
-
scope
|
|
5179
|
+
scope,
|
|
4863
5180
|
content_hash: sha256(text),
|
|
4864
5181
|
machine: {
|
|
4865
|
-
violations:
|
|
4866
|
-
metrics:
|
|
5182
|
+
violations: scopedMachine.violations,
|
|
5183
|
+
metrics: scopedMachine.metrics
|
|
4867
5184
|
},
|
|
4868
5185
|
semantic: { items: semantic },
|
|
4869
5186
|
verdict
|
|
@@ -4894,11 +5211,11 @@ var EngineService = class {
|
|
|
4894
5211
|
data: {
|
|
4895
5212
|
chapter: input.chapter,
|
|
4896
5213
|
verdict,
|
|
4897
|
-
violations:
|
|
4898
|
-
metrics:
|
|
5214
|
+
violations: scopedMachine.violations.length,
|
|
5215
|
+
metrics: scopedMachine.metrics,
|
|
4899
5216
|
stage: nextStage
|
|
4900
5217
|
},
|
|
4901
|
-
warnings: machineWarnings(
|
|
5218
|
+
warnings: machineWarnings(scopedMachine)
|
|
4902
5219
|
};
|
|
4903
5220
|
}
|
|
4904
5221
|
});
|
|
@@ -4939,6 +5256,7 @@ var EngineService = class {
|
|
|
4939
5256
|
const text = snapshot.text ?? "";
|
|
4940
5257
|
const summary = snapshot.plan?.summary;
|
|
4941
5258
|
if (summary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the draft has no summary; resubmit the draft with a summary", "call novel_write_draft with the full summary block");
|
|
5259
|
+
if (snapshot.review !== null && snapshot.pipeline.review_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review has no review_digest; it predates digest-bound reviews and cannot be archived blindly", "re-run novel_review for this chapter, then re-run the archive");
|
|
4942
5260
|
if (snapshot.review !== null && snapshot.pipeline.review_digest !== void 0 && snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review changed after submission; its digest no longer matches", "re-run novel_review instead of editing reviews/ directly");
|
|
4943
5261
|
await this.assertGates(snapshot, "archiveChapter", {
|
|
4944
5262
|
briefStage: "P8",
|
|
@@ -5155,6 +5473,7 @@ var EngineService = class {
|
|
|
5155
5473
|
this.assertChapter(snapshot, input.chapter);
|
|
5156
5474
|
if (!Object.prototype.hasOwnProperty.call(ROUND_TRANSITIONS, input.toStage)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown rollback target "${String(input.toStage)}"`, `allowed targets: ${Object.keys(ROUND_TRANSITIONS).join(", ")}`);
|
|
5157
5475
|
const stage = ROUND_TRANSITIONS[input.toStage];
|
|
5476
|
+
if (STAGE_RANK[stage] > STAGE_RANK[snapshot.pipeline.stage]) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `cannot roll back from ${snapshot.pipeline.stage} to later stage ${stage}`, "rollback only moves to the current stage or an earlier one; use the normal stage tools to advance");
|
|
5158
5477
|
const nextPipeline = pipelineWith(snapshot, context.revision, {
|
|
5159
5478
|
chapter: input.chapter,
|
|
5160
5479
|
stage,
|
|
@@ -5411,7 +5730,7 @@ var EngineService = class {
|
|
|
5411
5730
|
title: (snapshot.plan?.title ?? "").replace(/^第\s*\d+\s*章\s*/, "").trim() || `第${snapshot.pipeline.chapter}章`,
|
|
5412
5731
|
wordCount: draftWords,
|
|
5413
5732
|
score: snapshot.review?.total ?? 0,
|
|
5414
|
-
status: snapshot.
|
|
5733
|
+
status: this.effectiveBlocking(snapshot).length > 0 ? "blocked" : "draft",
|
|
5415
5734
|
details: (snapshot.plan?.summary?.events ?? []).join(";").slice(0, 240)
|
|
5416
5735
|
};
|
|
5417
5736
|
return {
|
|
@@ -5435,6 +5754,20 @@ var EngineService = class {
|
|
|
5435
5754
|
nextStep(snapshot) {
|
|
5436
5755
|
const stage = snapshot.pipeline.stage;
|
|
5437
5756
|
const chapter = snapshot.pipeline.chapter;
|
|
5757
|
+
if (this.stateErrors(snapshot).length > 0) {
|
|
5758
|
+
const recovery = this.recoveryFor(snapshot);
|
|
5759
|
+
const target = recovery.args.to_stage;
|
|
5760
|
+
if (STAGE_RANK[ROUND_TRANSITIONS[target]] < STAGE_RANK[stage]) return recovery;
|
|
5761
|
+
}
|
|
5762
|
+
const reviewDigest = this.reviewDigestProblem(snapshot);
|
|
5763
|
+
if (reviewDigest !== null) return {
|
|
5764
|
+
tool: "novel_rollback",
|
|
5765
|
+
args: {
|
|
5766
|
+
chapter,
|
|
5767
|
+
to_stage: "P4"
|
|
5768
|
+
},
|
|
5769
|
+
reason: `${reviewDigest.code}: ${reviewDigest.detail}; ${reviewDigest.hint}`
|
|
5770
|
+
};
|
|
5438
5771
|
const step = STAGE_NEXT[stage];
|
|
5439
5772
|
const target = snapshot.project.targets.total_chapters;
|
|
5440
5773
|
if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
|
|
@@ -5463,6 +5796,11 @@ var EngineService = class {
|
|
|
5463
5796
|
},
|
|
5464
5797
|
reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
|
|
5465
5798
|
};
|
|
5799
|
+
if (stage === "P6" && snapshot.checkError !== null) return {
|
|
5800
|
+
tool: "novel_check",
|
|
5801
|
+
args: this.nextArgs("P6", chapter),
|
|
5802
|
+
reason: `CHECK_UNREADABLE@${checkFile(chapter)}; repair the check with novel_check before any stale revision blocker`
|
|
5803
|
+
};
|
|
5466
5804
|
if (snapshot.pipeline.blocking.length > 0) {
|
|
5467
5805
|
if (stage === "P5" || stage === "P6") return {
|
|
5468
5806
|
tool: "novel_revise",
|
|
@@ -5500,9 +5838,121 @@ var EngineService = class {
|
|
|
5500
5838
|
}
|
|
5501
5839
|
/** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
|
|
5502
5840
|
effectiveBlocking(snapshot) {
|
|
5841
|
+
const state = this.stateErrors(snapshot).map((error) => ({
|
|
5842
|
+
code: "STATE_UNREADABLE",
|
|
5843
|
+
scope: "state",
|
|
5844
|
+
detail: `${error.code}: ${error.message}`,
|
|
5845
|
+
hint: this.recoveryFor(snapshot).reason
|
|
5846
|
+
}));
|
|
5503
5847
|
const extra = this.roundsLimitBlock(snapshot);
|
|
5504
|
-
|
|
5505
|
-
|
|
5848
|
+
const reviewDigest = this.reviewDigestProblem(snapshot);
|
|
5849
|
+
const base = [
|
|
5850
|
+
...state,
|
|
5851
|
+
...reviewDigest === null ? [] : [reviewDigest],
|
|
5852
|
+
...snapshot.pipeline.blocking
|
|
5853
|
+
];
|
|
5854
|
+
if (extra === null) return base;
|
|
5855
|
+
return base.some((item) => item.code === extra.code) ? base : [...base, extra];
|
|
5856
|
+
}
|
|
5857
|
+
/**
|
|
5858
|
+
* A review that exists but cannot be tied to the pipeline digest would let a
|
|
5859
|
+
* hand-edited review pass archive. Archive already fails closed; status and
|
|
5860
|
+
* next must surface the same repair path instead of pointing at the archive
|
|
5861
|
+
* command forever.
|
|
5862
|
+
*/
|
|
5863
|
+
reviewDigestProblem(snapshot) {
|
|
5864
|
+
if (snapshot.pipeline.stage !== "ready_to_archive" || snapshot.review === null) return null;
|
|
5865
|
+
const chapter = snapshot.pipeline.chapter;
|
|
5866
|
+
const hint = "call novel_rollback with to_stage P4, then re-run novel_review before archiving";
|
|
5867
|
+
if (snapshot.pipeline.review_digest === void 0) return {
|
|
5868
|
+
code: "REVIEW_DIGEST_MISSING",
|
|
5869
|
+
scope: "state",
|
|
5870
|
+
detail: `review ${reviewFile(chapter)} has no pipeline.review_digest; it predates digest-bound reviews`,
|
|
5871
|
+
hint
|
|
5872
|
+
};
|
|
5873
|
+
if (snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) return {
|
|
5874
|
+
code: "REVIEW_DIGEST_MISMATCH",
|
|
5875
|
+
scope: "state",
|
|
5876
|
+
detail: `review ${reviewFile(chapter)} changed after submission; its digest no longer matches`,
|
|
5877
|
+
hint
|
|
5878
|
+
};
|
|
5879
|
+
return null;
|
|
5880
|
+
}
|
|
5881
|
+
stateErrors(snapshot) {
|
|
5882
|
+
const errors = [];
|
|
5883
|
+
const chapter = snapshot.pipeline.chapter;
|
|
5884
|
+
if (snapshot.planError !== null) errors.push({
|
|
5885
|
+
path: planChapterFile(chapter),
|
|
5886
|
+
code: "PLAN_UNREADABLE",
|
|
5887
|
+
message: snapshot.planError
|
|
5888
|
+
});
|
|
5889
|
+
if (snapshot.reviewError !== null) errors.push({
|
|
5890
|
+
path: reviewFile(chapter),
|
|
5891
|
+
code: "REVIEW_UNREADABLE",
|
|
5892
|
+
message: snapshot.reviewError
|
|
5893
|
+
});
|
|
5894
|
+
if (snapshot.checkError !== null) errors.push({
|
|
5895
|
+
path: checkFile(chapter),
|
|
5896
|
+
code: "CHECK_UNREADABLE",
|
|
5897
|
+
message: snapshot.checkError
|
|
5898
|
+
});
|
|
5899
|
+
return errors;
|
|
5900
|
+
}
|
|
5901
|
+
/** The earliest rebuild point for the current state damage. */
|
|
5902
|
+
recoveryFor(snapshot) {
|
|
5903
|
+
const chapter = snapshot.pipeline.chapter;
|
|
5904
|
+
const errors = this.stateErrors(snapshot);
|
|
5905
|
+
const desired = snapshot.planError !== null ? "P2" : snapshot.reviewError !== null ? "P4" : "P6";
|
|
5906
|
+
const target = this.clampRollbackTarget(snapshot.pipeline.stage, desired);
|
|
5907
|
+
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
|
+
return {
|
|
5909
|
+
tool: "novel_rollback",
|
|
5910
|
+
args: {
|
|
5911
|
+
chapter,
|
|
5912
|
+
to_stage: target
|
|
5913
|
+
},
|
|
5914
|
+
reason: `${errors.map((error) => `${error.code}@${error.path}`).join("; ")}; ${action}`
|
|
5915
|
+
};
|
|
5916
|
+
}
|
|
5917
|
+
/** Never point at a stage later than the current one; rollback only moves backward. */
|
|
5918
|
+
clampRollbackTarget(stage, desired) {
|
|
5919
|
+
if (STAGE_RANK[ROUND_TRANSITIONS[desired]] <= STAGE_RANK[stage]) return desired;
|
|
5920
|
+
return {
|
|
5921
|
+
idle: "P1",
|
|
5922
|
+
P2: "P2",
|
|
5923
|
+
P3: "P3",
|
|
5924
|
+
P4: "P4",
|
|
5925
|
+
P5: "P5",
|
|
5926
|
+
P6: "P6",
|
|
5927
|
+
ready_to_archive: "P6",
|
|
5928
|
+
done: "P6"
|
|
5929
|
+
}[stage];
|
|
5930
|
+
}
|
|
5931
|
+
/** State damage that makes this operation unsafe to start. */
|
|
5932
|
+
stateGateBlock(snapshot, operation) {
|
|
5933
|
+
const planSensitive = [
|
|
5934
|
+
"submitDraft",
|
|
5935
|
+
"submitReview",
|
|
5936
|
+
"submitRevision",
|
|
5937
|
+
"runCheck",
|
|
5938
|
+
"archiveChapter"
|
|
5939
|
+
];
|
|
5940
|
+
const reviewSensitive = [
|
|
5941
|
+
"submitRevision",
|
|
5942
|
+
"runCheck",
|
|
5943
|
+
"archiveChapter"
|
|
5944
|
+
];
|
|
5945
|
+
const checkSensitive = ["archiveChapter"];
|
|
5946
|
+
const relevant = [];
|
|
5947
|
+
if (snapshot.planError !== null && planSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "PLAN_UNREADABLE"));
|
|
5948
|
+
if (snapshot.reviewError !== null && reviewSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "REVIEW_UNREADABLE"));
|
|
5949
|
+
if (snapshot.checkError !== null && checkSensitive.includes(operation)) relevant.push(...this.stateErrors(snapshot).filter((error) => error.code === "CHECK_UNREADABLE"));
|
|
5950
|
+
return relevant.map((error) => ({
|
|
5951
|
+
code: "STATE_UNREADABLE",
|
|
5952
|
+
scope: "state",
|
|
5953
|
+
detail: `${error.code}: ${error.message}`,
|
|
5954
|
+
hint: this.recoveryFor(snapshot).reason
|
|
5955
|
+
}));
|
|
5506
5956
|
}
|
|
5507
5957
|
/** Whether the stored brief still signs the current snapshot. */
|
|
5508
5958
|
briefIsCurrent(snapshot, stage) {
|
|
@@ -5599,7 +6049,9 @@ var EngineService = class {
|
|
|
5599
6049
|
plan: snapshot.plan,
|
|
5600
6050
|
plan_error: snapshot.planError,
|
|
5601
6051
|
review: snapshot.review,
|
|
6052
|
+
review_error: snapshot.reviewError,
|
|
5602
6053
|
check: snapshot.check,
|
|
6054
|
+
check_error: snapshot.checkError,
|
|
5603
6055
|
text: snapshot.text,
|
|
5604
6056
|
machine,
|
|
5605
6057
|
previousText: snapshot.previousText,
|
|
@@ -5635,6 +6087,8 @@ var EngineService = class {
|
|
|
5635
6087
|
async assertGates(snapshot, operation, extras) {
|
|
5636
6088
|
const text = extras.text ?? snapshot.text ?? "";
|
|
5637
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);
|
|
5638
6092
|
const archive = operation === "archiveChapter" ? {
|
|
5639
6093
|
chapterExists: snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter),
|
|
5640
6094
|
checkPassed: snapshot.check?.verdict === "pass",
|
|
@@ -5658,6 +6112,7 @@ var EngineService = class {
|
|
|
5658
6112
|
check: extras.check ?? snapshot.check,
|
|
5659
6113
|
semantic: extras.semantic ?? [],
|
|
5660
6114
|
proof: extras.proof ?? null,
|
|
6115
|
+
scope: extras.scope,
|
|
5661
6116
|
rounds: snapshot.pipeline.rounds,
|
|
5662
6117
|
archive,
|
|
5663
6118
|
evidence: {
|
|
@@ -5740,12 +6195,19 @@ var EngineService = class {
|
|
|
5740
6195
|
let plan = null;
|
|
5741
6196
|
let planError = null;
|
|
5742
6197
|
if (planRaw !== null) try {
|
|
5743
|
-
|
|
6198
|
+
const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
|
|
6199
|
+
validateStoredChapterPlan(parsed, planChapterFile(chapter));
|
|
6200
|
+
plan = parsed;
|
|
5744
6201
|
} catch (error) {
|
|
5745
|
-
|
|
6202
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
6203
|
+
planError = errorMessage(error);
|
|
5746
6204
|
}
|
|
5747
|
-
const
|
|
5748
|
-
const
|
|
6205
|
+
const reviewRead = await this.recoverableDoc(store, reviewFile(chapter), "review");
|
|
6206
|
+
const checkRead = await this.recoverableDoc(store, checkFile(chapter), "check");
|
|
6207
|
+
const review = reviewRead.value;
|
|
6208
|
+
const reviewError = reviewRead.error;
|
|
6209
|
+
const check = checkRead.value;
|
|
6210
|
+
const checkError = checkRead.error;
|
|
5749
6211
|
const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
5750
6212
|
if (latest === null || latest.revision !== pipeline.revision) {
|
|
5751
6213
|
if (retries > 0) return this.load(store.root, retries - 1);
|
|
@@ -5770,9 +6232,26 @@ var EngineService = class {
|
|
|
5770
6232
|
plan,
|
|
5771
6233
|
planError,
|
|
5772
6234
|
review,
|
|
5773
|
-
|
|
6235
|
+
reviewError,
|
|
6236
|
+
check,
|
|
6237
|
+
checkError
|
|
5774
6238
|
};
|
|
5775
6239
|
}
|
|
6240
|
+
/** Read a review/check without letting one corrupt file wedge every read. */
|
|
6241
|
+
async recoverableDoc(store, path, kind) {
|
|
6242
|
+
try {
|
|
6243
|
+
return {
|
|
6244
|
+
value: await store.readDoc(path, kind),
|
|
6245
|
+
error: null
|
|
6246
|
+
};
|
|
6247
|
+
} catch (error) {
|
|
6248
|
+
if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
|
|
6249
|
+
return {
|
|
6250
|
+
value: null,
|
|
6251
|
+
error: errorMessage(error)
|
|
6252
|
+
};
|
|
6253
|
+
}
|
|
6254
|
+
}
|
|
5776
6255
|
async mutate(options) {
|
|
5777
6256
|
const root = await this.resolveRoot(options.root);
|
|
5778
6257
|
const signal = this.signalContext.getStore();
|
|
@@ -5931,7 +6410,9 @@ var EngineService = class {
|
|
|
5931
6410
|
plan: null,
|
|
5932
6411
|
planError: null,
|
|
5933
6412
|
review: null,
|
|
5934
|
-
|
|
6413
|
+
reviewError: null,
|
|
6414
|
+
check: null,
|
|
6415
|
+
checkError: null
|
|
5935
6416
|
};
|
|
5936
6417
|
}
|
|
5937
6418
|
};
|
|
@@ -6020,6 +6501,54 @@ const stableJson = (value) => {
|
|
|
6020
6501
|
};
|
|
6021
6502
|
return JSON.stringify(normalize(value));
|
|
6022
6503
|
};
|
|
6504
|
+
const errorMessage = (error) => error instanceof Error ? `${error.message}${error.cause instanceof Error ? `: ${error.cause.message}` : ""}` : String(error);
|
|
6505
|
+
/** Validate the shape a stored chapter plan must have before any stage uses it. */
|
|
6506
|
+
const validateStoredChapterPlan = (plan, path) => {
|
|
6507
|
+
const fail = (detail) => {
|
|
6508
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not a usable chapter plan: ${detail}`);
|
|
6509
|
+
};
|
|
6510
|
+
const rawVersion = plan.schema_version;
|
|
6511
|
+
if (rawVersion !== void 0) {
|
|
6512
|
+
const version = typeof rawVersion === "number" ? rawVersion : null;
|
|
6513
|
+
if (version === null) fail("schema_version must be a positive integer");
|
|
6514
|
+
const checkedVersion = version;
|
|
6515
|
+
if (!Number.isInteger(checkedVersion) || checkedVersion < 1) fail("schema_version must be a positive integer");
|
|
6516
|
+
if (checkedVersion > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `${path} uses schema_version ${checkedVersion}, newer than supported 1`, "upgrade the plugin; the plan was left untouched");
|
|
6517
|
+
if (checkedVersion !== 1) fail(`schema_version must be 1`);
|
|
6518
|
+
}
|
|
6519
|
+
if (!Number.isInteger(plan.chapter) || plan.chapter < 1) fail("chapter must be a positive integer");
|
|
6520
|
+
for (const key of [
|
|
6521
|
+
"title",
|
|
6522
|
+
"narrator",
|
|
6523
|
+
"opening",
|
|
6524
|
+
"ending"
|
|
6525
|
+
]) if (typeof plan[key] !== "string" || plan[key].trim() === "") fail(`${key} must be non-empty`);
|
|
6526
|
+
if (!SCENE_TYPES.includes(plan.scene_type)) fail(`unknown scene_type "${String(plan.scene_type)}"`);
|
|
6527
|
+
if (!Array.isArray(plan.beats) || plan.beats.some((beat) => typeof beat !== "string")) fail("beats must be a string array");
|
|
6528
|
+
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 && (typeof plan.summary !== "object" || plan.summary === null)) fail("summary must be an object when present");
|
|
6530
|
+
};
|
|
6531
|
+
const CHECK_SCOPES = ["content", "integrity"];
|
|
6532
|
+
const normalizeCheckScope = (value) => {
|
|
6533
|
+
if (value === void 0) return [...CHECK_SCOPES];
|
|
6534
|
+
if (!Array.isArray(value) || value.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "scope must contain at least one of content, integrity");
|
|
6535
|
+
const unique = [...new Set(value)];
|
|
6536
|
+
for (const scope of unique) if (scope !== "content" && scope !== "integrity") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown check scope "${String(scope)}"`, `allowed: ${CHECK_SCOPES.join(", ")}`);
|
|
6537
|
+
return CHECK_SCOPES.filter((scope) => unique.includes(scope));
|
|
6538
|
+
};
|
|
6539
|
+
const stringList = (value, field) => {
|
|
6540
|
+
if (value === void 0) return [];
|
|
6541
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of non-empty strings`);
|
|
6542
|
+
return value;
|
|
6543
|
+
};
|
|
6544
|
+
const objectList = (value, field) => {
|
|
6545
|
+
if (value === void 0) return [];
|
|
6546
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "object" || item === null || Array.isArray(item))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of objects`);
|
|
6547
|
+
return value;
|
|
6548
|
+
};
|
|
6549
|
+
const requireText = (object, key, path) => {
|
|
6550
|
+
if (typeof object[key] !== "string" || object[key].trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${path} must be a non-empty string`);
|
|
6551
|
+
};
|
|
6023
6552
|
/** Parse a plan file with a machine code instead of leaking a YAML parser error. */
|
|
6024
6553
|
const parsePlanYaml = (text, path) => {
|
|
6025
6554
|
let parsed;
|