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/cli.js CHANGED
@@ -1,24 +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
12
  import { initProject, normalizePlatformId, resolveInitRootDir } from "./init.js";
13
13
  import { advanceCheckpointForStep } from "./advance.js";
14
14
  import { commitChapter } from "./commit.js";
15
+ import { commitVolume } from "./volume-commit.js";
15
16
  import { buildInstructionPacket } from "./instructions.js";
16
17
  import { getLockStatus, clearStaleLock, withWriteLock } from "./lock.js";
17
18
  import { computeNextStep } from "./next-step.js";
18
19
  import { computeEngagementReport, loadEngagementMetricsStream, writeEngagementLogs } from "./engagement.js";
19
20
  import { computePromiseLedgerReport, ensurePromiseLedgerInitialized, loadPromiseLedger, writePromiseLedgerLogs } from "./promise-ledger.js";
20
- import { parseStepId } from "./steps.js";
21
+ import { pad2, pad3, parseStepId } from "./steps.js";
22
+ import { isPlainObject } from "./type-guards.js";
21
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";
22
26
  function detectCommandName(argv) {
23
27
  for (const token of argv) {
24
28
  if (token === "--")
@@ -95,7 +99,7 @@ function buildProgram(argv) {
95
99
  return;
96
100
  }
97
101
  process.stdout.write(`Project: ${rootDir}\n`);
98
- 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`);
99
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`);
100
104
  if (lock.exists) {
101
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`);
@@ -187,13 +191,24 @@ function buildProgram(argv) {
187
191
  program
188
192
  .command("commit")
189
193
  .description("Commit staging artifacts into final locations (transaction).")
190
- .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))
191
196
  .option("--dry-run", "Show planned actions without applying them.")
192
197
  .action(async (localOpts) => {
193
198
  const opts = program.opts();
194
199
  const json = Boolean(opts.json);
195
200
  const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
196
- 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) });
197
212
  if (json) {
198
213
  printJson(okJson("commit", { rootDir, ...result }));
199
214
  return;
@@ -204,8 +219,187 @@ function buildProgram(argv) {
204
219
  for (const w of result.warnings)
205
220
  process.stdout.write(`WARN: ${w}\n`);
206
221
  }
207
- if (!localOpts.dryRun)
208
- 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`);
209
403
  });
210
404
  const promises = program.command("promises").description("Promise ledger (long-horizon narrative promises).");
211
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
+ }
package/dist/init.js CHANGED
@@ -106,6 +106,8 @@ const STAGING_SUBDIRS = [
106
106
  "staging/evaluations",
107
107
  "staging/logs",
108
108
  "staging/storylines",
109
+ "staging/volumes",
110
+ "staging/foreshadowing",
109
111
  "staging/manifests"
110
112
  ];
111
113
  export async function initProject(args) {