novel-writer-cli 0.0.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/agents/chapter-writer.md +142 -0
  4. package/agents/character-weaver.md +117 -0
  5. package/agents/consistency-auditor.md +85 -0
  6. package/agents/plot-architect.md +128 -0
  7. package/agents/quality-judge.md +232 -0
  8. package/agents/style-analyzer.md +109 -0
  9. package/agents/style-refiner.md +97 -0
  10. package/agents/summarizer.md +128 -0
  11. package/agents/world-builder.md +161 -0
  12. package/dist/__tests__/character-voice.test.js +445 -0
  13. package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
  14. package/dist/__tests__/engagement.test.js +382 -0
  15. package/dist/__tests__/foreshadow-visibility.test.js +131 -0
  16. package/dist/__tests__/hook-ledger.test.js +1028 -0
  17. package/dist/__tests__/naming-lint.test.js +132 -0
  18. package/dist/__tests__/narrative-health-injection.test.js +359 -0
  19. package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
  20. package/dist/__tests__/next-step-title-fix.test.js +153 -0
  21. package/dist/__tests__/platform-profile.test.js +274 -0
  22. package/dist/__tests__/promise-ledger.test.js +189 -0
  23. package/dist/__tests__/readability-lint.test.js +209 -0
  24. package/dist/__tests__/text-utils.test.js +39 -0
  25. package/dist/__tests__/title-policy.test.js +147 -0
  26. package/dist/advance.js +75 -0
  27. package/dist/character-voice.js +805 -0
  28. package/dist/checkpoint.js +126 -0
  29. package/dist/cli.js +563 -0
  30. package/dist/cliche-lint.js +515 -0
  31. package/dist/commit.js +1460 -0
  32. package/dist/consistency-auditor.js +684 -0
  33. package/dist/engagement.js +687 -0
  34. package/dist/errors.js +7 -0
  35. package/dist/fingerprint.js +16 -0
  36. package/dist/foreshadow-visibility.js +214 -0
  37. package/dist/fs-utils.js +68 -0
  38. package/dist/hook-ledger.js +721 -0
  39. package/dist/hook-policy.js +107 -0
  40. package/dist/instruction-gates.js +51 -0
  41. package/dist/instructions.js +406 -0
  42. package/dist/latest-summary-loader.js +29 -0
  43. package/dist/lock.js +121 -0
  44. package/dist/naming-lint.js +531 -0
  45. package/dist/ner.js +73 -0
  46. package/dist/next-step.js +408 -0
  47. package/dist/novel-ask.js +270 -0
  48. package/dist/output.js +9 -0
  49. package/dist/platform-constraints.js +518 -0
  50. package/dist/platform-profile.js +325 -0
  51. package/dist/prejudge-guardrails.js +370 -0
  52. package/dist/project.js +40 -0
  53. package/dist/promise-ledger.js +723 -0
  54. package/dist/readability-lint.js +555 -0
  55. package/dist/safe-parse.js +36 -0
  56. package/dist/safe-path.js +29 -0
  57. package/dist/scoring-weights.js +290 -0
  58. package/dist/steps.js +60 -0
  59. package/dist/text-utils.js +18 -0
  60. package/dist/title-policy.js +251 -0
  61. package/dist/type-guards.js +6 -0
  62. package/dist/validate.js +131 -0
  63. package/docs/user/README.md +17 -0
  64. package/docs/user/guardrails.md +179 -0
  65. package/docs/user/interactive-gates.md +124 -0
  66. package/docs/user/novel-cli.md +289 -0
  67. package/docs/user/ops.md +123 -0
  68. package/docs/user/quick-start.md +97 -0
  69. package/docs/user/spec-system.md +166 -0
  70. package/docs/user/storylines.md +144 -0
  71. package/package.json +48 -0
  72. package/schemas/README.md +18 -0
  73. package/schemas/character-voice-drift.schema.json +135 -0
  74. package/schemas/character-voice-profiles.schema.json +141 -0
  75. package/schemas/engagement-metrics.schema.json +38 -0
  76. package/schemas/hook-ledger.schema.json +108 -0
  77. package/schemas/platform-profile.schema.json +235 -0
  78. package/schemas/promise-ledger.schema.json +97 -0
  79. package/scripts/calibrate-quality-judge.sh +91 -0
  80. package/scripts/compare-regression-runs.sh +86 -0
  81. package/scripts/lib/_common.py +131 -0
  82. package/scripts/lib/calibrate_quality_judge.py +312 -0
  83. package/scripts/lib/compare_regression_runs.py +142 -0
  84. package/scripts/lib/run_regression.py +621 -0
  85. package/scripts/lint-blacklist.sh +201 -0
  86. package/scripts/lint-cliche.sh +370 -0
  87. package/scripts/lint-readability.sh +404 -0
  88. package/scripts/query-foreshadow.sh +252 -0
  89. package/scripts/run-ner.sh +669 -0
  90. package/scripts/run-regression.sh +122 -0
  91. package/skills/cli-step/SKILL.md +158 -0
  92. package/skills/continue/SKILL.md +348 -0
  93. package/skills/continue/references/context-contracts.md +169 -0
  94. package/skills/continue/references/continuity-checks.md +187 -0
  95. package/skills/continue/references/file-protocols.md +64 -0
  96. package/skills/continue/references/foreshadowing.md +130 -0
  97. package/skills/continue/references/gate-decision.md +53 -0
  98. package/skills/continue/references/periodic-maintenance.md +46 -0
  99. package/skills/novel-writing/SKILL.md +77 -0
  100. package/skills/novel-writing/references/quality-rubric.md +140 -0
  101. package/skills/novel-writing/references/style-guide.md +145 -0
  102. package/skills/start/SKILL.md +458 -0
  103. package/skills/start/references/quality-review.md +86 -0
  104. package/skills/start/references/setting-update.md +44 -0
  105. package/skills/start/references/vol-planning.md +61 -0
  106. package/skills/start/references/vol-review.md +58 -0
  107. package/skills/status/SKILL.md +116 -0
  108. package/skills/status/references/sample-output.md +60 -0
  109. package/templates/ai-blacklist.json +79 -0
  110. package/templates/brief-template.md +46 -0
  111. package/templates/genre-weight-profiles.json +90 -0
  112. package/templates/novel-ask/example.answer.json +12 -0
  113. package/templates/novel-ask/example.question.json +51 -0
  114. package/templates/platform-profile.json +148 -0
  115. package/templates/style-profile-template.json +58 -0
  116. package/templates/web-novel-cliche-lint.json +41 -0
package/dist/cli.js ADDED
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env node
2
+ import { Command, CommanderError } from "commander";
3
+ import { resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { buildCharacterVoiceProfiles, clearCharacterVoiceDriftFile, computeCharacterVoiceDrift, loadActiveCharacterVoiceDriftIds, loadCharacterVoiceProfiles, writeCharacterVoiceDriftFile, writeCharacterVoiceProfilesFile } from "./character-voice.js";
6
+ import { NovelCliError } from "./errors.js";
7
+ import { errJson, okJson, printJson } from "./output.js";
8
+ import { pathExists } from "./fs-utils.js";
9
+ import { resolveProjectRoot } from "./project.js";
10
+ import { readCheckpoint } from "./checkpoint.js";
11
+ import { advanceCheckpointForStep } from "./advance.js";
12
+ import { commitChapter } from "./commit.js";
13
+ import { buildInstructionPacket } from "./instructions.js";
14
+ import { getLockStatus, clearStaleLock, withWriteLock } from "./lock.js";
15
+ import { computeNextStep } from "./next-step.js";
16
+ import { computeEngagementReport, loadEngagementMetricsStream, writeEngagementLogs } from "./engagement.js";
17
+ import { computePromiseLedgerReport, ensurePromiseLedgerInitialized, loadPromiseLedger, writePromiseLedgerLogs } from "./promise-ledger.js";
18
+ import { parseStepId } from "./steps.js";
19
+ import { validateStep } from "./validate.js";
20
+ function detectCommandName(argv) {
21
+ for (const token of argv) {
22
+ if (token === "--")
23
+ return "unknown";
24
+ if (!token.startsWith("-"))
25
+ return token;
26
+ }
27
+ return "unknown";
28
+ }
29
+ function isJsonMode(argv) {
30
+ return argv.includes("--json");
31
+ }
32
+ function buildProgram(argv) {
33
+ const jsonMode = isJsonMode(argv);
34
+ const program = new Command();
35
+ program.name("novel").description("Executor-agnostic novel orchestration CLI.");
36
+ program.option("--json", "Emit machine-readable JSON (single object).");
37
+ program.option("--project <dir>", "Project root directory (defaults to auto-detect via .checkpoint.json).");
38
+ program.configureOutput({
39
+ writeOut: (str) => process.stdout.write(str),
40
+ writeErr: (str) => {
41
+ if (!jsonMode)
42
+ process.stderr.write(str);
43
+ }
44
+ });
45
+ program.showHelpAfterError(false);
46
+ program.showSuggestionAfterError(false);
47
+ program.exitOverride();
48
+ program
49
+ .command("status")
50
+ .description("Show project status (checkpoint, locks, next action).")
51
+ .action(async () => {
52
+ const opts = program.opts();
53
+ const json = Boolean(opts.json);
54
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
55
+ const checkpoint = await readCheckpoint(rootDir);
56
+ const lock = await getLockStatus(rootDir);
57
+ const next = await computeNextStep(rootDir, checkpoint);
58
+ const data = { rootDir, checkpoint, lock, next };
59
+ if (json) {
60
+ printJson(okJson("status", data));
61
+ return;
62
+ }
63
+ process.stdout.write(`Project: ${rootDir}\n`);
64
+ process.stdout.write(`Checkpoint: chapter=${checkpoint.last_completed_chapter} volume=${checkpoint.current_volume}\n`);
65
+ 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`);
66
+ if (lock.exists) {
67
+ process.stdout.write(`Lock: present${lock.stale ? " (stale)" : ""} started=${lock.info?.started ?? "unknown"} pid=${lock.info?.pid ?? "unknown"} chapter=${lock.info?.chapter ?? "unknown"}\n`);
68
+ }
69
+ else {
70
+ process.stdout.write("Lock: none\n");
71
+ }
72
+ process.stdout.write(`Next: ${next.step}\n`);
73
+ if (next.reason)
74
+ process.stdout.write(`Reason: ${next.reason}\n`);
75
+ });
76
+ program
77
+ .command("next")
78
+ .description("Compute the deterministic next step for the pipeline.")
79
+ .action(async () => {
80
+ const opts = program.opts();
81
+ const json = Boolean(opts.json);
82
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
83
+ const checkpoint = await readCheckpoint(rootDir);
84
+ const next = await computeNextStep(rootDir, checkpoint);
85
+ if (json) {
86
+ printJson(okJson("next", { rootDir, ...next }));
87
+ return;
88
+ }
89
+ process.stdout.write(`${next.step}\n`);
90
+ });
91
+ program
92
+ .command("instructions")
93
+ .description("Emit an instruction packet for a step.")
94
+ .argument("<step>", "Step id, e.g. chapter:048:draft")
95
+ .option("--write-manifest", "Persist packet under staging/manifests/.")
96
+ .option("--embed <mode>", "Optional embed mode (off by default). Example: --embed brief")
97
+ .action(async (step, localOpts) => {
98
+ const opts = program.opts();
99
+ const json = Boolean(opts.json);
100
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
101
+ const checkpoint = await readCheckpoint(rootDir);
102
+ const parsedStep = parseStepId(step);
103
+ const packet = await buildInstructionPacket({
104
+ rootDir,
105
+ checkpoint,
106
+ step: parsedStep,
107
+ embedMode: localOpts.embed ?? null,
108
+ writeManifest: Boolean(localOpts.writeManifest)
109
+ });
110
+ if (json) {
111
+ printJson(okJson("instructions", packet));
112
+ return;
113
+ }
114
+ process.stdout.write(`${JSON.stringify(packet, null, 2)}\n`);
115
+ });
116
+ program
117
+ .command("validate")
118
+ .description("Validate that a step output is complete and well-formed.")
119
+ .argument("<step>", "Step id to validate.")
120
+ .action(async (step) => {
121
+ const opts = program.opts();
122
+ const json = Boolean(opts.json);
123
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
124
+ const checkpoint = await readCheckpoint(rootDir);
125
+ const parsedStep = parseStepId(step);
126
+ const report = await validateStep({ rootDir, checkpoint, step: parsedStep });
127
+ if (json) {
128
+ printJson(okJson("validate", { rootDir, step: report.step, ok: report.ok, warnings: report.warnings }));
129
+ return;
130
+ }
131
+ process.stdout.write(`OK: ${report.step}\n`);
132
+ if (report.warnings.length > 0) {
133
+ for (const w of report.warnings)
134
+ process.stdout.write(`WARN: ${w}\n`);
135
+ }
136
+ });
137
+ program
138
+ .command("advance")
139
+ .description("Advance checkpoint after a step validates successfully.")
140
+ .argument("<step>", "Step id to advance.")
141
+ .action(async (step) => {
142
+ const opts = program.opts();
143
+ const json = Boolean(opts.json);
144
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
145
+ const parsedStep = parseStepId(step);
146
+ const updated = await advanceCheckpointForStep({ rootDir, step: parsedStep });
147
+ if (json) {
148
+ printJson(okJson("advance", { rootDir, checkpoint: updated }));
149
+ return;
150
+ }
151
+ process.stdout.write(`Advanced: ${step}\n`);
152
+ });
153
+ program
154
+ .command("commit")
155
+ .description("Commit staging artifacts into final locations (transaction).")
156
+ .requiredOption("--chapter <n>", "Chapter number to commit.", (v) => Number.parseInt(String(v), 10))
157
+ .option("--dry-run", "Show planned actions without applying them.")
158
+ .action(async (localOpts) => {
159
+ const opts = program.opts();
160
+ const json = Boolean(opts.json);
161
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
162
+ const result = await commitChapter({ rootDir, chapter: localOpts.chapter, dryRun: Boolean(localOpts.dryRun) });
163
+ if (json) {
164
+ printJson(okJson("commit", { rootDir, ...result }));
165
+ return;
166
+ }
167
+ for (const line of result.plan)
168
+ process.stdout.write(`${line}\n`);
169
+ if (result.warnings.length > 0) {
170
+ for (const w of result.warnings)
171
+ process.stdout.write(`WARN: ${w}\n`);
172
+ }
173
+ if (!localOpts.dryRun)
174
+ process.stdout.write(`Committed chapter ${localOpts.chapter}.\n`);
175
+ });
176
+ const promises = program.command("promises").description("Promise ledger (long-horizon narrative promises).");
177
+ promises
178
+ .command("init")
179
+ .description("Initialize promise-ledger.json from brief/outline/summaries (best-effort seed).")
180
+ .option("--apply", "Write promise-ledger.json (otherwise preview-only).")
181
+ .option("--max-recent-summaries <n>", "How many recent summaries to scan for seed candidates (default: 10).", (v) => Number.parseInt(String(v), 10))
182
+ .action(async (localOpts) => {
183
+ const opts = program.opts();
184
+ const json = Boolean(opts.json);
185
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
186
+ const checkpoint = await readCheckpoint(rootDir);
187
+ const maxRecentSummaries = localOpts.maxRecentSummaries ?? 10;
188
+ if (!Number.isInteger(maxRecentSummaries) || maxRecentSummaries < 0) {
189
+ throw new NovelCliError(`Invalid --max-recent-summaries: ${String(localOpts.maxRecentSummaries)} (expected int >= 0).`, 2);
190
+ }
191
+ const result = await ensurePromiseLedgerInitialized({
192
+ rootDir,
193
+ volume: checkpoint.current_volume,
194
+ maxRecentSummaries,
195
+ apply: Boolean(localOpts.apply)
196
+ });
197
+ if (json) {
198
+ printJson(okJson("promises init", { rootDir, ...result }));
199
+ return;
200
+ }
201
+ if (result.wrote) {
202
+ process.stdout.write(`Initialized ${result.rel}.\n`);
203
+ return;
204
+ }
205
+ process.stdout.write(`${result.rel} already exists or init is preview-only. Use --json to inspect the seed, or re-run with --apply.\n`);
206
+ });
207
+ promises
208
+ .command("report")
209
+ .description("Generate a promise-ledger report under logs/promises/ (latest.json + optional history).")
210
+ .option("--as-of <n>", "As-of chapter (defaults to checkpoint.last_completed_chapter).", (v) => Number.parseInt(String(v), 10))
211
+ .option("--volume <n>", "Volume number (defaults to checkpoint.current_volume).", (v) => Number.parseInt(String(v), 10))
212
+ .option("--start <n>", "Start chapter for report scope (defaults to max(1, end-9)).", (v) => Number.parseInt(String(v), 10))
213
+ .option("--end <n>", "End chapter for report scope (defaults to as-of chapter).", (v) => Number.parseInt(String(v), 10))
214
+ .option("--history", "Also write a history report file for the selected scope.")
215
+ .action(async (localOpts) => {
216
+ const opts = program.opts();
217
+ const json = Boolean(opts.json);
218
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
219
+ const checkpoint = await readCheckpoint(rootDir);
220
+ const volume = localOpts.volume ?? checkpoint.current_volume;
221
+ const end = localOpts.end ?? localOpts.asOf ?? checkpoint.last_completed_chapter;
222
+ const asOf = localOpts.asOf ?? end;
223
+ const start = localOpts.start ?? Math.max(1, end - 9);
224
+ if (!Number.isInteger(asOf) || asOf < 1)
225
+ throw new NovelCliError(`Invalid --as-of: ${String(asOf)} (expected int >= 1).`, 2);
226
+ if (!Number.isInteger(volume) || volume < 0)
227
+ throw new NovelCliError(`Invalid --volume: ${String(volume)} (expected int >= 0).`, 2);
228
+ if (!Number.isInteger(start) || start < 1)
229
+ throw new NovelCliError(`Invalid --start: ${String(start)} (expected int >= 1).`, 2);
230
+ if (!Number.isInteger(end) || end < 0)
231
+ throw new NovelCliError(`Invalid --end: ${String(end)} (expected int >= 0).`, 2);
232
+ if (end === 0) {
233
+ if (checkpoint.last_completed_chapter === 0 && localOpts.end === undefined && localOpts.asOf === undefined) {
234
+ throw new NovelCliError("No committed chapters yet (checkpoint.last_completed_chapter=0). Commit at least one chapter, or pass --end/--as-of >= 1.", 2);
235
+ }
236
+ if (localOpts.asOf !== undefined && localOpts.end === undefined) {
237
+ throw new NovelCliError("Invalid --as-of: 0 (expected int >= 1).", 2);
238
+ }
239
+ throw new NovelCliError("Invalid --end: 0 (expected int >= 1).", 2);
240
+ }
241
+ if (end < start)
242
+ throw new NovelCliError(`Invalid --end: ${String(end)} (expected int >= start).`, 2);
243
+ if (asOf < end)
244
+ throw new NovelCliError(`Invalid --as-of: ${String(asOf)} (expected int >= --end=${end}).`, 2);
245
+ const ledgerAbs = resolve(rootDir, "promise-ledger.json");
246
+ if (!(await pathExists(ledgerAbs))) {
247
+ throw new NovelCliError("Missing promise-ledger.json. Run: novel promises init --apply", 2);
248
+ }
249
+ const loaded = await loadPromiseLedger(rootDir);
250
+ const ledgerWarnings = loaded.warnings.slice();
251
+ const report = computePromiseLedgerReport({ ledger: loaded.ledger, asOfChapter: asOf, volume, chapterRange: { start, end } });
252
+ const written = await writePromiseLedgerLogs({ rootDir, report, historyRange: localOpts.history ? { start, end } : null });
253
+ if (json) {
254
+ printJson(okJson("promises report", { rootDir, report, ledger_warnings: ledgerWarnings, ...written }));
255
+ return;
256
+ }
257
+ if (ledgerWarnings.length > 0) {
258
+ for (const w of ledgerWarnings)
259
+ process.stdout.write(`WARN: ${w}\n`);
260
+ }
261
+ process.stdout.write(`Wrote ${written.latestRel}.\n`);
262
+ if (written.historyRel)
263
+ process.stdout.write(`Wrote ${written.historyRel}.\n`);
264
+ });
265
+ const engagement = program.command("engagement").description("Engagement density metrics (per-chapter stream + window reports).");
266
+ engagement
267
+ .command("report")
268
+ .description("Generate an engagement density report under logs/engagement/ (latest.json + optional history).")
269
+ .option("--as-of <n>", "As-of chapter (defaults to checkpoint.last_completed_chapter).", (v) => Number.parseInt(String(v), 10))
270
+ .option("--volume <n>", "Volume number (defaults to checkpoint.current_volume).", (v) => Number.parseInt(String(v), 10))
271
+ .option("--start <n>", "Start chapter for report scope (defaults to max(1, end-9)).", (v) => Number.parseInt(String(v), 10))
272
+ .option("--end <n>", "End chapter for report scope (defaults to as-of chapter).", (v) => Number.parseInt(String(v), 10))
273
+ .option("--history", "Also write a history report file for the selected scope.")
274
+ .action(async (localOpts) => {
275
+ const opts = program.opts();
276
+ const json = Boolean(opts.json);
277
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
278
+ const checkpoint = await readCheckpoint(rootDir);
279
+ const volume = localOpts.volume ?? checkpoint.current_volume;
280
+ const end = localOpts.end ?? localOpts.asOf ?? checkpoint.last_completed_chapter;
281
+ const asOf = localOpts.asOf ?? end;
282
+ const start = localOpts.start ?? Math.max(1, end - 9);
283
+ if (!Number.isInteger(asOf) || asOf < 1)
284
+ throw new NovelCliError(`Invalid --as-of: ${String(asOf)} (expected int >= 1).`, 2);
285
+ if (!Number.isInteger(volume) || volume < 0)
286
+ throw new NovelCliError(`Invalid --volume: ${String(volume)} (expected int >= 0).`, 2);
287
+ if (!Number.isInteger(start) || start < 1)
288
+ throw new NovelCliError(`Invalid --start: ${String(start)} (expected int >= 1).`, 2);
289
+ if (!Number.isInteger(end) || end < 0)
290
+ throw new NovelCliError(`Invalid --end: ${String(end)} (expected int >= 0).`, 2);
291
+ if (end === 0) {
292
+ if (checkpoint.last_completed_chapter === 0 && localOpts.end === undefined && localOpts.asOf === undefined) {
293
+ throw new NovelCliError("No committed chapters yet (checkpoint.last_completed_chapter=0). Commit at least one chapter, or pass --end/--as-of >= 1.", 2);
294
+ }
295
+ if (localOpts.asOf !== undefined && localOpts.end === undefined) {
296
+ throw new NovelCliError("Invalid --as-of: 0 (expected int >= 1).", 2);
297
+ }
298
+ throw new NovelCliError("Invalid --end: 0 (expected int >= 1).", 2);
299
+ }
300
+ if (end < start)
301
+ throw new NovelCliError(`Invalid --end: ${String(end)} (expected int >= start).`, 2);
302
+ if (asOf < end)
303
+ throw new NovelCliError(`Invalid --as-of: ${String(asOf)} (expected int >= --end=${end}).`, 2);
304
+ const metricsAbs = resolve(rootDir, "engagement-metrics.jsonl");
305
+ const streamExists = await pathExists(metricsAbs);
306
+ const loaded = await loadEngagementMetricsStream({ rootDir });
307
+ const streamWarnings = loaded.warnings.slice();
308
+ if (!streamExists)
309
+ streamWarnings.unshift("Missing engagement-metrics.jsonl; report will contain empty metrics.");
310
+ const report = computeEngagementReport({ records: loaded.records, asOfChapter: asOf, volume, chapterRange: { start, end }, metricsRelPath: loaded.rel });
311
+ const written = await writeEngagementLogs({ rootDir, report, historyRange: localOpts.history ? { start, end } : null });
312
+ if (json) {
313
+ printJson(okJson("engagement report", { rootDir, report, stream_warnings: streamWarnings, ...written }));
314
+ return;
315
+ }
316
+ if (streamWarnings.length > 0) {
317
+ for (const w of streamWarnings)
318
+ process.stdout.write(`WARN: ${w}\n`);
319
+ }
320
+ process.stdout.write(`Wrote ${written.latestRel}.\n`);
321
+ if (written.historyRel)
322
+ process.stdout.write(`Wrote ${written.historyRel}.\n`);
323
+ });
324
+ const voice = program.command("voice").description("Character voice profiles + drift directives (M7H.3).");
325
+ voice
326
+ .command("init")
327
+ .description("Initialize character-voice-profiles.json from early chapters (baseline calibration).")
328
+ .requiredOption("--protagonist <id>", "Protagonist character id.")
329
+ .option("--core-cast <ids>", "Comma-separated core cast character ids (optional).")
330
+ .option("--start <n>", "Baseline start chapter (default: 1).", (v) => Number.parseInt(String(v), 10))
331
+ .option("--end <n>", "Baseline end chapter (default: min(10, checkpoint.last_completed_chapter)).", (v) => Number.parseInt(String(v), 10))
332
+ .option("--window-chapters <n>", "Rolling window chapters for drift detection (default: 10).", (v) => Number.parseInt(String(v), 10))
333
+ .option("--force", "Allow overwriting character-voice-profiles.json (requires --apply; use with care).")
334
+ .option("--apply", "Write character-voice-profiles.json (otherwise preview-only).")
335
+ .action(async (localOpts) => {
336
+ const opts = program.opts();
337
+ const json = Boolean(opts.json);
338
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
339
+ const checkpoint = await readCheckpoint(rootDir);
340
+ const existingAbs = resolve(rootDir, "character-voice-profiles.json");
341
+ if (await pathExists(existingAbs)) {
342
+ const force = Boolean(localOpts.force);
343
+ if (!force) {
344
+ const loaded = await loadCharacterVoiceProfiles(rootDir);
345
+ if (json) {
346
+ printJson(okJson("voice init", { rootDir, wrote: false, rel: loaded.rel, warnings: loaded.warnings, profiles: loaded.profiles }));
347
+ return;
348
+ }
349
+ process.stdout.write(`character-voice-profiles.json already exists.\n`);
350
+ for (const w of loaded.warnings)
351
+ process.stdout.write(`WARN: ${w}\n`);
352
+ process.stdout.write(`Use --json to inspect, or re-run with --force --apply to overwrite.\n`);
353
+ return;
354
+ }
355
+ }
356
+ if (checkpoint.last_completed_chapter < 1) {
357
+ throw new NovelCliError("No committed chapters yet (checkpoint.last_completed_chapter=0). Commit at least one chapter before voice init, or create character-voice-profiles.json manually.", 2);
358
+ }
359
+ const start = localOpts.start ?? 1;
360
+ const end = localOpts.end ?? Math.min(10, checkpoint.last_completed_chapter);
361
+ const windowChapters = localOpts.windowChapters;
362
+ if (!Number.isInteger(start) || start < 1)
363
+ throw new NovelCliError(`Invalid --start: ${String(start)} (expected int >= 1).`, 2);
364
+ if (!Number.isInteger(end) || end < 1)
365
+ throw new NovelCliError(`Invalid --end: ${String(end)} (expected int >= 1).`, 2);
366
+ if (end < start)
367
+ throw new NovelCliError(`Invalid --end: ${String(end)} (expected int >= start=${start}).`, 2);
368
+ if (end > checkpoint.last_completed_chapter) {
369
+ throw new NovelCliError(`Invalid --end: ${String(end)} (expected <= checkpoint.last_completed_chapter=${checkpoint.last_completed_chapter}).`, 2);
370
+ }
371
+ if (windowChapters !== undefined) {
372
+ if (!Number.isInteger(windowChapters) || windowChapters < 1) {
373
+ throw new NovelCliError(`Invalid --window-chapters: ${String(windowChapters)} (expected int >= 1).`, 2);
374
+ }
375
+ }
376
+ const coreCastIds = typeof localOpts.coreCast === "string"
377
+ ? Array.from(new Set(localOpts.coreCast.split(",").map((s) => s.trim()).filter((s) => s.length > 0)))
378
+ : [];
379
+ const result = await buildCharacterVoiceProfiles({
380
+ rootDir,
381
+ protagonistId: localOpts.protagonist,
382
+ coreCastIds,
383
+ baselineRange: { start, end },
384
+ ...(windowChapters !== undefined ? { windowChapters } : {})
385
+ });
386
+ let wrote = false;
387
+ if (localOpts.apply) {
388
+ await withWriteLock(rootDir, { chapter: checkpoint.last_completed_chapter }, async () => {
389
+ await writeCharacterVoiceProfilesFile({ rootDir, profiles: result.profiles });
390
+ });
391
+ wrote = true;
392
+ }
393
+ if (json) {
394
+ printJson(okJson("voice init", { rootDir, wrote, ...result }));
395
+ return;
396
+ }
397
+ for (const w of result.warnings)
398
+ process.stdout.write(`WARN: ${w}\n`);
399
+ if (wrote) {
400
+ process.stdout.write(`Wrote ${result.rel}.\n`);
401
+ return;
402
+ }
403
+ process.stdout.write(`Preview-only. Use --apply to write ${result.rel}.\n`);
404
+ });
405
+ voice
406
+ .command("check")
407
+ .description("Compute voice drift from character-voice-profiles.json (optionally write/clear character-voice-drift.json).")
408
+ .option("--as-of <n>", "As-of chapter (defaults to checkpoint.last_completed_chapter).", (v) => Number.parseInt(String(v), 10))
409
+ .option("--volume <n>", "Volume number (defaults to checkpoint.current_volume).", (v) => Number.parseInt(String(v), 10))
410
+ .option("--apply", "Write/clear character-voice-drift.json (otherwise preview-only).")
411
+ .action(async (localOpts) => {
412
+ const opts = program.opts();
413
+ const json = Boolean(opts.json);
414
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
415
+ const checkpoint = await readCheckpoint(rootDir);
416
+ const asOf = localOpts.asOf ?? checkpoint.last_completed_chapter;
417
+ const volume = localOpts.volume ?? checkpoint.current_volume;
418
+ if (!Number.isInteger(asOf) || asOf < 1)
419
+ throw new NovelCliError(`Invalid --as-of: ${String(asOf)} (expected int >= 1).`, 2);
420
+ if (!Number.isInteger(volume) || volume < 0)
421
+ throw new NovelCliError(`Invalid --volume: ${String(volume)} (expected int >= 0).`, 2);
422
+ const profilesAbs = resolve(rootDir, "character-voice-profiles.json");
423
+ if (!(await pathExists(profilesAbs))) {
424
+ throw new NovelCliError("Missing character-voice-profiles.json. Run: novel voice init --protagonist <id> --apply", 2);
425
+ }
426
+ const loaded = await loadCharacterVoiceProfiles(rootDir);
427
+ if (!loaded.profiles)
428
+ throw new NovelCliError("Invalid character-voice-profiles.json: failed to load.", 2);
429
+ const previousActiveCharacterIds = await loadActiveCharacterVoiceDriftIds(rootDir);
430
+ const computed = await computeCharacterVoiceDrift({ rootDir, profiles: loaded.profiles, asOfChapter: asOf, volume, previousActiveCharacterIds });
431
+ let wrote = false;
432
+ let cleared = false;
433
+ if (localOpts.apply) {
434
+ await withWriteLock(rootDir, { chapter: asOf }, async () => {
435
+ if (computed.drift) {
436
+ await writeCharacterVoiceDriftFile({ rootDir, drift: computed.drift });
437
+ wrote = true;
438
+ return;
439
+ }
440
+ cleared = await clearCharacterVoiceDriftFile(rootDir);
441
+ });
442
+ }
443
+ const allWarnings = [...loaded.warnings, ...computed.warnings];
444
+ if (json) {
445
+ const action = wrote ? "wrote" : cleared ? "cleared" : localOpts.apply ? "noop" : computed.drift ? "preview_would_write" : "preview_no_drift";
446
+ printJson(okJson("voice check", {
447
+ rootDir,
448
+ drift: computed.drift,
449
+ warnings: allWarnings,
450
+ drift_rel: "character-voice-drift.json",
451
+ action,
452
+ applied: Boolean(localOpts.apply),
453
+ wrote,
454
+ cleared
455
+ }));
456
+ return;
457
+ }
458
+ for (const w of allWarnings)
459
+ process.stdout.write(`WARN: ${w}\n`);
460
+ if (!computed.drift) {
461
+ process.stdout.write(`No active voice drift.\n`);
462
+ if (cleared)
463
+ process.stdout.write(`Cleared character-voice-drift.json.\n`);
464
+ else if (!localOpts.apply)
465
+ process.stdout.write(`Preview-only. Use --apply to clear character-voice-drift.json on recovery.\n`);
466
+ return;
467
+ }
468
+ for (const c of computed.drift.characters) {
469
+ process.stdout.write(`\n[${c.character_id}] ${c.display_name}\n`);
470
+ for (const d of c.directives)
471
+ process.stdout.write(`- ${d}\n`);
472
+ }
473
+ if (wrote)
474
+ process.stdout.write(`\nWrote character-voice-drift.json.\n`);
475
+ else if (!localOpts.apply)
476
+ process.stdout.write(`\nPreview-only. Use --apply to write character-voice-drift.json.\n`);
477
+ });
478
+ const lock = program.command("lock").description("Manage project lock (.novel.lock).");
479
+ lock
480
+ .command("status")
481
+ .description("Show lock status.")
482
+ .action(async () => {
483
+ const opts = program.opts();
484
+ const json = Boolean(opts.json);
485
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
486
+ const status = await getLockStatus(rootDir);
487
+ if (json) {
488
+ printJson(okJson("lock status", { rootDir, ...status }));
489
+ return;
490
+ }
491
+ if (!status.exists) {
492
+ process.stdout.write("No lock.\n");
493
+ return;
494
+ }
495
+ process.stdout.write(`Lock present${status.stale ? " (stale)" : ""}: started=${status.info?.started ?? "unknown"} pid=${status.info?.pid ?? "unknown"} chapter=${status.info?.chapter ?? "unknown"}\n`);
496
+ });
497
+ lock
498
+ .command("clear")
499
+ .description("Clear a stale lock (or fail if lock is active).")
500
+ .action(async () => {
501
+ const opts = program.opts();
502
+ const json = Boolean(opts.json);
503
+ const rootDir = await resolveProjectRoot({ cwd: process.cwd(), projectOverride: opts.project });
504
+ const cleared = await clearStaleLock(rootDir);
505
+ if (json) {
506
+ printJson(okJson("lock clear", { rootDir, cleared }));
507
+ return;
508
+ }
509
+ process.stdout.write(cleared ? "Cleared stale lock.\n" : "No lock to clear.\n");
510
+ });
511
+ return program;
512
+ }
513
+ export async function main(argv = process.argv.slice(2)) {
514
+ const jsonMode = isJsonMode(argv);
515
+ const program = buildProgram(argv);
516
+ try {
517
+ await program.parseAsync(argv, { from: "user" });
518
+ return Number(process.exitCode ?? 0);
519
+ }
520
+ catch (err) {
521
+ const command = detectCommandName(argv);
522
+ if (err instanceof NovelCliError) {
523
+ if (jsonMode) {
524
+ printJson(errJson(command, err.message));
525
+ }
526
+ else {
527
+ process.stderr.write(`${err.message}\n`);
528
+ }
529
+ return err.exitCode;
530
+ }
531
+ if (err instanceof CommanderError) {
532
+ if (err.code === "commander.helpDisplayed") {
533
+ return 0;
534
+ }
535
+ if (jsonMode) {
536
+ printJson(errJson(command, err.message, err.code));
537
+ return err.exitCode;
538
+ }
539
+ process.stderr.write(`${err.message}\n`);
540
+ return err.exitCode;
541
+ }
542
+ const message = err instanceof Error ? err.message : String(err);
543
+ if (jsonMode) {
544
+ printJson(errJson(command, message));
545
+ }
546
+ else {
547
+ process.stderr.write(`${message}\n`);
548
+ }
549
+ return 1;
550
+ }
551
+ }
552
+ const entryPath = process.argv[1] ? resolve(process.argv[1]) : null;
553
+ if (entryPath && import.meta.url === pathToFileURL(entryPath).href) {
554
+ main()
555
+ .then((code) => {
556
+ process.exitCode = code;
557
+ })
558
+ .catch((err) => {
559
+ const message = err instanceof Error ? err.stack ?? err.message : String(err);
560
+ process.stderr.write(`${message}\n`);
561
+ process.exitCode = 1;
562
+ });
563
+ }