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.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/agents/chapter-writer.md +43 -14
  3. package/agents/character-weaver.md +7 -1
  4. package/agents/plot-architect.md +20 -7
  5. package/agents/quality-judge.md +199 -20
  6. package/agents/style-analyzer.md +14 -8
  7. package/agents/style-refiner.md +10 -3
  8. package/agents/world-builder.md +8 -1
  9. package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +194 -6
  10. package/dist/__tests__/agent-prompts-platform-expansion.test.js +33 -0
  11. package/dist/__tests__/anti-ai-infrastructure.test.js +548 -0
  12. package/dist/__tests__/anti-ai-templates.test.js +2 -2
  13. package/dist/__tests__/canon-status-lifecycle.test.js +481 -0
  14. package/dist/__tests__/commit-gate-decision.test.js +65 -0
  15. package/dist/__tests__/commit-prototype-pollution.test.js +1 -1
  16. package/dist/__tests__/excitement-type-annotation.test.js +240 -0
  17. package/dist/__tests__/excitement-type.test.js +21 -0
  18. package/dist/__tests__/gate-decision.test.js +62 -15
  19. package/dist/__tests__/genre-excitement-mapping.test.js +355 -0
  20. package/dist/__tests__/golden-chapter-gates.test.js +79 -0
  21. package/dist/__tests__/golden-chapter-mini-planning.test.js +485 -0
  22. package/dist/__tests__/helpers/quickstart-mini-planning.js +61 -0
  23. package/dist/__tests__/init.test.js +57 -5
  24. package/dist/__tests__/instructions-platform-expansion.test.js +125 -0
  25. package/dist/__tests__/next-step-gate-decision-routing.test.js +98 -0
  26. package/dist/__tests__/orchestrator-state-write-path.test.js +1 -1
  27. package/dist/__tests__/platform-profile.test.js +57 -1
  28. package/dist/__tests__/quickstart-pipeline.test.js +73 -6
  29. package/dist/__tests__/scoring-weights.test.js +193 -0
  30. package/dist/__tests__/steps-id.test.js +2 -0
  31. package/dist/__tests__/validate-quickstart-prereqs.test.js +2 -0
  32. package/dist/advance.js +27 -2
  33. package/dist/anti-ai-context.js +535 -0
  34. package/dist/cli.js +3 -1
  35. package/dist/commit.js +22 -0
  36. package/dist/excitement-type.js +12 -0
  37. package/dist/gate-decision.js +98 -2
  38. package/dist/golden-chapter-gates.js +143 -0
  39. package/dist/init.js +76 -7
  40. package/dist/instructions.js +552 -6
  41. package/dist/next-step.js +124 -88
  42. package/dist/platform-profile.js +20 -8
  43. package/dist/quickstart-mini-planning.js +30 -0
  44. package/dist/scoring-weights.js +38 -3
  45. package/dist/steps.js +1 -1
  46. package/dist/validate.js +293 -214
  47. package/dist/volume-commit.js +271 -5
  48. package/dist/volume-planning.js +78 -3
  49. package/docs/user/README.md +1 -0
  50. package/docs/user/migration-guide.md +166 -0
  51. package/docs/user/novel-cli.md +4 -3
  52. package/docs/user/quick-start.md +354 -57
  53. package/package.json +1 -1
  54. package/schemas/platform-profile.schema.json +2 -2
  55. package/scripts/lint-blacklist.sh +221 -76
  56. package/scripts/lint-structural.sh +538 -0
  57. package/skills/continue/SKILL.md +6 -0
  58. package/skills/continue/references/context-contracts.md +71 -6
  59. package/skills/continue/references/periodic-maintenance.md +12 -1
  60. package/skills/novel-writing/references/quality-rubric.md +79 -26
  61. package/skills/novel-writing/references/style-guide.md +129 -19
  62. package/skills/start/SKILL.md +23 -3
  63. package/skills/start/references/vol-planning.md +12 -3
  64. package/templates/ai-blacklist.json +1024 -246
  65. package/templates/ai-sentence-patterns.json +167 -0
  66. package/templates/genre-excitement-map.json +48 -0
  67. package/templates/genre-golden-standards.json +80 -0
  68. package/templates/genre-weight-profiles.json +15 -0
  69. package/templates/golden-chapter-gates.json +230 -0
  70. package/templates/novel-ask/example.question.json +3 -2
  71. package/templates/platform-profile.json +141 -1
  72. package/templates/platforms/fanqie.md +35 -0
  73. package/templates/platforms/jinjiang.md +35 -0
  74. package/templates/platforms/qidian.md +35 -0
  75. 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 { computeVolumeChapterRange, volumeFinalRelPaths, volumeForChapter, volumeStagingRelPaths } from "./volume-planning.js";
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 = computeVolumeChapterRange({ current_volume: volume, last_completed_chapter: args.checkpoint.last_completed_chapter });
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 requireVolumePlanArtifacts();
294
- const storylinesByChapter = await parseOutlineStorylineIds();
295
- const scheduleRaw = await readJsonFile(join(args.rootDir, rels.storylineScheduleJson));
296
- validateSchedule(scheduleRaw, storylinesByChapter);
297
- // JSON existence + basic schema.
298
- const foreshadowingRaw = await readJsonFile(join(args.rootDir, rels.foreshadowingJson));
299
- validateForeshadowingPlan(foreshadowingRaw);
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: {