novel-writer-cli 0.1.0 → 0.2.1

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.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, CommanderError } from "commander";
3
3
  import { realpathSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import { join, resolve } from "node:path";
5
6
  import { fileURLToPath, pathToFileURL } from "node:url";
6
7
  import { buildCharacterVoiceProfiles, clearCharacterVoiceDriftFile, computeCharacterVoiceDrift, loadActiveCharacterVoiceDriftIds, loadCharacterVoiceProfiles, writeCharacterVoiceDriftFile, writeCharacterVoiceProfilesFile } from "./character-voice.js";
@@ -8,7 +9,7 @@ import { NovelCliError } from "./errors.js";
8
9
  import { errJson, okJson, printJson } from "./output.js";
9
10
  import { pathExists, readJsonFile, writeJsonFile, writeTextFile } from "./fs-utils.js";
10
11
  import { resolveProjectRoot } from "./project.js";
11
- import { readCheckpoint } from "./checkpoint.js";
12
+ import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
12
13
  import { initProject, normalizePlatformId, resolveInitRootDir } from "./init.js";
13
14
  import { advanceCheckpointForStep } from "./advance.js";
14
15
  import { commitChapter } from "./commit.js";
@@ -17,16 +18,37 @@ import { buildInstructionPacket } from "./instructions.js";
17
18
  import { getLockStatus, clearStaleLock, withWriteLock } from "./lock.js";
18
19
  import { computeNextStep } from "./next-step.js";
19
20
  import { computeEngagementReport, loadEngagementMetricsStream, writeEngagementLogs } from "./engagement.js";
21
+ import { parseNovelAskQuestionSpec } from "./novel-ask.js";
20
22
  import { computePromiseLedgerReport, ensurePromiseLedgerInitialized, loadPromiseLedger, writePromiseLedgerLogs } from "./promise-ledger.js";
21
23
  import { pad2, pad3, parseStepId } from "./steps.js";
24
+ import { resolveProjectRelativePath } from "./safe-path.js";
22
25
  import { isPlainObject } from "./type-guards.js";
23
26
  import { validateStep } from "./validate.js";
24
27
  import { VOL_REVIEW_RELS, collectVolumeData, computeBridgeCheck, computeForeshadowingAudit, computeStorylineRhythm } from "./volume-review.js";
25
28
  import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
29
+ const require = createRequire(import.meta.url);
30
+ function resolveCliVersion() {
31
+ try {
32
+ const pkg = require("../package.json");
33
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
34
+ }
35
+ catch {
36
+ return "0.0.0";
37
+ }
38
+ }
39
+ const CLI_VERSION = resolveCliVersion();
26
40
  function detectCommandName(argv) {
27
- for (const token of argv) {
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const token = argv[i];
28
43
  if (token === "--")
29
44
  return "unknown";
45
+ // Global option: consumes the next token as a value.
46
+ if (token === "--project") {
47
+ i++;
48
+ continue;
49
+ }
50
+ if (token.startsWith("--project="))
51
+ continue;
30
52
  if (!token.startsWith("-"))
31
53
  return token;
32
54
  }
@@ -39,6 +61,7 @@ function buildProgram(argv) {
39
61
  const jsonMode = isJsonMode(argv);
40
62
  const program = new Command();
41
63
  program.name("novel").description("Executor-agnostic novel orchestration CLI.");
64
+ program.version(CLI_VERSION);
42
65
  program.option("--json", "Emit machine-readable JSON (single object).");
43
66
  program.option("--project <dir>", "Project root directory (defaults to auto-detect via .checkpoint.json).");
44
67
  program.configureOutput({
@@ -124,6 +147,16 @@ function buildProgram(argv) {
124
147
  printJson(okJson("next", { rootDir, ...next }));
125
148
  return;
126
149
  }
150
+ if (isPlainObject(next.evidence)) {
151
+ const blocked = next.evidence.recovery_blocked;
152
+ if (isPlainObject(blocked)) {
153
+ const obj = blocked;
154
+ const checkpointPhase = typeof obj.checkpoint_phase === "string" ? obj.checkpoint_phase : "unknown";
155
+ const inferredPhase = typeof obj.inferred_phase === "string" ? obj.inferred_phase : "unknown";
156
+ const expectedPath = typeof obj.expected_path === "string" ? obj.expected_path : "unknown";
157
+ process.stderr.write(`WARN: quickstart recovery blocked (checkpoint_phase=${checkpointPhase}, inferred_phase=${inferredPhase}, expected=${expectedPath}). Run 'novel status' for details.\n`);
158
+ }
159
+ }
127
160
  process.stdout.write(`${next.step}\n`);
128
161
  });
129
162
  program
@@ -132,18 +165,34 @@ function buildProgram(argv) {
132
165
  .argument("<step>", "Step id, e.g. chapter:048:draft")
133
166
  .option("--write-manifest", "Persist packet under staging/manifests/.")
134
167
  .option("--embed <mode>", "Optional embed mode (off by default). Example: --embed brief")
168
+ .option("--novel-ask-file <path>", "Project-relative path to a NOVEL_ASK QuestionSpec JSON file (enables gate).")
169
+ .option("--answer-path <path>", "Project-relative path to write the NOVEL_ASK AnswerSpec JSON record.")
135
170
  .action(async (step, localOpts) => {
136
171
  const opts = program.opts();
137
172
  const json = Boolean(opts.json);
138
173
  const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
139
174
  const checkpoint = await readCheckpoint(rootDir);
140
175
  const parsedStep = parseStepId(step);
176
+ const novelAskFile = localOpts.novelAskFile ?? null;
177
+ const answerPath = localOpts.answerPath ?? null;
178
+ let novelAskGate = null;
179
+ if (novelAskFile !== null || answerPath !== null) {
180
+ if (!novelAskFile || !answerPath) {
181
+ throw new NovelCliError(`Invalid NOVEL_ASK gate: provide both --novel-ask-file and --answer-path.`, 2);
182
+ }
183
+ const absAsk = resolveProjectRelativePath(rootDir, novelAskFile, "--novel-ask-file");
184
+ const rawSpec = await readJsonFile(absAsk);
185
+ const spec = parseNovelAskQuestionSpec(rawSpec);
186
+ resolveProjectRelativePath(rootDir, answerPath, "--answer-path");
187
+ novelAskGate = { novel_ask: spec, answer_path: answerPath };
188
+ }
141
189
  const packet = await buildInstructionPacket({
142
190
  rootDir,
143
191
  checkpoint,
144
192
  step: parsedStep,
145
193
  embedMode: localOpts.embed ?? null,
146
- writeManifest: Boolean(localOpts.writeManifest)
194
+ writeManifest: Boolean(localOpts.writeManifest),
195
+ novelAskGate
147
196
  });
148
197
  if (json) {
149
198
  printJson(okJson("instructions", packet));
@@ -703,6 +752,83 @@ function buildProgram(argv) {
703
752
  else if (!localOpts.apply)
704
753
  process.stdout.write(`\nPreview-only. Use --apply to write character-voice-drift.json.\n`);
705
754
  });
755
+ program
756
+ .command("repair")
757
+ .description("Repair project state (checkpoint recovery helpers).")
758
+ .option("--reset-quickstart", "Set .checkpoint.json.quickstart_phase to null.")
759
+ .option("--force", "Apply the repair (otherwise preview only).")
760
+ .action(async (localOpts) => {
761
+ const opts = program.opts();
762
+ const json = Boolean(opts.json);
763
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
764
+ if (!localOpts.resetQuickstart) {
765
+ throw new NovelCliError("Invalid repair: no actions specified. Use --reset-quickstart.", 2);
766
+ }
767
+ if (!localOpts.force) {
768
+ const checkpoint = await readCheckpoint(rootDir);
769
+ const beforePresent = Object.prototype.hasOwnProperty.call(checkpoint, "quickstart_phase");
770
+ const before = (checkpoint.quickstart_phase ?? null);
771
+ const wouldChange = !beforePresent || before !== null;
772
+ if (json) {
773
+ printJson(okJson("repair", {
774
+ rootDir,
775
+ applied: false,
776
+ actions: ["reset_quickstart"],
777
+ before_present: beforePresent,
778
+ after_present: true,
779
+ changed: false,
780
+ would_change: wouldChange,
781
+ before,
782
+ after: before,
783
+ target_after: null
784
+ }));
785
+ return;
786
+ }
787
+ if (!wouldChange) {
788
+ process.stdout.write("No changes needed: quickstart_phase is already null.\n");
789
+ return;
790
+ }
791
+ process.stdout.write(`Preview: set quickstart_phase ${beforePresent ? before : "<missing>"} -> null\n`);
792
+ process.stdout.write("Re-run with --force to apply.\n");
793
+ return;
794
+ }
795
+ const applied = await withWriteLock(rootDir, {}, async () => {
796
+ const checkpoint = await readCheckpoint(rootDir);
797
+ const beforePresent = Object.prototype.hasOwnProperty.call(checkpoint, "quickstart_phase");
798
+ const before = (checkpoint.quickstart_phase ?? null);
799
+ const wouldChange = !beforePresent || before !== null;
800
+ if (!wouldChange)
801
+ return { beforePresent, before, wouldChange, wrote: false, checkpoint };
802
+ const updated = { ...checkpoint, quickstart_phase: null, last_checkpoint_time: new Date().toISOString() };
803
+ await writeCheckpoint(rootDir, updated);
804
+ return { beforePresent, before, wouldChange, wrote: true, checkpoint: await readCheckpoint(rootDir) };
805
+ });
806
+ const afterPresent = Object.prototype.hasOwnProperty.call(applied.checkpoint, "quickstart_phase");
807
+ const after = applied.checkpoint.quickstart_phase ?? null;
808
+ if (!afterPresent || after !== null) {
809
+ throw new NovelCliError(`Repair failed: quickstart_phase is still ${String(after)} (expected null).`, 2);
810
+ }
811
+ if (json) {
812
+ printJson(okJson("repair", {
813
+ rootDir,
814
+ applied: true,
815
+ actions: ["reset_quickstart"],
816
+ before_present: applied.beforePresent,
817
+ after_present: afterPresent,
818
+ changed: applied.wrote,
819
+ would_change: applied.wouldChange,
820
+ before: applied.before,
821
+ after: null,
822
+ target_after: null
823
+ }));
824
+ return;
825
+ }
826
+ if (!applied.wrote) {
827
+ process.stdout.write("No changes needed: quickstart_phase is already null.\n");
828
+ return;
829
+ }
830
+ process.stdout.write(`Set quickstart_phase ${applied.beforePresent ? applied.before : "<missing>"} -> null\n`);
831
+ });
706
832
  const lock = program.command("lock").description("Manage project lock (.novel.lock).");
707
833
  lock
708
834
  .command("status")
@@ -757,7 +883,7 @@ export async function main(argv = process.argv.slice(2)) {
757
883
  return err.exitCode;
758
884
  }
759
885
  if (err instanceof CommanderError) {
760
- if (err.code === "commander.helpDisplayed") {
886
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version") {
761
887
  return 0;
762
888
  }
763
889
  if (jsonMode) {
package/dist/init.js CHANGED
@@ -108,7 +108,8 @@ const STAGING_SUBDIRS = [
108
108
  "staging/storylines",
109
109
  "staging/volumes",
110
110
  "staging/foreshadowing",
111
- "staging/manifests"
111
+ "staging/manifests",
112
+ "staging/quickstart"
112
113
  ];
113
114
  export async function initProject(args) {
114
115
  const force = Boolean(args.force);
@@ -10,6 +10,7 @@ import { parseNovelAskQuestionSpec } from "./novel-ask.js";
10
10
  import { loadPlatformProfile } from "./platform-profile.js";
11
11
  import { computePrejudgeGuardrailsReport, writePrejudgeGuardrailsReport } from "./prejudge-guardrails.js";
12
12
  import { loadPromiseLedgerLatestSummary } from "./promise-ledger.js";
13
+ import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
13
14
  import { resolveProjectRelativePath } from "./safe-path.js";
14
15
  import { computeTitlePolicyReport } from "./title-policy.js";
15
16
  import { chapterRelPaths, formatStepId, pad2, pad3, titleFixSnapshotRel } from "./steps.js";
@@ -187,10 +188,169 @@ async function buildReviewInstructionPacket(args) {
187
188
  ...(writtenPath ? { written_manifest_path: writtenPath } : {})
188
189
  };
189
190
  }
191
+ async function buildQuickStartInstructionPacket(args) {
192
+ const stepId = formatStepId(args.step);
193
+ if (args.step.kind !== "quickstart")
194
+ throw new NovelCliError(`Unsupported quickstart step: ${stepId}`, 2);
195
+ const step = args.step;
196
+ const embedMode = safeEmbedMode(args.embedMode);
197
+ const embed = {};
198
+ if (embedMode === "brief") {
199
+ const briefAbs = join(args.rootDir, "brief.md");
200
+ if (await pathExists(briefAbs)) {
201
+ const content = await readTextFile(briefAbs);
202
+ embed.brief_preview = content.slice(0, 2000);
203
+ }
204
+ else {
205
+ embed.brief_preview = null;
206
+ }
207
+ }
208
+ const trialChapter = Math.max(1, args.checkpoint.last_completed_chapter + 1);
209
+ const isTrialMode = step.phase === "trial" || step.phase === "results";
210
+ const inline = {
211
+ quickstart_phase: step.phase,
212
+ trial_mode: isTrialMode,
213
+ volume: args.checkpoint.current_volume,
214
+ ...(isTrialMode ? { chapter: trialChapter } : {})
215
+ };
216
+ const paths = {};
217
+ const maybeAddPath = async (key, relPath) => {
218
+ const absPath = join(args.rootDir, relPath);
219
+ if (await pathExists(absPath))
220
+ paths[key] = relPath;
221
+ };
222
+ await maybeAddPath("project_brief", "brief.md");
223
+ await maybeAddPath("platform_profile", "platform-profile.json");
224
+ await maybeAddPath("style_profile_template", "style-profile.json");
225
+ // Attach staging quickstart artifacts when present (for resume/debug).
226
+ await maybeAddPath("quickstart_rules", QUICKSTART_STAGING_RELS.rulesJson);
227
+ await maybeAddPath("quickstart_contracts_dir", QUICKSTART_STAGING_RELS.contractsDir);
228
+ await maybeAddPath("quickstart_style_profile", QUICKSTART_STAGING_RELS.styleProfileJson);
229
+ await maybeAddPath("quickstart_trial_chapter", QUICKSTART_STAGING_RELS.trialChapterMd);
230
+ await maybeAddPath("quickstart_evaluation", QUICKSTART_STAGING_RELS.evaluationJson);
231
+ const asString = (value) => (typeof value === "string" ? value : null);
232
+ const qsRules = asString(paths.quickstart_rules);
233
+ const qsContractsDir = asString(paths.quickstart_contracts_dir);
234
+ const qsStyleProfile = asString(paths.quickstart_style_profile);
235
+ const qsTrialChapter = asString(paths.quickstart_trial_chapter);
236
+ const styleTemplate = asString(paths.style_profile_template);
237
+ // Provide canonical manifest keys in addition to quickstart-scoped aliases.
238
+ if (qsRules)
239
+ paths.world_rules = qsRules;
240
+ if (qsContractsDir)
241
+ paths.character_contracts_dir = qsContractsDir;
242
+ if (qsStyleProfile)
243
+ paths.style_profile = qsStyleProfile;
244
+ else if (styleTemplate)
245
+ paths.style_profile = styleTemplate;
246
+ if (qsTrialChapter)
247
+ paths.chapter_draft = qsTrialChapter;
248
+ let agent;
249
+ const expected_outputs = [];
250
+ const next_actions = [];
251
+ if (step.phase === "world") {
252
+ agent = { kind: "subagent", name: "world-builder" };
253
+ expected_outputs.push({ path: QUICKSTART_STAGING_RELS.rulesJson, required: true });
254
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
255
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
256
+ next_actions.push({ kind: "command", command: `novel next`, note: "Compute next deterministic step (skips already-generated artifacts)." });
257
+ next_actions.push({
258
+ kind: "command",
259
+ command: `novel instructions quickstart:characters --json`,
260
+ note: "After advance, proceed to create initial characters/contracts."
261
+ });
262
+ }
263
+ else if (step.phase === "characters") {
264
+ agent = { kind: "subagent", name: "character-weaver" };
265
+ inline.contracts_output_dir = QUICKSTART_STAGING_RELS.contractsDir;
266
+ expected_outputs.push({
267
+ path: QUICKSTART_STAGING_RELS.contractsDir,
268
+ required: true,
269
+ note: "Write at least 1 character contract JSON under this dir."
270
+ });
271
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
272
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
273
+ next_actions.push({ kind: "command", command: `novel next`, note: "Compute next deterministic step (skips already-generated artifacts)." });
274
+ next_actions.push({ kind: "command", command: `novel instructions quickstart:style --json`, note: "After advance, proceed to style extraction." });
275
+ }
276
+ else if (step.phase === "style") {
277
+ agent = { kind: "subagent", name: "style-analyzer" };
278
+ expected_outputs.push({ path: QUICKSTART_STAGING_RELS.styleProfileJson, required: true });
279
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
280
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
281
+ 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." });
283
+ }
284
+ else if (step.phase === "trial") {
285
+ agent = { kind: "subagent", name: "chapter-writer" };
286
+ expected_outputs.push({ path: QUICKSTART_STAGING_RELS.trialChapterMd, required: true });
287
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
288
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
289
+ next_actions.push({ kind: "command", command: `novel next`, note: "Compute next deterministic step (skips already-generated artifacts)." });
290
+ next_actions.push({ kind: "command", command: `novel instructions quickstart:results --json`, note: "After advance, evaluate trial results." });
291
+ }
292
+ else if (step.phase === "results") {
293
+ agent = { kind: "subagent", name: "quality-judge" };
294
+ expected_outputs.push({
295
+ path: QUICKSTART_STAGING_RELS.evaluationJson,
296
+ required: true,
297
+ note: "QualityJudge returns JSON; the executor should write it to this path."
298
+ });
299
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
300
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
301
+ next_actions.push({ kind: "command", command: `novel next`, note: "After advance, the pipeline transitions to VOL_PLANNING." });
302
+ }
303
+ else {
304
+ const _exhaustive = step.phase;
305
+ throw new NovelCliError(`Unsupported quickstart phase: ${String(_exhaustive)}`, 2);
306
+ }
307
+ const gate = args.novelAskGate ?? null;
308
+ const gateSpec = gate ? parseNovelAskQuestionSpec(gate.novel_ask) : null;
309
+ if (gate) {
310
+ resolveProjectRelativePath(args.rootDir, gate.answer_path, "novelAskGate.answer_path");
311
+ expected_outputs.unshift({
312
+ path: gate.answer_path,
313
+ required: true,
314
+ note: "AnswerSpec JSON record for the NOVEL_ASK gate (written before main step execution)."
315
+ });
316
+ }
317
+ const packet = {
318
+ version: 1,
319
+ step: stepId,
320
+ agent,
321
+ ...(gate ? { novel_ask: gateSpec, answer_path: gate.answer_path } : {}),
322
+ manifest: {
323
+ mode: embedMode === "off" ? "paths" : "paths+embed",
324
+ inline,
325
+ paths,
326
+ ...(embedMode === "off" ? {} : { embed })
327
+ },
328
+ expected_outputs,
329
+ next_actions
330
+ };
331
+ let writtenPath = null;
332
+ if (args.writeManifest) {
333
+ const manifestsDir = join(args.rootDir, "staging/manifests");
334
+ await ensureDir(manifestsDir);
335
+ const fileName = `${stepId.replaceAll(":", "-")}.packet.json`;
336
+ writtenPath = `staging/manifests/${fileName}`;
337
+ await writeJsonFile(join(args.rootDir, writtenPath), packet);
338
+ }
339
+ return {
340
+ packet,
341
+ ...(writtenPath ? { written_manifest_path: writtenPath } : {})
342
+ };
343
+ }
190
344
  export async function buildInstructionPacket(args) {
191
345
  const stepId = formatStepId(args.step);
192
- if (args.step.kind === "review")
346
+ if (args.step.kind === "review") {
347
+ if (args.novelAskGate) {
348
+ throw new NovelCliError(`NOVEL_ASK gate is not supported for review steps: ${stepId}`, 2);
349
+ }
193
350
  return await buildReviewInstructionPacket(args);
351
+ }
352
+ if (args.step.kind === "quickstart")
353
+ return await buildQuickStartInstructionPacket(args);
194
354
  if (args.step.kind === "volume") {
195
355
  const step = args.step;
196
356
  const volume = args.checkpoint.current_volume;
@@ -661,6 +821,7 @@ export async function buildInstructionPacket(args) {
661
821
  agent = { kind: "cli", name: "novel" };
662
822
  expected_outputs.push({ path: `chapters/chapter-${String(step.chapter).padStart(3, "0")}.md`, required: true });
663
823
  next_actions.push({ kind: "command", command: `novel commit --chapter ${step.chapter}` });
824
+ next_actions.push({ kind: "command", command: `novel next`, note: "After commit, compute next step." });
664
825
  }
665
826
  else {
666
827
  throw new NovelCliError(`Unsupported step stage: ${step.stage}`, 2);
package/dist/next-step.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
3
  import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
3
4
  import { NovelCliError } from "./errors.js";
@@ -5,12 +6,14 @@ import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
5
6
  import { computeGateDecision, detectHighConfidenceViolation } from "./gate-decision.js";
6
7
  import { checkHookPolicy } from "./hook-policy.js";
7
8
  import { loadPlatformProfile } from "./platform-profile.js";
9
+ import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
10
+ import { validateQuickstartRulesSchema, validateQuickstartStyleProfileSchema } from "./quickstart-validators.js";
8
11
  import { computeReviewNext } from "./volume-review.js";
9
12
  import { computePrejudgeGuardrailsReport, loadPrejudgeGuardrailsReportIfFresh, prejudgeGuardrailsRelPath } from "./prejudge-guardrails.js";
10
13
  import { summarizeNamingIssues } from "./naming-lint.js";
11
14
  import { summarizeReadabilityIssues } from "./readability-lint.js";
12
15
  import { computeTitlePolicyReport } from "./title-policy.js";
13
- import { chapterRelPaths, formatStepId } from "./steps.js";
16
+ import { QUICKSTART_PHASES, chapterRelPaths, formatStepId } from "./steps.js";
14
17
  import { isPlainObject } from "./type-guards.js";
15
18
  import { computeVolumeNextStep } from "./volume-planning.js";
16
19
  function normalizeStage(stage) {
@@ -536,6 +539,224 @@ async function computeChapterNextStep(projectRootDir, checkpoint) {
536
539
  function notImplementedState(state) {
537
540
  throw new NovelCliError(`Not implemented: orchestrator_state=${state}`, 2);
538
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
+ }
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
+ }
539
760
  export async function computeNextStep(projectRootDir, checkpoint) {
540
761
  switch (checkpoint.orchestrator_state) {
541
762
  case "WRITING":
@@ -558,10 +779,12 @@ export async function computeNextStep(projectRootDir, checkpoint) {
558
779
  const next = await computeChapterNextStep(projectRootDir, normalizedCheckpoint);
559
780
  return { ...next, reason: `error_retry:${healPrefix}${next.reason}` };
560
781
  }
561
- case "INIT":
562
- return notImplementedState(checkpoint.orchestrator_state);
782
+ case "INIT": {
783
+ const next = await computeQuickStartNextStep(projectRootDir, checkpoint);
784
+ return { ...next, reason: `init:${next.reason}` };
785
+ }
563
786
  case "QUICK_START":
564
- return notImplementedState(checkpoint.orchestrator_state);
787
+ return await computeQuickStartNextStep(projectRootDir, checkpoint);
565
788
  case "VOL_PLANNING":
566
789
  return await computeVolumeNextStep(projectRootDir, checkpoint);
567
790
  case "VOL_REVIEW":