novel-writer-cli 0.0.2 → 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 (33) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  3. package/dist/__tests__/character-voice.test.js +1 -1
  4. package/dist/__tests__/gate-decision.test.js +66 -0
  5. package/dist/__tests__/init.test.js +245 -0
  6. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  7. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  8. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  9. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  10. package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
  11. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  12. package/dist/__tests__/steps-id.test.js +23 -0
  13. package/dist/__tests__/volume-pipeline.test.js +227 -0
  14. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  15. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  16. package/dist/advance.js +145 -48
  17. package/dist/checkpoint.js +83 -12
  18. package/dist/cli.js +235 -8
  19. package/dist/commit.js +1 -0
  20. package/dist/fs-utils.js +18 -3
  21. package/dist/gate-decision.js +59 -0
  22. package/dist/init.js +165 -0
  23. package/dist/instructions.js +322 -24
  24. package/dist/next-step.js +198 -34
  25. package/dist/platform-profile.js +3 -0
  26. package/dist/steps.js +60 -17
  27. package/dist/validate.js +275 -2
  28. package/dist/volume-commit.js +101 -0
  29. package/dist/volume-planning.js +143 -0
  30. package/dist/volume-review.js +448 -0
  31. package/docs/user/novel-cli.md +57 -0
  32. package/package.json +3 -2
  33. package/schemas/platform-profile.schema.json +5 -0
@@ -1,8 +1,24 @@
1
1
  import { join } from "node:path";
2
2
  import { NovelCliError } from "./errors.js";
3
3
  import { readJsonFile, writeJsonFile } from "./fs-utils.js";
4
+ import { ORCHESTRATOR_STATES, VOLUME_PHASES } from "./steps.js";
4
5
  import { isPlainObject } from "./type-guards.js";
5
6
  export const PIPELINE_STAGES = ["drafting", "drafted", "refined", "judged", "revising", "committed"];
7
+ export function createDefaultCheckpoint(nowIso) {
8
+ return {
9
+ last_completed_chapter: 0,
10
+ current_volume: 1,
11
+ // TODO(CS-O3): Default to INIT once the quickstart pipeline is implemented.
12
+ orchestrator_state: "WRITING",
13
+ pipeline_stage: "committed",
14
+ volume_pipeline_stage: null,
15
+ inflight_chapter: null,
16
+ revision_count: 0,
17
+ hook_fix_count: 0,
18
+ title_fix_count: 0,
19
+ last_checkpoint_time: nowIso ?? new Date().toISOString()
20
+ };
21
+ }
6
22
  function asInt(value) {
7
23
  if (typeof value !== "number")
8
24
  return null;
@@ -22,6 +38,23 @@ function asNullableInt(value) {
22
38
  return null;
23
39
  return asInt(value);
24
40
  }
41
+ function isOrchestratorState(value) {
42
+ return ORCHESTRATOR_STATES.includes(value);
43
+ }
44
+ export function inferLegacyState(args) {
45
+ const stage = args.pipeline_stage ?? null;
46
+ const inflight = args.inflight_chapter ?? null;
47
+ // Inconsistent legacy checkpoint: inflight present but stage is idle.
48
+ if ((stage === null || stage === "committed") && inflight !== null)
49
+ return "ERROR_RETRY";
50
+ // Inconsistent legacy checkpoint: pipeline in-flight but missing chapter pointer.
51
+ if (stage !== null && stage !== "committed" && inflight === null)
52
+ return "ERROR_RETRY";
53
+ if (stage === "revising")
54
+ return "CHAPTER_REWRITE";
55
+ // Default to WRITING to preserve the legacy single-chapter pipeline behavior.
56
+ return "WRITING";
57
+ }
25
58
  function parseCheckpoint(data) {
26
59
  if (!isPlainObject(data)) {
27
60
  throw new NovelCliError(".checkpoint.json must be a JSON object.", 2);
@@ -31,12 +64,8 @@ function parseCheckpoint(data) {
31
64
  throw new NovelCliError(".checkpoint.json.last_completed_chapter must be an int >= 0.", 2);
32
65
  }
33
66
  const currentVolume = asInt(data.current_volume);
34
- if (currentVolume === null || currentVolume < 0) {
35
- throw new NovelCliError(".checkpoint.json.current_volume must be an int >= 0.", 2);
36
- }
37
- const orchestratorState = data.orchestrator_state;
38
- if (orchestratorState !== undefined && asString(orchestratorState) === null) {
39
- throw new NovelCliError(".checkpoint.json.orchestrator_state must be a string when present.", 2);
67
+ if (currentVolume === null || currentVolume < 1) {
68
+ throw new NovelCliError(".checkpoint.json.current_volume must be an int >= 1.", 2);
40
69
  }
41
70
  const pipelineStageRaw = data.pipeline_stage;
42
71
  let pipelineStage;
@@ -60,10 +89,36 @@ function parseCheckpoint(data) {
60
89
  const inflightRaw = data.inflight_chapter;
61
90
  const inflight = asNullableInt(inflightRaw);
62
91
  if (inflightRaw !== undefined && inflight === null && inflightRaw !== null) {
63
- throw new NovelCliError(".checkpoint.json.inflight_chapter must be an int >= 0 (or null).", 2);
92
+ throw new NovelCliError(".checkpoint.json.inflight_chapter must be an int >= 1 (or null).", 2);
93
+ }
94
+ if (inflight !== undefined && inflight !== null && inflight < 1) {
95
+ throw new NovelCliError(".checkpoint.json.inflight_chapter must be an int >= 1 (or null).", 2);
96
+ }
97
+ const volumeStageRaw = data.volume_pipeline_stage;
98
+ let volumeStage;
99
+ if (volumeStageRaw === undefined) {
100
+ volumeStage = undefined;
101
+ }
102
+ else if (volumeStageRaw === null) {
103
+ volumeStage = null;
104
+ }
105
+ else if (typeof volumeStageRaw === "string") {
106
+ if (VOLUME_PHASES.includes(volumeStageRaw)) {
107
+ volumeStage = volumeStageRaw;
108
+ }
109
+ else {
110
+ throw new NovelCliError(`.checkpoint.json.volume_pipeline_stage must be one of: ${VOLUME_PHASES.join(", ")} (or null)`, 2);
111
+ }
64
112
  }
65
- if (inflight !== undefined && inflight !== null && inflight < 0) {
66
- throw new NovelCliError(".checkpoint.json.inflight_chapter must be an int >= 0 (or null).", 2);
113
+ else {
114
+ throw new NovelCliError(`.checkpoint.json.volume_pipeline_stage must be a string (or null)`, 2);
115
+ }
116
+ const lastCommitted = data.last_committed_volume;
117
+ if (lastCommitted !== undefined) {
118
+ const lc = asInt(lastCommitted);
119
+ if (lc === null || lc < 0) {
120
+ throw new NovelCliError(".checkpoint.json.last_committed_volume must be an int >= 0 when present.", 2);
121
+ }
67
122
  }
68
123
  const revision = data.revision_count;
69
124
  if (revision !== undefined) {
@@ -94,15 +149,31 @@ function parseCheckpoint(data) {
94
149
  if (lastTime !== undefined && asString(lastTime) === null) {
95
150
  throw new NovelCliError(".checkpoint.json.last_checkpoint_time must be a string when present.", 2);
96
151
  }
152
+ const orchestratorStateRaw = data.orchestrator_state;
153
+ let orchestratorState;
154
+ if (orchestratorStateRaw === undefined) {
155
+ orchestratorState = inferLegacyState({ pipeline_stage: pipelineStage ?? null, inflight_chapter: inflight ?? null });
156
+ }
157
+ else {
158
+ const raw = asString(orchestratorStateRaw);
159
+ if (raw === null) {
160
+ throw new NovelCliError(".checkpoint.json.orchestrator_state must be a string when present.", 2);
161
+ }
162
+ if (!isOrchestratorState(raw)) {
163
+ throw new NovelCliError(`.checkpoint.json.orchestrator_state must be one of: ${ORCHESTRATOR_STATES.join(", ")} (or omit for legacy inference).`, 2);
164
+ }
165
+ orchestratorState = raw;
166
+ }
97
167
  const checkpoint = {
98
168
  ...data,
99
169
  last_completed_chapter: lastCompleted,
100
- current_volume: currentVolume
170
+ current_volume: currentVolume,
171
+ orchestrator_state: orchestratorState
101
172
  };
102
- if (orchestratorState !== undefined)
103
- checkpoint.orchestrator_state = orchestratorState;
104
173
  if (pipelineStage !== undefined)
105
174
  checkpoint.pipeline_stage = pipelineStage;
175
+ if (volumeStage !== undefined)
176
+ checkpoint.volume_pipeline_stage = volumeStage;
106
177
  if (inflight !== undefined)
107
178
  checkpoint.inflight_chapter = inflight;
108
179
  return checkpoint;
package/dist/cli.js CHANGED
@@ -1,23 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, CommanderError } from "commander";
3
3
  import { realpathSync } from "node:fs";
4
- import { resolve } from "node:path";
4
+ import { join, resolve } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { buildCharacterVoiceProfiles, clearCharacterVoiceDriftFile, computeCharacterVoiceDrift, loadActiveCharacterVoiceDriftIds, loadCharacterVoiceProfiles, writeCharacterVoiceDriftFile, writeCharacterVoiceProfilesFile } from "./character-voice.js";
7
7
  import { NovelCliError } from "./errors.js";
8
8
  import { errJson, okJson, printJson } from "./output.js";
9
- import { pathExists } from "./fs-utils.js";
9
+ import { pathExists, readJsonFile, writeJsonFile, writeTextFile } from "./fs-utils.js";
10
10
  import { resolveProjectRoot } from "./project.js";
11
11
  import { readCheckpoint } from "./checkpoint.js";
12
+ import { initProject, normalizePlatformId, resolveInitRootDir } from "./init.js";
12
13
  import { advanceCheckpointForStep } from "./advance.js";
13
14
  import { commitChapter } from "./commit.js";
15
+ import { commitVolume } from "./volume-commit.js";
14
16
  import { buildInstructionPacket } from "./instructions.js";
15
17
  import { getLockStatus, clearStaleLock, withWriteLock } from "./lock.js";
16
18
  import { computeNextStep } from "./next-step.js";
17
19
  import { computeEngagementReport, loadEngagementMetricsStream, writeEngagementLogs } from "./engagement.js";
18
20
  import { computePromiseLedgerReport, ensurePromiseLedgerInitialized, loadPromiseLedger, writePromiseLedgerLogs } from "./promise-ledger.js";
19
- import { parseStepId } from "./steps.js";
21
+ import { pad2, pad3, parseStepId } from "./steps.js";
22
+ import { isPlainObject } from "./type-guards.js";
20
23
  import { validateStep } from "./validate.js";
24
+ import { VOL_REVIEW_RELS, collectVolumeData, computeBridgeCheck, computeForeshadowingAudit, computeStorylineRhythm } from "./volume-review.js";
25
+ import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
21
26
  function detectCommandName(argv) {
22
27
  for (const token of argv) {
23
28
  if (token === "--")
@@ -46,6 +51,38 @@ function buildProgram(argv) {
46
51
  program.showHelpAfterError(false);
47
52
  program.showSuggestionAfterError(false);
48
53
  program.exitOverride();
54
+ program
55
+ .command("init")
56
+ .description("Initialize a new novel project directory (.checkpoint.json + staging/** + optional templates).")
57
+ .option("--force", "Overwrite existing files when present.")
58
+ .option("--minimal", "Only create .checkpoint.json + staging/** (skip templates).")
59
+ .option("--platform <id>", "Also write platform-profile.json (+ genre-weight-profiles.json). Supported: qidian|tomato.")
60
+ .action(async (localOpts) => {
61
+ const opts = program.opts();
62
+ const json = Boolean(opts.json);
63
+ const rootDir = resolveInitRootDir({ cwd: process.cwd(), projectOverride: opts.project });
64
+ const platform = localOpts.platform ? normalizePlatformId(localOpts.platform) : undefined;
65
+ const result = await initProject({
66
+ rootDir,
67
+ force: Boolean(localOpts.force),
68
+ minimal: Boolean(localOpts.minimal),
69
+ platform
70
+ });
71
+ if (json) {
72
+ printJson(okJson("init", result));
73
+ return;
74
+ }
75
+ process.stdout.write(`Project: ${rootDir}\n`);
76
+ for (const d of result.ensuredDirs)
77
+ process.stdout.write(`MKDIR ${d}\n`);
78
+ for (const p of result.created)
79
+ process.stdout.write(`CREATE ${p}\n`);
80
+ for (const p of result.overwritten)
81
+ process.stdout.write(`OVERWRITE ${p}\n`);
82
+ for (const p of result.skipped)
83
+ process.stdout.write(`SKIP ${p}\n`);
84
+ process.stdout.write(`Next: novel next\n`);
85
+ });
49
86
  program
50
87
  .command("status")
51
88
  .description("Show project status (checkpoint, locks, next action).")
@@ -62,7 +99,7 @@ function buildProgram(argv) {
62
99
  return;
63
100
  }
64
101
  process.stdout.write(`Project: ${rootDir}\n`);
65
- process.stdout.write(`Checkpoint: chapter=${checkpoint.last_completed_chapter} volume=${checkpoint.current_volume}\n`);
102
+ process.stdout.write(`Checkpoint: state=${checkpoint.orchestrator_state} chapter=${checkpoint.last_completed_chapter} volume=${checkpoint.current_volume}\n`);
66
103
  process.stdout.write(`Pipeline: stage=${checkpoint.pipeline_stage ?? "null"} inflight=${checkpoint.inflight_chapter ?? "null"} revisions=${checkpoint.revision_count ?? 0} hook_fixes=${checkpoint.hook_fix_count ?? 0} title_fixes=${checkpoint.title_fix_count ?? 0}\n`);
67
104
  if (lock.exists) {
68
105
  process.stdout.write(`Lock: present${lock.stale ? " (stale)" : ""} started=${lock.info?.started ?? "unknown"} pid=${lock.info?.pid ?? "unknown"} chapter=${lock.info?.chapter ?? "unknown"}\n`);
@@ -154,13 +191,24 @@ function buildProgram(argv) {
154
191
  program
155
192
  .command("commit")
156
193
  .description("Commit staging artifacts into final locations (transaction).")
157
- .requiredOption("--chapter <n>", "Chapter number to commit.", (v) => Number.parseInt(String(v), 10))
194
+ .option("--chapter <n>", "Chapter number to commit.", (v) => Number.parseInt(String(v), 10))
195
+ .option("--volume <n>", "Volume number to commit (volume planning artifacts).", (v) => Number.parseInt(String(v), 10))
158
196
  .option("--dry-run", "Show planned actions without applying them.")
159
197
  .action(async (localOpts) => {
160
198
  const opts = program.opts();
161
199
  const json = Boolean(opts.json);
162
200
  const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
163
- const result = await commitChapter({ rootDir, chapter: localOpts.chapter, dryRun: Boolean(localOpts.dryRun) });
201
+ const chapter = localOpts.chapter;
202
+ const volume = localOpts.volume;
203
+ if (chapter !== undefined && volume !== undefined) {
204
+ throw new NovelCliError("Invalid commit: provide exactly one of --chapter or --volume.", 2);
205
+ }
206
+ if (chapter === undefined && volume === undefined) {
207
+ throw new NovelCliError("Invalid commit: missing required option --chapter or --volume.", 2);
208
+ }
209
+ const result = chapter !== undefined
210
+ ? await commitChapter({ rootDir, chapter, dryRun: Boolean(localOpts.dryRun) })
211
+ : await commitVolume({ rootDir, volume: volume, dryRun: Boolean(localOpts.dryRun) });
164
212
  if (json) {
165
213
  printJson(okJson("commit", { rootDir, ...result }));
166
214
  return;
@@ -171,8 +219,187 @@ function buildProgram(argv) {
171
219
  for (const w of result.warnings)
172
220
  process.stdout.write(`WARN: ${w}\n`);
173
221
  }
174
- if (!localOpts.dryRun)
175
- process.stdout.write(`Committed chapter ${localOpts.chapter}.\n`);
222
+ if (!localOpts.dryRun) {
223
+ if (chapter !== undefined)
224
+ process.stdout.write(`Committed chapter ${chapter}.\n`);
225
+ else
226
+ process.stdout.write(`Committed volume ${volume}.\n`);
227
+ }
228
+ });
229
+ const volumeReview = program.command("volume-review").description("Volume-end review helper commands (issue #144).");
230
+ volumeReview
231
+ .command("collect")
232
+ .description(`Generate ${VOL_REVIEW_RELS.qualitySummary} from committed evals (best-effort).`)
233
+ .action(async () => {
234
+ const opts = program.opts();
235
+ const json = Boolean(opts.json);
236
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
237
+ const result = await withWriteLock(rootDir, {}, async () => {
238
+ const checkpoint = await readCheckpoint(rootDir);
239
+ const summary = await collectVolumeData({ rootDir, checkpoint });
240
+ await writeJsonFile(join(rootDir, VOL_REVIEW_RELS.qualitySummary), summary);
241
+ return { checkpoint, summary };
242
+ });
243
+ if (json) {
244
+ printJson(okJson("volume-review collect", { rootDir, rel: VOL_REVIEW_RELS.qualitySummary, summary: result.summary }));
245
+ return;
246
+ }
247
+ process.stdout.write(`Wrote ${VOL_REVIEW_RELS.qualitySummary}.\n`);
248
+ });
249
+ volumeReview
250
+ .command("report")
251
+ .description(`Generate ${VOL_REVIEW_RELS.reviewReport} from quality summary + audit report (deterministic markdown).`)
252
+ .action(async () => {
253
+ const opts = program.opts();
254
+ const json = Boolean(opts.json);
255
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
256
+ await withWriteLock(rootDir, {}, async () => {
257
+ const checkpoint = await readCheckpoint(rootDir);
258
+ const volume = checkpoint.current_volume;
259
+ const endChapter = checkpoint.last_completed_chapter;
260
+ const resolvedRange = (await tryResolveVolumeChapterRange({ rootDir, volume })) ??
261
+ (Number.isInteger(endChapter) && endChapter >= 1 ? { start: Math.max(1, endChapter - 9), end: endChapter } : null);
262
+ if (!resolvedRange) {
263
+ throw new NovelCliError(`Cannot resolve volume review chapter_range (last_completed_chapter=${String(endChapter)}).`, 2);
264
+ }
265
+ // Best-effort reads: validation guards presence.
266
+ let summary = null;
267
+ let audit = null;
268
+ try {
269
+ summary = await readJsonFile(join(rootDir, VOL_REVIEW_RELS.qualitySummary));
270
+ }
271
+ catch {
272
+ summary = null;
273
+ }
274
+ try {
275
+ audit = await readJsonFile(join(rootDir, VOL_REVIEW_RELS.auditReport));
276
+ }
277
+ catch {
278
+ audit = null;
279
+ }
280
+ const lines = [];
281
+ lines.push(`# Volume Review Report`);
282
+ lines.push("");
283
+ lines.push(`- volume: ${volume}`);
284
+ lines.push(`- chapter_range: ${resolvedRange.start}-${resolvedRange.end}`);
285
+ if (isPlainObject(summary)) {
286
+ const stats = isPlainObject(summary.stats)
287
+ ? summary.stats
288
+ : null;
289
+ if (stats) {
290
+ const avg = typeof stats.overall_avg === "number" ? stats.overall_avg : null;
291
+ const min = typeof stats.overall_min === "number" ? stats.overall_min : null;
292
+ const max = typeof stats.overall_max === "number" ? stats.overall_max : null;
293
+ lines.push(`- overall_avg: ${avg ?? "n/a"} (min=${min ?? "n/a"}, max=${max ?? "n/a"})`);
294
+ }
295
+ const lows = Array.isArray(summary.low_chapters)
296
+ ? summary.low_chapters
297
+ : [];
298
+ if (lows.length > 0) {
299
+ lines.push("");
300
+ lines.push(`## Low Score Chapters (<3.5)`);
301
+ for (const it of lows.slice(0, 20)) {
302
+ if (!isPlainObject(it))
303
+ continue;
304
+ const ch = it.chapter;
305
+ const sc = it.overall_final;
306
+ if (typeof ch === "number" && typeof sc === "number")
307
+ lines.push(`- ch${pad3(ch)}: ${sc}`);
308
+ }
309
+ }
310
+ }
311
+ if (isPlainObject(audit)) {
312
+ const stats = isPlainObject(audit.stats) ? audit.stats : null;
313
+ if (stats) {
314
+ const total = typeof stats.issues_total === "number" ? stats.issues_total : null;
315
+ lines.push("");
316
+ lines.push(`## Consistency Audit`);
317
+ lines.push(`- issues_total: ${total ?? "n/a"}`);
318
+ }
319
+ }
320
+ await writeTextFile(join(rootDir, VOL_REVIEW_RELS.reviewReport), `${lines.join("\n")}\n`);
321
+ });
322
+ if (json) {
323
+ printJson(okJson("volume-review report", { rootDir, rel: VOL_REVIEW_RELS.reviewReport }));
324
+ return;
325
+ }
326
+ process.stdout.write(`Wrote ${VOL_REVIEW_RELS.reviewReport}.\n`);
327
+ });
328
+ volumeReview
329
+ .command("cleanup")
330
+ .description(`Generate ${VOL_REVIEW_RELS.foreshadowStatus} (foreshadowing audit + bridge check + storyline rhythm).`)
331
+ .action(async () => {
332
+ const opts = program.opts();
333
+ const json = Boolean(opts.json);
334
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
335
+ const payload = await withWriteLock(rootDir, {}, async () => {
336
+ const checkpoint = await readCheckpoint(rootDir);
337
+ const volume = checkpoint.current_volume;
338
+ const endChapter = checkpoint.last_completed_chapter;
339
+ const resolvedRange = (await tryResolveVolumeChapterRange({ rootDir, volume })) ??
340
+ (Number.isInteger(endChapter) && endChapter >= 1 ? { start: Math.max(1, endChapter - 9), end: endChapter } : null);
341
+ if (!resolvedRange) {
342
+ throw new NovelCliError(`Cannot resolve volume review chapter_range (last_completed_chapter=${String(endChapter)}).`, 2);
343
+ }
344
+ const foreshadowingAudit = await computeForeshadowingAudit({ rootDir, checkpoint });
345
+ // Best-effort: compute bridge check using available foreshadow ids.
346
+ const globalIds = new Set();
347
+ const planIds = new Set();
348
+ try {
349
+ const globalRaw = await readJsonFile(join(rootDir, "foreshadowing/global.json"));
350
+ const list = Array.isArray(globalRaw)
351
+ ? globalRaw
352
+ : isPlainObject(globalRaw) && Array.isArray(globalRaw.foreshadowing)
353
+ ? globalRaw.foreshadowing
354
+ : [];
355
+ for (const it of list) {
356
+ if (!isPlainObject(it))
357
+ continue;
358
+ const id = typeof it.id === "string" ? it.id.trim() : "";
359
+ if (id)
360
+ globalIds.add(id);
361
+ }
362
+ }
363
+ catch {
364
+ // optional
365
+ }
366
+ try {
367
+ const rel = `volumes/vol-${pad2(volume)}/foreshadowing.json`;
368
+ const planRaw = await readJsonFile(join(rootDir, rel));
369
+ const list = Array.isArray(planRaw)
370
+ ? planRaw
371
+ : isPlainObject(planRaw) && Array.isArray(planRaw.foreshadowing)
372
+ ? planRaw.foreshadowing
373
+ : [];
374
+ for (const it of list) {
375
+ if (!isPlainObject(it))
376
+ continue;
377
+ const id = typeof it.id === "string" ? it.id.trim() : "";
378
+ if (id)
379
+ planIds.add(id);
380
+ }
381
+ }
382
+ catch {
383
+ // optional
384
+ }
385
+ const bridgeCheck = await computeBridgeCheck({ rootDir, volume, foreshadowIds: { global: globalIds, plan: planIds } });
386
+ const rhythm = await computeStorylineRhythm({ rootDir, volume, chapter_range: [resolvedRange.start, resolvedRange.end] });
387
+ const out = {
388
+ schema_version: 1,
389
+ generated_at: new Date().toISOString(),
390
+ as_of: { volume, chapter: endChapter },
391
+ foreshadowing_audit: foreshadowingAudit,
392
+ bridge_check: bridgeCheck,
393
+ storyline_rhythm: rhythm
394
+ };
395
+ await writeJsonFile(join(rootDir, VOL_REVIEW_RELS.foreshadowStatus), out);
396
+ return out;
397
+ });
398
+ if (json) {
399
+ printJson(okJson("volume-review cleanup", { rootDir, rel: VOL_REVIEW_RELS.foreshadowStatus, payload }));
400
+ return;
401
+ }
402
+ process.stdout.write(`Wrote ${VOL_REVIEW_RELS.foreshadowStatus}.\n`);
176
403
  });
177
404
  const promises = program.command("promises").description("Promise ledger (long-horizon narrative promises).");
178
405
  promises
package/dist/commit.js CHANGED
@@ -1249,6 +1249,7 @@ export async function commitChapter(args) {
1249
1249
  }
1250
1250
  updatedCheckpoint.pipeline_stage = "committed";
1251
1251
  updatedCheckpoint.inflight_chapter = null;
1252
+ updatedCheckpoint.orchestrator_state = "WRITING";
1252
1253
  updatedCheckpoint.revision_count = 0;
1253
1254
  updatedCheckpoint.hook_fix_count = 0;
1254
1255
  updatedCheckpoint.title_fix_count = 0;
package/dist/fs-utils.js CHANGED
@@ -1,5 +1,5 @@
1
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
- import { dirname } from "node:path";
1
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
3
  import { NovelCliError } from "./errors.js";
4
4
  export async function pathExists(path) {
5
5
  try {
@@ -55,7 +55,22 @@ export async function writeTextFileIfMissing(path, contents) {
55
55
  }
56
56
  }
57
57
  export async function writeJsonFile(path, payload) {
58
- await writeTextFile(path, `${JSON.stringify(payload, null, 2)}\n`);
58
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
59
+ const tmp = join(dirname(path), `.${process.pid}.tmp`);
60
+ try {
61
+ await ensureDir(dirname(path));
62
+ await writeFile(tmp, content, "utf8");
63
+ await rename(tmp, path);
64
+ }
65
+ catch (err) {
66
+ // Best-effort cleanup of temp file on failure.
67
+ try {
68
+ await rm(tmp, { force: true });
69
+ }
70
+ catch { /* ignore */ }
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ throw new NovelCliError(`Failed to write file: ${path}. ${message}`);
73
+ }
59
74
  }
60
75
  export async function removePath(path) {
61
76
  try {
@@ -0,0 +1,59 @@
1
+ import { isPlainObject } from "./type-guards.js";
2
+ export const GATE_DECISIONS = ["pass", "polish", "revise", "pause_for_user", "pause_for_user_force_rewrite", "force_passed"];
3
+ function isHighViolation(check) {
4
+ return check.status === "violation" && check.confidence === "high";
5
+ }
6
+ export function detectHighConfidenceViolation(evalRaw) {
7
+ if (!isPlainObject(evalRaw))
8
+ return { has_high_confidence_violation: false, high_confidence_violations: [] };
9
+ const evalObj = evalRaw;
10
+ const cvRaw = evalObj.contract_verification;
11
+ if (!isPlainObject(cvRaw))
12
+ return { has_high_confidence_violation: false, high_confidence_violations: [] };
13
+ const cv = cvRaw;
14
+ const pick = (key) => {
15
+ const raw = cv[key];
16
+ if (!Array.isArray(raw))
17
+ return [];
18
+ return raw.filter((it) => isPlainObject(it));
19
+ };
20
+ const hardChecks = [];
21
+ for (const key of ["l1_checks", "l2_checks", "l3_checks"]) {
22
+ for (const it of pick(key)) {
23
+ if (!isHighViolation(it))
24
+ continue;
25
+ hardChecks.push(it);
26
+ }
27
+ }
28
+ for (const it of pick("ls_checks")) {
29
+ if (!isHighViolation(it))
30
+ continue;
31
+ const constraintType = typeof it.constraint_type === "string" ? it.constraint_type : null;
32
+ // Default to hard when missing to preserve safety.
33
+ const isHard = constraintType === null || constraintType === "hard";
34
+ if (!isHard)
35
+ continue;
36
+ hardChecks.push(constraintType === null ? { ...it, constraint_type_inferred: true } : it);
37
+ }
38
+ return { has_high_confidence_violation: hardChecks.length > 0, high_confidence_violations: hardChecks };
39
+ }
40
+ export function computeGateDecision(args) {
41
+ const maxRevisions = typeof args.max_revisions === "number" && Number.isInteger(args.max_revisions) && args.max_revisions >= 0 ? args.max_revisions : 2;
42
+ if (args.force_pass)
43
+ return "force_passed";
44
+ if (args.has_high_confidence_violation) {
45
+ return args.revision_count >= maxRevisions ? "pause_for_user" : "revise";
46
+ }
47
+ const score = args.overall_final;
48
+ if (!Number.isFinite(score))
49
+ return "pause_for_user_force_rewrite";
50
+ if (score >= 4.0)
51
+ return "pass";
52
+ if (score >= 3.5)
53
+ return args.revision_count >= maxRevisions ? "force_passed" : "polish";
54
+ if (score >= 3.0)
55
+ return args.revision_count >= maxRevisions ? "force_passed" : "revise";
56
+ if (score >= 2.0)
57
+ return "pause_for_user";
58
+ return "pause_for_user_force_rewrite";
59
+ }