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/next-step.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
4
4
  import { NovelCliError } from "./errors.js";
5
5
  import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
6
- import { computeGateDecision, detectHighConfidenceViolation } from "./gate-decision.js";
6
+ import { evaluateGateDecisionFromEval, normalizeGateMaxRevisions, normalizeGateRevisionCount } from "./gate-decision.js";
7
7
  import { checkHookPolicy } from "./hook-policy.js";
8
8
  import { loadPlatformProfile } from "./platform-profile.js";
9
9
  import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
@@ -15,7 +15,84 @@ import { summarizeReadabilityIssues } from "./readability-lint.js";
15
15
  import { computeTitlePolicyReport } from "./title-policy.js";
16
16
  import { QUICKSTART_PHASES, chapterRelPaths, formatStepId } from "./steps.js";
17
17
  import { isPlainObject } from "./type-guards.js";
18
- import { computeVolumeNextStep } from "./volume-planning.js";
18
+ import { computeVolumeNextStep, hasQuickstartMiniPlanningArtifacts, volumeFinalRelPaths } from "./volume-planning.js";
19
+ function resolveMaxRevisions(loadedProfile) {
20
+ return normalizeGateMaxRevisions(loadedProfile?.profile.scoring?.max_revisions);
21
+ }
22
+ function routeGateDrivenNextStep(args) {
23
+ const evaluated = evaluateGateDecisionFromEval({
24
+ evalRaw: args.evalRaw,
25
+ revision_count: args.revisionCount,
26
+ ...(args.maxRevisions === null ? {} : { max_revisions: args.maxRevisions })
27
+ });
28
+ if (!evaluated.ok) {
29
+ return {
30
+ step: formatStepId({ kind: "chapter", chapter: args.inflightChapter, stage: "judge" }),
31
+ reason: `${args.stagePrefix}:${evaluated.reason}`,
32
+ inflight: { chapter: args.inflightChapter, pipeline_stage: args.pipelineStage },
33
+ evidence: { ...args.evidence }
34
+ };
35
+ }
36
+ const gate = evaluated.gate;
37
+ const gateEvidence = {
38
+ ...args.evidence,
39
+ gate: {
40
+ decision: gate.decision,
41
+ overall_final: gate.overall_final,
42
+ revision_count: gate.revision_count,
43
+ max_revisions: gate.max_revisions,
44
+ has_high_confidence_violation: gate.has_high_confidence_violation,
45
+ high_confidence_violations: gate.high_confidence_violations.slice(0, 10),
46
+ has_golden_chapter_gate_failure: gate.has_golden_chapter_gate_failure,
47
+ golden_chapter_gate_failures: gate.golden_chapter_gate_failures.slice(0, 10)
48
+ },
49
+ quality_judge: {
50
+ recommendation: gate.recommendation
51
+ }
52
+ };
53
+ if (gate.decision === "pass") {
54
+ return {
55
+ step: formatStepId({ kind: "chapter", chapter: args.inflightChapter, stage: "commit" }),
56
+ reason: `${args.stagePrefix}:gate:pass`,
57
+ inflight: { chapter: args.inflightChapter, pipeline_stage: args.pipelineStage },
58
+ evidence: gateEvidence
59
+ };
60
+ }
61
+ if (gate.decision === "force_passed") {
62
+ return {
63
+ step: formatStepId({ kind: "chapter", chapter: args.inflightChapter, stage: "commit" }),
64
+ reason: `${args.stagePrefix}:gate:force_passed`,
65
+ inflight: { chapter: args.inflightChapter, pipeline_stage: args.pipelineStage },
66
+ evidence: gateEvidence
67
+ };
68
+ }
69
+ if (gate.decision === "polish") {
70
+ return {
71
+ step: formatStepId({ kind: "chapter", chapter: args.inflightChapter, stage: "refine" }),
72
+ reason: `${args.stagePrefix}:gate:polish`,
73
+ inflight: { chapter: args.inflightChapter, pipeline_stage: args.pipelineStage },
74
+ evidence: gateEvidence
75
+ };
76
+ }
77
+ if (gate.decision === "revise") {
78
+ return {
79
+ step: formatStepId({ kind: "chapter", chapter: args.inflightChapter, stage: "draft" }),
80
+ reason: `${args.stagePrefix}:gate:revise`,
81
+ inflight: { chapter: args.inflightChapter, pipeline_stage: args.pipelineStage },
82
+ evidence: gateEvidence
83
+ };
84
+ }
85
+ if (gate.decision === "pause_for_user" || gate.decision === "pause_for_user_force_rewrite") {
86
+ return {
87
+ step: formatStepId({ kind: "chapter", chapter: args.inflightChapter, stage: "review" }),
88
+ reason: `${args.stagePrefix}:gate:${gate.decision}`,
89
+ inflight: { chapter: args.inflightChapter, pipeline_stage: args.pipelineStage },
90
+ evidence: gateEvidence
91
+ };
92
+ }
93
+ const _exhaustive = gate.decision;
94
+ throw new NovelCliError(`Unsupported gate decision: ${String(_exhaustive)}`, 2);
95
+ }
19
96
  function normalizeStage(stage) {
20
97
  if (stage === null || stage === undefined)
21
98
  return null;
@@ -368,12 +445,29 @@ async function computeChapterNextStep(projectRootDir, checkpoint) {
368
445
  });
369
446
  if (guardrailsGate)
370
447
  return guardrailsGate;
371
- return {
372
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
373
- reason: "refined:ready_commit",
374
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
375
- evidence
376
- };
448
+ let evalRaw;
449
+ try {
450
+ evalRaw = await readJsonFile(join(projectRootDir, rel.staging.evalJson));
451
+ }
452
+ catch (err) {
453
+ const message = err instanceof Error ? err.message : String(err);
454
+ return {
455
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
456
+ reason: "refined:eval_read_failed",
457
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
458
+ evidence: { ...evidence, error: message }
459
+ };
460
+ }
461
+ const revisionCount = normalizeGateRevisionCount(checkpoint.revision_count);
462
+ return routeGateDrivenNextStep({
463
+ stagePrefix: "refined",
464
+ inflightChapter,
465
+ pipelineStage: stage,
466
+ evidence,
467
+ evalRaw,
468
+ revisionCount,
469
+ maxRevisions: resolveMaxRevisions(loadedProfile)
470
+ });
377
471
  }
378
472
  if (stage === "judged") {
379
473
  if (!hasChapter) {
@@ -451,87 +545,16 @@ async function computeChapterNextStep(projectRootDir, checkpoint) {
451
545
  evidence: { ...evidence }
452
546
  };
453
547
  }
454
- const evalObj = evalRaw;
455
- const overall = typeof evalObj.overall_final === "number" ? evalObj.overall_final : typeof evalObj.overall === "number" ? evalObj.overall : null;
456
- if (overall === null || !Number.isFinite(overall)) {
457
- return {
458
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
459
- reason: `judged:eval_missing_overall`,
460
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
461
- evidence: { ...evidence }
462
- };
463
- }
464
- const revisionCount = typeof checkpoint.revision_count === "number" && Number.isInteger(checkpoint.revision_count) && checkpoint.revision_count >= 0
465
- ? checkpoint.revision_count
466
- : 0;
467
- const violation = detectHighConfidenceViolation(evalRaw);
468
- const maxRevisions = typeof loadedProfile?.profile.scoring?.max_revisions === "number" &&
469
- Number.isInteger(loadedProfile.profile.scoring.max_revisions) &&
470
- loadedProfile.profile.scoring.max_revisions >= 0
471
- ? loadedProfile.profile.scoring.max_revisions
472
- : null;
473
- const gateDecision = computeGateDecision({
474
- overall_final: overall,
475
- revision_count: revisionCount,
476
- has_high_confidence_violation: violation.has_high_confidence_violation,
477
- ...(maxRevisions === null ? {} : { max_revisions: maxRevisions })
548
+ const revisionCount = normalizeGateRevisionCount(checkpoint.revision_count);
549
+ return routeGateDrivenNextStep({
550
+ stagePrefix: "judged",
551
+ inflightChapter,
552
+ pipelineStage: stage,
553
+ evidence,
554
+ evalRaw,
555
+ revisionCount,
556
+ maxRevisions: resolveMaxRevisions(loadedProfile)
478
557
  });
479
- const gateEvidence = {
480
- ...evidence,
481
- gate: {
482
- decision: gateDecision,
483
- overall_final: overall,
484
- revision_count: revisionCount,
485
- max_revisions: maxRevisions,
486
- has_high_confidence_violation: violation.has_high_confidence_violation,
487
- high_confidence_violations: violation.high_confidence_violations.slice(0, 10)
488
- },
489
- quality_judge: {
490
- recommendation: typeof evalObj.recommendation === "string" ? evalObj.recommendation : null
491
- }
492
- };
493
- if (gateDecision === "pass") {
494
- return {
495
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
496
- reason: "judged:gate:pass",
497
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
498
- evidence: gateEvidence
499
- };
500
- }
501
- if (gateDecision === "force_passed") {
502
- return {
503
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
504
- reason: "judged:gate:force_passed",
505
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
506
- evidence: gateEvidence
507
- };
508
- }
509
- if (gateDecision === "polish") {
510
- return {
511
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "refine" }),
512
- reason: "judged:gate:polish",
513
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
514
- evidence: gateEvidence
515
- };
516
- }
517
- if (gateDecision === "revise") {
518
- return {
519
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "draft" }),
520
- reason: "judged:gate:revise",
521
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
522
- evidence: gateEvidence
523
- };
524
- }
525
- if (gateDecision === "pause_for_user" || gateDecision === "pause_for_user_force_rewrite") {
526
- return {
527
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "review" }),
528
- reason: `judged:gate:${gateDecision}`,
529
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
530
- evidence: gateEvidence
531
- };
532
- }
533
- const _exhaustive = gateDecision;
534
- throw new NovelCliError(`Unsupported gate decision: ${String(_exhaustive)}`, 2);
535
558
  }
536
559
  // Unknown stage: upstream parseCheckpoint validates enum so this should be unreachable.
537
560
  throw new NovelCliError(`Checkpoint has unexpected pipeline_stage=${stage}. This should not happen; repair .checkpoint.json and rerun.`, 2);
@@ -576,6 +599,7 @@ async function computeQuickStartNextStep(projectRootDir, checkpoint) {
576
599
  const rulesExists = await pathExists(rulesAbs);
577
600
  const contracts = await countContractArtifacts(projectRootDir);
578
601
  const styleExists = await pathExists(styleAbs);
602
+ const miniPlanningExists = await hasQuickstartMiniPlanningArtifacts(projectRootDir);
579
603
  const trialExists = await pathExists(trialAbs);
580
604
  const evalExists = await pathExists(evalAbs);
581
605
  let rulesOk = false;
@@ -648,6 +672,7 @@ async function computeQuickStartNextStep(projectRootDir, checkpoint) {
648
672
  styleExists,
649
673
  styleOk,
650
674
  ...(styleError ? { styleError } : {}),
675
+ miniPlanningExists,
651
676
  trialExists,
652
677
  trialOk,
653
678
  ...(trialError ? { trialError } : {}),
@@ -685,6 +710,15 @@ async function computeQuickStartNextStep(projectRootDir, checkpoint) {
685
710
  evidence
686
711
  };
687
712
  }
713
+ else if (!miniPlanningExists) {
714
+ selectedPhase = "f0";
715
+ selected = {
716
+ step: formatStepId({ kind: "quickstart", phase: "f0" }),
717
+ reason: "quickstart:f0",
718
+ inflight: { chapter: null, pipeline_stage: null },
719
+ evidence
720
+ };
721
+ }
688
722
  else if (!trialOk) {
689
723
  selectedPhase = "trial";
690
724
  selected = {
@@ -728,6 +762,8 @@ async function computeQuickStartNextStep(projectRootDir, checkpoint) {
728
762
  return QUICKSTART_STAGING_RELS.contractsDir;
729
763
  case "style":
730
764
  return QUICKSTART_STAGING_RELS.styleProfileJson;
765
+ case "f0":
766
+ return volumeFinalRelPaths(1).outlineMd;
731
767
  case "trial":
732
768
  return QUICKSTART_STAGING_RELS.trialChapterMd;
733
769
  case "results":
@@ -2,6 +2,8 @@ import { join } from "node:path";
2
2
  import { NovelCliError } from "./errors.js";
3
3
  import { pathExists, readJsonFile } from "./fs-utils.js";
4
4
  import { isPlainObject } from "./type-guards.js";
5
+ export const PLATFORM_IDS = ["qidian", "tomato", "fanqie", "jinjiang"];
6
+ export const CANONICAL_PLATFORM_IDS = ["qidian", "fanqie", "jinjiang"];
5
7
  function requireIntField(obj, field, file) {
6
8
  const v = obj[field];
7
9
  if (typeof v !== "number" || !Number.isInteger(v))
@@ -33,9 +35,14 @@ function requireStringField(obj, field, file) {
33
35
  return v;
34
36
  }
35
37
  function requirePlatformId(value, file) {
36
- if (value === "qidian" || value === "tomato")
38
+ if (value === "qidian" || value === "tomato" || value === "fanqie" || value === "jinjiang")
37
39
  return value;
38
- throw new NovelCliError(`Invalid ${file}: 'platform' must be one of: qidian, tomato.`, 2);
40
+ throw new NovelCliError(`Invalid ${file}: 'platform' must be one of: ${PLATFORM_IDS.join(", ")}.`, 2);
41
+ }
42
+ export function canonicalPlatformId(id) {
43
+ if (id === "tomato")
44
+ return "fanqie";
45
+ return id;
39
46
  }
40
47
  function requireSeverityPolicy(value, file, field) {
41
48
  if (value === "warn" || value === "soft" || value === "hard")
@@ -46,12 +53,17 @@ function parseWordCountPolicy(raw, file) {
46
53
  if (!isPlainObject(raw))
47
54
  throw new NovelCliError(`Invalid ${file}: 'word_count' must be an object.`, 2);
48
55
  const obj = raw;
49
- return {
50
- target_min: requireIntField(obj, "target_min", file),
51
- target_max: requireIntField(obj, "target_max", file),
52
- hard_min: requireIntField(obj, "hard_min", file),
53
- hard_max: requireIntField(obj, "hard_max", file)
54
- };
56
+ const target_min = requireIntField(obj, "target_min", file);
57
+ const target_max = requireIntField(obj, "target_max", file);
58
+ const hard_min = requireIntField(obj, "hard_min", file);
59
+ const hard_max = requireIntField(obj, "hard_max", file);
60
+ if (target_min > target_max) {
61
+ throw new NovelCliError(`Invalid ${file}: 'word_count.target_min' must be <= 'word_count.target_max'.`, 2);
62
+ }
63
+ if (hard_min > hard_max) {
64
+ throw new NovelCliError(`Invalid ${file}: 'word_count.hard_min' must be <= 'word_count.hard_max'.`, 2);
65
+ }
66
+ return { target_min, target_max, hard_min, hard_max };
55
67
  }
56
68
  function parseInfoLoadPolicy(raw, file) {
57
69
  if (!isPlainObject(raw))
@@ -0,0 +1,30 @@
1
+ export const QUICKSTART_MINI_PLANNING_RANGE = { start: 1, end: 3 };
2
+ export function quickstartMiniPlanningChapters(range = QUICKSTART_MINI_PLANNING_RANGE) {
3
+ const chapters = [];
4
+ for (let chapter = range.start; chapter <= range.end; chapter++) {
5
+ chapters.push(chapter);
6
+ }
7
+ return chapters;
8
+ }
9
+ export function extractOutlineChapterNumbers(text) {
10
+ const chapterHeadingRe = /^###\s*第\s*(\d+)\s*章/u;
11
+ const chapters = [];
12
+ for (const line of text.split(/\r?\n/u)) {
13
+ const match = chapterHeadingRe.exec(line);
14
+ if (!match)
15
+ continue;
16
+ const chapter = Number.parseInt(match[1] ?? "", 10);
17
+ if (!Number.isInteger(chapter) || chapter < 1)
18
+ continue;
19
+ chapters.push(chapter);
20
+ }
21
+ return chapters;
22
+ }
23
+ export function matchesQuickstartMiniPlanningSeedSequence(chapters) {
24
+ const expectedChapters = quickstartMiniPlanningChapters();
25
+ return chapters.length === expectedChapters.length && chapters.every((chapter, index) => chapter === expectedChapters[index]);
26
+ }
27
+ export function startsWithQuickstartMiniPlanningSeedSequence(chapters) {
28
+ const expectedChapters = quickstartMiniPlanningChapters();
29
+ return chapters.length >= expectedChapters.length && expectedChapters.every((chapter, index) => chapters[index] === chapter);
30
+ }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { NovelCliError } from "./errors.js";
3
3
  import { pathExists, readJsonFile, writeJsonFile } from "./fs-utils.js";
4
+ import { CANONICAL_PLATFORM_IDS, canonicalPlatformId } from "./platform-profile.js";
4
5
  import { isPlainObject } from "./type-guards.js";
5
6
  const CORE_DIMENSIONS = [
6
7
  "plot_logic",
@@ -13,7 +14,11 @@ const CORE_DIMENSIONS = [
13
14
  "storyline_coherence",
14
15
  ];
15
16
  const OPTIONAL_DIMENSIONS = ["hook_strength"];
16
- const KNOWN_DIMENSIONS = new Set([...CORE_DIMENSIONS, ...OPTIONAL_DIMENSIONS]);
17
+ export const KNOWN_SCORING_DIMENSIONS = [...CORE_DIMENSIONS, ...OPTIONAL_DIMENSIONS];
18
+ const KNOWN_DIMENSIONS = new Set(KNOWN_SCORING_DIMENSIONS);
19
+ export function isKnownScoringDimension(value) {
20
+ return KNOWN_DIMENSIONS.has(value);
21
+ }
17
22
  function requireIntField(obj, field, file) {
18
23
  const v = obj[field];
19
24
  if (typeof v !== "number" || !Number.isInteger(v))
@@ -90,6 +95,7 @@ export function parseGenreWeightProfiles(raw, file) {
90
95
  throw new NovelCliError(`Invalid ${file}: 'profiles' must be an object.`, 2);
91
96
  const profilesRaw = obj.profiles;
92
97
  const profiles = {};
98
+ const canonicalPlatforms = new Set(CANONICAL_PLATFORM_IDS);
93
99
  const allowedDims = new Set(dimensions);
94
100
  for (const [profileId, profileRaw] of Object.entries(profilesRaw)) {
95
101
  if (!isPlainObject(profileRaw))
@@ -129,6 +135,31 @@ export function parseGenreWeightProfiles(raw, file) {
129
135
  default_profile_by_drive_type,
130
136
  profiles
131
137
  };
138
+ if (obj.platform_multipliers !== undefined) {
139
+ if (!isPlainObject(obj.platform_multipliers)) {
140
+ throw new NovelCliError(`Invalid ${file}: 'platform_multipliers' must be an object.`, 2);
141
+ }
142
+ const multipliersRaw = obj.platform_multipliers;
143
+ const platform_multipliers = {};
144
+ for (const [platformId, platformRaw] of Object.entries(multipliersRaw)) {
145
+ if (!canonicalPlatforms.has(platformId)) {
146
+ throw new NovelCliError(`Invalid ${file}: unknown platform_multipliers key '${platformId}' (allowed canonical ids: ${CANONICAL_PLATFORM_IDS.join(", ")}).`, 2);
147
+ }
148
+ if (!isPlainObject(platformRaw)) {
149
+ throw new NovelCliError(`Invalid ${file}: 'platform_multipliers.${platformId}' must be an object.`, 2);
150
+ }
151
+ const weightsRaw = platformRaw;
152
+ const parsedWeights = {};
153
+ for (const [dim, value] of Object.entries(weightsRaw)) {
154
+ if (!allowedDims.has(dim)) {
155
+ throw new NovelCliError(`Invalid ${file}: unknown dimension '${dim}' in platform_multipliers.${platformId} (allowed: ${dimensions.join(", ")}).`, 2);
156
+ }
157
+ parsedWeights[dim] = requireFiniteNonNegativeNumber(value, file, `platform_multipliers.${platformId}.${dim}`);
158
+ }
159
+ platform_multipliers[platformId] = parsedWeights;
160
+ }
161
+ out.platform_multipliers = platform_multipliers;
162
+ }
132
163
  if (typeof obj.description === "string" && obj.description.trim().length > 0)
133
164
  out.description = obj.description.trim();
134
165
  if (typeof obj.last_updated === "string" && obj.last_updated.trim().length > 0)
@@ -188,6 +219,7 @@ export function computeEffectiveScoringWeights(args) {
188
219
  const dims = args.hookPolicy?.required ? configDims : configDims.filter((d) => d !== "hook_strength");
189
220
  const overridesRaw = args.scoring.weight_overrides ?? null;
190
221
  const overrides = overridesRaw ? { ...overridesRaw } : null;
222
+ const platformMultipliers = args.platformId !== undefined ? args.config.platform_multipliers?.[canonicalPlatformId(args.platformId)] ?? null : null;
191
223
  const allowedDims = new Set(configDims);
192
224
  const effectiveDims = new Set(dims);
193
225
  if (overrides) {
@@ -207,7 +239,9 @@ export function computeEffectiveScoringWeights(args) {
207
239
  }
208
240
  const rawWeights = {};
209
241
  for (const dim of dims) {
210
- rawWeights[dim] = typeof overrides?.[dim] === "number" ? overrides[dim] : profile.weights[dim];
242
+ const baseWeight = typeof overrides?.[dim] === "number" ? overrides[dim] : profile.weights[dim];
243
+ const multiplier = platformMultipliers?.[dim] ?? 1.0;
244
+ rawWeights[dim] = baseWeight * multiplier;
211
245
  }
212
246
  const normalization = normalizeWeights({
213
247
  dimensions: dims,
@@ -247,7 +281,8 @@ export async function attachScoringWeightsToEval(args) {
247
281
  const effective = computeEffectiveScoringWeights({
248
282
  config: args.genreWeightProfiles.config,
249
283
  scoring,
250
- hookPolicy: args.platformProfile.hook_policy
284
+ hookPolicy: args.platformProfile.hook_policy,
285
+ platformId: args.platformProfile.platform
251
286
  });
252
287
  const raw = await readJsonFile(args.evalAbsPath);
253
288
  if (!isPlainObject(raw))
package/dist/steps.js CHANGED
@@ -10,7 +10,7 @@ export const ORCHESTRATOR_STATES = [
10
10
  ];
11
11
  export const CHAPTER_STAGES = ["draft", "summarize", "refine", "judge", "title-fix", "hook-fix", "review", "commit"];
12
12
  export const VOLUME_PHASES = ["outline", "validate", "commit"];
13
- export const QUICKSTART_PHASES = ["world", "characters", "style", "trial", "results"];
13
+ export const QUICKSTART_PHASES = ["world", "characters", "style", "f0", "trial", "results"];
14
14
  export const REVIEW_PHASES = ["collect", "audit", "report", "cleanup", "transition"];
15
15
  export function pad3(n) {
16
16
  return String(n).padStart(3, "0");