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
@@ -1,13 +1,16 @@
1
1
  import { readdir } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { join, relative } from "node:path";
3
3
  import { NovelCliError } from "./errors.js";
4
4
  import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile, writeTextFileIfMissing } from "./fs-utils.js";
5
5
  import { loadContinuityLatestSummary, tryResolveVolumeChapterRange } from "./consistency-auditor.js";
6
+ import { normalizeExcitementType } from "./excitement-type.js";
6
7
  import { loadEngagementLatestSummary } from "./engagement.js";
7
8
  import { computeForeshadowVisibilityReport, loadForeshadowGlobalItems } from "./foreshadow-visibility.js";
8
- import { computeEffectiveScoringWeights, loadGenreWeightProfiles } from "./scoring-weights.js";
9
+ import { loadGoldenChapterGates, selectGoldenChapterGatesForPlatform } from "./golden-chapter-gates.js";
10
+ import { computeEffectiveScoringWeights, isKnownScoringDimension, loadGenreWeightProfiles } from "./scoring-weights.js";
9
11
  import { parseNovelAskQuestionSpec } from "./novel-ask.js";
10
12
  import { loadPlatformProfile } from "./platform-profile.js";
13
+ import { loadAntiAiGenreOverrides, loadAntiAiJudgeContext, loadAntiAiStatisticalTargets } from "./anti-ai-context.js";
11
14
  import { computePrejudgeGuardrailsReport, writePrejudgeGuardrailsReport } from "./prejudge-guardrails.js";
12
15
  import { loadPromiseLedgerLatestSummary } from "./promise-ledger.js";
13
16
  import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
@@ -16,7 +19,7 @@ import { computeTitlePolicyReport } from "./title-policy.js";
16
19
  import { chapterRelPaths, formatStepId, pad2, pad3, titleFixSnapshotRel } from "./steps.js";
17
20
  import { isPlainObject } from "./type-guards.js";
18
21
  import { VOL_REVIEW_RELS } from "./volume-review.js";
19
- import { computeVolumeChapterRange, volumeFinalRelPaths, volumeStagingRelPaths } from "./volume-planning.js";
22
+ import { QUICKSTART_MINI_PLANNING_RANGE, resolveVolumeChapterRange, volumeFinalRelPaths, volumeStagingRelPaths } from "./volume-planning.js";
20
23
  function relIfExists(relPath, exists) {
21
24
  return exists ? relPath : null;
22
25
  }
@@ -27,6 +30,391 @@ function safeEmbedMode(mode) {
27
30
  return "brief";
28
31
  throw new NovelCliError(`Unsupported --embed mode: ${mode}. Supported: brief`, 2);
29
32
  }
33
+ async function loadOutlineExcitementType(args) {
34
+ const outlineRel = `volumes/vol-${pad2(args.volume)}/outline.md`;
35
+ const outlineAbs = join(args.rootDir, outlineRel);
36
+ if (!(await pathExists(outlineAbs)))
37
+ return null;
38
+ const lines = (await readTextFile(outlineAbs)).split(/\r?\n/u);
39
+ const headingRe = /^###\s*第\s*(\d+)\s*章/u;
40
+ const excitementPrefix = "- **ExcitementType**:";
41
+ let startLine = -1;
42
+ let endLine = lines.length;
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const match = headingRe.exec(lines[i] ?? "");
45
+ if (!match)
46
+ continue;
47
+ const chapter = Number.parseInt(match[1] ?? "", 10);
48
+ if (chapter !== args.chapter) {
49
+ if (startLine >= 0) {
50
+ endLine = i;
51
+ break;
52
+ }
53
+ continue;
54
+ }
55
+ startLine = i;
56
+ }
57
+ if (startLine < 0)
58
+ return null;
59
+ for (const line of lines.slice(startLine, endLine)) {
60
+ if (line.startsWith(excitementPrefix)) {
61
+ return normalizeExcitementType(line.slice(excitementPrefix.length));
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ async function loadChapterExcitementType(args) {
67
+ const contractRel = `volumes/vol-${pad2(args.volume)}/chapter-contracts/chapter-${pad3(args.chapter)}.json`;
68
+ const contractAbs = join(args.rootDir, contractRel);
69
+ if (await pathExists(contractAbs)) {
70
+ try {
71
+ const raw = await readJsonFile(contractAbs);
72
+ if (isPlainObject(raw) && Object.prototype.hasOwnProperty.call(raw, "excitement_type")) {
73
+ const normalized = normalizeExcitementType(raw.excitement_type);
74
+ return normalized === undefined ? null : normalized;
75
+ }
76
+ }
77
+ catch {
78
+ // Fall through to outline-based backward-compatible parsing.
79
+ }
80
+ }
81
+ const outlineExcitementType = await loadOutlineExcitementType(args);
82
+ return outlineExcitementType === undefined ? null : outlineExcitementType;
83
+ }
84
+ const GENRE_ALIASES = {
85
+ xuanhuan: "xuanhuan",
86
+ "玄幻": "xuanhuan",
87
+ dushi: "dushi",
88
+ "都市": "dushi",
89
+ scifi: "scifi",
90
+ sci_fi: "scifi",
91
+ "sci-fi": "scifi",
92
+ "科幻": "scifi",
93
+ history: "history",
94
+ "历史": "history",
95
+ suspense: "suspense",
96
+ mystery: "suspense",
97
+ "悬疑": "suspense",
98
+ romance: "romance",
99
+ "言情": "romance"
100
+ };
101
+ function normalizeProjectGenre(raw) {
102
+ if (typeof raw !== "string")
103
+ return null;
104
+ const trimmed = raw.trim();
105
+ if (trimmed.length === 0)
106
+ return null;
107
+ const withoutParens = trimmed.replace(/[((].*$/u, "").trim();
108
+ if (withoutParens.length === 0)
109
+ return null;
110
+ const compact = withoutParens.replace(/\s+/gu, "");
111
+ return GENRE_ALIASES[withoutParens] ?? GENRE_ALIASES[compact] ?? GENRE_ALIASES[compact.toLowerCase()] ?? null;
112
+ }
113
+ function toNonEmptyStringArray(raw) {
114
+ if (!Array.isArray(raw) || raw.length === 0)
115
+ return null;
116
+ const out = raw.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0);
117
+ return out.length === raw.length ? out : null;
118
+ }
119
+ function toKnownDimensionArray(raw) {
120
+ const dimensions = toNonEmptyStringArray(raw);
121
+ if (!dimensions)
122
+ return null;
123
+ return dimensions.every((dimension) => isKnownScoringDimension(dimension)) ? dimensions : null;
124
+ }
125
+ function toNumericThresholds(raw) {
126
+ if (!isPlainObject(raw))
127
+ return null;
128
+ const entries = Object.entries(raw).map(([key, value]) => [key.trim(), value]);
129
+ if (entries.length === 0
130
+ || entries.length !== Object.keys(raw).length
131
+ || entries.some(([key, value]) => key.length === 0 || !isKnownScoringDimension(key) || typeof value !== "number" || !Number.isFinite(value))) {
132
+ return null;
133
+ }
134
+ return Object.fromEntries(entries);
135
+ }
136
+ async function loadProjectGenre(rootDir) {
137
+ const briefAbs = join(rootDir, "brief.md");
138
+ if (!(await pathExists(briefAbs)))
139
+ return null;
140
+ try {
141
+ const lines = (await readTextFile(briefAbs)).split(/\r?\n/u);
142
+ for (const line of lines) {
143
+ const match = /^\s*-\s*\*\*(?:题材|Genre)\*\*[::]\s*(.+?)\s*$/u.exec(line);
144
+ if (!match)
145
+ continue;
146
+ const normalized = normalizeProjectGenre(match[1] ?? "");
147
+ if (normalized)
148
+ return normalized;
149
+ }
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ return null;
155
+ }
156
+ async function loadSelectedGenreExcitementMap(rootDir) {
157
+ const genre = await loadProjectGenre(rootDir);
158
+ if (!genre)
159
+ return null;
160
+ const relPath = "genre-excitement-map.json";
161
+ const absPath = join(rootDir, relPath);
162
+ if (!(await pathExists(absPath)))
163
+ return null;
164
+ try {
165
+ const raw = await readJsonFile(absPath);
166
+ if (!isPlainObject(raw) || raw.schema_version !== 1 || !isPlainObject(raw.genres))
167
+ return null;
168
+ const entry = raw.genres[genre];
169
+ if (!isPlainObject(entry) || !isPlainObject(entry.chapters))
170
+ return null;
171
+ const chapter1 = normalizeExcitementType(entry.chapters["1"]);
172
+ const chapter2 = normalizeExcitementType(entry.chapters["2"]);
173
+ const chapter3 = normalizeExcitementType(entry.chapters["3"]);
174
+ if (!chapter1 || !chapter2 || !chapter3)
175
+ return null;
176
+ return {
177
+ genre,
178
+ chapters: { "1": chapter1, "2": chapter2, "3": chapter3 },
179
+ source: relPath
180
+ };
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
186
+ async function loadSelectedGenreGoldenStandards(rootDir) {
187
+ const genre = await loadProjectGenre(rootDir);
188
+ if (!genre)
189
+ return null;
190
+ const relPath = "genre-golden-standards.json";
191
+ const absPath = join(rootDir, relPath);
192
+ if (!(await pathExists(absPath)))
193
+ return null;
194
+ try {
195
+ const raw = await readJsonFile(absPath);
196
+ if (!isPlainObject(raw) || raw.schema_version !== 1 || !isPlainObject(raw.genres))
197
+ return null;
198
+ const entry = raw.genres[genre];
199
+ if (!isPlainObject(entry))
200
+ return null;
201
+ const focusDimensions = toKnownDimensionArray(entry.focus_dimensions);
202
+ const criteria = toNonEmptyStringArray(entry.criteria);
203
+ const minimumThresholds = toNumericThresholds(entry.minimum_thresholds);
204
+ if (!focusDimensions || !criteria || !minimumThresholds)
205
+ return null;
206
+ return {
207
+ genre,
208
+ focus_dimensions: focusDimensions,
209
+ criteria,
210
+ minimum_thresholds: minimumThresholds,
211
+ source: relPath
212
+ };
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ function normalizeCanonStatus(raw, sourceLabel) {
219
+ if (raw == null)
220
+ return "established";
221
+ if (typeof raw !== "string") {
222
+ console.warn(`[canon_status] Invalid non-string canon_status${sourceLabel ? ` in ${sourceLabel}` : ""}; defaulting to "established".`);
223
+ return "established";
224
+ }
225
+ const normalized = raw.trim().toLowerCase();
226
+ if (normalized === "")
227
+ return "established";
228
+ if (normalized === "planned" || normalized === "deprecated" || normalized === "established")
229
+ return normalized;
230
+ console.warn(`[canon_status] Invalid canon_status "${raw}"${sourceLabel ? ` in ${sourceLabel}` : ""}; defaulting to "established".`);
231
+ return "established";
232
+ }
233
+ function asNonEmptyString(raw) {
234
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
235
+ }
236
+ function buildPlannedRuleInfo(args) {
237
+ const plannedRuleInfo = { canon_status: "planned", rule: args.rule };
238
+ if (args.id)
239
+ plannedRuleInfo.id = args.id;
240
+ if (args.category)
241
+ plannedRuleInfo.category = args.category;
242
+ if (args.constraintType)
243
+ plannedRuleInfo.constraint_type = args.constraintType;
244
+ return plannedRuleInfo;
245
+ }
246
+ async function loadRuleLifecycleContext(rootDir) {
247
+ const empty = { hardRulesList: [], plannedRulesInfo: [], degraded: false };
248
+ const rulesAbs = join(rootDir, "world/rules.json");
249
+ if (!(await pathExists(rulesAbs)))
250
+ return empty;
251
+ try {
252
+ const raw = await readJsonFile(rulesAbs);
253
+ if (!isPlainObject(raw))
254
+ return { ...empty, degraded: true };
255
+ const rules = raw.rules;
256
+ if (!Array.isArray(rules))
257
+ return { ...empty, degraded: true };
258
+ const hardRulesList = [];
259
+ const plannedRulesInfo = [];
260
+ for (const item of rules) {
261
+ if (!isPlainObject(item))
262
+ continue;
263
+ const rule = asNonEmptyString(item.rule);
264
+ if (!rule)
265
+ continue;
266
+ const status = normalizeCanonStatus(item.canon_status, `${relative(rootDir, rulesAbs)}${typeof item.id === "string" ? `#${item.id}` : ""}`);
267
+ const id = asNonEmptyString(item.id);
268
+ const category = asNonEmptyString(item.category);
269
+ const constraintType = asNonEmptyString(item.constraint_type);
270
+ if (status === "planned") {
271
+ plannedRulesInfo.push(buildPlannedRuleInfo({ id, category, constraintType, rule }));
272
+ continue;
273
+ }
274
+ if (status === "deprecated")
275
+ continue;
276
+ if (constraintType !== "hard")
277
+ continue;
278
+ hardRulesList.push(id ? `${id}: ${rule}` : rule);
279
+ }
280
+ return { hardRulesList, plannedRulesInfo, degraded: false };
281
+ }
282
+ catch {
283
+ return { ...empty, degraded: true };
284
+ }
285
+ }
286
+ function selectFallbackCharacterCandidates(candidates, options) {
287
+ const nonDeprecatedCandidates = candidates.filter((candidate) => candidate.canonStatus !== "deprecated");
288
+ if (!options.includePlannedCharacters) {
289
+ return nonDeprecatedCandidates.filter((candidate) => candidate.canonStatus !== "planned").slice(0, 15);
290
+ }
291
+ if (!options.prioritizePlannedOnFallback)
292
+ return nonDeprecatedCandidates.slice(0, 15);
293
+ const plannedCandidates = nonDeprecatedCandidates.filter((candidate) => candidate.canonStatus === "planned");
294
+ const activeCandidates = nonDeprecatedCandidates.filter((candidate) => candidate.canonStatus !== "planned");
295
+ // Fallback uses a shared 15-slot budget across active + planned candidates.
296
+ // Planned entries go first so future-facing foreshadowing survives truncation;
297
+ // explicit chapter-contract matches bypass this fallback path entirely.
298
+ return [...plannedCandidates, ...activeCandidates].slice(0, 15);
299
+ }
300
+ async function loadExistingCharacterProfiles(rootDir, candidates) {
301
+ const pathsOrNull = await Promise.all(candidates.map(async (candidate) => ((await pathExists(join(rootDir, candidate.mdRel))) ? candidate.mdRel : null)));
302
+ return pathsOrNull.filter((path) => path !== null);
303
+ }
304
+ async function loadCharacterContext(args) {
305
+ const empty = {
306
+ activeCharacterContracts: [],
307
+ activeCharacterProfiles: [],
308
+ plannedCharacterContracts: [],
309
+ plannedCharacterProfiles: []
310
+ };
311
+ const charsDirRel = "characters/active";
312
+ const charsDirAbs = join(args.rootDir, charsDirRel);
313
+ if (!(await pathExists(charsDirAbs)))
314
+ return empty;
315
+ const desiredRefs = new Set();
316
+ const contractAbs = join(args.rootDir, args.chapterContractRel);
317
+ if (await pathExists(contractAbs)) {
318
+ try {
319
+ const raw = await readJsonFile(contractAbs);
320
+ if (isPlainObject(raw)) {
321
+ const preconditions = isPlainObject(raw.preconditions) ? raw.preconditions : null;
322
+ const characterStates = preconditions && isPlainObject(preconditions.character_states) ? preconditions.character_states : null;
323
+ if (characterStates) {
324
+ for (const key of Object.keys(characterStates)) {
325
+ const normalized = key.trim().toLowerCase();
326
+ if (normalized.length > 0)
327
+ desiredRefs.add(normalized);
328
+ }
329
+ }
330
+ }
331
+ }
332
+ catch {
333
+ // Ignore malformed chapter contracts here; validateStep remains the source of truth.
334
+ }
335
+ }
336
+ try {
337
+ const entries = await readdir(charsDirAbs, { withFileTypes: true });
338
+ const candidates = [];
339
+ const hasDesiredRefs = desiredRefs.size > 0;
340
+ for (const entry of entries) {
341
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
342
+ continue;
343
+ const jsonRel = `${charsDirRel}/${entry.name}`;
344
+ try {
345
+ const raw = await readJsonFile(join(args.rootDir, jsonRel));
346
+ if (!isPlainObject(raw))
347
+ continue;
348
+ const id = asNonEmptyString(raw.id) ?? entry.name.replace(/\.json$/u, "");
349
+ const displayName = asNonEmptyString(raw.display_name) ?? id;
350
+ const canonStatus = normalizeCanonStatus(raw.canon_status, jsonRel);
351
+ const matched = hasDesiredRefs && (desiredRefs.has(id.toLowerCase()) || desiredRefs.has(displayName.toLowerCase()));
352
+ candidates.push({
353
+ id,
354
+ displayName,
355
+ canonStatus,
356
+ jsonRel,
357
+ mdRel: `${charsDirRel}/${entry.name.replace(/\.json$/u, '.md')}`,
358
+ matched
359
+ });
360
+ }
361
+ catch {
362
+ continue;
363
+ }
364
+ }
365
+ candidates.sort((left, right) => left.jsonRel.localeCompare(right.jsonRel));
366
+ const preferred = hasDesiredRefs
367
+ ? candidates.filter((candidate) => candidate.matched &&
368
+ candidate.canonStatus !== "deprecated" &&
369
+ (args.options.includePlannedCharacters || candidate.canonStatus !== "planned"))
370
+ : [];
371
+ const selectedCandidates = preferred.length > 0 ? preferred : selectFallbackCharacterCandidates(candidates, args.options);
372
+ const activeCandidates = selectedCandidates.filter((candidate) => candidate.canonStatus !== "planned");
373
+ const plannedCandidates = args.options.includePlannedCharacters
374
+ ? selectedCandidates.filter((candidate) => candidate.canonStatus === "planned")
375
+ : [];
376
+ const [activeCharacterProfiles, plannedCharacterProfiles] = await Promise.all([
377
+ loadExistingCharacterProfiles(args.rootDir, activeCandidates),
378
+ loadExistingCharacterProfiles(args.rootDir, plannedCandidates)
379
+ ]);
380
+ return {
381
+ activeCharacterContracts: activeCandidates.map((candidate) => candidate.jsonRel),
382
+ activeCharacterProfiles,
383
+ plannedCharacterContracts: plannedCandidates.map((candidate) => candidate.jsonRel),
384
+ plannedCharacterProfiles
385
+ };
386
+ }
387
+ catch {
388
+ return empty;
389
+ }
390
+ }
391
+ async function attachCanonStatusContext(args) {
392
+ const ruleLifecycle = await loadRuleLifecycleContext(args.rootDir);
393
+ args.inline.hard_rules_list = ruleLifecycle.hardRulesList;
394
+ if (ruleLifecycle.degraded)
395
+ args.inline.world_rules_context_degraded = true;
396
+ if (args.options.includePlannedRulesInfo && ruleLifecycle.plannedRulesInfo.length > 0) {
397
+ args.inline.planned_rules_info = ruleLifecycle.plannedRulesInfo;
398
+ }
399
+ const characterContext = await loadCharacterContext({
400
+ rootDir: args.rootDir,
401
+ chapterContractRel: args.chapterContractRel,
402
+ options: {
403
+ includePlannedCharacters: args.options.includePlannedCharacters,
404
+ prioritizePlannedOnFallback: args.options.prioritizePlannedCharactersOnFallback
405
+ }
406
+ });
407
+ if (characterContext.activeCharacterContracts.length > 0)
408
+ args.paths.character_contracts = characterContext.activeCharacterContracts;
409
+ if (characterContext.activeCharacterProfiles.length > 0)
410
+ args.paths.character_profiles = characterContext.activeCharacterProfiles;
411
+ if (args.options.includePlannedCharacters && characterContext.plannedCharacterContracts.length > 0) {
412
+ args.paths.planned_character_contracts = characterContext.plannedCharacterContracts;
413
+ }
414
+ if (args.options.includePlannedCharacters && characterContext.plannedCharacterProfiles.length > 0) {
415
+ args.paths.planned_character_profiles = characterContext.plannedCharacterProfiles;
416
+ }
417
+ }
30
418
  async function buildReviewInstructionPacket(args) {
31
419
  const stepId = formatStepId(args.step);
32
420
  if (args.step.kind !== "review")
@@ -206,6 +594,8 @@ async function buildQuickStartInstructionPacket(args) {
206
594
  }
207
595
  }
208
596
  const trialChapter = Math.max(1, args.checkpoint.last_completed_chapter + 1);
597
+ const miniPlanningStaging = volumeStagingRelPaths(1);
598
+ const miniPlanningFinal = volumeFinalRelPaths(1);
209
599
  const isTrialMode = step.phase === "trial" || step.phase === "results";
210
600
  const inline = {
211
601
  quickstart_phase: step.phase,
@@ -221,19 +611,33 @@ async function buildQuickStartInstructionPacket(args) {
221
611
  };
222
612
  await maybeAddPath("project_brief", "brief.md");
223
613
  await maybeAddPath("platform_profile", "platform-profile.json");
614
+ await maybeAddPath("platform_writing_guide", "platform-writing-guide.md");
615
+ await maybeAddPath("style_guide", "skills/novel-writing/references/style-guide.md");
224
616
  await maybeAddPath("style_profile_template", "style-profile.json");
617
+ await maybeAddPath("storylines", "storylines/storylines.json");
225
618
  // Attach staging quickstart artifacts when present (for resume/debug).
226
619
  await maybeAddPath("quickstart_rules", QUICKSTART_STAGING_RELS.rulesJson);
227
620
  await maybeAddPath("quickstart_contracts_dir", QUICKSTART_STAGING_RELS.contractsDir);
228
621
  await maybeAddPath("quickstart_style_profile", QUICKSTART_STAGING_RELS.styleProfileJson);
229
622
  await maybeAddPath("quickstart_trial_chapter", QUICKSTART_STAGING_RELS.trialChapterMd);
230
623
  await maybeAddPath("quickstart_evaluation", QUICKSTART_STAGING_RELS.evaluationJson);
624
+ // Attach committed mini-planning artifacts when present.
625
+ await maybeAddPath("mini_volume_outline", miniPlanningFinal.outlineMd);
626
+ await maybeAddPath("mini_storyline_schedule", miniPlanningFinal.storylineScheduleJson);
627
+ await maybeAddPath("mini_volume_foreshadowing", miniPlanningFinal.foreshadowingJson);
628
+ if (trialChapter <= QUICKSTART_MINI_PLANNING_RANGE.end) {
629
+ await maybeAddPath("mini_chapter_contract", miniPlanningFinal.chapterContractJson(trialChapter));
630
+ }
231
631
  const asString = (value) => (typeof value === "string" ? value : null);
232
632
  const qsRules = asString(paths.quickstart_rules);
233
633
  const qsContractsDir = asString(paths.quickstart_contracts_dir);
234
634
  const qsStyleProfile = asString(paths.quickstart_style_profile);
235
635
  const qsTrialChapter = asString(paths.quickstart_trial_chapter);
236
636
  const styleTemplate = asString(paths.style_profile_template);
637
+ const miniOutline = asString(paths.mini_volume_outline);
638
+ const miniStorylineSchedule = asString(paths.mini_storyline_schedule);
639
+ const miniVolumeForeshadowing = asString(paths.mini_volume_foreshadowing);
640
+ const miniChapterContract = asString(paths.mini_chapter_contract);
237
641
  // Provide canonical manifest keys in addition to quickstart-scoped aliases.
238
642
  if (qsRules)
239
643
  paths.world_rules = qsRules;
@@ -245,6 +649,14 @@ async function buildQuickStartInstructionPacket(args) {
245
649
  paths.style_profile = styleTemplate;
246
650
  if (qsTrialChapter)
247
651
  paths.chapter_draft = qsTrialChapter;
652
+ if (miniOutline)
653
+ paths.volume_outline = miniOutline;
654
+ if (miniStorylineSchedule)
655
+ paths.storyline_schedule = miniStorylineSchedule;
656
+ if (miniVolumeForeshadowing)
657
+ paths.volume_foreshadowing = miniVolumeForeshadowing;
658
+ if (miniChapterContract)
659
+ paths.chapter_contract = miniChapterContract;
248
660
  let agent;
249
661
  const expected_outputs = [];
250
662
  const next_actions = [];
@@ -279,7 +691,27 @@ async function buildQuickStartInstructionPacket(args) {
279
691
  next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
280
692
  next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
281
693
  next_actions.push({ kind: "command", command: `novel next`, note: "Compute next deterministic step (skips already-generated artifacts)." });
282
- next_actions.push({ kind: "command", command: `novel instructions quickstart:trial --json`, note: "After advance, proceed to trial chapter." });
694
+ next_actions.push({ kind: "command", command: `novel instructions quickstart:f0 --json`, note: "After advance, generate the opening mini-plan for chapters 1-3." });
695
+ }
696
+ else if (step.phase === "f0") {
697
+ agent = { kind: "subagent", name: "plot-architect" };
698
+ inline.quickstart_mini_planning = true;
699
+ inline.volume_plan = { volume: 1, chapter_range: [QUICKSTART_MINI_PLANNING_RANGE.start, QUICKSTART_MINI_PLANNING_RANGE.end] };
700
+ inline.expected_outputs_base_dir = miniPlanningStaging.dir;
701
+ const selectedGenreExcitementMap = await loadSelectedGenreExcitementMap(args.rootDir);
702
+ if (selectedGenreExcitementMap)
703
+ inline.genre_excitement_map = selectedGenreExcitementMap;
704
+ expected_outputs.push({ path: miniPlanningStaging.outlineMd, required: true });
705
+ expected_outputs.push({ path: miniPlanningStaging.storylineScheduleJson, required: true });
706
+ expected_outputs.push({ path: miniPlanningStaging.foreshadowingJson, required: true });
707
+ expected_outputs.push({ path: miniPlanningStaging.newCharactersJson, required: true });
708
+ for (let ch = QUICKSTART_MINI_PLANNING_RANGE.start; ch <= QUICKSTART_MINI_PLANNING_RANGE.end; ch++) {
709
+ expected_outputs.push({ path: miniPlanningStaging.chapterContractJson(ch), required: true });
710
+ }
711
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
712
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
713
+ next_actions.push({ kind: "command", command: `novel next`, note: "Compute next deterministic step (skips already-generated artifacts)." });
714
+ next_actions.push({ kind: "command", command: `novel instructions quickstart:trial --json`, note: "After mini-plan commit, proceed to trial chapter writing." });
283
715
  }
284
716
  else if (step.phase === "trial") {
285
717
  agent = { kind: "subagent", name: "chapter-writer" };
@@ -291,6 +723,43 @@ async function buildQuickStartInstructionPacket(args) {
291
723
  }
292
724
  else if (step.phase === "results") {
293
725
  agent = { kind: "subagent", name: "quality-judge" };
726
+ const loadedPlatform = await loadPlatformProfile(args.rootDir);
727
+ if (loadedPlatform?.profile.scoring) {
728
+ const loadedWeights = await loadGenreWeightProfiles(args.rootDir);
729
+ if (!loadedWeights) {
730
+ throw new NovelCliError("Missing required file: genre-weight-profiles.json (required when platform-profile.json.scoring is present). Copy it from templates/genre-weight-profiles.json.", 2);
731
+ }
732
+ inline.scoring_weights = {
733
+ ...computeEffectiveScoringWeights({
734
+ config: loadedWeights.config,
735
+ scoring: loadedPlatform.profile.scoring,
736
+ hookPolicy: loadedPlatform.profile.hook_policy,
737
+ platformId: loadedPlatform.profile.platform
738
+ }),
739
+ source: { platform_profile: loadedPlatform.relPath, genre_weight_profiles: loadedWeights.relPath }
740
+ };
741
+ }
742
+ if (loadedPlatform && trialChapter <= 3) {
743
+ const loadedGoldenGates = await loadGoldenChapterGates(args.rootDir);
744
+ if (loadedGoldenGates) {
745
+ const selectedGoldenGates = selectGoldenChapterGatesForPlatform({
746
+ config: loadedGoldenGates.config,
747
+ platformId: loadedPlatform.profile.platform,
748
+ chapter: trialChapter
749
+ });
750
+ if (selectedGoldenGates) {
751
+ inline.golden_chapter_gates = {
752
+ ...selectedGoldenGates,
753
+ source: loadedGoldenGates.relPath
754
+ };
755
+ }
756
+ }
757
+ }
758
+ if (trialChapter <= 3) {
759
+ const selectedGenreGoldenStandards = await loadSelectedGenreGoldenStandards(args.rootDir);
760
+ if (selectedGenreGoldenStandards)
761
+ inline.genre_golden_standards = selectedGenreGoldenStandards;
762
+ }
294
763
  expected_outputs.push({
295
764
  path: QUICKSTART_STAGING_RELS.evaluationJson,
296
765
  required: true,
@@ -354,7 +823,7 @@ export async function buildInstructionPacket(args) {
354
823
  if (args.step.kind === "volume") {
355
824
  const step = args.step;
356
825
  const volume = args.checkpoint.current_volume;
357
- const range = computeVolumeChapterRange({ current_volume: volume, last_completed_chapter: args.checkpoint.last_completed_chapter });
826
+ const range = await resolveVolumeChapterRange({ rootDir: args.rootDir, current_volume: volume, last_completed_chapter: args.checkpoint.last_completed_chapter });
358
827
  const embedMode = safeEmbedMode(args.embedMode);
359
828
  const embed = {};
360
829
  if (embedMode === "brief") {
@@ -402,6 +871,13 @@ export async function buildInstructionPacket(args) {
402
871
  const next_actions = [];
403
872
  const staging = volumeStagingRelPaths(volume);
404
873
  const final = volumeFinalRelPaths(volume);
874
+ if (range.start > 1) {
875
+ await maybeAddPath("existing_volume_outline", final.outlineMd);
876
+ await maybeAddPath("existing_storyline_schedule", final.storylineScheduleJson);
877
+ await maybeAddPath("existing_foreshadowing", final.foreshadowingJson);
878
+ await maybeAddPath("existing_chapter_contracts_dir", final.chapterContractsDir);
879
+ inline.volume_plan_seed_range = [1, range.start - 1];
880
+ }
405
881
  const addPlanningOutputs = (base) => {
406
882
  expected_outputs.push({ path: base.outlineMd, required: true });
407
883
  expected_outputs.push({ path: base.storylineScheduleJson, required: true });
@@ -413,6 +889,11 @@ export async function buildInstructionPacket(args) {
413
889
  };
414
890
  if (step.phase === "outline") {
415
891
  agent = { kind: "subagent", name: "plot-architect" };
892
+ if (range.start <= 3) {
893
+ const selectedGenreExcitementMap = await loadSelectedGenreExcitementMap(args.rootDir);
894
+ if (selectedGenreExcitementMap)
895
+ inline.genre_excitement_map = selectedGenreExcitementMap;
896
+ }
416
897
  inline.expected_outputs_base_dir = staging.dir;
417
898
  addPlanningOutputs(staging);
418
899
  next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
@@ -498,6 +979,7 @@ export async function buildInstructionPacket(args) {
498
979
  await maybeAddPath(commonPaths, "project_brief", "brief.md");
499
980
  await maybeAddPath(commonPaths, "style_profile", "style-profile.json");
500
981
  await maybeAddPath(commonPaths, "platform_profile", "platform-profile.json");
982
+ await maybeAddPath(commonPaths, "platform_writing_guide", "platform-writing-guide.md");
501
983
  await maybeAddPath(commonPaths, "ai_blacklist", "ai-blacklist.json");
502
984
  await maybeAddPath(commonPaths, "web_novel_cliche_lint", "web-novel-cliche-lint.json");
503
985
  await maybeAddPath(commonPaths, "genre_weight_profiles", "genre-weight-profiles.json");
@@ -613,6 +1095,23 @@ export async function buildInstructionPacket(args) {
613
1095
  inline.engagement_report_summary_degraded = true;
614
1096
  inline.promise_ledger_report_summary_degraded = true;
615
1097
  }
1098
+ await attachCanonStatusContext({
1099
+ rootDir: args.rootDir,
1100
+ chapterContractRel,
1101
+ inline,
1102
+ paths,
1103
+ options: {
1104
+ includePlannedRulesInfo: true,
1105
+ includePlannedCharacters: true,
1106
+ prioritizePlannedCharactersOnFallback: true
1107
+ }
1108
+ });
1109
+ const statisticalTargets = await loadAntiAiStatisticalTargets(args.rootDir);
1110
+ if (statisticalTargets)
1111
+ inline.statistical_targets = statisticalTargets;
1112
+ const genreOverrides = await loadAntiAiGenreOverrides(args.rootDir);
1113
+ if (genreOverrides)
1114
+ inline.genre_overrides = genreOverrides;
616
1115
  // Optional: inject non-spoiler light-touch reminders for dormant foreshadowing items (best-effort).
617
1116
  try {
618
1117
  const loadedPlatform = await loadPlatformProfile(args.rootDir).catch(() => null);
@@ -686,6 +1185,30 @@ export async function buildInstructionPacket(args) {
686
1185
  const chapterDraftRel = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
687
1186
  paths.chapter_draft = chapterDraftRel;
688
1187
  paths.cross_references = relIfExists(rel.staging.crossrefJson, await pathExists(join(args.rootDir, rel.staging.crossrefJson)));
1188
+ if (chapterDraftRel) {
1189
+ const antiAiContext = await loadAntiAiJudgeContext({ rootDir: args.rootDir, chapterRel: chapterDraftRel });
1190
+ if (antiAiContext.blacklistLint)
1191
+ inline.blacklist_lint = antiAiContext.blacklistLint;
1192
+ else if (antiAiContext.degraded.blacklist_lint)
1193
+ inline.blacklist_lint_degraded = true;
1194
+ if (antiAiContext.statisticalProfile)
1195
+ inline.statistical_profile = antiAiContext.statisticalProfile;
1196
+ if (antiAiContext.structuralRuleViolations)
1197
+ inline.structural_rule_violations = antiAiContext.structuralRuleViolations;
1198
+ else if (antiAiContext.degraded.structural_rule_violations)
1199
+ inline.structural_rule_violations_degraded = true;
1200
+ }
1201
+ await attachCanonStatusContext({
1202
+ rootDir: args.rootDir,
1203
+ chapterContractRel,
1204
+ inline,
1205
+ paths,
1206
+ options: {
1207
+ includePlannedRulesInfo: false,
1208
+ includePlannedCharacters: false,
1209
+ prioritizePlannedCharactersOnFallback: false
1210
+ }
1211
+ });
689
1212
  const loadedPlatform = await loadPlatformProfile(args.rootDir);
690
1213
  if (loadedPlatform?.profile.scoring) {
691
1214
  const loadedWeights = await loadGenreWeightProfiles(args.rootDir);
@@ -695,15 +1218,38 @@ export async function buildInstructionPacket(args) {
695
1218
  const effective = computeEffectiveScoringWeights({
696
1219
  config: loadedWeights.config,
697
1220
  scoring: loadedPlatform.profile.scoring,
698
- hookPolicy: loadedPlatform.profile.hook_policy
1221
+ hookPolicy: loadedPlatform.profile.hook_policy,
1222
+ platformId: loadedPlatform.profile.platform
699
1223
  });
700
1224
  inline.scoring_weights = {
701
1225
  ...effective,
702
1226
  source: { platform_profile: loadedPlatform.relPath, genre_weight_profiles: loadedWeights.relPath }
703
1227
  };
704
1228
  }
1229
+ if (loadedPlatform && step.chapter <= 3) {
1230
+ const loadedGoldenGates = await loadGoldenChapterGates(args.rootDir);
1231
+ if (loadedGoldenGates) {
1232
+ const selectedGoldenGates = selectGoldenChapterGatesForPlatform({
1233
+ config: loadedGoldenGates.config,
1234
+ platformId: loadedPlatform.profile.platform,
1235
+ chapter: step.chapter
1236
+ });
1237
+ if (selectedGoldenGates) {
1238
+ inline.golden_chapter_gates = {
1239
+ ...selectedGoldenGates,
1240
+ source: loadedGoldenGates.relPath
1241
+ };
1242
+ }
1243
+ }
1244
+ }
1245
+ if (step.chapter <= 3) {
1246
+ const selectedGenreGoldenStandards = await loadSelectedGenreGoldenStandards(args.rootDir);
1247
+ if (selectedGenreGoldenStandards)
1248
+ inline.genre_golden_standards = selectedGenreGoldenStandards;
1249
+ }
705
1250
  // Optional: inject compact continuity summary for LS-001 evidence (non-blocking).
706
1251
  inline.continuity_report_summary = await loadContinuityLatestSummary(args.rootDir);
1252
+ inline.excitement_type = await loadChapterExcitementType({ rootDir: args.rootDir, volume, chapter: step.chapter });
707
1253
  // Optional: pre-judge guardrails report (title/readability/naming). Non-blocking here; gate engine decides.
708
1254
  inline.prejudge_guardrails = null;
709
1255
  if (loadedPlatform && chapterDraftRel) {