novel-writer-cli 0.0.3 → 0.1.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 (32) 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__/gate-decision.test.js +66 -0
  4. package/dist/__tests__/init.test.js +7 -2
  5. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  6. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  7. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  8. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  9. package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
  10. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  11. package/dist/__tests__/steps-id.test.js +23 -0
  12. package/dist/__tests__/volume-pipeline.test.js +227 -0
  13. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  14. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  15. package/dist/advance.js +145 -48
  16. package/dist/checkpoint.js +71 -12
  17. package/dist/cli.js +202 -8
  18. package/dist/commit.js +1 -0
  19. package/dist/fs-utils.js +18 -3
  20. package/dist/gate-decision.js +59 -0
  21. package/dist/init.js +2 -0
  22. package/dist/instructions.js +322 -24
  23. package/dist/next-step.js +198 -34
  24. package/dist/platform-profile.js +3 -0
  25. package/dist/steps.js +60 -17
  26. package/dist/validate.js +275 -2
  27. package/dist/volume-commit.js +101 -0
  28. package/dist/volume-planning.js +143 -0
  29. package/dist/volume-review.js +448 -0
  30. package/docs/user/novel-cli.md +29 -0
  31. package/package.json +3 -2
  32. package/schemas/platform-profile.schema.json +5 -0
package/dist/next-step.js CHANGED
@@ -1,12 +1,18 @@
1
1
  import { join } from "node:path";
2
+ import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
3
+ import { NovelCliError } from "./errors.js";
2
4
  import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
5
+ import { computeGateDecision, detectHighConfidenceViolation } from "./gate-decision.js";
3
6
  import { checkHookPolicy } from "./hook-policy.js";
4
7
  import { loadPlatformProfile } from "./platform-profile.js";
8
+ import { computeReviewNext } from "./volume-review.js";
5
9
  import { computePrejudgeGuardrailsReport, loadPrejudgeGuardrailsReportIfFresh, prejudgeGuardrailsRelPath } from "./prejudge-guardrails.js";
6
10
  import { summarizeNamingIssues } from "./naming-lint.js";
7
11
  import { summarizeReadabilityIssues } from "./readability-lint.js";
8
12
  import { computeTitlePolicyReport } from "./title-policy.js";
9
13
  import { chapterRelPaths, formatStepId } from "./steps.js";
14
+ import { isPlainObject } from "./type-guards.js";
15
+ import { computeVolumeNextStep } from "./volume-planning.js";
10
16
  function normalizeStage(stage) {
11
17
  if (stage === null || stage === undefined)
12
18
  return null;
@@ -15,8 +21,7 @@ function normalizeStage(stage) {
15
21
  return null;
16
22
  }
17
23
  async function checkHookPolicyForStage(args) {
18
- const loadedProfile = await loadPlatformProfile(args.projectRootDir);
19
- const hookPolicy = loadedProfile?.profile.hook_policy;
24
+ const hookPolicy = args.loadedProfile?.profile.hook_policy;
20
25
  if (!hookPolicy?.required)
21
26
  return null;
22
27
  let evalRaw;
@@ -60,10 +65,9 @@ async function checkHookPolicyForStage(args) {
60
65
  return null;
61
66
  }
62
67
  async function checkTitlePolicyForStage(args) {
63
- const loadedProfile = await loadPlatformProfile(args.projectRootDir);
64
- if (!loadedProfile)
68
+ if (!args.loadedProfile)
65
69
  return null;
66
- const titlePolicy = loadedProfile.profile.retention?.title_policy;
70
+ const titlePolicy = args.loadedProfile.profile.retention?.title_policy;
67
71
  if (!titlePolicy?.enabled)
68
72
  return null;
69
73
  if (!args.hasChapter) {
@@ -87,7 +91,7 @@ async function checkTitlePolicyForStage(args) {
87
91
  evidence: { ...args.evidence, titleFixCount: args.titleFixCount, error: message }
88
92
  };
89
93
  }
90
- const report = computeTitlePolicyReport({ chapter: args.inflightChapter, chapterText, platformProfile: loadedProfile.profile });
94
+ const report = computeTitlePolicyReport({ chapter: args.inflightChapter, chapterText, platformProfile: args.loadedProfile.profile });
91
95
  if (report.status === "pass" || report.status === "skipped")
92
96
  return null;
93
97
  if (!report.has_hard_violations && !titlePolicy.auto_fix)
@@ -118,8 +122,7 @@ async function checkTitlePolicyForStage(args) {
118
122
  };
119
123
  }
120
124
  async function checkPrejudgeGuardrailsForStage(args) {
121
- const loadedProfile = await loadPlatformProfile(args.projectRootDir);
122
- if (!loadedProfile)
125
+ if (!args.loadedProfile)
123
126
  return null;
124
127
  const chapterAbsPath = join(args.projectRootDir, args.chapterRelPath);
125
128
  const cacheRelPath = prejudgeGuardrailsRelPath(args.inflightChapter);
@@ -128,8 +131,8 @@ async function checkPrejudgeGuardrailsForStage(args) {
128
131
  rootDir: args.projectRootDir,
129
132
  chapter: args.inflightChapter,
130
133
  chapterAbsPath,
131
- platformProfileRelPath: loadedProfile.relPath,
132
- platformProfile: loadedProfile.profile
134
+ platformProfileRelPath: args.loadedProfile.relPath,
135
+ platformProfile: args.loadedProfile.profile
133
136
  });
134
137
  if (report)
135
138
  cacheStatus = "hit";
@@ -139,8 +142,8 @@ async function checkPrejudgeGuardrailsForStage(args) {
139
142
  rootDir: args.projectRootDir,
140
143
  chapter: args.inflightChapter,
141
144
  chapterAbsPath,
142
- platformProfileRelPath: loadedProfile.relPath,
143
- platformProfile: loadedProfile.profile
145
+ platformProfileRelPath: args.loadedProfile.relPath,
146
+ platformProfile: args.loadedProfile.profile
144
147
  });
145
148
  }
146
149
  catch (err) {
@@ -193,13 +196,33 @@ async function checkPrejudgeGuardrailsForStage(args) {
193
196
  }
194
197
  };
195
198
  }
196
- export async function computeNextStep(projectRootDir, checkpoint) {
199
+ async function computeChapterNextStep(projectRootDir, checkpoint) {
197
200
  const inflightChapter = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
198
201
  const stage = normalizeStage(checkpoint.pipeline_stage);
199
202
  const hookFixCount = typeof checkpoint.hook_fix_count === "number" ? checkpoint.hook_fix_count : 0;
200
203
  const titleFixCount = typeof checkpoint.title_fix_count === "number" ? checkpoint.title_fix_count : 0;
201
- // Fresh start.
202
- if (inflightChapter === null || stage === null || stage === "committed") {
204
+ if (inflightChapter !== null && inflightChapter < 1) {
205
+ throw new NovelCliError(".checkpoint.json.inflight_chapter must be an int >= 1 (or null).", 2);
206
+ }
207
+ if (stage === null || stage === "committed") {
208
+ if (inflightChapter !== null) {
209
+ throw new NovelCliError(`Checkpoint inconsistent: pipeline_stage=${stage ?? "null"} but inflight_chapter=${inflightChapter}. Set inflight_chapter to null.`, 2);
210
+ }
211
+ // Volume-end: enter deterministic volume review pipeline (issue #144).
212
+ if (checkpoint.last_completed_chapter > 0) {
213
+ let range = null;
214
+ try {
215
+ range = await tryResolveVolumeChapterRange({ rootDir: projectRootDir, volume: checkpoint.current_volume });
216
+ }
217
+ catch {
218
+ // Best-effort: if we can't resolve range, fall back to chapter pipeline.
219
+ range = null;
220
+ }
221
+ if (range && checkpoint.last_completed_chapter === range.end) {
222
+ const next = await computeReviewNext(projectRootDir, checkpoint);
223
+ return { ...next, reason: `volume_end:${next.reason}` };
224
+ }
225
+ }
203
226
  const nextChapter = checkpoint.last_completed_chapter + 1;
204
227
  return {
205
228
  step: formatStepId({ kind: "chapter", chapter: nextChapter, stage: "draft" }),
@@ -207,6 +230,9 @@ export async function computeNextStep(projectRootDir, checkpoint) {
207
230
  inflight: { chapter: null, pipeline_stage: stage }
208
231
  };
209
232
  }
233
+ if (inflightChapter === null) {
234
+ throw new NovelCliError(`Checkpoint inconsistent: pipeline_stage=${stage} requires inflight_chapter. Repair .checkpoint.json and rerun.`, 2);
235
+ }
210
236
  const rel = chapterRelPaths(inflightChapter);
211
237
  const hasChapter = await pathExists(join(projectRootDir, rel.staging.chapterMd));
212
238
  const hasSummary = await pathExists(join(projectRootDir, rel.staging.summaryMd));
@@ -281,6 +307,7 @@ export async function computeNextStep(projectRootDir, checkpoint) {
281
307
  evidence
282
308
  };
283
309
  }
310
+ const loadedProfile = await loadPlatformProfile(projectRootDir);
284
311
  if (!hasEval) {
285
312
  const titleGate = await checkTitlePolicyForStage({
286
313
  projectRootDir,
@@ -290,7 +317,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
290
317
  evidence,
291
318
  titleFixCount,
292
319
  hasChapter,
293
- chapterRelPath: rel.staging.chapterMd
320
+ chapterRelPath: rel.staging.chapterMd,
321
+ loadedProfile
294
322
  });
295
323
  if (titleGate)
296
324
  return titleGate;
@@ -309,7 +337,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
309
337
  evidence,
310
338
  titleFixCount,
311
339
  hasChapter,
312
- chapterRelPath: rel.staging.chapterMd
340
+ chapterRelPath: rel.staging.chapterMd,
341
+ loadedProfile
313
342
  });
314
343
  if (titleGate)
315
344
  return titleGate;
@@ -320,7 +349,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
320
349
  pipelineStage: stage,
321
350
  evidence,
322
351
  hookFixCount,
323
- evalRelPath: rel.staging.evalJson
352
+ evalRelPath: rel.staging.evalJson,
353
+ loadedProfile
324
354
  });
325
355
  if (hookGate)
326
356
  return hookGate;
@@ -330,7 +360,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
330
360
  inflightChapter,
331
361
  pipelineStage: stage,
332
362
  evidence,
333
- chapterRelPath: rel.staging.chapterMd
363
+ chapterRelPath: rel.staging.chapterMd,
364
+ loadedProfile
334
365
  });
335
366
  if (guardrailsGate)
336
367
  return guardrailsGate;
@@ -358,6 +389,7 @@ export async function computeNextStep(projectRootDir, checkpoint) {
358
389
  evidence
359
390
  };
360
391
  }
392
+ const loadedProfile = await loadPlatformProfile(projectRootDir);
361
393
  const titleGate = await checkTitlePolicyForStage({
362
394
  projectRootDir,
363
395
  stagePrefix: "judged",
@@ -366,7 +398,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
366
398
  evidence,
367
399
  titleFixCount,
368
400
  hasChapter,
369
- chapterRelPath: rel.staging.chapterMd
401
+ chapterRelPath: rel.staging.chapterMd,
402
+ loadedProfile
370
403
  });
371
404
  if (titleGate)
372
405
  return titleGate;
@@ -377,7 +410,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
377
410
  pipelineStage: stage,
378
411
  evidence,
379
412
  hookFixCount,
380
- evalRelPath: rel.staging.evalJson
413
+ evalRelPath: rel.staging.evalJson,
414
+ loadedProfile
381
415
  });
382
416
  if (hookGate)
383
417
  return hookGate;
@@ -387,22 +421,152 @@ export async function computeNextStep(projectRootDir, checkpoint) {
387
421
  inflightChapter,
388
422
  pipelineStage: stage,
389
423
  evidence,
390
- chapterRelPath: rel.staging.chapterMd
424
+ chapterRelPath: rel.staging.chapterMd,
425
+ loadedProfile
391
426
  });
392
427
  if (guardrailsGate)
393
428
  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
429
+ // Gate decision: deterministic mapping from QualityJudge outputs → next action.
430
+ let evalRaw;
431
+ try {
432
+ evalRaw = await readJsonFile(join(projectRootDir, rel.staging.evalJson));
433
+ }
434
+ catch (err) {
435
+ const message = err instanceof Error ? err.message : String(err);
436
+ return {
437
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
438
+ reason: `judged:eval_read_failed`,
439
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
440
+ evidence: { ...evidence, error: message }
441
+ };
442
+ }
443
+ if (!isPlainObject(evalRaw)) {
444
+ return {
445
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
446
+ reason: `judged:eval_invalid`,
447
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
448
+ evidence: { ...evidence }
449
+ };
450
+ }
451
+ const evalObj = evalRaw;
452
+ const overall = typeof evalObj.overall_final === "number" ? evalObj.overall_final : typeof evalObj.overall === "number" ? evalObj.overall : null;
453
+ if (overall === null || !Number.isFinite(overall)) {
454
+ return {
455
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
456
+ reason: `judged:eval_missing_overall`,
457
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
458
+ evidence: { ...evidence }
459
+ };
460
+ }
461
+ const revisionCount = typeof checkpoint.revision_count === "number" && Number.isInteger(checkpoint.revision_count) && checkpoint.revision_count >= 0
462
+ ? checkpoint.revision_count
463
+ : 0;
464
+ const violation = detectHighConfidenceViolation(evalRaw);
465
+ const maxRevisions = typeof loadedProfile?.profile.scoring?.max_revisions === "number" &&
466
+ Number.isInteger(loadedProfile.profile.scoring.max_revisions) &&
467
+ loadedProfile.profile.scoring.max_revisions >= 0
468
+ ? loadedProfile.profile.scoring.max_revisions
469
+ : null;
470
+ const gateDecision = computeGateDecision({
471
+ overall_final: overall,
472
+ revision_count: revisionCount,
473
+ has_high_confidence_violation: violation.has_high_confidence_violation,
474
+ ...(maxRevisions === null ? {} : { max_revisions: maxRevisions })
475
+ });
476
+ const gateEvidence = {
477
+ ...evidence,
478
+ gate: {
479
+ decision: gateDecision,
480
+ overall_final: overall,
481
+ revision_count: revisionCount,
482
+ max_revisions: maxRevisions,
483
+ has_high_confidence_violation: violation.has_high_confidence_violation,
484
+ high_confidence_violations: violation.high_confidence_violations.slice(0, 10)
485
+ },
486
+ quality_judge: {
487
+ recommendation: typeof evalObj.recommendation === "string" ? evalObj.recommendation : null
488
+ }
399
489
  };
490
+ if (gateDecision === "pass") {
491
+ return {
492
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
493
+ reason: "judged:gate:pass",
494
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
495
+ evidence: gateEvidence
496
+ };
497
+ }
498
+ if (gateDecision === "force_passed") {
499
+ return {
500
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
501
+ reason: "judged:gate:force_passed",
502
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
503
+ evidence: gateEvidence
504
+ };
505
+ }
506
+ if (gateDecision === "polish") {
507
+ return {
508
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "refine" }),
509
+ reason: "judged:gate:polish",
510
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
511
+ evidence: gateEvidence
512
+ };
513
+ }
514
+ if (gateDecision === "revise") {
515
+ return {
516
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "draft" }),
517
+ reason: "judged:gate:revise",
518
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
519
+ evidence: gateEvidence
520
+ };
521
+ }
522
+ if (gateDecision === "pause_for_user" || gateDecision === "pause_for_user_force_rewrite") {
523
+ return {
524
+ step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "review" }),
525
+ reason: `judged:gate:${gateDecision}`,
526
+ inflight: { chapter: inflightChapter, pipeline_stage: stage },
527
+ evidence: gateEvidence
528
+ };
529
+ }
530
+ const _exhaustive = gateDecision;
531
+ throw new NovelCliError(`Unsupported gate decision: ${String(_exhaustive)}`, 2);
532
+ }
533
+ // Unknown stage: upstream parseCheckpoint validates enum so this should be unreachable.
534
+ throw new NovelCliError(`Checkpoint has unexpected pipeline_stage=${stage}. This should not happen; repair .checkpoint.json and rerun.`, 2);
535
+ }
536
+ function notImplementedState(state) {
537
+ throw new NovelCliError(`Not implemented: orchestrator_state=${state}`, 2);
538
+ }
539
+ export async function computeNextStep(projectRootDir, checkpoint) {
540
+ switch (checkpoint.orchestrator_state) {
541
+ case "WRITING":
542
+ case "CHAPTER_REWRITE":
543
+ return await computeChapterNextStep(projectRootDir, checkpoint);
544
+ case "ERROR_RETRY": {
545
+ const stage = normalizeStage(checkpoint.pipeline_stage);
546
+ const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
547
+ let normalizedCheckpoint = checkpoint;
548
+ let healPrefix = "";
549
+ // Only auto-heal invariants when explicitly in ERROR_RETRY.
550
+ if ((stage === null || stage === "committed") && inflight !== null) {
551
+ normalizedCheckpoint = { ...checkpoint, inflight_chapter: null };
552
+ healPrefix = "healed_drop_inflight:";
553
+ }
554
+ else if (stage !== null && stage !== "committed" && inflight === null) {
555
+ normalizedCheckpoint = { ...checkpoint, inflight_chapter: checkpoint.last_completed_chapter + 1 };
556
+ healPrefix = "healed_infer_inflight:";
557
+ }
558
+ const next = await computeChapterNextStep(projectRootDir, normalizedCheckpoint);
559
+ return { ...next, reason: `error_retry:${healPrefix}${next.reason}` };
560
+ }
561
+ case "INIT":
562
+ return notImplementedState(checkpoint.orchestrator_state);
563
+ case "QUICK_START":
564
+ return notImplementedState(checkpoint.orchestrator_state);
565
+ case "VOL_PLANNING":
566
+ return await computeVolumeNextStep(projectRootDir, checkpoint);
567
+ case "VOL_REVIEW":
568
+ return await computeReviewNext(projectRootDir, checkpoint);
569
+ default:
570
+ return notImplementedState(checkpoint.orchestrator_state);
400
571
  }
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
407
- };
408
572
  }
@@ -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);
package/dist/steps.js CHANGED
@@ -1,5 +1,17 @@
1
1
  import { NovelCliError } from "./errors.js";
2
+ export const ORCHESTRATOR_STATES = [
3
+ "INIT",
4
+ "QUICK_START",
5
+ "VOL_PLANNING",
6
+ "WRITING",
7
+ "CHAPTER_REWRITE",
8
+ "VOL_REVIEW",
9
+ "ERROR_RETRY"
10
+ ];
2
11
  export const CHAPTER_STAGES = ["draft", "summarize", "refine", "judge", "title-fix", "hook-fix", "review", "commit"];
12
+ export const VOLUME_PHASES = ["outline", "validate", "commit"];
13
+ export const QUICKSTART_PHASES = ["world", "characters", "style", "trial", "results"];
14
+ export const REVIEW_PHASES = ["collect", "audit", "report", "cleanup", "transition"];
3
15
  export function pad3(n) {
4
16
  return String(n).padStart(3, "0");
5
17
  }
@@ -10,29 +22,60 @@ export function titleFixSnapshotRel(chapter) {
10
22
  return `staging/logs/title-fix-chapter-${pad3(chapter)}-before.md`;
11
23
  }
12
24
  export function formatStepId(step) {
13
- if (step.kind !== "chapter")
14
- throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
15
- return `chapter:${pad3(step.chapter)}:${step.stage}`;
25
+ switch (step.kind) {
26
+ case "chapter":
27
+ return `chapter:${pad3(step.chapter)}:${step.stage}`;
28
+ case "volume":
29
+ return `volume:${step.phase}`;
30
+ case "quickstart":
31
+ return `quickstart:${step.phase}`;
32
+ case "review":
33
+ return `review:${step.phase}`;
34
+ default:
35
+ throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
36
+ }
16
37
  }
17
38
  export function parseStepId(input) {
18
39
  const trimmed = input.trim();
19
40
  const parts = trimmed.split(":");
20
- if (parts.length !== 3) {
21
- throw new NovelCliError(`Invalid step id: ${input}. Expected format: chapter:048:draft`, 2);
22
- }
23
- const [kind, chapterRaw, stageRaw] = parts;
24
- if (kind !== "chapter")
25
- throw new NovelCliError(`Invalid step id: ${input}. Only 'chapter' steps are supported.`, 2);
26
- if (!/^\d+$/.test(chapterRaw))
27
- throw new NovelCliError(`Invalid step id: ${input}. Chapter must be a number.`, 2);
28
- const chapter = Number.parseInt(chapterRaw, 10);
29
- if (!Number.isInteger(chapter) || chapter <= 0) {
30
- throw new NovelCliError(`Invalid step id: ${input}. Chapter must be an int >= 1.`, 2);
41
+ if (parts.length === 3) {
42
+ const [kind, chapterRaw, stageRaw] = parts;
43
+ if (kind !== "chapter")
44
+ throw new NovelCliError(`Invalid step id: ${input}. Expected kind 'chapter'.`, 2);
45
+ if (!/^\d+$/.test(chapterRaw))
46
+ throw new NovelCliError(`Invalid step id: ${input}. Chapter must be a number.`, 2);
47
+ const chapter = Number.parseInt(chapterRaw, 10);
48
+ if (!Number.isInteger(chapter) || chapter <= 0) {
49
+ throw new NovelCliError(`Invalid step id: ${input}. Chapter must be an int >= 1.`, 2);
50
+ }
51
+ if (!CHAPTER_STAGES.includes(stageRaw)) {
52
+ throw new NovelCliError(`Invalid step id: ${input}. Stage must be one of: ${CHAPTER_STAGES.join(", ")}`, 2);
53
+ }
54
+ return { kind: "chapter", chapter, stage: stageRaw };
31
55
  }
32
- if (!CHAPTER_STAGES.includes(stageRaw)) {
33
- throw new NovelCliError(`Invalid step id: ${input}. Stage must be one of: ${CHAPTER_STAGES.join(", ")}`, 2);
56
+ if (parts.length === 2) {
57
+ const [kind, phaseRaw] = parts;
58
+ if (kind === "volume") {
59
+ if (!VOLUME_PHASES.includes(phaseRaw)) {
60
+ throw new NovelCliError(`Invalid step id: ${input}. Phase must be one of: ${VOLUME_PHASES.join(", ")}`, 2);
61
+ }
62
+ return { kind: "volume", phase: phaseRaw };
63
+ }
64
+ if (kind === "quickstart") {
65
+ if (!QUICKSTART_PHASES.includes(phaseRaw)) {
66
+ throw new NovelCliError(`Invalid step id: ${input}. Phase must be one of: ${QUICKSTART_PHASES.join(", ")}`, 2);
67
+ }
68
+ return { kind: "quickstart", phase: phaseRaw };
69
+ }
70
+ if (kind === "review") {
71
+ if (!REVIEW_PHASES.includes(phaseRaw)) {
72
+ throw new NovelCliError(`Invalid step id: ${input}. Phase must be one of: ${REVIEW_PHASES.join(", ")}`, 2);
73
+ }
74
+ return { kind: "review", phase: phaseRaw };
75
+ }
76
+ throw new NovelCliError(`Invalid step id: ${input}. Supported kinds: chapter, volume, quickstart, review.`, 2);
34
77
  }
35
- return { kind: "chapter", chapter, stage: stageRaw };
78
+ throw new NovelCliError(`Invalid step id: ${input}. Expected format: chapter:048:draft (or quickstart:world).`, 2);
36
79
  }
37
80
  export function chapterRelPaths(chapter, storylineId) {
38
81
  const id = pad3(chapter);