novel-writer-cli 0.3.0 → 0.5.0
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/README.md +1 -1
- package/agents/chapter-writer.md +43 -14
- package/agents/character-weaver.md +7 -1
- package/agents/plot-architect.md +20 -7
- package/agents/quality-judge.md +199 -20
- package/agents/style-analyzer.md +14 -8
- package/agents/style-refiner.md +10 -3
- package/agents/world-builder.md +8 -1
- package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +194 -6
- package/dist/__tests__/agent-prompts-platform-expansion.test.js +33 -0
- package/dist/__tests__/anti-ai-infrastructure.test.js +548 -0
- package/dist/__tests__/anti-ai-templates.test.js +2 -2
- package/dist/__tests__/canon-status-lifecycle.test.js +481 -0
- package/dist/__tests__/commit-gate-decision.test.js +65 -0
- package/dist/__tests__/commit-prototype-pollution.test.js +1 -1
- package/dist/__tests__/excitement-type-annotation.test.js +240 -0
- package/dist/__tests__/excitement-type.test.js +21 -0
- package/dist/__tests__/gate-decision.test.js +62 -15
- package/dist/__tests__/genre-excitement-mapping.test.js +355 -0
- package/dist/__tests__/golden-chapter-gates.test.js +79 -0
- package/dist/__tests__/golden-chapter-mini-planning.test.js +485 -0
- package/dist/__tests__/helpers/quickstart-mini-planning.js +61 -0
- package/dist/__tests__/init.test.js +57 -5
- package/dist/__tests__/instructions-platform-expansion.test.js +125 -0
- package/dist/__tests__/next-step-gate-decision-routing.test.js +98 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +1 -1
- package/dist/__tests__/platform-profile.test.js +57 -1
- package/dist/__tests__/quickstart-pipeline.test.js +73 -6
- package/dist/__tests__/scoring-weights.test.js +193 -0
- package/dist/__tests__/steps-id.test.js +2 -0
- package/dist/__tests__/validate-quickstart-prereqs.test.js +2 -0
- package/dist/advance.js +27 -2
- package/dist/anti-ai-context.js +535 -0
- package/dist/cli.js +3 -1
- package/dist/commit.js +22 -0
- package/dist/excitement-type.js +12 -0
- package/dist/gate-decision.js +98 -2
- package/dist/golden-chapter-gates.js +143 -0
- package/dist/init.js +76 -7
- package/dist/instructions.js +552 -6
- package/dist/next-step.js +124 -88
- package/dist/platform-profile.js +20 -8
- package/dist/quickstart-mini-planning.js +30 -0
- package/dist/scoring-weights.js +38 -3
- package/dist/steps.js +1 -1
- package/dist/validate.js +293 -214
- package/dist/volume-commit.js +271 -5
- package/dist/volume-planning.js +78 -3
- package/docs/user/README.md +1 -0
- package/docs/user/migration-guide.md +166 -0
- package/docs/user/novel-cli.md +4 -3
- package/docs/user/quick-start.md +354 -57
- package/package.json +1 -1
- package/schemas/platform-profile.schema.json +2 -2
- package/scripts/lint-blacklist.sh +221 -76
- package/scripts/lint-structural.sh +538 -0
- package/skills/continue/SKILL.md +6 -0
- package/skills/continue/references/context-contracts.md +71 -6
- package/skills/continue/references/periodic-maintenance.md +12 -1
- package/skills/novel-writing/references/quality-rubric.md +79 -26
- package/skills/novel-writing/references/style-guide.md +129 -19
- package/skills/start/SKILL.md +23 -3
- package/skills/start/references/vol-planning.md +12 -3
- package/templates/ai-blacklist.json +1024 -246
- package/templates/ai-sentence-patterns.json +167 -0
- package/templates/genre-excitement-map.json +48 -0
- package/templates/genre-golden-standards.json +80 -0
- package/templates/genre-weight-profiles.json +15 -0
- package/templates/golden-chapter-gates.json +230 -0
- package/templates/novel-ask/example.question.json +3 -2
- package/templates/platform-profile.json +141 -1
- package/templates/platforms/fanqie.md +35 -0
- package/templates/platforms/jinjiang.md +35 -0
- package/templates/platforms/qidian.md +35 -0
- package/templates/style-profile-template.json +3 -0
package/dist/validate.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import { NovelCliError } from "./errors.js";
|
|
4
|
+
import { normalizeExcitementType } from "./excitement-type.js";
|
|
3
5
|
import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
|
|
4
6
|
import { checkHookPolicy } from "./hook-policy.js";
|
|
5
7
|
import { loadPlatformProfile } from "./platform-profile.js";
|
|
@@ -9,7 +11,7 @@ import { QUICKSTART_PHASES, chapterRelPaths, formatStepId, titleFixSnapshotRel }
|
|
|
9
11
|
import { assertTitleFixOnlyChangedTitleLine, extractChapterTitleFromMarkdown } from "./title-policy.js";
|
|
10
12
|
import { isPlainObject } from "./type-guards.js";
|
|
11
13
|
import { VOL_REVIEW_RELS } from "./volume-review.js";
|
|
12
|
-
import {
|
|
14
|
+
import { hasQuickstartMiniPlanningArtifacts, QUICKSTART_MINI_PLANNING_RANGE, resolveVolumeChapterRange, volumeFinalRelPaths, volumeForChapter, volumeStagingRelPaths } from "./volume-planning.js";
|
|
13
15
|
import { validateQuickstartContractsDir, validateQuickstartRulesSchema, validateQuickstartStyleProfileSchema, validateQuickstartTrialChapter } from "./quickstart-validators.js";
|
|
14
16
|
function requireFile(exists, relPath) {
|
|
15
17
|
if (!exists)
|
|
@@ -27,6 +29,265 @@ function requireNumberField(obj, field, file) {
|
|
|
27
29
|
throw new NovelCliError(`Invalid ${file}: missing number field '${field}'.`, 2);
|
|
28
30
|
return v;
|
|
29
31
|
}
|
|
32
|
+
function assertOutlineHasNoOutOfRangeChapters(args) {
|
|
33
|
+
const extras = Array.from(args.seen)
|
|
34
|
+
.filter((chapter) => chapter < args.range.start || chapter > args.range.end)
|
|
35
|
+
.sort((left, right) => left - right);
|
|
36
|
+
if (extras.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
throw new NovelCliError(`Invalid outline: unexpected chapter block(s) ${extras.join(", ")} outside required range (${args.range.start}-${args.range.end}). File: ${args.relPath}`, 2);
|
|
39
|
+
}
|
|
40
|
+
async function assertContractDirMatchesRange(args) {
|
|
41
|
+
requireFile(await pathExists(join(args.rootDir, args.rels.chapterContractsDir)), args.rels.chapterContractsDir);
|
|
42
|
+
const entries = await readdir(join(args.rootDir, args.rels.chapterContractsDir), { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.name.startsWith("."))
|
|
45
|
+
continue;
|
|
46
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
47
|
+
continue;
|
|
48
|
+
const match = /^chapter-(\d+)\.json$/u.exec(entry.name);
|
|
49
|
+
if (!match) {
|
|
50
|
+
throw new NovelCliError(`Invalid ${args.rels.chapterContractsDir}: unexpected filename '${entry.name}'.`, 2);
|
|
51
|
+
}
|
|
52
|
+
const chapter = Number.parseInt(match[1] ?? "", 10);
|
|
53
|
+
if (chapter < args.range.start || chapter > args.range.end) {
|
|
54
|
+
throw new NovelCliError(`Invalid ${args.rels.chapterContractsDir}: unexpected contract chapter ${chapter} outside required range (${args.range.start}-${args.range.end}).`, 2);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function requireCommittedQuickstartMiniPlanning(rootDir) {
|
|
59
|
+
if (await hasQuickstartMiniPlanningArtifacts(rootDir))
|
|
60
|
+
return;
|
|
61
|
+
throw new NovelCliError(`Missing committed quickstart mini-planning artifacts required before trial/results: ${volumeFinalRelPaths(1).outlineMd}`, 2);
|
|
62
|
+
}
|
|
63
|
+
async function validateVolumePlanArtifacts(args) {
|
|
64
|
+
const { rootDir, volume, range, rels, warnings } = args;
|
|
65
|
+
const requireVolumePlanArtifacts = async () => {
|
|
66
|
+
requireFile(await pathExists(join(rootDir, rels.outlineMd)), rels.outlineMd);
|
|
67
|
+
requireFile(await pathExists(join(rootDir, rels.storylineScheduleJson)), rels.storylineScheduleJson);
|
|
68
|
+
requireFile(await pathExists(join(rootDir, rels.foreshadowingJson)), rels.foreshadowingJson);
|
|
69
|
+
requireFile(await pathExists(join(rootDir, rels.newCharactersJson)), rels.newCharactersJson);
|
|
70
|
+
requireFile(await pathExists(join(rootDir, rels.chapterContractsDir)), rels.chapterContractsDir);
|
|
71
|
+
for (let ch = range.start; ch <= range.end; ch++) {
|
|
72
|
+
requireFile(await pathExists(join(rootDir, rels.chapterContractJson(ch))), rels.chapterContractJson(ch));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const outlineAbs = join(rootDir, rels.outlineMd);
|
|
76
|
+
const parseOutlineChapterMetadata = async () => {
|
|
77
|
+
const text = await readTextFile(outlineAbs);
|
|
78
|
+
if (text.trim().length === 0)
|
|
79
|
+
throw new NovelCliError(`Empty outline file: ${rels.outlineMd}`, 2);
|
|
80
|
+
const lines = text.split(/\r?\n/u);
|
|
81
|
+
const chapterRe = /^###\s*第\s*(\d+)\s*章/u;
|
|
82
|
+
const chapters = [];
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
const m = chapterRe.exec(lines[i] ?? "");
|
|
85
|
+
if (!m)
|
|
86
|
+
continue;
|
|
87
|
+
const chapter = Number.parseInt(m[1] ?? "", 10);
|
|
88
|
+
if (!Number.isInteger(chapter) || chapter <= 0)
|
|
89
|
+
continue;
|
|
90
|
+
chapters.push({ chapter, startLine: i, endLine: lines.length });
|
|
91
|
+
}
|
|
92
|
+
if (chapters.length === 0) {
|
|
93
|
+
throw new NovelCliError(`Invalid outline: could not parse any chapter headings. Expected lines like "### 第 1 章" (spaces are flexible). File: ${rels.outlineMd}`, 2);
|
|
94
|
+
}
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (let i = 0; i < chapters.length; i++) {
|
|
97
|
+
const c = chapters[i];
|
|
98
|
+
const next = chapters[i + 1];
|
|
99
|
+
if (seen.has(c.chapter))
|
|
100
|
+
throw new NovelCliError(`Invalid outline: duplicate chapter block for chapter ${c.chapter} (${rels.outlineMd}).`, 2);
|
|
101
|
+
seen.add(c.chapter);
|
|
102
|
+
c.endLine = next ? next.startLine : lines.length;
|
|
103
|
+
}
|
|
104
|
+
for (let ch = range.start; ch <= range.end; ch++) {
|
|
105
|
+
if (!seen.has(ch)) {
|
|
106
|
+
throw new NovelCliError(`Invalid outline: missing chapter block for chapter ${ch} (expected continuous coverage ${range.start}-${range.end}). File: ${rels.outlineMd}`, 2);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
assertOutlineHasNoOutOfRangeChapters({ seen, relPath: rels.outlineMd, range });
|
|
110
|
+
const requiredKeys = [
|
|
111
|
+
"Storyline",
|
|
112
|
+
"POV",
|
|
113
|
+
"Location",
|
|
114
|
+
"Conflict",
|
|
115
|
+
"Arc",
|
|
116
|
+
"Foreshadowing",
|
|
117
|
+
"StateChanges",
|
|
118
|
+
"TransitionHint"
|
|
119
|
+
];
|
|
120
|
+
const excitementPrefix = "- **ExcitementType**:";
|
|
121
|
+
const chapterMetadata = new Map();
|
|
122
|
+
for (const c of chapters) {
|
|
123
|
+
if (c.chapter < range.start || c.chapter > range.end)
|
|
124
|
+
continue;
|
|
125
|
+
const blockLines = lines.slice(c.startLine, c.endLine);
|
|
126
|
+
const keyFound = new Set();
|
|
127
|
+
let storylineId = null;
|
|
128
|
+
let excitementType = null;
|
|
129
|
+
for (const line of blockLines) {
|
|
130
|
+
for (const key of requiredKeys) {
|
|
131
|
+
const prefix = `- **${key}**:`;
|
|
132
|
+
if (line.startsWith(prefix)) {
|
|
133
|
+
keyFound.add(key);
|
|
134
|
+
if (key === "Storyline") {
|
|
135
|
+
const value = line.slice(prefix.length).trim();
|
|
136
|
+
if (value.length > 0)
|
|
137
|
+
storylineId = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (line.startsWith(excitementPrefix)) {
|
|
142
|
+
const rawExcitementType = line.slice(excitementPrefix.length).trim();
|
|
143
|
+
const normalizedExcitementType = normalizeExcitementType(rawExcitementType);
|
|
144
|
+
if (normalizedExcitementType === undefined) {
|
|
145
|
+
warnings.push(`Outline chapter ${c.chapter} has unknown ExcitementType '${rawExcitementType}' in ${rels.outlineMd}; treating as null.`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
excitementType = normalizedExcitementType;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const key of requiredKeys) {
|
|
153
|
+
if (!keyFound.has(key)) {
|
|
154
|
+
throw new NovelCliError(`Invalid outline: chapter ${c.chapter} block missing required key '${key}' line. File: ${rels.outlineMd}`, 2);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!storylineId) {
|
|
158
|
+
throw new NovelCliError(`Invalid outline: chapter ${c.chapter} missing non-empty Storyline value. File: ${rels.outlineMd}`, 2);
|
|
159
|
+
}
|
|
160
|
+
chapterMetadata.set(c.chapter, { storylineId, excitementType });
|
|
161
|
+
}
|
|
162
|
+
return chapterMetadata;
|
|
163
|
+
};
|
|
164
|
+
const validateNewCharacters = (raw) => {
|
|
165
|
+
if (!Array.isArray(raw))
|
|
166
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: expected an array.`, 2);
|
|
167
|
+
for (const [idx, item] of raw.entries()) {
|
|
168
|
+
if (!isPlainObject(item))
|
|
169
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} must be an object.`, 2);
|
|
170
|
+
const obj = item;
|
|
171
|
+
const name = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
172
|
+
const role = typeof obj.role === "string" ? obj.role.trim() : "";
|
|
173
|
+
const brief = typeof obj.brief === "string" ? obj.brief.trim() : "";
|
|
174
|
+
const firstChapter = typeof obj.first_chapter === "number" && Number.isInteger(obj.first_chapter) ? obj.first_chapter : null;
|
|
175
|
+
if (name.length === 0)
|
|
176
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} missing name.`, 2);
|
|
177
|
+
if (brief.length === 0)
|
|
178
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} missing brief.`, 2);
|
|
179
|
+
if (firstChapter === null || firstChapter < range.start || firstChapter > range.end) {
|
|
180
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} first_chapter=${String(obj.first_chapter)} out of range (${range.start}-${range.end}).`, 2);
|
|
181
|
+
}
|
|
182
|
+
if (role !== "antagonist" && role !== "supporting" && role !== "minor") {
|
|
183
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} role must be one of: antagonist, supporting, minor.`, 2);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const validateSchedule = (raw, chapterMetadata) => {
|
|
188
|
+
if (!isPlainObject(raw))
|
|
189
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: must be an object.`, 2);
|
|
190
|
+
const obj = raw;
|
|
191
|
+
const active = obj.active_storylines;
|
|
192
|
+
if (!Array.isArray(active))
|
|
193
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: missing active_storylines array.`, 2);
|
|
194
|
+
const activeIds = [];
|
|
195
|
+
for (const [idx, value] of active.entries()) {
|
|
196
|
+
if (typeof value !== "string") {
|
|
197
|
+
warnings.push(`WARN ${rels.storylineScheduleJson}: active_storylines[${idx}] is not a string; ignoring.`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const id = value.trim();
|
|
201
|
+
if (id.length > 0)
|
|
202
|
+
activeIds.push(id);
|
|
203
|
+
}
|
|
204
|
+
if (activeIds.length === 0)
|
|
205
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: active_storylines must be non-empty.`, 2);
|
|
206
|
+
if (activeIds.length > 4)
|
|
207
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: active_storylines length must be <= 4.`, 2);
|
|
208
|
+
const activeSet = new Set(activeIds);
|
|
209
|
+
for (const [chapter, metadata] of chapterMetadata.entries()) {
|
|
210
|
+
if (!activeSet.has(metadata.storylineId)) {
|
|
211
|
+
throw new NovelCliError(`Invalid volume plan: outline chapter ${chapter} Storyline=${metadata.storylineId} not in storyline-schedule.json.active_storylines.`, 2);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const validateForeshadowingPlan = (raw) => {
|
|
216
|
+
if (!isPlainObject(raw)) {
|
|
217
|
+
throw new NovelCliError(`Invalid ${rels.foreshadowingJson}: expected JSON object with {schema_version, items}.`, 2);
|
|
218
|
+
}
|
|
219
|
+
const obj = raw;
|
|
220
|
+
if (obj.schema_version !== 1)
|
|
221
|
+
warnings.push(`Unexpected schema_version in ${rels.foreshadowingJson}.`);
|
|
222
|
+
if (!Array.isArray(obj.items))
|
|
223
|
+
throw new NovelCliError(`Invalid ${rels.foreshadowingJson}: items must be an array.`, 2);
|
|
224
|
+
};
|
|
225
|
+
const validateContracts = async (chapterMetadata) => {
|
|
226
|
+
await assertContractDirMatchesRange({ rootDir, rels, range });
|
|
227
|
+
for (let ch = range.start; ch <= range.end; ch++) {
|
|
228
|
+
const rel = rels.chapterContractJson(ch);
|
|
229
|
+
const raw = await readJsonFile(join(rootDir, rel));
|
|
230
|
+
if (!isPlainObject(raw))
|
|
231
|
+
throw new NovelCliError(`Invalid ${rel}: must be an object.`, 2);
|
|
232
|
+
const obj = raw;
|
|
233
|
+
const chapter = requireNumberField(obj, "chapter", rel);
|
|
234
|
+
if (!Number.isInteger(chapter) || chapter !== ch)
|
|
235
|
+
throw new NovelCliError(`Invalid ${rel}: chapter=${chapter} expected ${ch}.`, 2);
|
|
236
|
+
const storylineId = requireStringField(obj, "storyline_id", rel).trim();
|
|
237
|
+
const expectedStorylineId = chapterMetadata.get(ch)?.storylineId ?? null;
|
|
238
|
+
if (!expectedStorylineId || storylineId !== expectedStorylineId) {
|
|
239
|
+
throw new NovelCliError(`Invalid ${rel}: storyline_id=${storylineId} expected ${expectedStorylineId ?? "(missing in outline)"}.`, 2);
|
|
240
|
+
}
|
|
241
|
+
if (!Object.prototype.hasOwnProperty.call(obj, "excitement_type")) {
|
|
242
|
+
warnings.push(`Missing optional excitement_type in ${rel}; treating as null.`);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const normalizedExcitementType = normalizeExcitementType(obj.excitement_type);
|
|
246
|
+
if (normalizedExcitementType === undefined) {
|
|
247
|
+
warnings.push(`Unknown excitement_type '${String(obj.excitement_type)}' in ${rel}; treating as null.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const objectivesRaw = obj.objectives;
|
|
251
|
+
if (!Array.isArray(objectivesRaw))
|
|
252
|
+
throw new NovelCliError(`Invalid ${rel}: objectives must be an array.`, 2);
|
|
253
|
+
const hasRequired = objectivesRaw.some((it) => isPlainObject(it) && it.required === true);
|
|
254
|
+
if (!hasRequired)
|
|
255
|
+
throw new NovelCliError(`Invalid ${rel}: objectives must include at least one entry with required=true.`, 2);
|
|
256
|
+
const prevChapter = ch - 1;
|
|
257
|
+
if (prevChapter >= 1) {
|
|
258
|
+
const prevRel = prevChapter >= range.start
|
|
259
|
+
? rels.chapterContractJson(prevChapter)
|
|
260
|
+
: volumeFinalRelPaths(volumeForChapter(prevChapter)).chapterContractJson(prevChapter);
|
|
261
|
+
if (await pathExists(join(rootDir, prevRel))) {
|
|
262
|
+
const prevRaw = await readJsonFile(join(rootDir, prevRel));
|
|
263
|
+
if (isPlainObject(prevRaw)) {
|
|
264
|
+
const prevObj = prevRaw;
|
|
265
|
+
const post = isPlainObject(prevObj.postconditions) ? prevObj.postconditions : null;
|
|
266
|
+
const stateChanges = post && isPlainObject(post.state_changes) ? post.state_changes : null;
|
|
267
|
+
const pre = isPlainObject(obj.preconditions) ? obj.preconditions : null;
|
|
268
|
+
const characterStates = pre && isPlainObject(pre.character_states) ? pre.character_states : null;
|
|
269
|
+
if (stateChanges && Object.keys(stateChanges).length > 0) {
|
|
270
|
+
if (!characterStates) {
|
|
271
|
+
throw new NovelCliError(`Invalid ${rel}: missing preconditions.character_states required by chain propagation from ${prevRel}.`, 2);
|
|
272
|
+
}
|
|
273
|
+
for (const characterKey of Object.keys(stateChanges)) {
|
|
274
|
+
if (!(characterKey in characterStates)) {
|
|
275
|
+
throw new NovelCliError(`Invalid ${rel}: preconditions.character_states missing '${characterKey}' (required by ${prevRel}.postconditions.state_changes).`, 2);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
await requireVolumePlanArtifacts();
|
|
285
|
+
const chapterMetadata = await parseOutlineChapterMetadata();
|
|
286
|
+
validateSchedule(await readJsonFile(join(rootDir, rels.storylineScheduleJson)), chapterMetadata);
|
|
287
|
+
validateForeshadowingPlan(await readJsonFile(join(rootDir, rels.foreshadowingJson)));
|
|
288
|
+
validateNewCharacters(await readJsonFile(join(rootDir, rels.newCharactersJson)));
|
|
289
|
+
await validateContracts(chapterMetadata);
|
|
290
|
+
}
|
|
30
291
|
export async function validateStep(args) {
|
|
31
292
|
const warnings = [];
|
|
32
293
|
const stepId = formatStepId(args.step);
|
|
@@ -84,222 +345,17 @@ export async function validateStep(args) {
|
|
|
84
345
|
}
|
|
85
346
|
if (args.step.kind === "volume") {
|
|
86
347
|
const volume = args.checkpoint.current_volume;
|
|
87
|
-
const range =
|
|
88
|
-
const rels = volumeStagingRelPaths(volume);
|
|
89
|
-
const requireVolumePlanArtifacts = async () => {
|
|
90
|
-
requireFile(await pathExists(join(args.rootDir, rels.outlineMd)), rels.outlineMd);
|
|
91
|
-
requireFile(await pathExists(join(args.rootDir, rels.storylineScheduleJson)), rels.storylineScheduleJson);
|
|
92
|
-
requireFile(await pathExists(join(args.rootDir, rels.foreshadowingJson)), rels.foreshadowingJson);
|
|
93
|
-
requireFile(await pathExists(join(args.rootDir, rels.newCharactersJson)), rels.newCharactersJson);
|
|
94
|
-
for (let ch = range.start; ch <= range.end; ch++) {
|
|
95
|
-
requireFile(await pathExists(join(args.rootDir, rels.chapterContractJson(ch))), rels.chapterContractJson(ch));
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
const outlineAbs = join(args.rootDir, rels.outlineMd);
|
|
99
|
-
const parseOutlineStorylineIds = async () => {
|
|
100
|
-
const text = await readTextFile(outlineAbs);
|
|
101
|
-
if (text.trim().length === 0)
|
|
102
|
-
throw new NovelCliError(`Empty outline file: ${rels.outlineMd}`, 2);
|
|
103
|
-
const lines = text.split(/\r?\n/u);
|
|
104
|
-
const chapters = [];
|
|
105
|
-
const chapterHeadingRe = /^###\s*第\s*(\d+)\s*章/u;
|
|
106
|
-
for (let i = 0; i < lines.length; i++) {
|
|
107
|
-
const m = chapterHeadingRe.exec(lines[i] ?? "");
|
|
108
|
-
if (!m)
|
|
109
|
-
continue;
|
|
110
|
-
const chapter = Number.parseInt(m[1] ?? "", 10);
|
|
111
|
-
if (!Number.isInteger(chapter) || chapter < 1)
|
|
112
|
-
continue;
|
|
113
|
-
chapters.push({ chapter, startLine: i, endLine: lines.length });
|
|
114
|
-
}
|
|
115
|
-
if (chapters.length === 0) {
|
|
116
|
-
throw new NovelCliError(`Invalid outline: could not parse any chapter headings. Expected lines like "### 第 1 章" (spaces are flexible). File: ${rels.outlineMd}`, 2);
|
|
117
|
-
}
|
|
118
|
-
for (let i = 0; i < chapters.length; i++) {
|
|
119
|
-
const cur = chapters[i];
|
|
120
|
-
const next = chapters[i + 1];
|
|
121
|
-
if (next)
|
|
122
|
-
cur.endLine = next.startLine;
|
|
123
|
-
}
|
|
124
|
-
const seen = new Set();
|
|
125
|
-
for (const c of chapters) {
|
|
126
|
-
if (seen.has(c.chapter))
|
|
127
|
-
throw new NovelCliError(`Invalid outline: duplicate chapter block for chapter ${c.chapter} (${rels.outlineMd}).`, 2);
|
|
128
|
-
seen.add(c.chapter);
|
|
129
|
-
}
|
|
130
|
-
for (let ch = range.start; ch <= range.end; ch++) {
|
|
131
|
-
if (!seen.has(ch)) {
|
|
132
|
-
throw new NovelCliError(`Invalid outline: missing chapter block for chapter ${ch} (expected continuous coverage ${range.start}-${range.end}). File: ${rels.outlineMd}`, 2);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
const requiredKeys = [
|
|
136
|
-
"Storyline",
|
|
137
|
-
"POV",
|
|
138
|
-
"Location",
|
|
139
|
-
"Conflict",
|
|
140
|
-
"Arc",
|
|
141
|
-
"Foreshadowing",
|
|
142
|
-
"StateChanges",
|
|
143
|
-
"TransitionHint"
|
|
144
|
-
];
|
|
145
|
-
const storylinesByChapter = new Map();
|
|
146
|
-
for (const c of chapters) {
|
|
147
|
-
if (c.chapter < range.start || c.chapter > range.end)
|
|
148
|
-
continue;
|
|
149
|
-
const blockLines = lines.slice(c.startLine, c.endLine);
|
|
150
|
-
const keyFound = new Set();
|
|
151
|
-
let storylineId = null;
|
|
152
|
-
for (const line of blockLines) {
|
|
153
|
-
for (const k of requiredKeys) {
|
|
154
|
-
const prefix = `- **${k}**:`;
|
|
155
|
-
if (line.startsWith(prefix)) {
|
|
156
|
-
keyFound.add(k);
|
|
157
|
-
if (k === "Storyline") {
|
|
158
|
-
const val = line.slice(prefix.length).trim();
|
|
159
|
-
if (val.length > 0)
|
|
160
|
-
storylineId = val;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
for (const k of requiredKeys) {
|
|
166
|
-
if (!keyFound.has(k)) {
|
|
167
|
-
throw new NovelCliError(`Invalid outline: chapter ${c.chapter} block missing required key '${k}' line. File: ${rels.outlineMd}`, 2);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (!storylineId) {
|
|
171
|
-
throw new NovelCliError(`Invalid outline: chapter ${c.chapter} missing non-empty Storyline value. File: ${rels.outlineMd}`, 2);
|
|
172
|
-
}
|
|
173
|
-
storylinesByChapter.set(c.chapter, storylineId);
|
|
174
|
-
}
|
|
175
|
-
return storylinesByChapter;
|
|
176
|
-
};
|
|
177
|
-
const validateNewCharacters = (raw) => {
|
|
178
|
-
if (!Array.isArray(raw))
|
|
179
|
-
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: expected an array.`, 2);
|
|
180
|
-
for (const [idx, item] of raw.entries()) {
|
|
181
|
-
if (!isPlainObject(item))
|
|
182
|
-
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} must be an object.`, 2);
|
|
183
|
-
const obj = item;
|
|
184
|
-
const name = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
185
|
-
const role = typeof obj.role === "string" ? obj.role.trim() : "";
|
|
186
|
-
const brief = typeof obj.brief === "string" ? obj.brief.trim() : "";
|
|
187
|
-
const firstChapter = typeof obj.first_chapter === "number" && Number.isInteger(obj.first_chapter) ? obj.first_chapter : null;
|
|
188
|
-
if (name.length === 0)
|
|
189
|
-
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} missing name.`, 2);
|
|
190
|
-
if (brief.length === 0)
|
|
191
|
-
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} missing brief.`, 2);
|
|
192
|
-
if (firstChapter === null || firstChapter < range.start || firstChapter > range.end) {
|
|
193
|
-
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} first_chapter=${String(obj.first_chapter)} out of range (${range.start}-${range.end}).`, 2);
|
|
194
|
-
}
|
|
195
|
-
if (role !== "antagonist" && role !== "supporting" && role !== "minor") {
|
|
196
|
-
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} role must be one of: antagonist, supporting, minor.`, 2);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
const validateSchedule = (raw, storylinesByChapter) => {
|
|
201
|
-
if (!isPlainObject(raw))
|
|
202
|
-
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: must be an object.`, 2);
|
|
203
|
-
const obj = raw;
|
|
204
|
-
const active = obj.active_storylines;
|
|
205
|
-
if (!Array.isArray(active))
|
|
206
|
-
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: missing active_storylines array.`, 2);
|
|
207
|
-
const activeIds = [];
|
|
208
|
-
for (const [idx, v] of active.entries()) {
|
|
209
|
-
if (typeof v !== "string") {
|
|
210
|
-
warnings.push(`WARN ${rels.storylineScheduleJson}: active_storylines[${idx}] is not a string; ignoring.`);
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
const id = v.trim();
|
|
214
|
-
if (id.length > 0)
|
|
215
|
-
activeIds.push(id);
|
|
216
|
-
}
|
|
217
|
-
if (activeIds.length === 0)
|
|
218
|
-
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: active_storylines must be non-empty.`, 2);
|
|
219
|
-
if (activeIds.length > 4)
|
|
220
|
-
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: active_storylines length must be <= 4.`, 2);
|
|
221
|
-
const activeSet = new Set(activeIds);
|
|
222
|
-
for (const [ch, storylineId] of storylinesByChapter.entries()) {
|
|
223
|
-
if (!activeSet.has(storylineId)) {
|
|
224
|
-
throw new NovelCliError(`Invalid volume plan: outline chapter ${ch} Storyline=${storylineId} not in storyline-schedule.json.active_storylines.`, 2);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
const validateForeshadowingPlan = (raw) => {
|
|
229
|
-
if (!isPlainObject(raw)) {
|
|
230
|
-
throw new NovelCliError(`Invalid ${rels.foreshadowingJson}: expected JSON object with {schema_version, items}.`, 2);
|
|
231
|
-
}
|
|
232
|
-
const obj = raw;
|
|
233
|
-
const schema = obj.schema_version;
|
|
234
|
-
if (schema !== 1)
|
|
235
|
-
warnings.push(`Unexpected schema_version in ${rels.foreshadowingJson}.`);
|
|
236
|
-
const items = obj.items;
|
|
237
|
-
if (!Array.isArray(items))
|
|
238
|
-
throw new NovelCliError(`Invalid ${rels.foreshadowingJson}: items must be an array.`, 2);
|
|
239
|
-
};
|
|
240
|
-
const validateContracts = async (storylinesByChapter) => {
|
|
241
|
-
for (let ch = range.start; ch <= range.end; ch++) {
|
|
242
|
-
const rel = rels.chapterContractJson(ch);
|
|
243
|
-
const raw = await readJsonFile(join(args.rootDir, rel));
|
|
244
|
-
if (!isPlainObject(raw))
|
|
245
|
-
throw new NovelCliError(`Invalid ${rel}: must be an object.`, 2);
|
|
246
|
-
const obj = raw;
|
|
247
|
-
const chapter = requireNumberField(obj, "chapter", rel);
|
|
248
|
-
if (!Number.isInteger(chapter) || chapter !== ch)
|
|
249
|
-
throw new NovelCliError(`Invalid ${rel}: chapter=${chapter} expected ${ch}.`, 2);
|
|
250
|
-
const storylineId = requireStringField(obj, "storyline_id", rel).trim();
|
|
251
|
-
const expectedStorylineId = storylinesByChapter.get(ch) ?? null;
|
|
252
|
-
if (!expectedStorylineId || storylineId !== expectedStorylineId) {
|
|
253
|
-
throw new NovelCliError(`Invalid ${rel}: storyline_id=${storylineId} expected ${expectedStorylineId ?? "(missing in outline)"}.`, 2);
|
|
254
|
-
}
|
|
255
|
-
const objectivesRaw = obj.objectives;
|
|
256
|
-
if (!Array.isArray(objectivesRaw))
|
|
257
|
-
throw new NovelCliError(`Invalid ${rel}: objectives must be an array.`, 2);
|
|
258
|
-
const hasRequired = objectivesRaw.some((it) => isPlainObject(it) && it.required === true);
|
|
259
|
-
if (!hasRequired)
|
|
260
|
-
throw new NovelCliError(`Invalid ${rel}: objectives must include at least one entry with required=true.`, 2);
|
|
261
|
-
// Chain propagation (minimal): prev.postconditions.state_changes keys must exist in current.preconditions.character_states.
|
|
262
|
-
const prevChapter = ch - 1;
|
|
263
|
-
if (prevChapter >= 1) {
|
|
264
|
-
const prevRel = prevChapter >= range.start
|
|
265
|
-
? rels.chapterContractJson(prevChapter)
|
|
266
|
-
: volumeFinalRelPaths(volumeForChapter(prevChapter)).chapterContractJson(prevChapter);
|
|
267
|
-
if (await pathExists(join(args.rootDir, prevRel))) {
|
|
268
|
-
const prevRaw = await readJsonFile(join(args.rootDir, prevRel));
|
|
269
|
-
if (isPlainObject(prevRaw)) {
|
|
270
|
-
const prevObj = prevRaw;
|
|
271
|
-
const post = isPlainObject(prevObj.postconditions) ? prevObj.postconditions : null;
|
|
272
|
-
const stateChanges = post && isPlainObject(post.state_changes) ? post.state_changes : null;
|
|
273
|
-
const pre = isPlainObject(obj.preconditions) ? obj.preconditions : null;
|
|
274
|
-
const characterStates = pre && isPlainObject(pre.character_states) ? pre.character_states : null;
|
|
275
|
-
if (stateChanges && Object.keys(stateChanges).length > 0) {
|
|
276
|
-
if (!characterStates) {
|
|
277
|
-
throw new NovelCliError(`Invalid ${rel}: missing preconditions.character_states required by chain propagation from ${prevRel}.`, 2);
|
|
278
|
-
}
|
|
279
|
-
for (const characterKey of Object.keys(stateChanges)) {
|
|
280
|
-
if (!(characterKey in characterStates)) {
|
|
281
|
-
throw new NovelCliError(`Invalid ${rel}: preconditions.character_states missing '${characterKey}' (required by ${prevRel}.postconditions.state_changes).`, 2);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
};
|
|
348
|
+
const range = await resolveVolumeChapterRange({ rootDir: args.rootDir, current_volume: volume, last_completed_chapter: args.checkpoint.last_completed_chapter });
|
|
290
349
|
if (args.step.phase === "commit") {
|
|
291
350
|
throw new NovelCliError(`Use 'novel commit --volume ${volume}' for commit.`, 2);
|
|
292
351
|
}
|
|
293
|
-
await
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const newCharsRaw = await readJsonFile(join(args.rootDir, rels.newCharactersJson));
|
|
301
|
-
validateNewCharacters(newCharsRaw);
|
|
302
|
-
await validateContracts(storylinesByChapter);
|
|
352
|
+
await validateVolumePlanArtifacts({
|
|
353
|
+
rootDir: args.rootDir,
|
|
354
|
+
volume,
|
|
355
|
+
range,
|
|
356
|
+
rels: volumeStagingRelPaths(volume),
|
|
357
|
+
warnings
|
|
358
|
+
});
|
|
303
359
|
return { ok: true, step: stepId, warnings };
|
|
304
360
|
}
|
|
305
361
|
if (args.step.kind === "quickstart") {
|
|
@@ -308,10 +364,29 @@ export async function validateStep(args) {
|
|
|
308
364
|
const styleAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.styleProfileJson);
|
|
309
365
|
const trialAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.trialChapterMd);
|
|
310
366
|
const evalAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.evaluationJson);
|
|
367
|
+
if (args.step.phase === "f0") {
|
|
368
|
+
requireFile(await pathExists(rulesAbs), QUICKSTART_STAGING_RELS.rulesJson);
|
|
369
|
+
requireFile(await pathExists(contractsAbs), QUICKSTART_STAGING_RELS.contractsDir);
|
|
370
|
+
requireFile(await pathExists(styleAbs), QUICKSTART_STAGING_RELS.styleProfileJson);
|
|
371
|
+
const rulesCount = await validateQuickstartRulesSchema(rulesAbs);
|
|
372
|
+
if (rulesCount === 0)
|
|
373
|
+
warnings.push(`Empty rules list in ${QUICKSTART_STAGING_RELS.rulesJson}.`);
|
|
374
|
+
await validateQuickstartContractsDir(contractsAbs);
|
|
375
|
+
await validateQuickstartStyleProfileSchema(styleAbs);
|
|
376
|
+
await validateVolumePlanArtifacts({
|
|
377
|
+
rootDir: args.rootDir,
|
|
378
|
+
volume: 1,
|
|
379
|
+
range: QUICKSTART_MINI_PLANNING_RANGE,
|
|
380
|
+
rels: volumeStagingRelPaths(1),
|
|
381
|
+
warnings
|
|
382
|
+
});
|
|
383
|
+
return { ok: true, step: stepId, warnings };
|
|
384
|
+
}
|
|
311
385
|
if (args.step.phase === "results") {
|
|
312
386
|
requireFile(await pathExists(rulesAbs), QUICKSTART_STAGING_RELS.rulesJson);
|
|
313
387
|
requireFile(await pathExists(contractsAbs), QUICKSTART_STAGING_RELS.contractsDir);
|
|
314
388
|
requireFile(await pathExists(styleAbs), QUICKSTART_STAGING_RELS.styleProfileJson);
|
|
389
|
+
await requireCommittedQuickstartMiniPlanning(args.rootDir);
|
|
315
390
|
requireFile(await pathExists(trialAbs), QUICKSTART_STAGING_RELS.trialChapterMd);
|
|
316
391
|
requireFile(await pathExists(evalAbs), QUICKSTART_STAGING_RELS.evaluationJson);
|
|
317
392
|
// Re-validate the whole quickstart staging set before committing to final dirs.
|
|
@@ -361,6 +436,10 @@ export async function validateStep(args) {
|
|
|
361
436
|
warnings.push(warning);
|
|
362
437
|
break;
|
|
363
438
|
}
|
|
439
|
+
case "f0": {
|
|
440
|
+
await requireCommittedQuickstartMiniPlanning(args.rootDir);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
364
443
|
case "results":
|
|
365
444
|
break;
|
|
366
445
|
default: {
|