novel-writer-cli 0.0.3 → 0.2.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 (48) hide show
  1. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  2. package/dist/__tests__/character-voice.test.js +1 -1
  3. package/dist/__tests__/checkpoint-quickstart-phase.test.js +49 -0
  4. package/dist/__tests__/cli-instructions-novel-ask-gate.test.js +83 -0
  5. package/dist/__tests__/cli-repair-reset-quickstart.test.js +194 -0
  6. package/dist/__tests__/gate-decision.test.js +66 -0
  7. package/dist/__tests__/init.test.js +14 -6
  8. package/dist/__tests__/instructions-review-novel-ask-gate.test.js +31 -0
  9. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  10. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  11. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  12. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  13. package/dist/__tests__/orchestrator-state-routing.test.js +172 -0
  14. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  15. package/dist/__tests__/quickstart-pipeline.test.js +346 -0
  16. package/dist/__tests__/safe-path-symlink.test.js +41 -0
  17. package/dist/__tests__/steps-id.test.js +23 -0
  18. package/dist/__tests__/validate-quickstart-prereqs.test.js +73 -0
  19. package/dist/__tests__/volume-pipeline.test.js +227 -0
  20. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  21. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  22. package/dist/advance.js +234 -52
  23. package/dist/checkpoint.js +93 -13
  24. package/dist/cli.js +318 -11
  25. package/dist/commit.js +1 -0
  26. package/dist/fs-utils.js +18 -3
  27. package/dist/gate-decision.js +59 -0
  28. package/dist/init.js +4 -1
  29. package/dist/instructions.js +483 -24
  30. package/dist/next-step.js +421 -34
  31. package/dist/platform-profile.js +3 -0
  32. package/dist/quickstart-validators.js +84 -0
  33. package/dist/quickstart.js +16 -0
  34. package/dist/safe-path.js +23 -1
  35. package/dist/steps.js +60 -17
  36. package/dist/validate.js +347 -3
  37. package/dist/volume-commit.js +101 -0
  38. package/dist/volume-planning.js +143 -0
  39. package/dist/volume-review.js +448 -0
  40. package/docs/user/README.md +0 -1
  41. package/docs/user/novel-cli.md +29 -0
  42. package/package.json +3 -2
  43. package/schemas/platform-profile.schema.json +5 -0
  44. package/scripts/sync-final-spec-skills.mjs +65 -0
  45. package/skills/cli-step/SKILL.md +186 -32
  46. package/skills/continue/SKILL.md +30 -326
  47. package/skills/shared/thin-adapter-loop.md +67 -0
  48. package/skills/start/SKILL.md +23 -440
package/dist/next-step.js CHANGED
@@ -1,12 +1,21 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import { join } from "node:path";
3
+ import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
4
+ import { NovelCliError } from "./errors.js";
2
5
  import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
6
+ import { computeGateDecision, detectHighConfidenceViolation } from "./gate-decision.js";
3
7
  import { checkHookPolicy } from "./hook-policy.js";
4
8
  import { loadPlatformProfile } from "./platform-profile.js";
9
+ import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
10
+ import { validateQuickstartRulesSchema, validateQuickstartStyleProfileSchema } from "./quickstart-validators.js";
11
+ import { computeReviewNext } from "./volume-review.js";
5
12
  import { computePrejudgeGuardrailsReport, loadPrejudgeGuardrailsReportIfFresh, prejudgeGuardrailsRelPath } from "./prejudge-guardrails.js";
6
13
  import { summarizeNamingIssues } from "./naming-lint.js";
7
14
  import { summarizeReadabilityIssues } from "./readability-lint.js";
8
15
  import { computeTitlePolicyReport } from "./title-policy.js";
9
- import { chapterRelPaths, formatStepId } from "./steps.js";
16
+ import { QUICKSTART_PHASES, chapterRelPaths, formatStepId } from "./steps.js";
17
+ import { isPlainObject } from "./type-guards.js";
18
+ import { computeVolumeNextStep } from "./volume-planning.js";
10
19
  function normalizeStage(stage) {
11
20
  if (stage === null || stage === undefined)
12
21
  return null;
@@ -15,8 +24,7 @@ function normalizeStage(stage) {
15
24
  return null;
16
25
  }
17
26
  async function checkHookPolicyForStage(args) {
18
- const loadedProfile = await loadPlatformProfile(args.projectRootDir);
19
- const hookPolicy = loadedProfile?.profile.hook_policy;
27
+ const hookPolicy = args.loadedProfile?.profile.hook_policy;
20
28
  if (!hookPolicy?.required)
21
29
  return null;
22
30
  let evalRaw;
@@ -60,10 +68,9 @@ async function checkHookPolicyForStage(args) {
60
68
  return null;
61
69
  }
62
70
  async function checkTitlePolicyForStage(args) {
63
- const loadedProfile = await loadPlatformProfile(args.projectRootDir);
64
- if (!loadedProfile)
71
+ if (!args.loadedProfile)
65
72
  return null;
66
- const titlePolicy = loadedProfile.profile.retention?.title_policy;
73
+ const titlePolicy = args.loadedProfile.profile.retention?.title_policy;
67
74
  if (!titlePolicy?.enabled)
68
75
  return null;
69
76
  if (!args.hasChapter) {
@@ -87,7 +94,7 @@ async function checkTitlePolicyForStage(args) {
87
94
  evidence: { ...args.evidence, titleFixCount: args.titleFixCount, error: message }
88
95
  };
89
96
  }
90
- const report = computeTitlePolicyReport({ chapter: args.inflightChapter, chapterText, platformProfile: loadedProfile.profile });
97
+ const report = computeTitlePolicyReport({ chapter: args.inflightChapter, chapterText, platformProfile: args.loadedProfile.profile });
91
98
  if (report.status === "pass" || report.status === "skipped")
92
99
  return null;
93
100
  if (!report.has_hard_violations && !titlePolicy.auto_fix)
@@ -118,8 +125,7 @@ async function checkTitlePolicyForStage(args) {
118
125
  };
119
126
  }
120
127
  async function checkPrejudgeGuardrailsForStage(args) {
121
- const loadedProfile = await loadPlatformProfile(args.projectRootDir);
122
- if (!loadedProfile)
128
+ if (!args.loadedProfile)
123
129
  return null;
124
130
  const chapterAbsPath = join(args.projectRootDir, args.chapterRelPath);
125
131
  const cacheRelPath = prejudgeGuardrailsRelPath(args.inflightChapter);
@@ -128,8 +134,8 @@ async function checkPrejudgeGuardrailsForStage(args) {
128
134
  rootDir: args.projectRootDir,
129
135
  chapter: args.inflightChapter,
130
136
  chapterAbsPath,
131
- platformProfileRelPath: loadedProfile.relPath,
132
- platformProfile: loadedProfile.profile
137
+ platformProfileRelPath: args.loadedProfile.relPath,
138
+ platformProfile: args.loadedProfile.profile
133
139
  });
134
140
  if (report)
135
141
  cacheStatus = "hit";
@@ -139,8 +145,8 @@ async function checkPrejudgeGuardrailsForStage(args) {
139
145
  rootDir: args.projectRootDir,
140
146
  chapter: args.inflightChapter,
141
147
  chapterAbsPath,
142
- platformProfileRelPath: loadedProfile.relPath,
143
- platformProfile: loadedProfile.profile
148
+ platformProfileRelPath: args.loadedProfile.relPath,
149
+ platformProfile: args.loadedProfile.profile
144
150
  });
145
151
  }
146
152
  catch (err) {
@@ -193,13 +199,33 @@ async function checkPrejudgeGuardrailsForStage(args) {
193
199
  }
194
200
  };
195
201
  }
196
- export async function computeNextStep(projectRootDir, checkpoint) {
202
+ async function computeChapterNextStep(projectRootDir, checkpoint) {
197
203
  const inflightChapter = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
198
204
  const stage = normalizeStage(checkpoint.pipeline_stage);
199
205
  const hookFixCount = typeof checkpoint.hook_fix_count === "number" ? checkpoint.hook_fix_count : 0;
200
206
  const titleFixCount = typeof checkpoint.title_fix_count === "number" ? checkpoint.title_fix_count : 0;
201
- // Fresh start.
202
- if (inflightChapter === null || stage === null || stage === "committed") {
207
+ if (inflightChapter !== null && inflightChapter < 1) {
208
+ throw new NovelCliError(".checkpoint.json.inflight_chapter must be an int >= 1 (or null).", 2);
209
+ }
210
+ if (stage === null || stage === "committed") {
211
+ if (inflightChapter !== null) {
212
+ throw new NovelCliError(`Checkpoint inconsistent: pipeline_stage=${stage ?? "null"} but inflight_chapter=${inflightChapter}. Set inflight_chapter to null.`, 2);
213
+ }
214
+ // Volume-end: enter deterministic volume review pipeline (issue #144).
215
+ if (checkpoint.last_completed_chapter > 0) {
216
+ let range = null;
217
+ try {
218
+ range = await tryResolveVolumeChapterRange({ rootDir: projectRootDir, volume: checkpoint.current_volume });
219
+ }
220
+ catch {
221
+ // Best-effort: if we can't resolve range, fall back to chapter pipeline.
222
+ range = null;
223
+ }
224
+ if (range && checkpoint.last_completed_chapter === range.end) {
225
+ const next = await computeReviewNext(projectRootDir, checkpoint);
226
+ return { ...next, reason: `volume_end:${next.reason}` };
227
+ }
228
+ }
203
229
  const nextChapter = checkpoint.last_completed_chapter + 1;
204
230
  return {
205
231
  step: formatStepId({ kind: "chapter", chapter: nextChapter, stage: "draft" }),
@@ -207,6 +233,9 @@ export async function computeNextStep(projectRootDir, checkpoint) {
207
233
  inflight: { chapter: null, pipeline_stage: stage }
208
234
  };
209
235
  }
236
+ if (inflightChapter === null) {
237
+ throw new NovelCliError(`Checkpoint inconsistent: pipeline_stage=${stage} requires inflight_chapter. Repair .checkpoint.json and rerun.`, 2);
238
+ }
210
239
  const rel = chapterRelPaths(inflightChapter);
211
240
  const hasChapter = await pathExists(join(projectRootDir, rel.staging.chapterMd));
212
241
  const hasSummary = await pathExists(join(projectRootDir, rel.staging.summaryMd));
@@ -281,6 +310,7 @@ export async function computeNextStep(projectRootDir, checkpoint) {
281
310
  evidence
282
311
  };
283
312
  }
313
+ const loadedProfile = await loadPlatformProfile(projectRootDir);
284
314
  if (!hasEval) {
285
315
  const titleGate = await checkTitlePolicyForStage({
286
316
  projectRootDir,
@@ -290,7 +320,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
290
320
  evidence,
291
321
  titleFixCount,
292
322
  hasChapter,
293
- chapterRelPath: rel.staging.chapterMd
323
+ chapterRelPath: rel.staging.chapterMd,
324
+ loadedProfile
294
325
  });
295
326
  if (titleGate)
296
327
  return titleGate;
@@ -309,7 +340,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
309
340
  evidence,
310
341
  titleFixCount,
311
342
  hasChapter,
312
- chapterRelPath: rel.staging.chapterMd
343
+ chapterRelPath: rel.staging.chapterMd,
344
+ loadedProfile
313
345
  });
314
346
  if (titleGate)
315
347
  return titleGate;
@@ -320,7 +352,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
320
352
  pipelineStage: stage,
321
353
  evidence,
322
354
  hookFixCount,
323
- evalRelPath: rel.staging.evalJson
355
+ evalRelPath: rel.staging.evalJson,
356
+ loadedProfile
324
357
  });
325
358
  if (hookGate)
326
359
  return hookGate;
@@ -330,7 +363,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
330
363
  inflightChapter,
331
364
  pipelineStage: stage,
332
365
  evidence,
333
- chapterRelPath: rel.staging.chapterMd
366
+ chapterRelPath: rel.staging.chapterMd,
367
+ loadedProfile
334
368
  });
335
369
  if (guardrailsGate)
336
370
  return guardrailsGate;
@@ -358,6 +392,7 @@ export async function computeNextStep(projectRootDir, checkpoint) {
358
392
  evidence
359
393
  };
360
394
  }
395
+ const loadedProfile = await loadPlatformProfile(projectRootDir);
361
396
  const titleGate = await checkTitlePolicyForStage({
362
397
  projectRootDir,
363
398
  stagePrefix: "judged",
@@ -366,7 +401,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
366
401
  evidence,
367
402
  titleFixCount,
368
403
  hasChapter,
369
- chapterRelPath: rel.staging.chapterMd
404
+ chapterRelPath: rel.staging.chapterMd,
405
+ loadedProfile
370
406
  });
371
407
  if (titleGate)
372
408
  return titleGate;
@@ -377,7 +413,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
377
413
  pipelineStage: stage,
378
414
  evidence,
379
415
  hookFixCount,
380
- evalRelPath: rel.staging.evalJson
416
+ evalRelPath: rel.staging.evalJson,
417
+ loadedProfile
381
418
  });
382
419
  if (hookGate)
383
420
  return hookGate;
@@ -387,22 +424,372 @@ export async function computeNextStep(projectRootDir, checkpoint) {
387
424
  inflightChapter,
388
425
  pipelineStage: stage,
389
426
  evidence,
390
- chapterRelPath: rel.staging.chapterMd
427
+ chapterRelPath: rel.staging.chapterMd,
428
+ loadedProfile
391
429
  });
392
430
  if (guardrailsGate)
393
431
  return guardrailsGate;
394
- return {
395
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
396
- reason: "judged:ready_commit",
397
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
398
- evidence
432
+ // Gate decision: deterministic mapping from QualityJudge outputs → next action.
433
+ let evalRaw;
434
+ try {
435
+ evalRaw = await readJsonFile(join(projectRootDir, rel.staging.evalJson));
436
+ }
437
+ catch (err) {
438
+ const message = err instanceof Error ? err.message : String(err);
439
+ return {
440
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
441
+ reason: `judged:eval_read_failed`,
442
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
443
+ evidence: { ...evidence, error: message }
444
+ };
445
+ }
446
+ if (!isPlainObject(evalRaw)) {
447
+ return {
448
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
449
+ reason: `judged:eval_invalid`,
450
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
451
+ evidence: { ...evidence }
452
+ };
453
+ }
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 })
478
+ });
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
+ }
399
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);
400
535
  }
401
- // Unknown stage: fall back to safest.
402
- return {
403
- step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "draft" }),
404
- reason: `unknown_stage:${stage}`,
405
- inflight: { chapter: inflightChapter, pipeline_stage: stage },
406
- evidence
536
+ // Unknown stage: upstream parseCheckpoint validates enum so this should be unreachable.
537
+ throw new NovelCliError(`Checkpoint has unexpected pipeline_stage=${stage}. This should not happen; repair .checkpoint.json and rerun.`, 2);
538
+ }
539
+ function notImplementedState(state) {
540
+ throw new NovelCliError(`Not implemented: orchestrator_state=${state}`, 2);
541
+ }
542
+ async function countContractArtifacts(rootDir) {
543
+ const absDir = join(rootDir, QUICKSTART_STAGING_RELS.contractsDir);
544
+ const hasDir = await pathExists(absDir);
545
+ if (!hasDir)
546
+ return { hasDir, fileCount: 0, sample: [], degraded: false };
547
+ try {
548
+ const entries = await readdir(absDir, { withFileTypes: true });
549
+ const files = entries.filter((e) => e.isFile()).map((e) => e.name);
550
+ const sample = files
551
+ .filter((n) => n.endsWith(".json"))
552
+ .sort()
553
+ .slice(0, 3);
554
+ const fileCount = files.filter((n) => n.endsWith(".json")).length;
555
+ return { hasDir, fileCount, sample, degraded: false };
556
+ }
557
+ catch (err) {
558
+ const message = err instanceof Error ? err.message : String(err);
559
+ // If the dir exists but is unreadable, treat as present but degraded.
560
+ return { hasDir, fileCount: 0, sample: [], degraded: true, error: message };
561
+ }
562
+ }
563
+ async function computeQuickStartNextStep(projectRootDir, checkpoint) {
564
+ const stage = normalizeStage(checkpoint.pipeline_stage);
565
+ const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
566
+ if ((stage === null || stage === "committed") && inflight !== null) {
567
+ throw new NovelCliError(`Checkpoint inconsistent for QUICK_START: pipeline_stage=${stage ?? "null"} but inflight_chapter=${inflight}. Set inflight_chapter to null.`, 2);
568
+ }
569
+ if (stage !== null && stage !== "committed") {
570
+ throw new NovelCliError(`Checkpoint inconsistent for QUICK_START: pipeline_stage=${stage} (expected null or committed). Finish the chapter pipeline or repair .checkpoint.json.`, 2);
571
+ }
572
+ const rulesAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.rulesJson);
573
+ const styleAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.styleProfileJson);
574
+ const trialAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.trialChapterMd);
575
+ const evalAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.evaluationJson);
576
+ const rulesExists = await pathExists(rulesAbs);
577
+ const contracts = await countContractArtifacts(projectRootDir);
578
+ const styleExists = await pathExists(styleAbs);
579
+ const trialExists = await pathExists(trialAbs);
580
+ const evalExists = await pathExists(evalAbs);
581
+ let rulesOk = false;
582
+ let rulesError = null;
583
+ if (rulesExists) {
584
+ try {
585
+ await validateQuickstartRulesSchema(rulesAbs, { trimRequiredStrings: true });
586
+ rulesOk = true;
587
+ }
588
+ catch (err) {
589
+ rulesError = err instanceof Error ? err.message : String(err);
590
+ rulesOk = false;
591
+ }
592
+ }
593
+ let styleOk = false;
594
+ let styleError = null;
595
+ if (styleExists) {
596
+ try {
597
+ await validateQuickstartStyleProfileSchema(styleAbs);
598
+ styleOk = true;
599
+ }
600
+ catch (err) {
601
+ styleError = err instanceof Error ? err.message : String(err);
602
+ styleOk = false;
603
+ }
604
+ }
605
+ let trialOk = false;
606
+ let trialError = null;
607
+ if (trialExists) {
608
+ try {
609
+ const text = await readTextFile(trialAbs);
610
+ if (text.trim().length === 0)
611
+ throw new Error("empty trial chapter");
612
+ trialOk = true;
613
+ }
614
+ catch (err) {
615
+ trialError = err instanceof Error ? err.message : String(err);
616
+ trialOk = false;
617
+ }
618
+ }
619
+ let evalOk = false;
620
+ let evalError = null;
621
+ if (evalExists) {
622
+ try {
623
+ const raw = await readJsonFile(evalAbs);
624
+ if (!isPlainObject(raw))
625
+ throw new Error("expected JSON object");
626
+ evalOk = true;
627
+ }
628
+ catch (err) {
629
+ evalError = err instanceof Error ? err.message : String(err);
630
+ evalOk = false;
631
+ }
632
+ }
633
+ const evidence = {
634
+ checkpoint: {
635
+ quickstart_phase: (checkpoint.quickstart_phase ?? null)
636
+ },
637
+ staging: {
638
+ rulesExists,
639
+ rulesOk,
640
+ ...(rulesError ? { rulesError } : {}),
641
+ contracts: {
642
+ hasDir: contracts.hasDir,
643
+ jsonFileCount: contracts.fileCount,
644
+ sample: contracts.sample,
645
+ degraded: contracts.degraded,
646
+ ...(contracts.error ? { error: contracts.error } : {})
647
+ },
648
+ styleExists,
649
+ styleOk,
650
+ ...(styleError ? { styleError } : {}),
651
+ trialExists,
652
+ trialOk,
653
+ ...(trialError ? { trialError } : {}),
654
+ evalExists,
655
+ evalOk,
656
+ ...(evalError ? { evalError } : {})
657
+ }
407
658
  };
659
+ let selected;
660
+ let selectedPhase;
661
+ if (!rulesOk) {
662
+ selectedPhase = "world";
663
+ selected = {
664
+ step: formatStepId({ kind: "quickstart", phase: "world" }),
665
+ reason: "quickstart:world",
666
+ inflight: { chapter: null, pipeline_stage: null },
667
+ evidence
668
+ };
669
+ }
670
+ else if (!contracts.hasDir || contracts.fileCount === 0) {
671
+ selectedPhase = "characters";
672
+ selected = {
673
+ step: formatStepId({ kind: "quickstart", phase: "characters" }),
674
+ reason: "quickstart:characters",
675
+ inflight: { chapter: null, pipeline_stage: null },
676
+ evidence
677
+ };
678
+ }
679
+ else if (!styleOk) {
680
+ selectedPhase = "style";
681
+ selected = {
682
+ step: formatStepId({ kind: "quickstart", phase: "style" }),
683
+ reason: "quickstart:style",
684
+ inflight: { chapter: null, pipeline_stage: null },
685
+ evidence
686
+ };
687
+ }
688
+ else if (!trialOk) {
689
+ selectedPhase = "trial";
690
+ selected = {
691
+ step: formatStepId({ kind: "quickstart", phase: "trial" }),
692
+ reason: "quickstart:trial",
693
+ inflight: { chapter: null, pipeline_stage: null },
694
+ evidence
695
+ };
696
+ }
697
+ else if (!evalOk) {
698
+ selectedPhase = "results";
699
+ selected = {
700
+ step: formatStepId({ kind: "quickstart", phase: "results" }),
701
+ reason: "quickstart:results",
702
+ inflight: { chapter: null, pipeline_stage: null },
703
+ evidence
704
+ };
705
+ }
706
+ else {
707
+ selectedPhase = "results";
708
+ selected = {
709
+ step: formatStepId({ kind: "quickstart", phase: "results" }),
710
+ reason: "quickstart:results:artifacts_present",
711
+ inflight: { chapter: null, pipeline_stage: null },
712
+ evidence
713
+ };
714
+ }
715
+ const selectedPhaseIdx = QUICKSTART_PHASES.indexOf(selectedPhase);
716
+ if (selectedPhaseIdx < 0) {
717
+ throw new NovelCliError(`Internal error: invalid quickstart phase=${selectedPhase}`, 2);
718
+ }
719
+ const checkpointPhase = checkpoint.quickstart_phase ?? null;
720
+ if (checkpointPhase) {
721
+ const checkpointIdx = QUICKSTART_PHASES.indexOf(checkpointPhase);
722
+ if (checkpointIdx >= 0 && selectedPhaseIdx < checkpointIdx) {
723
+ const expectedPath = (() => {
724
+ switch (selectedPhase) {
725
+ case "world":
726
+ return QUICKSTART_STAGING_RELS.rulesJson;
727
+ case "characters":
728
+ return QUICKSTART_STAGING_RELS.contractsDir;
729
+ case "style":
730
+ return QUICKSTART_STAGING_RELS.styleProfileJson;
731
+ case "trial":
732
+ return QUICKSTART_STAGING_RELS.trialChapterMd;
733
+ case "results":
734
+ return QUICKSTART_STAGING_RELS.evaluationJson;
735
+ default: {
736
+ const _exhaustive = selectedPhase;
737
+ return String(_exhaustive);
738
+ }
739
+ }
740
+ })();
741
+ return {
742
+ ...selected,
743
+ reason: `quickstart:recovery_blocked:${checkpointPhase}`,
744
+ evidence: {
745
+ ...evidence,
746
+ recovery_blocked: {
747
+ checkpoint_phase: checkpointPhase,
748
+ checkpoint_phase_idx: checkpointIdx,
749
+ inferred_phase: selectedPhase,
750
+ inferred_phase_idx: selectedPhaseIdx,
751
+ inferred_reason: selected.reason,
752
+ expected_path: expectedPath
753
+ }
754
+ }
755
+ };
756
+ }
757
+ }
758
+ return selected;
759
+ }
760
+ export async function computeNextStep(projectRootDir, checkpoint) {
761
+ switch (checkpoint.orchestrator_state) {
762
+ case "WRITING":
763
+ case "CHAPTER_REWRITE":
764
+ return await computeChapterNextStep(projectRootDir, checkpoint);
765
+ case "ERROR_RETRY": {
766
+ const stage = normalizeStage(checkpoint.pipeline_stage);
767
+ const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
768
+ let normalizedCheckpoint = checkpoint;
769
+ let healPrefix = "";
770
+ // Only auto-heal invariants when explicitly in ERROR_RETRY.
771
+ if ((stage === null || stage === "committed") && inflight !== null) {
772
+ normalizedCheckpoint = { ...checkpoint, inflight_chapter: null };
773
+ healPrefix = "healed_drop_inflight:";
774
+ }
775
+ else if (stage !== null && stage !== "committed" && inflight === null) {
776
+ normalizedCheckpoint = { ...checkpoint, inflight_chapter: checkpoint.last_completed_chapter + 1 };
777
+ healPrefix = "healed_infer_inflight:";
778
+ }
779
+ const next = await computeChapterNextStep(projectRootDir, normalizedCheckpoint);
780
+ return { ...next, reason: `error_retry:${healPrefix}${next.reason}` };
781
+ }
782
+ case "INIT": {
783
+ const next = await computeQuickStartNextStep(projectRootDir, checkpoint);
784
+ return { ...next, reason: `init:${next.reason}` };
785
+ }
786
+ case "QUICK_START":
787
+ return await computeQuickStartNextStep(projectRootDir, checkpoint);
788
+ case "VOL_PLANNING":
789
+ return await computeVolumeNextStep(projectRootDir, checkpoint);
790
+ case "VOL_REVIEW":
791
+ return await computeReviewNext(projectRootDir, checkpoint);
792
+ default:
793
+ return notImplementedState(checkpoint.orchestrator_state);
794
+ }
408
795
  }
@@ -122,6 +122,9 @@ function parseScoringPolicy(raw, file) {
122
122
  genre_drive_type: requireStringField(obj, "genre_drive_type", file),
123
123
  weight_profile_id: requireStringField(obj, "weight_profile_id", file)
124
124
  };
125
+ if (obj.max_revisions !== undefined) {
126
+ out.max_revisions = requireNonNegativeIntValue(obj.max_revisions, file, "scoring.max_revisions");
127
+ }
125
128
  if (obj.weight_overrides !== undefined) {
126
129
  if (!isPlainObject(obj.weight_overrides))
127
130
  throw new NovelCliError(`Invalid ${file}: 'scoring.weight_overrides' must be an object.`, 2);
@@ -0,0 +1,84 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { NovelCliError } from "./errors.js";
4
+ import { readJsonFile, readTextFile } from "./fs-utils.js";
5
+ import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
6
+ import { isPlainObject } from "./type-guards.js";
7
+ function requireStringField(obj, field, file, opts) {
8
+ const v = obj[field];
9
+ if (typeof v !== "string" || (opts?.trim ? v.trim().length === 0 : v.length === 0)) {
10
+ throw new NovelCliError(`Invalid ${file}: missing string field '${field}'.`, 2);
11
+ }
12
+ return v;
13
+ }
14
+ export async function validateQuickstartRulesSchema(absPath, options) {
15
+ const raw = await readJsonFile(absPath);
16
+ if (!isPlainObject(raw))
17
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: expected JSON object.`, 2);
18
+ const obj = raw;
19
+ const rules = obj.rules;
20
+ if (!Array.isArray(rules))
21
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: missing 'rules' array.`, 2);
22
+ const trimRequiredStrings = options?.trimRequiredStrings === true;
23
+ for (const [idx, rule] of rules.entries()) {
24
+ if (!isPlainObject(rule))
25
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: rules[${idx}] must be an object.`, 2);
26
+ const r = rule;
27
+ requireStringField(r, "id", QUICKSTART_STAGING_RELS.rulesJson, { trim: trimRequiredStrings });
28
+ requireStringField(r, "category", QUICKSTART_STAGING_RELS.rulesJson, { trim: trimRequiredStrings });
29
+ requireStringField(r, "rule", QUICKSTART_STAGING_RELS.rulesJson, { trim: trimRequiredStrings });
30
+ const ct = requireStringField(r, "constraint_type", QUICKSTART_STAGING_RELS.rulesJson, { trim: false });
31
+ if (ct !== "hard" && ct !== "soft") {
32
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: rules[${idx}].constraint_type must be hard|soft.`, 2);
33
+ }
34
+ if (!Array.isArray(r.exceptions)) {
35
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: rules[${idx}].exceptions must be an array.`, 2);
36
+ }
37
+ }
38
+ return rules.length;
39
+ }
40
+ export async function listQuickstartContractJsonFiles(absContractsDir) {
41
+ const entries = await readdir(absContractsDir, { withFileTypes: true });
42
+ const jsonFiles = entries
43
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
44
+ .map((e) => e.name)
45
+ .sort();
46
+ if (jsonFiles.length === 0) {
47
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.contractsDir}: expected at least 1 *.json contract file.`, 2);
48
+ }
49
+ return jsonFiles;
50
+ }
51
+ export async function validateQuickstartContractJsonFiles(absContractsDir, jsonFiles) {
52
+ for (const file of jsonFiles) {
53
+ const raw = await readJsonFile(join(absContractsDir, file));
54
+ if (!isPlainObject(raw)) {
55
+ throw new NovelCliError(`Invalid contract JSON: ${QUICKSTART_STAGING_RELS.contractsDir}/${file} must be an object.`, 2);
56
+ }
57
+ }
58
+ }
59
+ export async function validateQuickstartContractsDir(absContractsDir) {
60
+ const jsonFiles = await listQuickstartContractJsonFiles(absContractsDir);
61
+ await validateQuickstartContractJsonFiles(absContractsDir, jsonFiles);
62
+ }
63
+ export async function validateQuickstartStyleProfileSchema(absPath) {
64
+ const raw = await readJsonFile(absPath);
65
+ if (!isPlainObject(raw))
66
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.styleProfileJson}: expected JSON object.`, 2);
67
+ const obj = raw;
68
+ const sourceType = obj.source_type;
69
+ if (typeof sourceType !== "string" || sourceType.trim().length === 0) {
70
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.styleProfileJson}: source_type must be a non-empty string.`, 2);
71
+ }
72
+ if (sourceType !== "original" && sourceType !== "reference" && sourceType !== "template" && sourceType !== "write_then_extract") {
73
+ throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.styleProfileJson}: source_type must be one of: original, reference, template, write_then_extract.`, 2);
74
+ }
75
+ }
76
+ export async function validateQuickstartTrialChapter(absPath) {
77
+ const text = await readTextFile(absPath);
78
+ if (text.trim().length === 0)
79
+ throw new NovelCliError(`Empty draft file: ${QUICKSTART_STAGING_RELS.trialChapterMd}`, 2);
80
+ if (!text.trimStart().startsWith("#")) {
81
+ return `Trial chapter does not start with a Markdown H1 (# ...): ${QUICKSTART_STAGING_RELS.trialChapterMd}`;
82
+ }
83
+ return null;
84
+ }