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.
- package/README.md +9 -1
- package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
- package/dist/__tests__/character-voice.test.js +1 -1
- package/dist/__tests__/gate-decision.test.js +66 -0
- package/dist/__tests__/init.test.js +245 -0
- package/dist/__tests__/narrative-health-injection.test.js +8 -8
- package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
- package/dist/__tests__/next-step-title-fix.test.js +64 -8
- package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
- package/dist/__tests__/steps-id.test.js +23 -0
- package/dist/__tests__/volume-pipeline.test.js +227 -0
- package/dist/__tests__/volume-review-pipeline.test.js +112 -0
- package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
- package/dist/advance.js +145 -48
- package/dist/checkpoint.js +83 -12
- package/dist/cli.js +235 -8
- package/dist/commit.js +1 -0
- package/dist/fs-utils.js +18 -3
- package/dist/gate-decision.js +59 -0
- package/dist/init.js +165 -0
- package/dist/instructions.js +322 -24
- package/dist/next-step.js +198 -34
- package/dist/platform-profile.js +3 -0
- package/dist/steps.js +60 -17
- package/dist/validate.js +275 -2
- package/dist/volume-commit.js +101 -0
- package/dist/volume-planning.js +143 -0
- package/dist/volume-review.js +448 -0
- package/docs/user/novel-cli.md +57 -0
- package/package.json +3 -2
- package/schemas/platform-profile.schema.json +5 -0
package/dist/checkpoint.js
CHANGED
|
@@ -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 <
|
|
35
|
-
throw new NovelCliError(".checkpoint.json.current_volume must be an int >=
|
|
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 >=
|
|
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
|
-
|
|
66
|
-
throw new NovelCliError(
|
|
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
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|